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 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
- if month is not None and months is not None:
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 either --month (daily breakdown) or --months "
687
- "(monthly summary), not both",
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 month is None and months is None:
855
+ if not chosen:
691
856
  raise click.UsageError(
692
- "Either --months (for monthly summary) or --month "
693
- "(for daily breakdown) is required",
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("--year", type=int, required=True, help="Year to query")
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 either --months for monthly summary or --month for daily breakdown.
724
- Which option was passed decides the view, not how many months it names:
725
- ``--months 5`` is a one-month summary, ``--month 5`` is a daily
726
- breakdown of that month.
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 two during
915
+ # _EnergySelection rejected every combination but these during
729
916
  # parsing, so exactly one of the options is set here.
730
- if month is not None:
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 ReservationSchedule
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: