Skip to content

Releases: eman/nwp500-python

v9.2.0

Choose a tag to compare

@github-actions github-actions released this 06 Jul 17:39

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.py owns all data-shaping for CLI output
    (field selection, labels, units, ordering, value formatting and energy
    aggregation) as presentation-neutral structures. Because the CLI
    hard-requires rich (there is no plain-text fallback), the redundant
    plain-text human renderer and the Rich-vs-plain fallback machinery were
    removed: rich_output.py no longer contains _should_use_rich,
    _rich_available, the NWP500_NO_RICH toggle, or any
    _print_*_plain methods, and now renders the neutral structures
    (including the energy TOTAL SUMMARY) exclusively with Rich, consuming
    the presentation dataclasses directly instead of dict adapters.
    output_formatters.py keeps 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 typed subscribe_*/unsubscribe_*
    device-subscription proxies were moved out of NavienMqttClient into
    two focused mixins, DeviceControlCommandsMixin
    (mqtt/_control_commands.py) and DeviceSubscriptionsMixin
    (mqtt/_device_subscriptions.py). NavienMqttClient now inherits
    both, so its public API is unchanged, while mqtt/client.py shrinks
    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.py and
    mqtt_events.py are not two competing event mechanisms.
    EventEmitter (events.py) is the sole delivery mechanism, while
    mqtt_events.py provides the event-name registry
    (MqttClientEvents) and the typed dataclass payloads it carries.
    Internal emit call sites in mqtt/state_tracker.py,
    mqtt/client.py and mqtt/subscriptions.py now reference the
    MqttClientEvents constants 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 exposes awscrt SDK types on its own public/semi-public API
    surface. A library-owned nwp500.mqtt.QoS IntEnum (also exported
    as nwp500.QoS) now replaces awscrt.mqtt.QoS on the publish
    and subscribe methods of NavienMqttClient, MqttConnection
    and MqttSubscriptionManager, on MqttCommandQueue.enqueue and on
    the QueuedCommand dataclass. awscrt.mqtt.Connection handles are
    typed behind the MqttConnectionHandle alias, and translation to/from
    awscrt happens only at the connection-layer boundary
    (nwp500/mqtt/types.py). NavienMqttClient.publish now wraps
    otherwise-uncaught AwsCrtError in MqttPublishError so awscrt
    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()
    in mqtt/connection.py and the inline subscribe/unsubscribe
    acknowledgement waits in mqtt/subscriptions.py shield 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. AwsCrtError from 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 in nwp500.unit_system is 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, and TOUPeriod) 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.py verifies 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

Choose a tag to compare

@github-actions github-actions released this 05 Jul 17:45

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 annotations project-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, and PERF,
    with documented ignores for intentional patterns (grouped __all__
    lists, unicode degree signs, public timeout parameters). 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, a ClassVar annotation on the capability map, and
    dangling asyncio.create_task references in tests.
  • PeriodicRequestType is now a StrEnum (was a plain Enum with
    string values), matching the enum style used elsewhere.
  • Event payload dataclasses use slots=True: the frozen event
    dataclasses in mqtt_events.py and events.EventListener are
    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 .control property: 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, and DeviceOperationError. 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-exports requires_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
    unused converters.str_enum_validator, the unused module-level
    temperature.half_celsius_to_fahrenheit /
    deci_celsius_to_fahrenheit wrappers, the no-op
    NavienMqttClient._on_message_received placeholder, and unused
    width calculations in the CLI formatters.

Changed

  • Temperature classes deduplicated: HalfCelsius, DeciCelsius,
    RawCelsius, and DeciCelsiusDelta were four near-identical
    copies differing only in a scale constant. All conversions are now
    implemented once on the Temperature base 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.py now uses
    converters.device_bool_from_python() instead of inline
    2 if enabled else 1 literals.
  • Version resolution decoupled: auth.py resolves the package
    version from distribution metadata instead of from . 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-converted temperature alongside the raw
    half-Celsius param — into device commands. Both now send the flat,
    raw protocol shape used by update_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 before power_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 than MqttConnectionConfig.max_queued_command_age
    (default 300 s, None to 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
    uses math.fmod semantics.
  • 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=2 encoded to 12 instead of 13) — now uses
    Decimal half-up rounding. build_tou_period() also treated
    bool prices as pre-encoded integers (True sent as price 1).
  • Fix protocol documentation errors: decode_reservation_hex()
    documented the enable flag inverted (1=enabled instead of 2=enabled);
    the build_reservation_entry() example showed week: 158 for
    Mon/Wed/Fri instead of the correct 84; temperature doctest examples
    showed int raw values where float is 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 a ContextVar set 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 monitor logged 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
    finally block.
  • Fix wait_for() docstring examples: examples showed
    args, _ = await emitter.wait_for(...), but wait_for returns
    just the args tuple — following the documented pattern raised
    ValueError or 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...
Read more

v8.1.3

Choose a tag to compare

@github-actions github-actions released this 15 Jun 21:41

Bug Fixes

  • Fix MQTT reconnection storm caused by non-thread-safe Task.cancel(): The
    on_connection_resumed callback is invoked from an AWS IoT SDK background
    thread. It was calling asyncio.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_backoff task
    would then complete its sleep, call _reconnect_func, and tear down an
    otherwise healthy connection — restarting the entire disconnect → reconnect →
    AWS_ERROR_MQTT_UNEXPECTED_HANGUP cycle. 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_task to avoid wiping a newer task created during the
    await, and clears stale references to already-done tasks.

v8.1.2

Choose a tag to compare

@github-actions github-actions released this 25 May 20:56
Release version 8.1.2

v8.1.1

Choose a tag to compare

@github-actions github-actions released this 18 May 20:10
Release version 8.1.1

v8.1.0

Choose a tag to compare

@github-actions github-actions released this 17 May 00:07

Bug Fixes

  • Fix MQTT connection flapping after reconnect: When _active_reconnect()
    created a new MqttConnection, 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_interrupted and starting yet another reconnection — an
    infinite connect/disconnect loop. Fixed by adding MqttConnection.close()
    (unconditional teardown regardless of _connected state) and calling it
    before creating the replacement connection in both _active_reconnect() and
    _deep_reconnect().

  • Thread-safety race in ensure_device_info_cached: The future.done()
    check and future.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 a call_soon_threadsafe callback so they execute
    atomically on the event loop thread.

  • ZeroDivisionError when deep_reconnect_threshold is 0: Config validation
    now clamps deep_reconnect_threshold to a minimum of 1, preventing a
    ZeroDivisionError in the exponential-backoff reconnection logic.

  • Reconnect counter never incremented: total_reconnect_attempts in
    diagnostics was not incremented on connection drops, so it always reported 0
    despite active reconnections. Counter is now incremented on each
    on_connection_interrupted event.

  • shortest_session_seconds not JSON-serialisable: The diagnostics
    to_dict() method used float('inf') as the initial value for
    shortest_session_seconds, which is not valid JSON. Changed to None
    so serialisation succeeds when no session has completed yet.

  • wait_for() future not bound to running loop: wait_for() created a
    bare asyncio.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 spurious ValueError rejections for valid temperatures.

  • Malformed reservation data silently dropped: build_reservation_entry now
    logs a warning when reservation hex data contains unexpected trailing bytes
    instead of silently dropping partial entries.

  • Unknown PeriodicRequestType silently 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 during get_all_cached() to prevent unbounded growth.

v8.0.0

Choose a tag to compare

@github-actions github-actions released this 14 May 04:06

Release 8.0.0

v7.4.10

Choose a tag to compare

@github-actions github-actions released this 13 Apr 15:14

Changed

  • Loosened pydantic version requirement: Changed from pydantic>=2.12.5 to
    pydantic>=2.0.0 to resolve dependency conflicts with Home Assistant, which
    ships with pydantic==2.12.2.

v7.4.9

Choose a tag to compare

@eman eman released this 13 Apr 06:00
21f97b7

Added

  • Firmware Payload Capture Tool: New example script
    examples/advanced/firmware_payload_capture.py for 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 for jq/diff comparison across firmware
    versions.

Fixed

  • Timezone-naive datetime in token expiry checks: AuthTokens.is_expired,
    are_aws_credentials_expired, and time_until_expiry used
    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 the issued_at field default
    to datetime.now(UTC), and adding a field validator to normalize any
    timezone-naive issued_at values loaded from old stored token files to UTC
    (previously this would raise a TypeError at 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 by to_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) with param=[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()
    called connection.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-period was calling enable_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 to anti-legionella enable.
  • Subscription State Lost After Failed Resubscription: resubscribe_all()
    cleared _subscriptions and _message_handlers before 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 type UnitSystemType but returned None on
    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 a set of (event, callback) tuples. If
    the same callback was registered twice with once=True, the set
    deduplicated the tuple — after the first emit the second listener lost its
    once-status and became permanent. Fixed by checking listener.once
    directly on the EventListener object.
  • Auth Session Leaked on Client Construction Failure: In
    create_navien_clients(), if NavienAPIClient or
    NavienMqttClient construction raised after a successful
    auth_client.__aenter__(), the auth session and its underlying
    aiohttp session would leak. Client construction is now wrapped in a
    try/except that calls auth_client.__aexit__() on failure.
    Additionally, both except BaseException blocks have been replaced with
    except Exception (passing real exception info to __aexit__) plus a
    separate except asyncio.CancelledError block 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
    imported hypothesis at module level; when it was not installed, pytest
    failed to collect every test in the suite. hypothesis is 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 awsiotsdk version
    from >=1.27.0 to >=1.28.2 to track the current patch release.
    awscrt 0.31.3 is pulled in transitively.

v7.4.8

Choose a tag to compare

@github-actions github-actions released this 17 Feb 23:09

Added

  • Reservation CRUD Helpers: New public functions fetch_reservations(),
    add_reservation(), delete_reservation(), and update_reservation()
    in nwp500.reservations (and exported from nwp500). 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.