Skip to content

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
    asyncio.Lock with 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 with async 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 raised TokenRefreshError even
    though credentials for a full sign_in() were available.
  • Stop pinning the auth session in the API client:
    NavienAPIClient captured auth_client.session at 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 and OpenEIClient created
    ClientSessions without a ClientTimeout; requests could hang
    indefinitely. Both now use a 30-second total timeout.
  • Fix reconnection loop dying on authentication errors: the backoff
    loop caught only AwsCrtError and RuntimeError, so
    TokenRefreshError, AuthenticationError, and
    MqttCredentialsError raised 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
    InvalidCredentialsError is fatal and stops the loop with a
    reconnection_failed event.
  • Fix disconnect() being a no-op while the connection is interrupted:
    calling disconnect() 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's on_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 only AwsCrtError and RuntimeError;
    MqttNotConnectedError/MqttPublishError raised 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 by run_coroutine_threadsafe were 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 caught asyncio.CancelledError and break-ed,
    so cancelled tasks ended "successfully" (and the reconnection loop
    could emit reconnection_failed during a manual disconnect).
    Cancellation now propagates correctly.
  • Fix AttributeError in configure_reservation_water_program: The
    NavienMqttClient proxy referenced self._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 vacation always failed
    because vacation mode (5) requires a day count that was never supplied, and
    mode standby sent the invalid writable mode value 0. Both choices
    were removed from the mode command; use the dedicated vacation DAYS
    and power off commands instead.
  • Fix CLI exit codes: click ignores command return values in standalone
    mode, so the CLI always exited 0 even when a command failed. Failures
    now propagate through ctx.exit() and produce a non-zero exit code for
    scripts and automation.
  • Fix cached tokens being reused for a different account: passing
    --email for 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 raises APIError with the
    API's error message.
  • Fix broken example import: examples/advanced/mqtt_diagnostics.py
    imported MqttConnectionConfig from the nonexistent nwp500.mqtt_utils
    module and used the deprecated datetime.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 at max_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 mode 0600, and existing files are tightened on save.