Releases: eman/nwp500-python
Release list
v9.2.0
Changed
-
CLI formatting stacks merged; Rich is the sole human renderer
(#100 <https://github.com/eman/nwp500-python/issues/100>_): a new
src/nwp500/cli/presentation.pyowns all data-shaping for CLI output
(field selection, labels, units, ordering, value formatting and energy
aggregation) as presentation-neutral structures. Because the CLI
hard-requiresrich(there is no plain-text fallback), the redundant
plain-text human renderer and the Rich-vs-plain fallback machinery were
removed:rich_output.pyno longer contains_should_use_rich,
_rich_available, theNWP500_NO_RICHtoggle, or any
_print_*_plainmethods, and now renders the neutral structures
(including the energyTOTAL SUMMARY) exclusively with Rich, consuming
the presentation dataclasses directly instead of dict adapters.
output_formatters.pykeeps only JSON/CSV rendering plus thin
human-output dispatch. Net CLI code drops from ~2088 to ~1838 lines with a
single data-shaping layer and a single human renderer. Non-energy output
is byte-for-byte unchanged (verified by golden capture and the existing
CLI tests); energy output now renders a summary table plus a breakdown
table entirely in Rich rather than printing plain text and a Rich table. -
mqtt/client.py slimmed toward a thin façade (
#99 <https://github.com/eman/nwp500-python/issues/99>_): the ~40 device
control command proxies and the typedsubscribe_*/unsubscribe_*
device-subscription proxies were moved out ofNavienMqttClientinto
two focused mixins,DeviceControlCommandsMixin
(mqtt/_control_commands.py) andDeviceSubscriptionsMixin
(mqtt/_device_subscriptions.py).NavienMqttClientnow inherits
both, so its public API is unchanged, whilemqtt/client.pyshrinks
from ~1572 to ~1196 lines and reads more clearly as connection
orchestration plus a public façade. No behavior change. -
Event system relationship clarified and de-duplicated (
#102 <https://github.com/eman/nwp500-python/issues/102>_):events.pyand
mqtt_events.pyare not two competing event mechanisms.
EventEmitter(events.py) is the sole delivery mechanism, while
mqtt_events.pyprovides the event-name registry
(MqttClientEvents) and the typed dataclass payloads it carries.
Internalemitcall sites inmqtt/state_tracker.py,
mqtt/client.pyandmqtt/subscriptions.pynow reference the
MqttClientEventsconstants instead of duplicating the raw event-name
strings, so each event name is defined in exactly one place. Module
docstrings and the event-system reference docs were updated to document
the relationship, and typed-payload delivery is now covered by tests. -
awscrt types wrapped out of public MQTT signatures (
#101 <https://github.com/eman/nwp500-python/issues/101>_): the library no
longer exposesawscrtSDK types on its own public/semi-public API
surface. A library-ownednwp500.mqtt.QoSIntEnum(also exported
asnwp500.QoS) now replacesawscrt.mqtt.QoSon thepublish
andsubscribemethods ofNavienMqttClient,MqttConnection
andMqttSubscriptionManager, onMqttCommandQueue.enqueueand on
theQueuedCommanddataclass.awscrt.mqtt.Connectionhandles are
typed behind theMqttConnectionHandlealias, and translation to/from
awscrthappens only at the connection-layer boundary
(nwp500/mqtt/types.py).NavienMqttClient.publishnow wraps
otherwise-uncaughtAwsCrtErrorinMqttPublishErrorsoawscrt
exceptions no longer leak out of the public boundary... code-block:: python
OLD
from awscrt import mqtt
await client.publish(topic, payload, qos=mqtt.QoS.AT_LEAST_ONCE)NEW
from nwp500 import QoS
await client.publish(topic, payload, qos=QoS.AT_LEAST_ONCE)
Fixed
- MQTT ack futures now consumed on abandonment (
#97 <https://github.com/eman/nwp500-python/issues/97>_):_await_ack()
inmqtt/connection.pyand the inline subscribe/unsubscribe
acknowledgement waits inmqtt/subscriptions.pyshield the AWS CRT
future so a timeout or cancellation doesn't propagate into the SDK
future. Previously, if the shielded future later completed with an
exception (e.g.AwsCrtErrorfrom clean-session cancellation during
reconnect) after the awaiting task had already given up, nobody
retrieved that exception, and asyncio logged a "Future exception was
never retrieved" warning at garbage-collection time. A done callback
is now attached whenever a wait is abandoned so the eventual
result/exception is always retrieved and logged at debug level
instead of leaking as an unhandled asyncio warning.
Documentation
- Unit-system preference is a deliberate process-wide global (
#103 <https://github.com/eman/nwp500-python/issues/103>_): clarified and
locked in that the preference innwp500.unit_systemis an intentional
process-wide module-level global rather than a
contextvars.ContextVar. A context-local value silently reverted to
auto-detect for real-time data because MQTT message handling runs in tasks
scheduled from AWS CRT callback threads whose context never inherits from
the application task. Added prominent docstring notes to the affected
models (DeviceStatus,DeviceFeature,ReservationEntry,
WeeklyReservationEntry, andTOUPeriod) explaining that unit-aware
computed fields read the preference at access time and share it across all
async tasks and threads. This is a non-breaking documentation and test
change; no API changes.
Added
- Tests for process-wide unit-system semantics (
#103 <https://github.com/eman/nwp500-python/issues/103>_): new
tests/test_unit_system_process_wide.pyverifies that setting the global
preference affects already-constructed model instances at access time and
that the preference is visible across async tasks and threads.
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
``asynci...
v8.1.3
Bug Fixes
- Fix MQTT reconnection storm caused by non-thread-safe Task.cancel(): The
on_connection_resumedcallback is invoked from an AWS IoT SDK background
thread. It was callingasyncio.Task.cancel()directly on that thread, which
is not thread-safe. When the event loop was busy at the moment of cancellation
(e.g. the sleeping task's timer callback had already been enqueued), the
cancellation was silently dropped. The stale_reconnect_with_backofftask
would then complete its sleep, call_reconnect_func, and tear down an
otherwise healthy connection — restarting the entire disconnect → reconnect →
AWS_ERROR_MQTT_UNEXPECTED_HANGUPcycle. Fixed by replacing the direct
task.cancel()call with a_cancel_pending_reconnect()coroutine scheduled
via_schedule_coroutine, so the cancellation runs on the event loop where
asyncio operations are safe. The method also uses an identity check before
clearing_reconnect_taskto avoid wiping a newer task created during the
await, and clears stale references to already-done tasks.
v8.1.2
v8.1.1
v8.1.0
Bug Fixes
-
Fix MQTT connection flapping after reconnect: When
_active_reconnect()
created a newMqttConnection, the old connection was never closed. The old
SDK connection's built-in auto-reconnect would eventually succeed, creating two
active connections sharing the same client ID. Because AWS IoT allows only one
connection per client ID, the broker would kick one off, triggering
on_connection_interruptedand starting yet another reconnection — an
infinite connect/disconnect loop. Fixed by addingMqttConnection.close()
(unconditional teardown regardless of_connectedstate) and calling it
before creating the replacement connection in both_active_reconnect()and
_deep_reconnect(). -
Thread-safety race in
ensure_device_info_cached: Thefuture.done()
check andfuture.set_result()were performed in the AWS SDK callback thread
without synchronisation, creating a race against the asyncio event loop thread.
Moved both operations inside acall_soon_threadsafecallback so they execute
atomically on the event loop thread. -
ZeroDivisionError when
deep_reconnect_thresholdis 0: Config validation
now clampsdeep_reconnect_thresholdto a minimum of 1, preventing a
ZeroDivisionErrorin the exponential-backoff reconnection logic. -
Reconnect counter never incremented:
total_reconnect_attemptsin
diagnostics was not incremented on connection drops, so it always reported 0
despite active reconnections. Counter is now incremented on each
on_connection_interruptedevent. -
shortest_session_secondsnot JSON-serialisable: The diagnostics
to_dict()method usedfloat('inf')as the initial value for
shortest_session_seconds, which is not valid JSON. Changed toNone
so serialisation succeeds when no session has completed yet. -
wait_for()future not bound to running loop:wait_for()created a
bareasyncio.Future()rather than
asyncio.get_running_loop().create_future(), which could bind the future to
a different loop in multi-loop test setups. -
Reservation temperature validation was US-only:
build_reservation_entry
validated set-point temperatures against hardcoded Fahrenheit bounds (95–150 °F)
regardless of the active unit system. Validation now uses the current unit system
context: 35–65 °C in metric mode, 95–150 °F in US mode. Celsius users previously
received spuriousValueErrorrejections for valid temperatures. -
Malformed reservation data silently dropped:
build_reservation_entrynow
logs a warning when reservation hex data contains unexpected trailing bytes
instead of silently dropping partial entries. -
Unknown
PeriodicRequestTypesilently ignored: The periodic-request handler
now logs an error and breaks when it encounters an unknown request type instead of
doing nothing. -
Memory leak in device info cache:
get_all_cached()only filtered expired
entries from its return value but left them in the cache dictionary. Expired
entries are now evicted duringget_all_cached()to prevent unbounded growth.
v8.0.0
v7.4.10
Changed
- Loosened pydantic version requirement: Changed from
pydantic>=2.12.5to
pydantic>=2.0.0to resolve dependency conflicts with Home Assistant, which
ships withpydantic==2.12.2.
v7.4.9
Added
- Firmware Payload Capture Tool: New example script
examples/advanced/firmware_payload_capture.pyfor capturing raw MQTT
payloads to detect firmware-introduced protocol changes. Subscribes to all
response and event topics via wildcards, requests the full scheduling
data set (weekly reservations, TOU, device info), and saves everything to a
timestamped JSON file suitable forjq/diffcomparison across firmware
versions.
Fixed
- Timezone-naive datetime in token expiry checks:
AuthTokens.is_expired,
are_aws_credentials_expired, andtime_until_expiryused
datetime.now()(naive, local time). During DST transitions or timezone
changes this could cause incorrect expiry detection, leading to premature
re-authentication or use of an actually-expired token. Fixed by using
datetime.now(UTC)throughout, switching theissued_atfield default
todatetime.now(UTC), and adding a field validator to normalize any
timezone-naiveissued_atvalues loaded from old stored token files to UTC
(previously this would raise aTypeErrorat comparison time). The
validator was further extended to also handle ISO 8601 strings without
timezone info (e.g."2026-02-17T14:47:01.686943"), which is the actual
format written byto_dict()for tokens stored before this fix. - Vacation mode sent wrong MQTT command:
set_vacation_days()used
CommandCode.GOOUT_DAY(33554466), which the device silently accepted
but did not activate vacation mode — the operating mode remained unchanged.
HAR capture of the official Navien app confirms the correct command is
DHW_MODE(33554437) withparam=[5, days]
(DhwOperationSetting.VACATION). The valid range has also been corrected
from 1–365 to 1–30 to match the device's actual constraint. - Duplicate AWS IoT subscribe calls on reconnect:
resubscribe_all()
calledconnection.subscribe()(a network round-trip to AWS IoT) once per
handler per topic. If a topic had N handlers, N identical subscribe requests
were sent on every reconnect. Fixed by making one network call per unique
topic and registering remaining handlers directly into_message_handlers. - Anti-Legionella set-period State Preservation:
nwp-cli anti-legionella set-periodwas callingenable_anti_legionella()in both the enabled and
disabled branches, silently re-enabling the feature when it was off. The
command now informs the user that the period can only be updated while the
feature is enabled and directs them toanti-legionella enable. - Subscription State Lost After Failed Resubscription:
resubscribe_all()
cleared_subscriptionsand_message_handlersbefore the re-subscribe
loop. Topics that failed to resubscribe were permanently dropped from internal
state and could not be retried on the next reconnection. Failed topics are now
restored so they are retried automatically. - Unit System Detection Returns None on Timeout:
_detect_unit_system()
declared return typeUnitSystemTypebut returnedNoneon
TimeoutError, violating the type contract. Now returns
"us_customary"consistent with the warning message. - Once-Listener Becomes Permanent With Duplicate Callbacks:
emit()
identified once-listeners via asetof(event, callback)tuples. If
the same callback was registered twice withonce=True, the set
deduplicated the tuple — after the first emit the second listener lost its
once-status and became permanent. Fixed by checkinglistener.once
directly on theEventListenerobject. - Auth Session Leaked on Client Construction Failure: In
create_navien_clients(), ifNavienAPIClientor
NavienMqttClientconstruction raised after a successful
auth_client.__aenter__(), the auth session and its underlying
aiohttpsession would leak. Client construction is now wrapped in a
try/exceptthat callsauth_client.__aexit__()on failure.
Additionally, bothexcept BaseExceptionblocks have been replaced with
except Exception(passing real exception info to__aexit__) plus a
separateexcept asyncio.CancelledErrorblock that uses
asyncio.shield()to ensure cleanup completes even when the task is
being cancelled. - Hypothesis Tests Broke All Test Collection:
test_mqtt_hypothesis.py
importedhypothesisat module level; when it was not installed, pytest
failed to collect every test in the suite.hypothesisis now mandated
as a[testing]extra dependency, restoring correct collection behaviour.
Changed
- Dependency updates: Bumped minimum versions to track current releases:
aiohttp >= 3.13.5,pydantic >= 2.12.5,click >= 8.3.0,
rich >= 14.3.0. - Dependency: awsiotsdk >= 1.28.2: Bumped minimum
awsiotsdkversion
from>=1.27.0to>=1.28.2to track the current patch release.
awscrt0.31.3 is pulled in transitively.
v7.4.8
Added
- Reservation CRUD Helpers: New public functions
fetch_reservations(),
add_reservation(),delete_reservation(), andupdate_reservation()
innwp500.reservations(and exported fromnwp500). These abstract the
read-modify-write pattern for single-entry schedule management so library
users no longer need to fetch the full schedule, splice it manually, and send
it back. The CLI now delegates to these library functions.