nwp500-python 9.3.2__py3-none-any.whl → 9.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nwp500/__init__.py +12 -4
- nwp500/cli/__main__.py +205 -16
- nwp500/cli/handlers.py +283 -1
- nwp500/cli/output_formatters.py +62 -0
- nwp500/cli/presentation.py +251 -8
- nwp500/cli/rich_output.py +55 -10
- nwp500/converters.py +2 -2
- nwp500/enums.py +40 -16
- nwp500/models/__init__.py +13 -4
- nwp500/models/diagnostics.py +147 -0
- nwp500/models/energy.py +35 -3
- nwp500/models/firmware.py +47 -0
- nwp500/models/schedule.py +105 -157
- nwp500/models/status.py +13 -1
- nwp500/mqtt/_control_commands.py +42 -33
- nwp500/mqtt/_device_subscriptions.py +105 -17
- nwp500/mqtt/client.py +76 -3
- nwp500/mqtt/control.py +435 -157
- nwp500/mqtt/reconnection.py +7 -0
- nwp500/mqtt/subscriptions.py +327 -157
- nwp500/mqtt/utils.py +9 -0
- nwp500/topic_builder.py +49 -6
- {nwp500_python-9.3.2.dist-info → nwp500_python-9.4.1.dist-info}/METADATA +1 -1
- {nwp500_python-9.3.2.dist-info → nwp500_python-9.4.1.dist-info}/RECORD +28 -26
- {nwp500_python-9.3.2.dist-info → nwp500_python-9.4.1.dist-info}/WHEEL +0 -0
- {nwp500_python-9.3.2.dist-info → nwp500_python-9.4.1.dist-info}/entry_points.txt +0 -0
- {nwp500_python-9.3.2.dist-info → nwp500_python-9.4.1.dist-info}/licenses/LICENSE.txt +0 -0
- {nwp500_python-9.3.2.dist-info → nwp500_python-9.4.1.dist-info}/top_level.txt +0 -0
nwp500/__init__.py
CHANGED
|
@@ -77,13 +77,19 @@ from nwp500.models import (
|
|
|
77
77
|
ConvertedTOUPlan,
|
|
78
78
|
DescalingInfo,
|
|
79
79
|
Device,
|
|
80
|
+
DeviceDiagnostics,
|
|
80
81
|
DeviceErrorSummary,
|
|
81
82
|
DeviceFeature,
|
|
82
83
|
DeviceInfo,
|
|
83
84
|
DeviceStatus,
|
|
85
|
+
DiagnosticsComponentCounters,
|
|
86
|
+
DiagnosticsDhwUsage,
|
|
87
|
+
DiagnosticsEventCounters,
|
|
84
88
|
EnergyUsageDay,
|
|
85
89
|
EnergyUsageResponse,
|
|
86
90
|
EnergyUsageTotal,
|
|
91
|
+
FirmwareDownloadEntry,
|
|
92
|
+
FirmwareDownloadInfo,
|
|
87
93
|
FirmwareInfo,
|
|
88
94
|
Location,
|
|
89
95
|
MonthlyEnergyData,
|
|
@@ -98,8 +104,6 @@ from nwp500.models import (
|
|
|
98
104
|
TOUPeriod,
|
|
99
105
|
TOUReservationSchedule,
|
|
100
106
|
TOUSchedule,
|
|
101
|
-
WeeklyReservationEntry,
|
|
102
|
-
WeeklyReservationSchedule,
|
|
103
107
|
fahrenheit_to_half_celsius,
|
|
104
108
|
preferred_to_half_celsius,
|
|
105
109
|
reservation_param_to_preferred,
|
|
@@ -154,11 +158,15 @@ __all__ = [
|
|
|
154
158
|
"FirmwareInfo",
|
|
155
159
|
"ReservationEntry",
|
|
156
160
|
"ReservationSchedule",
|
|
157
|
-
"WeeklyReservationEntry",
|
|
158
|
-
"WeeklyReservationSchedule",
|
|
159
161
|
"RecirculationScheduleEntry",
|
|
160
162
|
"RecirculationSchedule",
|
|
161
163
|
"OtaCommitPayload",
|
|
164
|
+
"DeviceDiagnostics",
|
|
165
|
+
"DiagnosticsEventCounters",
|
|
166
|
+
"DiagnosticsDhwUsage",
|
|
167
|
+
"DiagnosticsComponentCounters",
|
|
168
|
+
"FirmwareDownloadEntry",
|
|
169
|
+
"FirmwareDownloadInfo",
|
|
162
170
|
"TOUSchedule",
|
|
163
171
|
"TOUInfo",
|
|
164
172
|
"TOUPeriod",
|
nwp500/cli/__main__.py
CHANGED
|
@@ -28,6 +28,7 @@ from nwp500.exceptions import (
|
|
|
28
28
|
TokenRefreshError,
|
|
29
29
|
ValidationError,
|
|
30
30
|
)
|
|
31
|
+
from nwp500.mqtt.control import MAX_ENERGY_YEAR, MIN_ENERGY_YEAR
|
|
31
32
|
from nwp500.unit_system import UnitSystemType
|
|
32
33
|
|
|
33
34
|
from . import handlers
|
|
@@ -69,6 +70,28 @@ async def _detect_unit_system(
|
|
|
69
70
|
return "us_customary"
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def _parse_filter_hours(
|
|
74
|
+
ctx: click.Context, param: click.Parameter, value: int
|
|
75
|
+
) -> int:
|
|
76
|
+
"""Reject filter intervals the device does not accept, before connecting."""
|
|
77
|
+
if value != 0 and not (1000 <= value <= 10000 and value % 500 == 0):
|
|
78
|
+
raise click.BadParameter(
|
|
79
|
+
f"{value} is not accepted; use 0 or 1000-10000 in steps of 500"
|
|
80
|
+
)
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_recirc_schedule(
|
|
85
|
+
ctx: click.Context, param: click.Parameter, value: str
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Validate the recirculation schedule JSON before connecting."""
|
|
88
|
+
try:
|
|
89
|
+
handlers.parse_recirculation_schedule_json(value, enabled=True)
|
|
90
|
+
except ValueError as e:
|
|
91
|
+
raise click.BadParameter(str(e)) from None
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
72
95
|
def async_command(f: Any) -> Any:
|
|
73
96
|
"""Decorator to run click commands asynchronously with device connection."""
|
|
74
97
|
|
|
@@ -275,6 +298,94 @@ async def reset_filter(mqtt: NavienMqttClient, device: Any) -> None:
|
|
|
275
298
|
await handlers.handle_reset_air_filter_request(mqtt, device)
|
|
276
299
|
|
|
277
300
|
|
|
301
|
+
@cli.command() # type: ignore[attr-defined]
|
|
302
|
+
@click.argument("hours", type=int, callback=_parse_filter_hours)
|
|
303
|
+
@async_command
|
|
304
|
+
async def filter_life(mqtt: NavienMqttClient, device: Any, hours: int) -> None:
|
|
305
|
+
"""Set the air filter service interval in fan hours.
|
|
306
|
+
|
|
307
|
+
HOURS is 0 to disable the alarm, or 1000-10000 in steps of 500 (the
|
|
308
|
+
values the NaviLink app offers).
|
|
309
|
+
"""
|
|
310
|
+
await handlers.handle_set_air_filter_life_request(mqtt, device, hours)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@cli.command() # type: ignore[attr-defined]
|
|
314
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON")
|
|
315
|
+
@async_command
|
|
316
|
+
async def diagnostics(
|
|
317
|
+
mqtt: NavienMqttClient, device: Any, output_json: bool = False
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Show the installer diagnostics counters.
|
|
320
|
+
|
|
321
|
+
Lifetime energy and fault counters, hot-water draw statistics and
|
|
322
|
+
component run times, as shown on the NaviLink app's installer screen.
|
|
323
|
+
"""
|
|
324
|
+
await handlers.handle_diagnostics_request(mqtt, device, output_json)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@cli.command() # type: ignore[attr-defined]
|
|
328
|
+
@async_command
|
|
329
|
+
async def reset_condenser_fault(mqtt: NavienMqttClient, device: Any) -> None:
|
|
330
|
+
"""Clear a condenser fault (installer-level in the NaviLink app)."""
|
|
331
|
+
await handlers.handle_reset_condenser_fault_request(mqtt, device)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@cli.group() # type: ignore[attr-defined]
|
|
335
|
+
def firmware() -> None:
|
|
336
|
+
"""Firmware update information."""
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@firmware.command("info") # type: ignore[attr-defined]
|
|
341
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON")
|
|
342
|
+
@async_command
|
|
343
|
+
async def firmware_info(
|
|
344
|
+
mqtt: NavienMqttClient, device: Any, output_json: bool = False
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Show downloadable firmware (OTA) information."""
|
|
347
|
+
await handlers.handle_firmware_download_info_request(
|
|
348
|
+
mqtt, device, output_json
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@cli.group() # type: ignore[attr-defined]
|
|
353
|
+
def recirc_schedule() -> None:
|
|
354
|
+
"""Manage the recirculation pump schedule."""
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@recirc_schedule.command("get") # type: ignore[attr-defined]
|
|
359
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON")
|
|
360
|
+
@async_command
|
|
361
|
+
async def recirc_schedule_get(
|
|
362
|
+
mqtt: NavienMqttClient, device: Any, output_json: bool = False
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Read the recirculation pump schedule from the device."""
|
|
365
|
+
await handlers.handle_get_recirculation_schedule_request(
|
|
366
|
+
mqtt, device, output_json
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@recirc_schedule.command("set") # type: ignore[attr-defined]
|
|
371
|
+
@click.argument("json_str", metavar="JSON", callback=_parse_recirc_schedule)
|
|
372
|
+
@click.option("--disabled", is_flag=True, help="Write the schedule disabled")
|
|
373
|
+
@async_command
|
|
374
|
+
async def recirc_schedule_set(
|
|
375
|
+
mqtt: NavienMqttClient, device: Any, json_str: str, disabled: bool
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Write the recirculation pump schedule.
|
|
378
|
+
|
|
379
|
+
JSON is an array of up to 20 entries, for example Monday-Friday
|
|
380
|
+
at 06:00: {"enable": 2, "week": 124, "hour": 6, "min": 0, "mode": 2}.
|
|
381
|
+
week is the day bitfield (Sun=128, Mon=64 ... Sat=2), mode 2 switches
|
|
382
|
+
the pump on and 1 off.
|
|
383
|
+
"""
|
|
384
|
+
await handlers.handle_set_recirculation_schedule_request(
|
|
385
|
+
mqtt, device, json_str, enabled=not disabled
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
278
389
|
@cli.command() # type: ignore[attr-defined]
|
|
279
390
|
@async_command
|
|
280
391
|
async def water_program(mqtt: NavienMqttClient, device: Any) -> None:
|
|
@@ -327,6 +438,20 @@ async def vacation(mqtt: NavienMqttClient, device: Any, days: int) -> None:
|
|
|
327
438
|
await handlers.handle_set_vacation_days_request(mqtt, device, days)
|
|
328
439
|
|
|
329
440
|
|
|
441
|
+
@cli.command() # type: ignore[attr-defined]
|
|
442
|
+
@click.argument("days", type=click.IntRange(1, 30))
|
|
443
|
+
@async_command
|
|
444
|
+
async def vacation_duration(
|
|
445
|
+
mqtt: NavienMqttClient, device: Any, days: int
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Set the vacation day count without entering vacation mode.
|
|
448
|
+
|
|
449
|
+
Sends the app's goout-day command. Use ``vacation`` to enter vacation
|
|
450
|
+
mode for a number of days.
|
|
451
|
+
"""
|
|
452
|
+
await handlers.handle_set_vacation_duration_request(mqtt, device, days)
|
|
453
|
+
|
|
454
|
+
|
|
330
455
|
@cli.command() # type: ignore[attr-defined]
|
|
331
456
|
@click.argument(
|
|
332
457
|
"mode_val", type=click.Choice(["1", "2", "3", "4"]), metavar="MODE"
|
|
@@ -667,8 +792,37 @@ def _parse_months(
|
|
|
667
792
|
return months
|
|
668
793
|
|
|
669
794
|
|
|
795
|
+
def _parse_years(
|
|
796
|
+
ctx: click.Context, param: click.Parameter, value: str | None
|
|
797
|
+
) -> list[int] | None:
|
|
798
|
+
"""Parse ``--years`` at parse time, so bad input is a usage error.
|
|
799
|
+
|
|
800
|
+
Years must fall in the range the library accepts; duplicates are
|
|
801
|
+
dropped, keeping the first occurrence.
|
|
802
|
+
"""
|
|
803
|
+
if value is None:
|
|
804
|
+
return None
|
|
805
|
+
years: list[int] = []
|
|
806
|
+
for raw in value.split(","):
|
|
807
|
+
try:
|
|
808
|
+
year = int(raw.strip())
|
|
809
|
+
except ValueError:
|
|
810
|
+
raise click.BadParameter(
|
|
811
|
+
f"{raw.strip()!r} is not a year; expected a comma-separated "
|
|
812
|
+
"list like 2025,2026"
|
|
813
|
+
) from None
|
|
814
|
+
if not MIN_ENERGY_YEAR <= year <= MAX_ENERGY_YEAR:
|
|
815
|
+
raise click.BadParameter(
|
|
816
|
+
f"{year} is not in the range "
|
|
817
|
+
f"{MIN_ENERGY_YEAR}-{MAX_ENERGY_YEAR}"
|
|
818
|
+
)
|
|
819
|
+
if year not in years:
|
|
820
|
+
years.append(year)
|
|
821
|
+
return years
|
|
822
|
+
|
|
823
|
+
|
|
670
824
|
class _EnergySelection(click.Command):
|
|
671
|
-
"""Rejects an unusable --month/--months combination during parsing.
|
|
825
|
+
"""Rejects an unusable --month/--months/--years combination during parsing.
|
|
672
826
|
|
|
673
827
|
Cross-option checks have no natural home in a per-option callback, and
|
|
674
828
|
the command body is the wrong place: it runs inside ``async_command``,
|
|
@@ -681,23 +835,48 @@ class _EnergySelection(click.Command):
|
|
|
681
835
|
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
682
836
|
rest = super().parse_args(ctx, args)
|
|
683
837
|
month, months = ctx.params.get("month"), ctx.params.get("months")
|
|
684
|
-
|
|
838
|
+
years, year = ctx.params.get("years"), ctx.params.get("year")
|
|
839
|
+
chosen = [
|
|
840
|
+
name
|
|
841
|
+
for name, value in (
|
|
842
|
+
("--month", month),
|
|
843
|
+
("--months", months),
|
|
844
|
+
("--years", years),
|
|
845
|
+
)
|
|
846
|
+
if value is not None
|
|
847
|
+
]
|
|
848
|
+
if len(chosen) > 1:
|
|
685
849
|
raise click.UsageError(
|
|
686
|
-
"Use
|
|
687
|
-
"(monthly summary),
|
|
850
|
+
"Use only one of --month (daily breakdown), --months "
|
|
851
|
+
"(monthly summary) or --years (per-month for whole years), "
|
|
852
|
+
f"not {' and '.join(chosen)}",
|
|
688
853
|
ctx=ctx,
|
|
689
854
|
)
|
|
690
|
-
if
|
|
855
|
+
if not chosen:
|
|
691
856
|
raise click.UsageError(
|
|
692
|
-
"
|
|
693
|
-
"(
|
|
857
|
+
"One of --months (monthly summary), --month (daily "
|
|
858
|
+
"breakdown) or --years (whole years) is required",
|
|
694
859
|
ctx=ctx,
|
|
695
860
|
)
|
|
861
|
+
if years is not None and year is not None:
|
|
862
|
+
raise click.UsageError(
|
|
863
|
+
"--years already names the years; do not pass --year",
|
|
864
|
+
ctx=ctx,
|
|
865
|
+
)
|
|
866
|
+
if years is None and year is None:
|
|
867
|
+
raise click.UsageError(
|
|
868
|
+
"--year is required with --month or --months", ctx=ctx
|
|
869
|
+
)
|
|
696
870
|
return rest
|
|
697
871
|
|
|
698
872
|
|
|
699
873
|
@cli.command(cls=_EnergySelection) # type: ignore[attr-defined]
|
|
700
|
-
@click.option(
|
|
874
|
+
@click.option(
|
|
875
|
+
"--year",
|
|
876
|
+
type=click.IntRange(MIN_ENERGY_YEAR, MAX_ENERGY_YEAR),
|
|
877
|
+
required=False,
|
|
878
|
+
help="Year to query",
|
|
879
|
+
)
|
|
701
880
|
@click.option(
|
|
702
881
|
"--months",
|
|
703
882
|
required=False,
|
|
@@ -710,28 +889,38 @@ class _EnergySelection(click.Command):
|
|
|
710
889
|
required=False,
|
|
711
890
|
help="Show daily breakdown for a specific month (1-12)",
|
|
712
891
|
)
|
|
892
|
+
@click.option(
|
|
893
|
+
"--years",
|
|
894
|
+
required=False,
|
|
895
|
+
callback=_parse_years,
|
|
896
|
+
help="Comma-separated years for a per-month breakdown (e.g. 2025,2026)",
|
|
897
|
+
)
|
|
713
898
|
@async_command
|
|
714
899
|
async def energy(
|
|
715
900
|
mqtt: NavienMqttClient,
|
|
716
901
|
device: Any,
|
|
717
|
-
year: int,
|
|
902
|
+
year: int | None,
|
|
718
903
|
months: list[int] | None,
|
|
719
904
|
month: int | None,
|
|
905
|
+
years: list[int] | None,
|
|
720
906
|
) -> None:
|
|
721
907
|
"""Query historical energy usage.
|
|
722
908
|
|
|
723
|
-
Use
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
909
|
+
Use --year with --months for a monthly summary or --month for a daily
|
|
910
|
+
breakdown, or --years alone for a per-month breakdown of whole years
|
|
911
|
+
(the device's monthly query). Which option was passed decides the
|
|
912
|
+
view, not how many months it names: ``--months 5`` is a one-month
|
|
913
|
+
summary, ``--month 5`` is a daily breakdown of that month.
|
|
727
914
|
"""
|
|
728
|
-
# _EnergySelection rejected every combination but these
|
|
915
|
+
# _EnergySelection rejected every combination but these during
|
|
729
916
|
# parsing, so exactly one of the options is set here.
|
|
730
|
-
if
|
|
917
|
+
if years is not None:
|
|
918
|
+
await handlers.handle_get_energy_monthly_request(mqtt, device, years)
|
|
919
|
+
elif month is not None and year is not None:
|
|
731
920
|
await handlers.handle_get_energy_request(
|
|
732
921
|
mqtt, device, year, [month], daily=True
|
|
733
922
|
)
|
|
734
|
-
elif months is not None:
|
|
923
|
+
elif months is not None and year is not None:
|
|
735
924
|
await handlers.handle_get_energy_request(
|
|
736
925
|
mqtt, device, year, months, daily=False
|
|
737
926
|
)
|
nwp500/cli/handlers.py
CHANGED
|
@@ -21,7 +21,12 @@ from nwp500.exceptions import (
|
|
|
21
21
|
RangeValidationError,
|
|
22
22
|
ValidationError,
|
|
23
23
|
)
|
|
24
|
-
from nwp500.models import
|
|
24
|
+
from nwp500.models import (
|
|
25
|
+
RecirculationSchedule,
|
|
26
|
+
RecirculationScheduleEntry,
|
|
27
|
+
ReservationSchedule,
|
|
28
|
+
)
|
|
29
|
+
from nwp500.mqtt.control import validate_recirculation_schedule
|
|
25
30
|
from nwp500.mqtt.utils import get_response_data, redact_serial
|
|
26
31
|
from nwp500.reservations import (
|
|
27
32
|
add_reservation,
|
|
@@ -35,8 +40,12 @@ from nwp500.unit_system import get_unit_system
|
|
|
35
40
|
from .output_formatters import (
|
|
36
41
|
print_device_info,
|
|
37
42
|
print_device_status,
|
|
43
|
+
print_diagnostics,
|
|
38
44
|
print_energy_usage,
|
|
45
|
+
print_firmware_download_info,
|
|
39
46
|
print_json,
|
|
47
|
+
print_recirculation_schedule,
|
|
48
|
+
print_yearly_energy_usage,
|
|
40
49
|
)
|
|
41
50
|
from .rich_output import get_formatter
|
|
42
51
|
|
|
@@ -897,6 +906,238 @@ async def handle_get_energy_request(
|
|
|
897
906
|
_logger.error(f"Error getting energy data: {e}")
|
|
898
907
|
|
|
899
908
|
|
|
909
|
+
async def handle_get_energy_monthly_request(
|
|
910
|
+
mqtt: NavienMqttClient, device: Device, years: list[int]
|
|
911
|
+
) -> None:
|
|
912
|
+
"""Request per-month energy usage for whole years and print each year."""
|
|
913
|
+
try:
|
|
914
|
+
res: Any = await _wait_for_response(
|
|
915
|
+
mqtt.subscribe_energy_usage_monthly,
|
|
916
|
+
device,
|
|
917
|
+
lambda: mqtt.request_energy_usage_monthly(device, years),
|
|
918
|
+
action_name="monthly energy usage",
|
|
919
|
+
timeout=15,
|
|
920
|
+
)
|
|
921
|
+
print_yearly_energy_usage(
|
|
922
|
+
cast(EnergyUsageResponse, res), list(dict.fromkeys(years))
|
|
923
|
+
)
|
|
924
|
+
except TimeoutError:
|
|
925
|
+
_print_no_answer("monthly energy usage")
|
|
926
|
+
except (ValidationError, Nwp500Error) as e:
|
|
927
|
+
_logger.error(f"Error getting energy data: {e}")
|
|
928
|
+
_formatter.print_error(str(e), title="Monthly Energy Usage")
|
|
929
|
+
except Exception as e:
|
|
930
|
+
_logger.error(f"Error getting energy data: {e}")
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
async def handle_diagnostics_request(
|
|
934
|
+
mqtt: NavienMqttClient, device: Device, output_json: bool = False
|
|
935
|
+
) -> None:
|
|
936
|
+
"""Request the installer diagnostics counters and print them."""
|
|
937
|
+
try:
|
|
938
|
+
diagnostics: Any = await _wait_for_response(
|
|
939
|
+
mqtt.subscribe_diagnostics,
|
|
940
|
+
device,
|
|
941
|
+
lambda: mqtt.request_diagnostics(device),
|
|
942
|
+
action_name="diagnostics",
|
|
943
|
+
timeout=15,
|
|
944
|
+
)
|
|
945
|
+
if output_json:
|
|
946
|
+
print_json(diagnostics.model_dump())
|
|
947
|
+
else:
|
|
948
|
+
print_diagnostics(diagnostics)
|
|
949
|
+
except TimeoutError:
|
|
950
|
+
_print_no_answer("diagnostics")
|
|
951
|
+
except Nwp500Error as e:
|
|
952
|
+
_logger.error(f"Error getting diagnostics: {e}")
|
|
953
|
+
_formatter.print_error(str(e), title="Diagnostics")
|
|
954
|
+
except Exception as e:
|
|
955
|
+
_logger.error(f"Error getting diagnostics: {e}")
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
async def handle_firmware_download_info_request(
|
|
959
|
+
mqtt: NavienMqttClient, device: Device, output_json: bool = False
|
|
960
|
+
) -> None:
|
|
961
|
+
"""Request the firmware download (OTA) information and print it."""
|
|
962
|
+
try:
|
|
963
|
+
info: Any = await _wait_for_response(
|
|
964
|
+
mqtt.subscribe_firmware_download_info,
|
|
965
|
+
device,
|
|
966
|
+
lambda: mqtt.request_firmware_download_info(device),
|
|
967
|
+
action_name="firmware download info",
|
|
968
|
+
)
|
|
969
|
+
if output_json:
|
|
970
|
+
print_json(info.model_dump())
|
|
971
|
+
else:
|
|
972
|
+
print_firmware_download_info(info)
|
|
973
|
+
except TimeoutError:
|
|
974
|
+
_print_no_answer("firmware download info")
|
|
975
|
+
except Nwp500Error as e:
|
|
976
|
+
_logger.error(f"Error getting firmware download info: {e}")
|
|
977
|
+
_formatter.print_error(str(e), title="Firmware Download Info")
|
|
978
|
+
except Exception as e:
|
|
979
|
+
_logger.error(f"Error getting firmware download info: {e}")
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
async def handle_get_recirculation_schedule_request(
|
|
983
|
+
mqtt: NavienMqttClient, device: Device, output_json: bool = False
|
|
984
|
+
) -> None:
|
|
985
|
+
"""Request the recirculation pump schedule and print it."""
|
|
986
|
+
try:
|
|
987
|
+
schedule: Any = await _wait_for_response(
|
|
988
|
+
mqtt.subscribe_recirculation_schedule_response,
|
|
989
|
+
device,
|
|
990
|
+
lambda: mqtt.request_recirculation_schedule(device),
|
|
991
|
+
action_name="recirculation schedule",
|
|
992
|
+
)
|
|
993
|
+
if output_json:
|
|
994
|
+
print_json(schedule.model_dump())
|
|
995
|
+
else:
|
|
996
|
+
print_recirculation_schedule(schedule)
|
|
997
|
+
except TimeoutError:
|
|
998
|
+
_print_no_answer("recirculation schedule")
|
|
999
|
+
except (DeviceError, Nwp500Error) as e:
|
|
1000
|
+
_logger.error(f"Error getting recirculation schedule: {e}")
|
|
1001
|
+
_formatter.print_error(str(e), title="Recirculation Schedule")
|
|
1002
|
+
except Exception as e:
|
|
1003
|
+
_logger.error(f"Error getting recirculation schedule: {e}")
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
RECIRCULATION_ENTRY_KEYS = frozenset(
|
|
1007
|
+
{"enable", "week", "hour", "min", "mode", "param"}
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def parse_recirculation_schedule_json(
|
|
1012
|
+
schedule_json: str, enabled: bool
|
|
1013
|
+
) -> RecirculationSchedule:
|
|
1014
|
+
"""Parse and validate a CLI recirculation schedule.
|
|
1015
|
+
|
|
1016
|
+
``schedule_json`` is a JSON array of objects with ``enable`` (2/1),
|
|
1017
|
+
``week`` (day bitfield), ``hour``, ``min`` and ``mode`` (2 = pump on,
|
|
1018
|
+
1 = pump off); ``param`` may be given but must be ``-1``. Unknown keys
|
|
1019
|
+
are rejected rather than ignored, so an entry in another format cannot
|
|
1020
|
+
silently become an all-default entry.
|
|
1021
|
+
|
|
1022
|
+
Raises:
|
|
1023
|
+
ValueError: With a user-facing message on malformed input.
|
|
1024
|
+
"""
|
|
1025
|
+
try:
|
|
1026
|
+
entries = json.loads(schedule_json)
|
|
1027
|
+
except json.JSONDecodeError as e:
|
|
1028
|
+
raise ValueError(f"not valid JSON: {e.msg}") from None
|
|
1029
|
+
if not isinstance(entries, list):
|
|
1030
|
+
raise ValueError("schedule must be a JSON array of entries")
|
|
1031
|
+
parsed: list[RecirculationScheduleEntry] = []
|
|
1032
|
+
for index, raw in enumerate(cast(list[Any], entries), start=1):
|
|
1033
|
+
if not isinstance(raw, dict):
|
|
1034
|
+
raise ValueError(f"entry {index} must be a JSON object")
|
|
1035
|
+
entry = cast(dict[str, Any], raw)
|
|
1036
|
+
unknown = sorted(set(entry) - RECIRCULATION_ENTRY_KEYS)
|
|
1037
|
+
if unknown:
|
|
1038
|
+
raise ValueError(
|
|
1039
|
+
f"entry {index} has unknown keys {', '.join(unknown)}; "
|
|
1040
|
+
"expected enable, week, hour, min, mode, param"
|
|
1041
|
+
)
|
|
1042
|
+
for key in ("week", "hour", "min"):
|
|
1043
|
+
if key not in entry:
|
|
1044
|
+
raise ValueError(f"entry {index} is missing {key!r}")
|
|
1045
|
+
for key, value in entry.items():
|
|
1046
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
1047
|
+
raise ValueError(f"entry {index}: {key} must be an integer")
|
|
1048
|
+
# Checked before model_validate, which maps a read-back's 255 to -1.
|
|
1049
|
+
if entry.get("param", -1) != -1:
|
|
1050
|
+
raise ValueError(f"entry {index}: param must be -1 if given")
|
|
1051
|
+
parsed.append(RecirculationScheduleEntry.model_validate(entry))
|
|
1052
|
+
schedule = RecirculationSchedule(
|
|
1053
|
+
reservationUse=2 if enabled else 1, reservation=parsed
|
|
1054
|
+
)
|
|
1055
|
+
try:
|
|
1056
|
+
validate_recirculation_schedule(schedule)
|
|
1057
|
+
except ValidationError as e:
|
|
1058
|
+
raise ValueError(str(e)) from None
|
|
1059
|
+
return schedule
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
async def handle_set_recirculation_schedule_request(
|
|
1063
|
+
mqtt: NavienMqttClient,
|
|
1064
|
+
device: Device,
|
|
1065
|
+
schedule_json: str,
|
|
1066
|
+
enabled: bool,
|
|
1067
|
+
timeout: float = 10.0,
|
|
1068
|
+
) -> None:
|
|
1069
|
+
"""Write the recirculation pump schedule and confirm it from the echo.
|
|
1070
|
+
|
|
1071
|
+
See :func:`parse_recirculation_schedule_json` for the input format.
|
|
1072
|
+
The write asks for the schedule back on ``recirc-rsv/rd``. Success is
|
|
1073
|
+
reported only when a reply's canonical form matches what was written,
|
|
1074
|
+
so a stale reply (from an earlier write or a concurrent read) or a
|
|
1075
|
+
schedule the device stored differently is not mistaken for
|
|
1076
|
+
confirmation. The echo has not been observed on a unit with
|
|
1077
|
+
recirculation, so no reply at all is reported as sent but unconfirmed
|
|
1078
|
+
rather than as a failure.
|
|
1079
|
+
"""
|
|
1080
|
+
try:
|
|
1081
|
+
schedule = parse_recirculation_schedule_json(schedule_json, enabled)
|
|
1082
|
+
except ValueError as e:
|
|
1083
|
+
_logger.error(f"Invalid schedule: {e}")
|
|
1084
|
+
_formatter.print_error(str(e), title="Invalid Schedule")
|
|
1085
|
+
return
|
|
1086
|
+
|
|
1087
|
+
expected = schedule.canonical()
|
|
1088
|
+
confirmed: asyncio.Future[RecirculationSchedule] = (
|
|
1089
|
+
asyncio.get_running_loop().create_future()
|
|
1090
|
+
)
|
|
1091
|
+
last_seen: list[RecirculationSchedule] = []
|
|
1092
|
+
|
|
1093
|
+
def on_schedule(reply: RecirculationSchedule) -> None:
|
|
1094
|
+
last_seen.append(reply)
|
|
1095
|
+
if not confirmed.done() and reply.canonical() == expected:
|
|
1096
|
+
confirmed.set_result(reply)
|
|
1097
|
+
|
|
1098
|
+
try:
|
|
1099
|
+
await mqtt.subscribe_recirculation_schedule_response(
|
|
1100
|
+
device, on_schedule
|
|
1101
|
+
)
|
|
1102
|
+
try:
|
|
1103
|
+
await mqtt.configure_recirculation_schedule(device, schedule)
|
|
1104
|
+
stored = await asyncio.wait_for(confirmed, timeout=timeout)
|
|
1105
|
+
_formatter.print_success("Recirculation schedule updated")
|
|
1106
|
+
print_recirculation_schedule(stored)
|
|
1107
|
+
except TimeoutError:
|
|
1108
|
+
if last_seen:
|
|
1109
|
+
_formatter.print_error(
|
|
1110
|
+
"The device replied with a schedule that does not match "
|
|
1111
|
+
"the one written. It may have rejected or changed it; "
|
|
1112
|
+
"the last schedule it reported is shown below.",
|
|
1113
|
+
title="Recirculation Schedule Not Confirmed",
|
|
1114
|
+
)
|
|
1115
|
+
print_recirculation_schedule(last_seen[-1])
|
|
1116
|
+
else:
|
|
1117
|
+
_formatter.print_info(
|
|
1118
|
+
"Recirculation schedule sent, but the device did not "
|
|
1119
|
+
"echo it back. Run 'recirc-schedule get' to check what "
|
|
1120
|
+
"it stored."
|
|
1121
|
+
)
|
|
1122
|
+
finally:
|
|
1123
|
+
await mqtt.unsubscribe_recirculation_schedule_response(
|
|
1124
|
+
device, on_schedule
|
|
1125
|
+
)
|
|
1126
|
+
except (ValidationError, DeviceError, Nwp500Error) as e:
|
|
1127
|
+
_logger.error(f"Error updating recirculation schedule: {e}")
|
|
1128
|
+
_formatter.print_error(str(e), title="Recirculation Schedule")
|
|
1129
|
+
except Exception as e:
|
|
1130
|
+
_logger.error(f"Error updating recirculation schedule: {e}")
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _print_no_answer(what: str) -> None:
|
|
1134
|
+
"""Report a query the device did not answer in time."""
|
|
1135
|
+
_formatter.print_error(
|
|
1136
|
+
f"The device did not answer the {what} request in time.",
|
|
1137
|
+
title="No Response",
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
|
|
900
1141
|
async def handle_reset_air_filter_request(
|
|
901
1142
|
mqtt: NavienMqttClient, device: Device
|
|
902
1143
|
) -> None:
|
|
@@ -910,6 +1151,34 @@ async def handle_reset_air_filter_request(
|
|
|
910
1151
|
)
|
|
911
1152
|
|
|
912
1153
|
|
|
1154
|
+
async def handle_set_air_filter_life_request(
|
|
1155
|
+
mqtt: NavienMqttClient, device: Device, hours: int
|
|
1156
|
+
) -> None:
|
|
1157
|
+
"""Set the air filter service interval."""
|
|
1158
|
+
await _handle_command_with_status_feedback(
|
|
1159
|
+
mqtt,
|
|
1160
|
+
device,
|
|
1161
|
+
lambda: mqtt.set_air_filter_life(device, hours),
|
|
1162
|
+
"setting air filter life",
|
|
1163
|
+
f"Air filter life set to {hours} hours"
|
|
1164
|
+
if hours
|
|
1165
|
+
else "Air filter alarm disabled",
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
async def handle_reset_condenser_fault_request(
|
|
1170
|
+
mqtt: NavienMqttClient, device: Device
|
|
1171
|
+
) -> None:
|
|
1172
|
+
"""Clear a condenser fault."""
|
|
1173
|
+
await _handle_command_with_status_feedback(
|
|
1174
|
+
mqtt,
|
|
1175
|
+
device,
|
|
1176
|
+
lambda: mqtt.reset_condenser_fault(device),
|
|
1177
|
+
"resetting condenser fault",
|
|
1178
|
+
"Condenser fault reset sent",
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
|
|
913
1182
|
async def handle_set_vacation_days_request(
|
|
914
1183
|
mqtt: NavienMqttClient, device: Device, days: int
|
|
915
1184
|
) -> None:
|
|
@@ -923,6 +1192,19 @@ async def handle_set_vacation_days_request(
|
|
|
923
1192
|
)
|
|
924
1193
|
|
|
925
1194
|
|
|
1195
|
+
async def handle_set_vacation_duration_request(
|
|
1196
|
+
mqtt: NavienMqttClient, device: Device, days: int
|
|
1197
|
+
) -> None:
|
|
1198
|
+
"""Set the vacation day count without entering vacation mode."""
|
|
1199
|
+
await _handle_command_with_status_feedback(
|
|
1200
|
+
mqtt,
|
|
1201
|
+
device,
|
|
1202
|
+
lambda: mqtt.set_vacation_duration(device, days),
|
|
1203
|
+
"setting vacation duration",
|
|
1204
|
+
f"Vacation duration set to {days} days",
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
|
|
926
1208
|
async def handle_set_recirculation_mode_request(
|
|
927
1209
|
mqtt: NavienMqttClient, device: Device, mode: int
|
|
928
1210
|
) -> None:
|