v9.0.0
BREAKING CHANGES: Public API surface trimmed and dead code removed.
These changes require a major version bump.
Modernization (Python 3.14)
- Removed
from __future__ import annotationsproject-wide
(57 files): redundant on a Python >=3.14-only package where lazy
annotation evaluation (PEP 649) is the default. - Adopted additional ruff rule sets:
RUF(Ruff-specific),
DTZ(naive datetime),PTH(pathlib),ASYNC, andPERF,
with documented ignores for intentional patterns (grouped__all__
lists, unicode degree signs, publictimeoutparameters). Fixes
applied for all resulting findings, including a timezone-naive
timestamp in CSV exports (now timezone-aware local time),
Path.open()usage,itertools.pairwise()in the CLI range
collapser, aClassVarannotation on the capability map, and
danglingasyncio.create_taskreferences in tests. - PeriodicRequestType is now a StrEnum (was a plain
Enumwith
string values), matching the enum style used elsewhere. - Event payload dataclasses use
slots=True: the frozen event
dataclasses inmqtt_events.pyandevents.EventListenerare
created per state change; slots reduce their memory footprint. - match statement for the periodic request-type dispatch.
Testing
- New unit tests for previously untested modules:
topic_builder.py(full topic schema),field_factory.py
(metadata defaults, overrides, merge semantics), and
models/_converters.py(unit-preference conversions and
round-trips).
Removed
-
Deprecated
.controlproperty: removed
NavienMqttClient.control(deprecated shim slated for v9.0.0; the
project policy is to remove rather than deprecate). Use the delegated
methods on the client directly:.. code-block:: python
OLD (removed)
await client.control.set_power(device, True)
NEW
await client.set_power(device, True)
-
Unused exception classes: removed
TokenExpiredError,
MqttSubscriptionError,DeviceNotFoundError,
DeviceOfflineError, andDeviceOperationError. None of them was
ever raised by the library, so no working error handling can break;
catch the parent classes (AuthenticationError,MqttError,
DeviceError) instead. -
Internal plumbing removed from the top-level namespace: the
package no longer re-exportsrequires_capability,
MqttDeviceInfoCache,MqttDeviceCapabilityChecker,
log_performance, or the bit-encoding helpers
(encode_week_bitfield,decode_week_bitfield,
encode_season_bitfield,decode_season_bitfield,
encode_price,decode_price,build_reservation_entry,
build_tou_period). Import them from their owning modules:.. code-block:: python
OLD (removed)
from nwp500 import build_reservation_entry, encode_price
NEW
from nwp500.encoding import build_reservation_entry, encode_price
-
Dead code: removed the never-imported
nwp500.cli.commands
module (its metadata had drifted from the real click commands), the
unusedconverters.str_enum_validator, the unused module-level
temperature.half_celsius_to_fahrenheit/
deci_celsius_to_fahrenheitwrappers, the no-op
NavienMqttClient._on_message_receivedplaceholder, and unused
width calculations in the CLI formatters.
Changed
- Temperature classes deduplicated:
HalfCelsius,DeciCelsius,
RawCelsius, andDeciCelsiusDeltawere four near-identical
copies differing only in a scale constant. All conversions are now
implemented once on theTemperaturebase class with a per-class
_scale; only special rounding (RawCelsius) and delta semantics
(DeciCelsiusDelta) are overridden. Behavior is unchanged
(~200 lines removed). - field_factory deduplicated: the four field factories shared a
copy-pasted metadata-merge block, now extracted into a single private
helper. Behavior is unchanged. - Device boolean encoding centralized:
mqtt/control.pynow uses
converters.device_bool_from_python()instead of inline
2 if enabled else 1literals. - Version resolution decoupled:
auth.pyresolves the package
version from distribution metadata instead offrom . import __version__, removing an order-dependent near-circular import.
Bug Fixes
- Fix malformed weekly reservation and recirculation schedule
payloads:update_weekly_reservation()and
configure_recirculation_schedule()dumped the schedule models
as-is, double-nesting the request (request.reservation.reservation
/request.schedule.schedule) and leaking pydantic computed display
fields — including a unit-convertedtemperaturealongside the raw
half-Celsiusparam— into device commands. Both now send the flat,
raw protocol shape used byupdate_reservations(). A new
NavienBaseModel.to_protocol_dict()dumps only declared protocol
fields. - Preserve command order when a queued flush fails: a command that
failed mid-flush was re-queued at the tail, behind commands queued
after it, inverting order-sensitive sequences (e.g.set_temp
replayed beforepower_on). The queue is now a deque and failed
commands are re-inserted at the front. - Expire stale queued commands: queued commands stored a timestamp
that was never checked, so a multi-hour outage replayed hours-old
control commands (e.g.set_power) to the appliance on reconnect.
Commands older thanMqttConnectionConfig.max_queued_command_age
(default 300 s,Noneto disable) are now discarded at send time. - Fix Fahrenheit conversion for sub-zero temperatures (ASYMMETRIC
formula): the rounding used Python's floored%, which is always
non-negative, while the firmware/app uses a truncated remainder. Raw
-11(-5.5 °C) decoded to 22 °F instead of the app's 23 °F. Now
usesmath.fmodsemantics. - Fix freeze protection default limits: the defaults (43/65) were
Fahrenheit display values stored in raw half-Celsius fields, decoding
to 70.7 °F / 90.5 °F when the device omitted them. Corrected to raw
12/20 (43 °F / 50 °F), matching the documented fixed limits. - Emit error_detected when the error code changes: a transition
between two non-zero error codes (e.g. E799 → E407) emitted no event,
so consumers kept displaying the stale error. - Fix TOU price encoding:
encode_price()used banker's rounding,
under-encoding exact half values at even boundaries (0.125 at
decimal_point=2encoded to 12 instead of 13) — now uses
Decimalhalf-up rounding.build_tou_period()also treated
boolprices as pre-encoded integers (Truesent as price 1). - Fix protocol documentation errors:
decode_reservation_hex()
documented the enable flag inverted (1=enabled instead of 2=enabled);
thebuild_reservation_entry()example showedweek: 158for
Mon/Wed/Fri instead of the correct 84; temperature doctest examples
showedintraw values wherefloatis returned. - Run MQTT message dispatch on the event loop: JSON parsing, pydantic
model validation, and user callbacks all executed directly on the AWS
CRT network thread. A slow or blocking callback (e.g. the CLI monitor's
CSV writes) stalled all MQTT message processing, user callbacks ran on
an undocumented SDK thread where asyncio operations are unsafe, and the
handler registry could be mutated on the event loop while the CRT
thread iterated it (RuntimeError: dictionary changed size during iteration, dropping the message — most likely during the
reconnect/resubscribe window). The awscrt callback now only marshals
the raw payload onto the event loop; parsing and dispatch run there,
iterating snapshots of the handler registries. - Stop one raising handler from aborting message delivery: handler
dispatch caught only(TypeError, AttributeError, KeyError); a user
callback raising anything else (e.g.ValueError) escaped into the
awscrt callback machinery and skipped the remaining handlers for that
message. Individual handler failures are now logged and isolated. - Make the unit system preference process-wide:
set_unit_system()stored the preference in aContextVarset in
the caller's task. Tasks scheduled from AWS CRT callback threads never
inherit that context, so the preference silently reverted to
auto-detect for all MQTT-delivered data —nwp-cli --unit-system metric monitorlogged temperatures in the device's native unit while
labeling them °C. The preference is now a process-wide setting visible
from every task and thread. - Fix once-listeners firing more than once:
EventEmitter.emit()
removed one-time listeners only after invoking them, so a callback
that raised stayed registered forever, and two overlapping emits could
both fire the same once-listener. Once-listeners are now removed
before invocation. - Fix wait_for() leaking its listener on cancellation:
EventEmitter.wait_for()removed its listener only on timeout; a
cancelled waiter left the listener registered until the event next
fired, setting a result on a dead future. Cleanup now happens in a
finallyblock. - Fix wait_for() docstring examples: examples showed
args, _ = await emitter.wait_for(...), butwait_forreturns
just the args tuple — following the documented pattern raised
ValueErroror silently mis-assigned. - Serialize concurrent token refresh:
ensure_valid_token()and
refresh_token()had no lock, so concurrent callers (API 401 retry,
MQTT reconnect, periodic requests) at token expiry fired parallel
refresh requests; with token rotation the losers were left holding
invalidated tokens. Refreshes are now serialized behind an
asyncio.Lockwith a post-acquire re-check: callers that lose the
race receive the already-refreshed tokens, callers passing a stale
(pre-rotation) refresh token get the fresh tokens instead of a
guaranteed failure, and an explicit refresh with the current token
still forces a refresh (deep-reconnect behavior preserved). - Preserve refresh_token/id_token across refreshes: the refresh
response merge preserved AWS credential fields but not
refresh_token/id_token. A refresh response omitting them wiped
the stored refresh token, so the next refresh posted an empty string
and failed unconditionally. - Fix aiohttp session leak when authentication fails in
__aenter__: Python never calls__aexit__when__aenter__
raises, so bad credentials or a network error leaked the owned
ClientSession(one per retry attempt). The session is now closed
before the exception propagates. - Make
NavienAuthClient.__aenter__idempotent:
create_navien_clients()pre-enters the context and its docstring
instructs users to enter again withasync with auth:; the second
entry created a fresh session and orphaned the first — which the API
client was still pinned to. Re-entering now reuses the existing
session. - Fall back to full sign-in when stored-token refresh fails:
restoring expired stored tokens raisedTokenRefreshErroreven
though credentials for a fullsign_in()were available. - Stop pinning the auth session in the API client:
NavienAPIClientcapturedauth_client.sessionat construction
and kept using it after the auth client recreated its session
(RuntimeError: Session is closed). The session is now resolved per
request; an explicitly provided session still takes precedence. - Add HTTP timeouts to unguarded sessions: the standalone
refresh_access_token()helper andOpenEIClientcreated
ClientSessions without aClientTimeout; requests could hang
indefinitely. Both now use a 30-second total timeout. - Fix reconnection loop dying on authentication errors: the backoff
loop caught onlyAwsCrtErrorandRuntimeError, so
TokenRefreshError,AuthenticationError, and
MqttCredentialsErrorraised during quick/deep reconnection escaped
and silently killed the reconnect task. A routine outage coinciding
with token expiry left the client permanently offline despite unlimited
retries. All library errors (Nwp500Error) and operation timeouts
are now treated as failed attempts and retried; only
InvalidCredentialsErroris fatal and stops the loop with a
reconnection_failedevent. - Fix disconnect() being a no-op while the connection is interrupted:
callingdisconnect()during an interruption returned early without
disabling automatic reconnection or stopping periodic tasks, so the
backoff loop would resurrect the connection after the application shut
the client down.disconnect()now always disables reconnection and
stops periodic tasks, and tears down the SDK connection even when not
connected. - Fix queued commands being lost after active/deep reconnection: the
command queue was only flushed from the SDK'son_connection_resumed
callback, which never fires for the new connection built by
active/deep reconnection. Commands queued while offline were silently
dropped. Both reconnect paths now flush the queue after subscriptions
are restored. - Fix periodic request tasks dying on MQTT errors: the periodic loop
caught onlyAwsCrtErrorandRuntimeError;
MqttNotConnectedError/MqttPublishErrorraised by a publish
racing a disconnection permanently killed the polling task while it
still appeared active. The loop now survives all library errors. - Fix silent failures in thread-scheduled coroutines: futures
returned byrun_coroutine_threadsafewere discarded, so exceptions
from scheduled work (e.g. a failed resubscribe after a clean-session
resume, leaving the client connected but deaf) vanished. A done
callback now logs them. - Fix CancelledError being swallowed in reconnection and periodic
loops: both loops caughtasyncio.CancelledErrorandbreak-ed,
so cancelled tasks ended "successfully" (and the reconnection loop
could emitreconnection_failedduring a manual disconnect).
Cancellation now propagates correctly. - Fix AttributeError in configure_reservation_water_program: The
NavienMqttClientproxy referencedself._control, which is never
assigned (the attribute is_device_controller), so every call raised
AttributeError. Now delegates correctly; a regression test guards all
proxies against references to the undefined attribute. - Fix broken CLI mode choices:
nwp-cli mode vacationalways failed
because vacation mode (5) requires a day count that was never supplied, and
mode standbysent the invalid writable mode value0. Both choices
were removed from themodecommand; use the dedicatedvacation DAYS
andpower offcommands instead. - Fix CLI exit codes: click ignores command return values in standalone
mode, so the CLI always exited0even when a command failed. Failures
now propagate throughctx.exit()and produce a non-zero exit code for
scripts and automation. - Fix cached tokens being reused for a different account: passing
--emailfor account B while tokens for account A were cached silently
ran commands against account A's session. Cached tokens are now discarded
when the provided email does not match the cached one. - Surface OpenEI application errors: OpenEI reports errors such as an
invalid API key in the body of an HTTP 200 response; these were masked as
"no rate plans found".fetch_rates()now raisesAPIErrorwith the
API's error message. - Fix broken example import:
examples/advanced/mqtt_diagnostics.py
importedMqttConnectionConfigfrom the nonexistentnwp500.mqtt_utils
module and used the deprecateddatetime.utcnow().
Improvements
- MQTT operation acknowledgement timeouts: connect, publish,
subscribe, unsubscribe, and disconnect acknowledgements are now awaited
with a timeout (MqttConnectionConfig.operation_timeout, default 30
seconds). Previously a half-open TCP connection could hang callers
until the 20-minute keep-alive expired. - Reconnection backoff jitter: reconnect delays are now randomized
(±50%, capped atmax_reconnect_delay) so fleets of clients
disconnected simultaneously (e.g. the AWS IoT 24-hour disconnect) no
longer reconnect in synchronized waves.
Security
- Restrict token cache file permissions:
~/.nwp500_tokens.json
(refresh token and AWS credentials) was written world-readable. It is now
created with mode0600, and existing files are tightened on save.