nwp500-python 9.4.1__py3-none-any.whl → 9.4.3__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/models/status.py CHANGED
@@ -240,7 +240,9 @@ class DeviceStatus(NavienBaseModel):
240
240
  "the current setpoint, in Watt-hours. This is NOT a fixed tank "
241
241
  "size: it tracks the setpoint, rising about 140 Wh per 0.5 degC "
242
242
  "of setpoint increase. Use it as the cost of a full recovery, "
243
- "not as the tank's total heat content."
243
+ "not as the tank's total heat content. During a TOU window that "
244
+ "raises hp_upper_on_temp_setting, the reference is 2 degC "
245
+ "higher (about 108.5 degF)."
244
246
  ),
245
247
  json_schema_extra={
246
248
  "unit_of_measurement": "Wh",
@@ -252,9 +254,10 @@ class DeviceStatus(NavienBaseModel):
252
254
  description=(
253
255
  "Energy still NEEDED to bring the tank up to the setpoint, in "
254
256
  "Watt-hours - a heating deficit, not stored energy. It falls as "
255
- "the tank heats and reaches zero at the setpoint. Despite the "
256
- "protocol name 'availableEnergyCapacity' it is the inverse of "
257
- "available energy."
257
+ "the tank heats and reaches zero at the setpoint - or, during a "
258
+ "TOU window that raises hp_upper_on_temp_setting, about 2 degC "
259
+ "short of it. Despite the protocol name 'availableEnergyCapacity' "
260
+ "it is the inverse of available energy."
258
261
  ),
259
262
  json_schema_extra={
260
263
  "unit_of_measurement": "Wh",
@@ -276,8 +279,8 @@ class DeviceStatus(NavienBaseModel):
276
279
  recirc_error_status: int = Field(description="Recirculation error status")
277
280
  current_inst_power: float = Field(
278
281
  description=(
279
- "Current instantaneous power consumption in Watts. "
280
- "Does not include heating element power when active"
282
+ "Current instantaneous power consumption of the whole unit "
283
+ "in Watts, including heating element power when active"
281
284
  ),
282
285
  json_schema_extra={
283
286
  "unit_of_measurement": "W",
@@ -958,12 +961,19 @@ class DeviceStatus(NavienBaseModel):
958
961
  estimate of what can actually be drawn - water colder than that
959
962
  still holds heat, but not heat you can wash with.
960
963
 
961
- Robust in practice: ``full_recovery_energy`` is bimodal, taking
962
- one of two values 2 degC apart at a fixed setpoint, but both
963
- fields shift together so the difference is unaffected. Checked
964
- against the tank thermistors over 12275 samples, the implied tank
964
+ Usually robust: ``full_recovery_energy`` is bimodal, taking one
965
+ of two values 2 degC apart at a fixed setpoint, but both fields
966
+ shift together so the difference is unaffected. Checked against
967
+ the tank thermistors over 12275 samples, the implied tank
965
968
  temperature agrees to a standard deviation of 0.57 degF.
966
969
 
970
+ The exception is a Time-of-Use window, when
971
+ ``hp_upper_on_temp_setting`` is raised to about the setpoint.
972
+ There ``energy_to_setpoint`` reaches 0 while the tank is still up
973
+ to about 2 degC short. This value then equals
974
+ ``full_recovery_energy`` and under-reads by up to about 560 Wh on
975
+ a 65-gallon tank. In that case, use the thermistors.
976
+
967
977
  Returns:
968
978
  Drawable energy in Watt-hours, clamped at zero.
969
979
  """
nwp500/mqtt/control.py CHANGED
@@ -27,7 +27,7 @@ weekly reservation) have no method.
27
27
  """
28
28
 
29
29
  import logging
30
- from collections.abc import Awaitable, Callable, Sequence
30
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
31
31
  from datetime import UTC, datetime
32
32
  from typing import Any
33
33
 
@@ -120,6 +120,120 @@ def validate_recirculation_schedule(schedule: RecirculationSchedule) -> None:
120
120
  )
121
121
 
122
122
 
123
+ RESERVATION_ENTRY_FIELDS = ("enable", "week", "hour", "min", "mode", "param")
124
+ # DhwOperationSetting ids are contiguous (1-6), so a range check covers them.
125
+ _DHW_MODE_MIN = min(DhwOperationSetting)
126
+ _DHW_MODE_MAX = max(DhwOperationSetting)
127
+
128
+
129
+ def validate_reservation_entries(
130
+ reservations: Sequence[Mapping[str, Any]],
131
+ features: DeviceFeature | None = None,
132
+ ) -> None:
133
+ """Check raw reservation entries before they are written to the device.
134
+
135
+ :class:`~nwp500.models.ReservationEntry` accepts any integers so that
136
+ device read-backs always parse; writes are held to what the device
137
+ accepts. Each entry must carry the six protocol fields as plain
138
+ integers (a ``bool`` or ``float`` is rejected): ``enable`` 1 (off) or
139
+ 2 (on), ``week`` a day bitfield (2-254, bit 0 clear), ``hour`` 0-23,
140
+ ``min`` 0-59 and ``mode`` a :class:`~nwp500.enums.DhwOperationSetting`
141
+ id.
142
+
143
+ ``param`` is the setpoint in half-degrees Celsius. With ``features``
144
+ it must lie within the range the heater reports,
145
+ ``dhw_temperature_min_raw`` to ``dhw_temperature_max_raw``; without
146
+ them, only within the single byte the protocol carries (0-255).
147
+
148
+ Args:
149
+ reservations: Raw entry dicts, as passed to ``update_reservations``.
150
+ features: The device's feature data, for its setpoint limits.
151
+
152
+ Raises:
153
+ ParameterValidationError: On a missing field, a non-integer field,
154
+ or a bad ``enable`` or ``week``.
155
+ RangeValidationError: On ``hour``, ``min``, ``mode`` or ``param``
156
+ outside its range.
157
+ """
158
+ for index, entry in enumerate(reservations, start=1):
159
+ _check_reservation_entry(index, entry)
160
+ if features is not None:
161
+ _check_reservation_setpoints(reservations, features)
162
+
163
+
164
+ def _check_reservation_entry(index: int, entry: Mapping[str, Any]) -> None:
165
+ """Check one entry's fields, except ``param`` against device limits."""
166
+ for name in RESERVATION_ENTRY_FIELDS:
167
+ if name not in entry:
168
+ raise ParameterValidationError(
169
+ f"entry {index}: missing field {name!r}",
170
+ parameter=f"reservation[{index}].{name}",
171
+ )
172
+ if not _is_int(entry[name]):
173
+ raise ParameterValidationError(
174
+ f"entry {index}: {name} must be an integer",
175
+ parameter=f"reservation[{index}].{name}",
176
+ value=entry[name],
177
+ )
178
+
179
+ enable = entry["enable"]
180
+ if enable not in (1, 2):
181
+ raise ParameterValidationError(
182
+ f"entry {index}: enable must be 1 (off) or 2 (on), got {enable}",
183
+ parameter=f"reservation[{index}].enable",
184
+ value=enable,
185
+ )
186
+ week = entry["week"]
187
+ if not (0 < week <= 254 and week % 2 == 0):
188
+ raise ParameterValidationError(
189
+ f"entry {index}: week={week} is not a day bitfield "
190
+ "(Sun=128 .. Sat=2, at least one day, bit 0 clear)",
191
+ parameter=f"reservation[{index}].week",
192
+ value=week,
193
+ )
194
+
195
+ ranges = [
196
+ ("hour", 0, 23),
197
+ ("min", 0, 59),
198
+ ("mode", int(_DHW_MODE_MIN), int(_DHW_MODE_MAX)),
199
+ ("param", 0, 255),
200
+ ]
201
+ for name, low, high in ranges:
202
+ value = entry[name]
203
+ if not low <= value <= high:
204
+ raise RangeValidationError(
205
+ f"entry {index}: {name} must be between {low} and {high}, "
206
+ f"got {value}",
207
+ field=f"reservation[{index}].{name}",
208
+ value=value,
209
+ min_value=low,
210
+ max_value=high,
211
+ )
212
+
213
+
214
+ def _check_reservation_setpoints(
215
+ reservations: Sequence[Mapping[str, Any]], features: DeviceFeature
216
+ ) -> None:
217
+ """Hold each entry's ``param`` to the setpoint range the heater reports.
218
+
219
+ Both ``param`` and the feature limits are half-degrees Celsius, so they
220
+ compare without conversion.
221
+ """
222
+ low = features.dhw_temperature_min_raw
223
+ high = features.dhw_temperature_max_raw
224
+ for index, entry in enumerate(reservations, start=1):
225
+ param = entry["param"]
226
+ if not low <= param <= high:
227
+ raise RangeValidationError(
228
+ f"entry {index}: param={param} (half-degrees C) is outside "
229
+ f"the device's setpoint range {low}-{high}",
230
+ field=f"reservation[{index}].param",
231
+ value=param,
232
+ min_value=low,
233
+ max_value=high,
234
+ )
235
+
236
+
123
237
  class MqttDeviceController:
124
238
  """
125
239
  Manages device control commands for Navien devices.
@@ -569,6 +683,27 @@ class MqttDeviceController:
569
683
  [preferred_to_half_celsius(temperature)],
570
684
  )
571
685
 
686
+ async def _get_reservation_limits(self, device: Device) -> DeviceFeature:
687
+ """Fetch the feature data that carries the device's setpoint range.
688
+
689
+ Mirrors :func:`~nwp500.command_decorators.requires_capability`: a
690
+ failed fetch (a feature-response timeout, a lost connection) is
691
+ reported as :class:`DeviceCapabilityError`, like a missing result.
692
+ """
693
+ capability = "dhw_temperature_setting_use"
694
+ message = "Unable to validate reservation temperatures"
695
+ try:
696
+ features = await self._get_device_features(device)
697
+ except DeviceCapabilityError:
698
+ raise
699
+ except Exception as e:
700
+ raise DeviceCapabilityError(capability, f"{message}: {e!s}") from e
701
+ if features is None:
702
+ raise DeviceCapabilityError(
703
+ capability, f"{message}: device features not available."
704
+ )
705
+ return features
706
+
572
707
  async def update_reservations(
573
708
  self,
574
709
  device: Device,
@@ -579,6 +714,11 @@ class MqttDeviceController:
579
714
  """
580
715
  Update programmed reservations for temperature/mode changes.
581
716
 
717
+ Every entry is checked before anything is sent (see
718
+ :func:`validate_reservation_entries`). Each ``param`` is held to
719
+ the setpoint range the heater reports in its feature data, which is
720
+ requested from the device if it is not cached yet.
721
+
582
722
  Args:
583
723
  device: Device object
584
724
  reservations: List of reservation entries
@@ -586,7 +726,21 @@ class MqttDeviceController:
586
726
 
587
727
  Returns:
588
728
  Publish packet ID
729
+
730
+ Raises:
731
+ ParameterValidationError: If an entry has a missing or
732
+ non-integer field, or a bad ``enable`` or ``week``.
733
+ RangeValidationError: If an entry's ``hour``, ``min`` or
734
+ ``mode`` is out of range, or its ``param`` is outside the
735
+ device's setpoint range.
736
+ DeviceCapabilityError: If the device's feature data, and so
737
+ its setpoint range, is not available.
589
738
  """
739
+ validate_reservation_entries(reservations)
740
+ if reservations:
741
+ features = await self._get_reservation_limits(device)
742
+ _check_reservation_setpoints(reservations, features)
743
+
590
744
  # See docs/reference/protocol/mqtt_protocol.rst "Reservations" for the
591
745
  # command code (16777226) and the reservation object fields
592
746
  # (enable, week, hour, min, mode, param).
@@ -40,7 +40,12 @@ from ..mqtt_events import (
40
40
  from ..topic_builder import MqttTopicBuilder
41
41
  from .state_tracker import DeviceStateTracker
42
42
  from .types import QoS, to_awscrt_qos
43
- from .utils import get_response_data, redact_topic, topic_matches_pattern
43
+ from .utils import (
44
+ get_response_data,
45
+ redact_mac,
46
+ redact_topic,
47
+ topic_matches_pattern,
48
+ )
44
49
 
45
50
  if TYPE_CHECKING:
46
51
  from ..device_info_cache import MqttDeviceInfoCache
@@ -614,13 +619,45 @@ class MqttSubscriptionManager:
614
619
  """
615
620
 
616
621
  def handler(topic: str, message: dict[str, Any]) -> None:
622
+ # Every branch that declines a message says so at debug level.
623
+ # Silence here is expensive to diagnose: a caller watching for a
624
+ # reply that never reaches its callback cannot tell a dropped
625
+ # message from one the cloud never sent, and the two want
626
+ # opposite fixes. The logs are the only evidence either way.
627
+ #
628
+ # Each log is guarded: redact_topic runs a dozen regexes, and
629
+ # these branches are the normal case for a handler registered
630
+ # under the device wildcard, which sees every message for that
631
+ # device and keeps only its own.
617
632
  if topic_suffix and not topic.endswith(topic_suffix):
633
+ if _logger.isEnabledFor(logging.DEBUG):
634
+ _logger.debug(
635
+ "Ignoring a message on %s: the topic does not end "
636
+ "with %r, which this handler requires",
637
+ redact_topic(topic),
638
+ topic_suffix,
639
+ )
618
640
  return
619
641
  try:
620
642
  data = get_response_data(message, key)
621
643
  if not data:
644
+ if _logger.isEnabledFor(logging.DEBUG):
645
+ _logger.debug(
646
+ "Ignoring a message on %s: no %s data to parse "
647
+ "as %s",
648
+ redact_topic(topic),
649
+ f"{key!r}" if key else "response",
650
+ model.__name__,
651
+ )
622
652
  return
623
653
  if only_mac and not _same_device(data, message, only_mac):
654
+ if _logger.isEnabledFor(logging.DEBUG):
655
+ _logger.debug(
656
+ "Ignoring a message on %s: it names another "
657
+ "device, not %s",
658
+ redact_topic(topic),
659
+ redact_mac(only_mac),
660
+ )
624
661
  return
625
662
 
626
663
  parsed = parse(data) if parse else model.model_validate(data)
nwp500/reservations.py CHANGED
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any
17
17
  from .converters import device_bool_from_python
18
18
  from .encoding import build_reservation_entry, encode_week_bitfield
19
19
  from .models import ReservationEntry, ReservationSchedule
20
+ from .mqtt.control import validate_reservation_entries
20
21
 
21
22
  if TYPE_CHECKING:
22
23
  from .models import Device
@@ -119,7 +120,22 @@ async def update_reservations_confirmed(
119
120
  what was just written. This avoids resolving on a stale/unrelated
120
121
  ``rsv/rd`` message (e.g. from a concurrent read or a previous
121
122
  write) that happens to arrive in the same window.
123
+
124
+ Entries are checked before anything is sent; see
125
+ :func:`nwp500.mqtt.control.validate_reservation_entries`.
126
+
127
+ Raises:
128
+ ParameterValidationError: If an entry has a missing or non-integer
129
+ field, or a bad ``enable`` or ``week``.
130
+ RangeValidationError: If an entry's ``hour``, ``min`` or ``mode``
131
+ is out of range, or its ``param`` is outside the device's
132
+ setpoint range.
122
133
  """
134
+ # Structural checks first, so a bad entry neither subscribes nor builds
135
+ # a coerced expectation (``ReservationEntry`` turns ``True`` into 1).
136
+ # The setpoint range is checked by ``update_reservations`` against the
137
+ # device's feature data, still before anything is published.
138
+ validate_reservation_entries(reservations)
123
139
  expected = ReservationSchedule(
124
140
  reservationUse=device_bool_from_python(enabled),
125
141
  reservation=[ReservationEntry(**entry) for entry in reservations],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.4.1
3
+ Version: 9.4.3
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
@@ -16,7 +16,7 @@ nwp500/field_factory.py,sha256=DMQGOLZU5S-sAdT0Ue5nkmqyMAEMlGvCkEg2C8eCkB8,5200
16
16
  nwp500/mqtt_events.py,sha256=2qWDkWEzoMurZw5G6WwzL9nEtlvJ-GUCs_HvMa0024E,10148
17
17
  nwp500/openei.py,sha256=2gM3hEwrUvUEFykDgN-pKkamWomGuQNVT46XzFwOlxA,7071
18
18
  nwp500/py.typed,sha256=vlGtiygNDDmA_Ji2HjDl1SBJEcvpIC6cANFx_GonPMk,69
19
- nwp500/reservations.py,sha256=0i8QJ5SD8oaXX9VjLAGtx5TNbxXoTVh67lVmGneqpgU,12553
19
+ nwp500/reservations.py,sha256=il5f0xVM0Kxanp5WsgpYwLjUgSgnVP-8hVurIh-aix0,13381
20
20
  nwp500/temperature.py,sha256=O9pyjFaMstzfKxWtQTUDNP12YOJbS9rSwBH76s4p4MY,7982
21
21
  nwp500/topic_builder.py,sha256=AmFFY-_b6qpS3VRzuGSyNYv6CPYDc6y-LrNDoWVz9s0,4137
22
22
  nwp500/tou_schedule.py,sha256=sPwceU3sXbEqJCrFMsNZsZRg4DE4TsROkgVN-qXzlk0,3675
@@ -39,7 +39,7 @@ nwp500/models/feature.py,sha256=7z1pDoW0LQGxv8__AlQn5M35lZRKgeybgwfnw2OCKIM,1543
39
39
  nwp500/models/firmware.py,sha256=cyp9fnewmenvQnsDcTXAsCz-298tqrdswOs1CSZ4duc,1490
40
40
  nwp500/models/mqtt_models.py,sha256=1Etfn0lqiWvoUsW2niT6wXdZtNvN0sGjn7F1uPJQ9yU,774
41
41
  nwp500/models/schedule.py,sha256=qQzWJP7Ik1x47GRYv6tfWeF2Ja4ONVkbLPkhK-mEjcs,11605
42
- nwp500/models/status.py,sha256=VOm6ARy3v8AITv04v1FBVjVoegu_fCpqKwdwWfHpXnI,37722
42
+ nwp500/models/status.py,sha256=Riq42GD56n7UPALTF3tdOrlRziLJOH2lZwK4wWmv5UE,38358
43
43
  nwp500/models/tou.py,sha256=wwxhYwCSY4JIWgVDa2SlwUzlCEDqqRBN994NrkqdVg8,7018
44
44
  nwp500/mqtt/__init__.py,sha256=WYdBfpW-3tSLdFMObDWCX4pcLm_IqRBDluJqkPnQBN4,974
45
45
  nwp500/mqtt/_control_commands.py,sha256=NksQ7Fku_4Vswmyi3LONwse33MN9hJUFOF4qhdw1-80,8775
@@ -47,17 +47,17 @@ nwp500/mqtt/_device_subscriptions.py,sha256=rnTHQQ4l9i-pv1VAqnxOr106CFyS3yLA0I1n
47
47
  nwp500/mqtt/client.py,sha256=vEj5mZscGR5u-XePnmqvpDqGlYcL68tb_K_pe1SuVIQ,49284
48
48
  nwp500/mqtt/command_queue.py,sha256=wiNxNzQe6psMqa0PQ3wzatf9N6IDrtDwpl4DPq1kDdk,6517
49
49
  nwp500/mqtt/connection.py,sha256=fNrOtBJxsqk054YDUmipnwI3yfBHx1anXb_07e0X9XU,17712
50
- nwp500/mqtt/control.py,sha256=tw4Ih7ZClpHIE2oqcPQZuw5E174RiiJvuccPKYQWsr0,41071
50
+ nwp500/mqtt/control.py,sha256=VRoX80Nm5hEOAr-13vHG9ve1OUNVfNTQI5zceBG4flA,47381
51
51
  nwp500/mqtt/diagnostics.py,sha256=NDkPExS4FObGCWJXAhENqNQUm8Bda5GDBR7_o3ylSAo,15723
52
52
  nwp500/mqtt/periodic.py,sha256=WKyyO61yTZJfOm9JAtBp6Onyk-XNj0U544sYMCKvTZw,14729
53
53
  nwp500/mqtt/reconnection.py,sha256=g9OuLlf1lVx_pbV6d7QHq7IcCflKkypPYSLSDTp58tQ,16499
54
54
  nwp500/mqtt/state_tracker.py,sha256=uZlDFNYn7DOXmXPbk-8jXva_QKEvLES9T_BYbQSkhvM,6364
55
- nwp500/mqtt/subscriptions.py,sha256=iCcXwP8KDRXqShlxdkbxGFk9e11i4YrD88kWKcp8cP8,38094
55
+ nwp500/mqtt/subscriptions.py,sha256=lBO4IM_5bQbexhARy-PNf36aKNJNx9iiWQMgiMe5Y_0,39853
56
56
  nwp500/mqtt/types.py,sha256=86R7gwsc1XabA_ABWeALeC5EImlTziFFn1DrP1x8Yy4,1605
57
57
  nwp500/mqtt/utils.py,sha256=qXafc9GDpVY9o9o8DQLKBM8t0E401j3EPpZ0rC4snCY,13415
58
- nwp500_python-9.4.1.dist-info/licenses/LICENSE.txt,sha256=EUOsypJ3iEqJ-9tdo_TSZHwB0Rd-7p0JzToEI5CMBJU,1085
59
- nwp500_python-9.4.1.dist-info/METADATA,sha256=O5IuUShR2IRTsVTgs_C_sl12kP4Y9TdGLAOq2PuSTmQ,5091
60
- nwp500_python-9.4.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
61
- nwp500_python-9.4.1.dist-info/entry_points.txt,sha256=p8vGDEbUIOT_TzDG-XnJ3lw-9yaeHrDPNN7fAx4-78I,52
62
- nwp500_python-9.4.1.dist-info/top_level.txt,sha256=DRPIenxKnssWeaetq428gYfwrKjLGN1z2NOtztIvxZE,7
63
- nwp500_python-9.4.1.dist-info/RECORD,,
58
+ nwp500_python-9.4.3.dist-info/licenses/LICENSE.txt,sha256=EUOsypJ3iEqJ-9tdo_TSZHwB0Rd-7p0JzToEI5CMBJU,1085
59
+ nwp500_python-9.4.3.dist-info/METADATA,sha256=LWySo-xhXgEEApUxOXin2dM-DDlFTySXKVCt1Z_dIR4,5091
60
+ nwp500_python-9.4.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
61
+ nwp500_python-9.4.3.dist-info/entry_points.txt,sha256=p8vGDEbUIOT_TzDG-XnJ3lw-9yaeHrDPNN7fAx4-78I,52
62
+ nwp500_python-9.4.3.dist-info/top_level.txt,sha256=DRPIenxKnssWeaetq428gYfwrKjLGN1z2NOtztIvxZE,7
63
+ nwp500_python-9.4.3.dist-info/RECORD,,