Skip to content

Release v5.10.0-beta - #70

Merged
tis24dev merged 52 commits into
mainfrom
dev
Jul 30, 2026
Merged

Release v5.10.0-beta#70
tis24dev merged 52 commits into
mainfrom
dev

Conversation

@tis24dev

@tis24dev tis24dev commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Automated release PR for v5.10.0-beta.

Summary by Sourcery

Add transactional command dispatching, detailed command diagnostics, and air purifier (AP) support (fan, light, switches, aroma control, experimental entities, and diagnostics) while tightening options handling, Home Assistant stubs, and release tagging.

New Features:

  • Introduce full Home Assistant support for air purifiers, including sensors, binary sensors, fan, light, switches, aroma select, timing numbers, and translations for all new entities.
  • Add an experimental options toggle that gates AP experimental entities and behaviors without affecting standard functionality.
  • Provide passive diagnostics for future AP capabilities by capturing unmapped command parameters, enum value deltas, and unhandled live states.

Bug Fixes:

  • Fix the transactional dispatcher to canonicalize exact payloads, roll back only its own parameter mutations, and leave concurrent MQTT-driven updates intact on failure.
  • Harden command diagnostics and MQTT correlation to avoid misattributing updates, handle deep or cyclic structures safely, and strictly bound logged data and identity exposure.
  • Ensure options updates reapply log levels only when debug toggles change, reload entries only when experimental support changes, and preserve unknown option keys across updates.
  • Correct Home Assistant test stubs to avoid clobbering shared base classes or incomplete enums that previously caused order-dependent or missing-device-class issues.
  • Refine release tag handling so beta and numbered beta tags are correctly recognized and never published as stable releases.

Enhancements:

  • Extend the Hon client to expose a dedicated CommandDispatcher and synchronous patch-dispatch helper that run patches on the internal event loop.
  • Enhance engine commands and appliances with canonical exact payload handling and targeted shadow sync helpers to support transactional dispatch paths.
  • Improve diagnostics output with richer per-appliance blocks, including AP-specific coverage and future capability signals, while maintaining strict redaction.
  • Tighten entity translation tests to enforce option-screen parity, AP key lists per platform, capitalization/style rules, and semantic constraints on labels.

Build:

  • Update the release-policy script to support numbered beta tags and drive prerelease detection with a regex-based beta suffix matcher.

Tests:

  • Add extensive unit and integration-style tests for the transactional dispatcher, command diagnostics, MQTT correlation, AP entities, options flow, diagnostics coverage, translations, and stub hygiene.
  • Introduce contract fixtures and tests that exercise AP intents and dispatcher behavior end-to-end against real engine command objects and snapshots.

Summary by CodeRabbit

  • New Features
    • Added support for air purifier devices, including fan modes, panel-light brightness, aroma controls (including custom timing), and additional controls/sensors/diagnostics.
    • Added an “Experimental” option to enable extra entities (including experimental CO alarm and custom aroma timing numbers).
    • Improved UI translations for the new air purifier controls, states, and error messages.
  • Bug Fixes
    • Improved reliability of command dispatch, rollback correctness, MQTT update correlation, and concurrency handling.
  • Release/Workflow
    • Beta release tags now support optional numeric suffixes (e.g., -beta1, -beta2).

Greptile Summary

This release adds transactional command dispatch and comprehensive air-purifier support while refining diagnostics, options, test infrastructure, and beta-release handling.

  • Adds capability-gated air-purifier fan, light, switch, select, number, sensor, and binary-sensor entities.
  • Introduces transactional command patches with targeted rollback, refresh, and bounded diagnostics.
  • Adds an experimental-feature option and preserves unrelated option keys during updates.
  • Expands beta tag recognition to support both -beta and numbered -betaN releases.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the boolean power-state handling now canonicalizes unwrapped booleans, and explicit unsupported percentages are rejected without affecting ordinary turn-on calls.

Important Files Changed

Filename Overview
custom_components/addhon/fan.py Adds the capability-gated purifier fan and correctly canonicalizes boolean power state while rejecting unsupported percentage requests.
custom_components/addhon/air_purifier.py Centralizes purifier capability discovery, raw-value normalization, mappings, and sparse command-intent construction.
custom_components/addhon/command_dispatch.py Adds transactional sparse-patch dispatch with scoped rollback, command correlation, and refresh behavior.
custom_components/addhon/diagnostics.py Extends bounded, redacted diagnostics with purifier capability coverage and unhandled-value signals.
custom_components/addhon/config_flow.py Adds experimental-feature configuration with targeted reload and option preservation.
.github/scripts/release-policy.sh Recognizes bare and numbered beta suffixes and consistently classifies them as prereleases.

Sequence Diagram

sequenceDiagram
    participant HA as Home Assistant Entity
    participant Intent as AP Intent Builder
    participant Dispatcher as Command Dispatcher
    participant Engine as Hon Command Engine
    participant Device as Cloud / Appliance
    HA->>Intent: Build capability-validated patch
    Intent-->>HA: Sparse command patch
    HA->>Dispatcher: Dispatch patch
    Dispatcher->>Engine: Apply transactional parameters
    Engine->>Device: Send canonical payload
    alt command succeeds
        Device-->>Engine: Success
        Engine-->>Dispatcher: Updated shadow
        Dispatcher-->>HA: Refresh coordinator
    else command fails
        Device-->>Engine: Error
        Engine-->>Dispatcher: Failure
        Dispatcher->>Engine: Roll back owned mutations
        Dispatcher-->>HA: Localized command error
    end
Loading

Reviews (3): Last reviewed commit: "chore: mark an unused loop variable as u..." | Re-trigger Greptile

tis24dev added 30 commits July 28, 2026 09:44
Rollback blind-restored the whole shadow and every command's parameters
from a pre-send snapshot, even though the exact-send path never mutates
the shadow before commit and the transaction only ever mutates the one
command _prepare() actually touched. The awscrt MQTT callback runs on
its own thread outside the dispatcher's lock and can update those same
objects while send_exact is awaited; on failure, the old rollback threw
that authoritative update away.

Shadow is no longer restored on rollback (dispatch never owns it
pre-commit). Command parameters now use compare-and-restore: a second
snapshot taken right after _prepare() (before the await) lets rollback
tell the transaction's own write apart from a concurrent one, and only
undoes what it can prove is still its own.
…rsal

observe_mqtt_update matched the first FIFO pending command sharing ANY
expected key with an incoming push, so an older command sharing only a
mandatory field (common to every command) could consume a push that
belonged to a newer, more fully-confirmed one. It now scores every
pending entry by how many expected key/value pairs the push actually
confirms and keeps the best match, with FIFO order breaking ties.

emit_command_event ran two full, unbounded redact_identity passes plus
a full unbounded set-materialization pass BEFORE _bound ever applied
its depth/size limits, so a cyclic or very deep payload silently
dropped the event (RecursionError) and a large mapping/set paid for a
full sort before being trimmed. _bound is now the single traversal: it
caps recursion depth, breaks cycles via a path-scoped id set, and
samples every collection through islice before sorting, so only a
bounded slice of a huge collection is ever touched. redact_identity
(a widely shared helper, left untouched) now runs once, after
bounding, on data already guaranteed small.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @tis24dev, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds air purifier capability discovery, sensors and controls, transactional command dispatch, MQTT diagnostics, experimental feature gating, beta tag support, translations, fixtures, and broad regression coverage. It also records campaign progress, validation constraints, and repository tooling changes.

Changes

Air purifier support

Layer / File(s) Summary
Capability model and entity controls
custom_components/addhon/air_purifier.py, custom_components/addhon/{sensor,binary_sensor,fan,light,select,number,switch}.py
Adds schema-driven air purifier capabilities, normalized value handling, capability-gated entities, transactional writes, and availability rules.
Experimental configuration and translations
custom_components/addhon/{config_flow.py,__init__.py,const.py}, custom_components/addhon/translations/*
Adds the experimental option, targeted reload behavior, platform registration, persisted state keys, and English/Italian entity and exception translations.

Transactional dispatch and diagnostics

Layer / File(s) Summary
Command dispatch and engine integration
custom_components/addhon/command_dispatch.py, custom_components/addhon/client/engine/*, custom_components/addhon/param_rollback.py, custom_components/addhon/hon_client.py
Adds exact payload dispatch, per-appliance locking, rollback of owned writes, targeted shadow synchronization, and client-loop integration.
MQTT and diagnostic processing
custom_components/addhon/command_diagnostics.py, custom_components/addhon/client/transport/mqtt.py, custom_components/addhon/diagnostics.py, custom_components/addhon/debug_utils.py
Adds comparable value normalization, MQTT expectation correlation, bounded diagnostic output, and air-purifier future-capability reporting.

Validation and repository support

Layer / File(s) Summary
Contract and regression coverage
tests/test_air_purifier_contracts.py, tests/test_air_purifier_entities.py, tests/test_command_dispatch.py, tests/test_diagnostics.py, tests/test_transport_mqtt.py, tests/test_release_policy.py
Covers capability contracts, entity behavior, transactional writes, rollback, concurrency, diagnostics, translations, and numbered beta tags.
Test infrastructure and compatibility guards
tests/conftest.py, tests/contract_fixtures.py, tests/test_stub_hygiene.py, tests/test_*
Adds shared Home Assistant stubs, normalized fixture validation, import-order-safe stub installation, main-guard checks, and compatibility updates.
Release and campaign records
.github/scripts/release-policy.sh, .github/workflows/release-intake.yml, .gitignore, docs/release-workflow.md, .superpowers/sdd/..., custom_components/addhon/manifest.json
Expands beta tag formats, centralizes format messages, updates intake documentation, ignores local artifacts, records campaign status, and sets version 5.10.0-beta.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: telard-pixel

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the release-focused changeset and identifies the promoted version.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds full air purifier (AP) support and experimental diagnostics/controls, wires those features through the transactional command dispatcher and MQTT correlation/diagnostics, tightens Home Assistant stubs and options handling, extends diagnostics and release-tag policy, and bumps the integration to v5.10.0-beta.

Sequence diagram for AP command dispatch, diagnostics, and MQTT correlation

sequenceDiagram
    actor User
    participant HA as HomeAssistant_platform
    participant APMod as air_purifier.ap_patch
    participant Disp as CommandDispatcher
    participant HC as HonClient
    participant Cmd as HonCommand
    participant App as HonAppliance
    participant MQTT as MqttTransport
    participant CD as command_diagnostics

    User->>HA: change entity state (e.g. HonAirPurifierFan.async_turn_on)
    HA->>APMod: ap_patch(action, capabilities, values)
    APMod-->>HA: CommandPatch

    HA->>HC: dispatch_patch_sync(appliance, CommandPatch)
    HC->>Disp: dispatch(appliance, CommandPatch)

    Disp->>CD: emit_command_event(command_intent)
    Disp->>Cmd: canonical_exact_payload(params)
    Disp->>Cmd: send_exact(payload)
    Cmd->>App: appliance.api.send_command(...)
    App-->>Cmd: result True

    Cmd-->>Disp: True
    Disp->>App: sync_payload_to_params(payload)
    Disp->>CD: record_expected_update(appliance, action, payload)
    Disp->>CD: emit_command_event(command_result)

    par later MQTT push
      MQTT->>App: apply parameter updates
      MQTT->>CD: observe_mqtt_update(appliance, observed_values)
      CD->>CD: match pending action by key/value coverage
      CD->>CD: emit_command_event(shadow_update)
      CD->>CD: emit_command_event(contract_check)
    end

    Disp-->>HC: True
    HC-->>HA: True
    HA-->>User: state updated (after coordinator refresh)
Loading

File-Level Changes

Change Details Files
Introduce air purifier capability model, dispatcher-backed intents, and entity wiring (fan, light, switches, aroma select, experimental numbers/sensors/binaries).
  • Add air_purifier.py to model AP commands, capabilities, writable values and build CommandPatch intents (including custom aroma timing).
  • Wire AP entities (fan, light, switches, select, experimental numbers/sensors/binaries) to discover_capabilities/ap_patch and async_dispatch_patch, with capability and attribute gating, power-based availability where required, and sparse settings patches.
  • Add AP-specific constants and coordinator stores (APPLIANCE_AP, AP_LAST_MODE_STORE, AP_LAST_LIGHT_STORE, CONF_ENABLE_EXPERIMENTAL, AP_ENTITY_PARAMS, AP_HANDLED_VALUES) and include AP in platform setup paths.
custom_components/addhon/air_purifier.py
custom_components/addhon/fan.py
custom_components/addhon/light.py
custom_components/addhon/switch.py
custom_components/addhon/select.py
custom_components/addhon/number.py
custom_components/addhon/sensor.py
custom_components/addhon/binary_sensor.py
custom_components/addhon/const.py
Extend CommandDispatcher, command diagnostics, and MQTT transport to support transactional sparse writes, expected-update correlation, and bounded identity-safe logging.
  • Teach HonCommand to canonicalize exact payloads (canonical_exact_payload) and add send_exact that avoids pre-send shadow sync while reusing canonical mapping.
  • Implement CommandDispatcher with per-appliance locking, preparation (mandatory/rule-added/requested split), transactional rollback using new restore_owned_params, and integration adapter async_dispatch_patch/dispatch_patch_sync.
  • Add command_diagnostics helpers to emit bounded, identity-redacted JSON events, track pending expected payloads per appliance, and correlate MQTT updates; hook MQTT client to observe_mqtt_update and make diagnostics robust to failures and pathological inputs.
custom_components/addhon/client/engine/commands.py
custom_components/addhon/command_dispatch.py
custom_components/addhon/command_diagnostics.py
custom_components/addhon/client/transport/mqtt.py
custom_components/addhon/hon_client.py
custom_components/addhon/param_rollback.py
Introduce experimental option flow flag, behavior, and translations, gating experimental AP entities and diagnostics-driven reloads.
  • Extend options flow to include CONF_ENABLE_EXPERIMENTAL with labels/descriptions in both languages, preserve unknown options on submit, and default from existing options.
  • Change options listener to track a 3-tuple of (enable_debug, enable_mqtt_debug, enable_experimental), only reapply log levels when debug bits change, and reload the entry only when experimental changes (with snapshot handling).
  • Gate experimental AP entities (AQ label, CO alarm, aroma timing numbers) on the option and wire them to use experimental wording and exception keys.
custom_components/addhon/config_flow.py
custom_components/addhon/__init__.py
custom_components/addhon/const.py
custom_components/addhon/sensor.py
custom_components/addhon/binary_sensor.py
custom_components/addhon/number.py
custom_components/addhon/translations/en.json
custom_components/addhon/translations/it.json
Enhance diagnostics to report AP coverage, future capabilities, and keep dumps bounded and identity-free, while ensuring platform parity and translation quality.
  • Extend diagnostics coverage to account for AP parameters controlled by fixed-key entities and add future_capabilities section (enum_deltas, state_values_unhandled, truncation flags) using AP_HANDLED_VALUES.
  • Ensure no identity-bearing values appear in diagnostics or command events by redacting extra keys and bounding record size, depth, and collection size.
  • Add translation tests to enforce options-screen parity, AP entity key presence, capitalization/style rules, experimental labeling, and non-leakage of implementation details.
custom_components/addhon/diagnostics.py
custom_components/addhon/command_diagnostics.py
tests/test_diagnostics.py
tests/test_translations.py
Tighten Home Assistant test stubs, entity translation-key collection, and stub hygiene to avoid order-dependent failures and partial platform surfaces.
  • Move shared HA stubs (CoordinatorEntity, FanEntity, LightEntity, SwitchEntity, SelectEntity, Sensor/BinarySensor/Number descriptions and enums, AddEntitiesCallback) into conftest, and change per-test modules to use getattr guards instead of clobbering.
  • Extend entity-translation-key collectors to account for AP-specific sources (fan, light, AP timing numbers, AP switches, aroma select state options) and ensure parity against translation JSON.
  • Add stub hygiene test that AST-scans tests for unguarded assignments to shared stub symbols, enforcing the first-wins getattr pattern and preventing future order-dependence.
tests/conftest.py
tests/test_entity_translation_keys.py
tests/test_stub_hygiene.py
tests/test_ac_write_path.py
tests/test_wash_option_params.py
tests/test_tier2_sensors.py
tests/test_entity_availability.py
tests/test_switch_params.py
tests/test_program_options.py
Add AP contract fixtures and end-to-end dispatcher tests, and expand dispatcher unit tests for concurrency, rollback, and legacy-call guards.
  • Define AP command/attribute contract fixtures (air_purifier.json, ap/schema.json) and add tests that validate schema self-consistency, payload minimality (intent+mandatory), and protected shadow behavior.
  • Run AP contract cases end-to-end through CommandDispatcher with real HonCommand objects and a recording API, asserting payloads, shadow deltas, rollback on error, and serialization semantics.
  • Extend dispatcher tests to cover legacy-call edges, guard against production callers outside allow-listed modules, and verify rollback preserves concurrent MQTT updates while undoing only own writes.
tests/fixtures/contracts/air_purifier.json
tests/fixtures/ap/schema.json
tests/contract_fixtures.py
tests/test_air_purifier_contracts.py
tests/test_command_dispatch.py
tests/test_engine_cluster.py
Update release policy to support numbered beta tags and add tests around tag classification and mapping.
  • Relax release and trigger tag regexes to accept numbered beta suffixes (-beta, -beta1, -betaN) while keeping existing tags valid.
  • Change is_beta_tag to use a regex on -beta[0-9]* instead of a glob so numbered betas are correctly treated as prereleases.
  • Add a bash-driven test harness that exercises release-policy.sh functions (is_release_tag, is_pr_tag, is_beta_tag, version_from_tag, release_tag_from_pr_tag) over a matrix of tags.
.github/scripts/release-policy.sh
tests/test_release_policy.py
Bump integration version and drop an obsolete ignore entry, plus record SDD progress and open items.
  • Update manifest.json version from 5.9.3 to 5.10.0-beta.
  • Clean up .gitignore entry (empty diff marker).
  • Add SDD progress and per-task reports (air purifier campaign, dispatcher aggregate fixes) and open-items ledger documenting residual risks and deviations.
custom_components/addhon/manifest.json
.gitignore
.superpowers/sdd/2026-07-27-air-purifier-support/progress.md
.superpowers/sdd/2026-07-27-air-purifier-support/OPEN-ITEMS.md
.superpowers/sdd/2026-07-27-air-purifier-support/task-*.md
.superpowers/sdd/2026-07-27-dispatcher-aggregate-fixes/progress.md
.superpowers/sdd/2026-07-27-dispatcher-aggregate-fixes/task-*.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Comment thread custom_components/addhon/fan.py Outdated
Comment thread custom_components/addhon/fan.py
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Release v5.10.0-beta: transactional command dispatch + air purifier support

✨ Enhancement 🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Adds a new transactional CommandDispatcher/CommandPatch seam that sends sparse, exact command
 payloads and rolls back only its own writes on failure.
• Fixes a race where rollback could clobber concurrent MQTT shadow/parameter updates by using
 compare-and-restore instead of blind restore.
• Adds full Air Purifier (AP) support: new fan, light, air_purifier modules plus AP entities
 across binary_sensor, sensor, switch, select, number.
• Introduces a reload-triggering enable_experimental config option gating unconfirmed AP
 interpretations.
• Adds command diagnostics/tracing correlating sent intents with observed MQTT pushes, plus a
 "future_capabilities" diagnostics report.
• Hardens the release-policy script to accept numbered beta tags (-betaN) as prereleases.
• Bumps manifest version to 5.10.0-beta and adds extensive contract/unit tests.
Diagram

graph TD
  A["Entity Platforms"] --> B["CommandPatch"] --> C["CommandDispatcher.dispatch"]
  C --> D["HonCommand.send_exact"] --> E["Cloud API"]
  C --> F["param_rollback"]
  G["MQTT Transport"] -->|"concurrent thread"| H["Appliance Shadow/Params"]
  C -.->|"reads/writes"| H
  G --> I["command_diagnostics"]
  C --> I

  subgraph Legend
    direction LR
    _mod["Module"] ~~~ _flow(["Flow step"])
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Hold a broader lock across the MQTT callback and dispatch
  • ➕ Simpler mental model: no compare-and-restore needed
  • ➖ Would block realtime MQTT updates during every command send
  • ➖ Requires coordinating locks across threads (asyncio + AWS IoT SDK thread), much higher complexity/risk
2. Keep blind restore_params but skip shadow restore only
  • ➕ Smaller diff
  • ➖ Still discards a concurrent parameter write on the same command that isn't the shadow, reintroducing the original bug for parameters

Recommendation: The compare-and-restore rollback approach (snapshotting right after _prepare() and comparing before restoring) correctly fixes the identified race without changing the awscrt MQTT thread model, keeping rollback logic local to the dispatcher. A lock spanning the whole await (blocking MQTT updates) was implicitly considered and correctly rejected, as it would stall realtime state updates for the appliance during every cloud round-trip.

Files changed (39) +12395 / -65

Enhancement (15) +1899 / -29
command_dispatch.pyNew transactional CommandDispatcher and CommandPatch +493/-0

New transactional CommandDispatcher and CommandPatch

• New module implementing a per-appliance-locked dispatcher that prepares, sends and, on failure, rolls back a sparse command patch; never restores the shadow and uses compare-and-restore for parameters to avoid clobbering concurrent MQTT updates.

custom_components/addhon/command_dispatch.py

command_diagnostics.pyNew command diagnostics/tracing module +330/-0

New command diagnostics/tracing module

• New module emitting bounded, redacted DEBUG events for command intents/payloads/results and correlating them with observed MQTT pushes via a time-windowed pending queue.

custom_components/addhon/command_diagnostics.py

commands.pyAdd send_exact/canonical_exact_payload seam to HonCommand +44/-5

Add send_exact/canonical_exact_payload seam to HonCommand

• Splits send_parameters into a shared _send_parameters with a sync_shadow flag, adding send_exact (no shadow sync) and canonical_exact_payload for the transactional dispatcher.

custom_components/addhon/client/engine/commands.py

appliance.pyAdd sync_payload_to_params for exact-send commit +12/-0

Add sync_payload_to_params for exact-send commit

• Adds sync_payload_to_params to shield-update only the shadow keys present in a committed exact payload.

custom_components/addhon/client/engine/appliance.py

interfaces.pyExtend Command/Appliance protocols for exact send +8/-0

Extend Command/Appliance protocols for exact send

• Adds send_exact and sync_payload_to_params method signatures to the Protocol classes.

custom_components/addhon/client/interfaces.py

mqtt.pyCorrelate MQTT parameter pushes with pending commands +8/-0

Correlate MQTT parameter pushes with pending commands

• Collects observed parameter values from the realtime push and forwards them to observe_mqtt_update for diagnostic correlation.

custom_components/addhon/client/transport/mqtt.py

hon_client.pyWire CommandDispatcher into hon_client +7/-0

Wire CommandDispatcher into hon_client

• Instantiates a CommandDispatcher per client and adds dispatch_patch_sync to run a patch dispatch on the client's event loop.

custom_components/addhon/hon_client.py

binary_sensor.pyAdd AP binary sensors and value_fn/experimental support +71/-0

Add AP binary sensors and value_fn/experimental support

• Adds eco/problem/CO-alarm AP binary sensors, a value_fn hook, and experimental/unavailable-when-unmapped flags gated by the new config option.

custom_components/addhon/binary_sensor.py

sensor.pyAdd AP sensor table with power-gating and experimental sensors +182/-1

Add AP sensor table with power-gating and experimental sensors

• Adds a full AP sensor table (temp, humidity, PM, VOC, filters, work time, errors, air quality label) with requires_power and experimental gating.

custom_components/addhon/sensor.py

switch.pyAdd AP sparse-patch switches (lock, touch tone) +158/-0

Add AP sparse-patch switches (lock, touch tone)

• Adds HonAirPurifierSwitchDescription/HonAirPurifierSwitch that write single fields via the transactional dispatcher instead of the legacy settings sender.

custom_components/addhon/switch.py

select.pyAdd AP aroma select entity +165/-1

Add AP aroma select entity

• Adds HonAirPurifierAromaSelect supporting aroma mode selection including the custom mode's paired timing writes.

custom_components/addhon/select.py

number.pyAdd experimental AP custom-aroma timing numbers +239/-0

Add experimental AP custom-aroma timing numbers

• Adds HonAirPurifierTimeNumber entities for aroma on/off timings, built outside the legacy NUMBERS table and gated by the experimental option.

custom_components/addhon/number.py

__init__.pyTrack enable_experimental option and reload entry on change +40/-16

Track enable_experimental option and reload entry on change

• Extends the options-changed listener to track a third (experimental) toggle that reloads the config entry when changed, while debug toggles still apply live.

custom_components/addhon/init.py

config_flow.pyAdd experimental entities toggle to options flow +20/-6

Add experimental entities toggle to options flow

• Adds the enable_experimental checkbox to the options form and preserves unrendered option keys on save.

custom_components/addhon/config_flow.py

diagnostics.pyAdd future_capabilities passive diagnostics section +122/-0

Add future_capabilities passive diagnostics section

• Adds enum-delta and unhandled-state-value detection for AP to surface firmware capabilities the integration doesn't yet handle.

custom_components/addhon/diagnostics.py

Bug fix (1) +32 / -0
param_rollback.pyAdd compare-and-restore rollback helper +32/-0

Add compare-and-restore rollback helper

• Adds restore_owned_params, which restores a parameter only if it still matches the snapshot taken right after the caller's own mutation, preserving any concurrent write.

custom_components/addhon/param_rollback.py

Tests (14) +9263 / -21
test_command_dispatch.pyAdd extensive CommandDispatcher/patch tests +1765/-0

Add extensive CommandDispatcher/patch tests

• New large test suite covering transactional dispatch, rollback semantics, legacy call-path guards, and contract-driven cases.

tests/test_command_dispatch.py

test_transport_mqtt.pyAdd MQTT command correlation tests +275/-0

Add MQTT command correlation tests

• Adds tests validating that MQTT pushes are correlated to pending expected updates via FIFO key-value matching.

tests/test_transport_mqtt.py

test_release_policy.pyAdd bash-level release policy tests +126/-0

Add bash-level release policy tests

• New test suite exercising release-policy.sh directly via bash for tag validation, beta classification, and version extraction.

tests/test_release_policy.py

test_air_purifier_entities.pyAdd AP entity tests across platforms +2253/-0

Add AP entity tests across platforms

• Large new test suite covering fan/light/switch/select/number/sensor/binary_sensor AP entities and capability gating.

tests/test_air_purifier_entities.py

test_air_purifier_contracts.pyAdd AP contract-driven tests +969/-0

Add AP contract-driven tests

• New tests validating AP behavior against declarative contract fixtures.

tests/test_air_purifier_contracts.py

contract_fixtures.pyAdd contract fixture loader/validator +37/-0

Add contract fixture loader/validator

• New helper validating structure of JSON contract test fixtures (schema, shadow, expected payload/delta).

tests/contract_fixtures.py

dispatcher.jsonAdd dispatcher contract fixtures +62/-0

Add dispatcher contract fixtures

• New JSON fixtures describing expected dispatcher payload/shadow behavior for sparse patches.

tests/fixtures/contracts/dispatcher.json

air_purifier.jsonAdd large AP contract fixture set +2487/-0

Add large AP contract fixture set

• New extensive JSON fixture set describing AP command/shadow contracts used by the contract-driven tests.

tests/fixtures/contracts/air_purifier.json

conftest.pyAdd shared fan/light/switch/select/number platform stubs +252/-0

Add shared fan/light/switch/select/number platform stubs

• Adds shared HA platform stubs (FanEntity etc.) and coordinator entity helpers used across the new AP tests.

tests/conftest.py

test_diagnostics.pyExtend diagnostics tests for future_capabilities +250/-5

Extend diagnostics tests for future_capabilities

• Adds test coverage for the new future_capabilities diagnostics section.

tests/test_diagnostics.py

test_stub_hygiene.pyExtend stub hygiene checks +154/-0

Extend stub hygiene checks

• Extends stub hygiene tests to cover new platform stubs introduced for AP entities.

tests/test_stub_hygiene.py

test_translations.pyExtend translation key coverage tests +237/-0

Extend translation key coverage tests

• Adds checks ensuring new AP/experimental translation keys are present and consistent across locales.

tests/test_translations.py

test_options_flow.pyExtend options flow tests for experimental toggle +181/-16

Extend options flow tests for experimental toggle

• Adds tests for the enable_experimental option including entry reload behavior and option-preservation on save.

tests/test_options_flow.py

test_log_identity_redaction.pyAdd redaction tests for command diagnostics +215/-0

Add redaction tests for command diagnostics

• New tests ensuring command diagnostic events redact identity fields correctly.

tests/test_log_identity_redaction.py

Documentation (2) +130 / -8
en.jsonAdd AP and experimental-option translation strings +64/-3

Add AP and experimental-option translation strings

• Adds English strings for new AP entities, options screen title, and the experimental toggle description.

custom_components/addhon/translations/en.json

it.jsonAdd AP and experimental-option Italian translations +66/-5

Add AP and experimental-option Italian translations

• Adds Italian strings mirroring the English translations for AP entities and the experimental option.

custom_components/addhon/translations/it.json

Other (7) +1071 / -7
air_purifier.pyNew air purifier mappings, capability discovery and intents +559/-0

New air purifier mappings, capability discovery and intents

• New module deriving AP capabilities from the live schema/state and building CommandPatch intents (turn on/off, presets, light, lock, tone, aroma) plus value-mapping/normalization helpers.

custom_components/addhon/air_purifier.py

fan.pyAdd air purifier fan entity +233/-0

Add air purifier fan entity

• New fan platform exposing purifier power and Sleep/Auto/Max presets, remembering the last active mode for bare turn-on, dispatched via the transactional patch API.

custom_components/addhon/fan.py

light.pyAdd air purifier panel light entity +225/-0

Add air purifier panel light entity

• New light platform modeling the purifier's inversely-encoded three-level panel light with brightness quantization and last-level memory.

custom_components/addhon/light.py

const.pyAdd AP constants and experimental config key +24/-1

Add AP constants and experimental config key

• Adds APPLIANCE_AP, AP last-mode/light stores, and CONF_ENABLE_EXPERIMENTAL along with the fan/light platforms.

custom_components/addhon/const.py

manifest.jsonBump version to 5.10.0-beta +1/-1

Bump version to 5.10.0-beta

• Updates the integration version string for the release.

custom_components/addhon/manifest.json

release-policy.shSupport numbered beta suffixes in release tag policy +12/-5

Support numbered beta suffixes in release tag policy

• Generalizes the beta regex to accept -betaN sequence numbers so multiple prereleases can be cut per version while still classifying them as prereleases.

.github/scripts/release-policy.sh

.gitignoreIgnore local dev/agent tooling and caches +17/-0

Ignore local dev/agent tooling and caches

• Adds ignores for agent tooling directories and local test/coverage caches.

.gitignore

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (11)
.gitignore (1)

11-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the .superpowers/ ignore rule.

This PR tracks campaign documentation under .superpowers/sdd/...; ignoring the whole tree means future documentation there is silently skipped by git add unless force-added. Ignore only generated artifacts, or move this rule to a local/global ignore.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 11 - 17, Narrow the .superpowers/ entry in
.gitignore so tracked campaign documentation under .superpowers/sdd/... remains
discoverable by git add. Ignore only the specific generated artifacts, or remove
this repository rule and rely on local/global ignore configuration.
tests/conftest.py (1)

142-149: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename _install_fan_stubs to match its actual scope.

It now installs fan, light, switch, select, sensor, binary_sensor, number and entity_platform stubs; the name reads as fan-only and will mislead the next person deciding where to add a platform stub.

♻️ Suggested rename
-def _install_fan_stubs() -> None:
+def _install_platform_stubs() -> None:
     """Shared `fan`, `light`, `switch`, `select` and `number` platform stubs.

And at the call site (Line 409):

-_install_fan_stubs()
+_install_platform_stubs()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conftest.py` around lines 142 - 149, Rename _install_fan_stubs to a
name reflecting that it installs shared platform stubs for fan, light, switch,
select, sensor, binary_sensor, number, and entity_platform, then update its call
site and any references consistently.
tests/test_air_purifier_entities.py (1)

1762-1764: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the leftover ON = None placeholder.

Nothing reads it, and the comment ("set in setUp-free helpers below") describes an approach that was not taken — _label passes _experimental(True) directly.

🧹 Proposed cleanup
 class ExperimentalAirQualityLabelTest(unittest.IsolatedAsyncioTestCase):
-    ON = None  # set in setUp-free helpers below
-
     async def _label(self, raw: str | None):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_air_purifier_entities.py` around lines 1762 - 1764, Remove the
unused ON = None placeholder and its accompanying comment from
ExperimentalAirQualityLabelTest; leave the existing _label and _experimental
behavior unchanged.
custom_components/addhon/command_dispatch.py (2)

278-311: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Add a comment for selector-free callbacks, no change needed.

prepare is only a CommandPatch slot here and none of the in-repo callers pass it, so there’s no current selected-category callback mutation path to fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/command_dispatch.py` around lines 278 - 311, Add a
concise comment adjacent to the patch.prepare call documenting that
selector-free callbacks are the supported path and that no selected-category
callback mutation is currently performed. Do not change the behavior of
CommandPatch handling or parameter mutation.

26-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Log swallowed diagnostic failures at debug level.

Diagnostics must never break a dispatch, but bare except: pass (repeated at Lines 85, 116, 143, 179) makes a systematically broken diagnostics path invisible. A _LOGGER.debug(..., exc_info=True) keeps the guarantee and preserves observability.

♻️ Suggested change
 def _emit_safely(event: str, fields: Mapping[str, object]) -> None:
     try:
         emit_command_event(event, fields)
-    except Exception:
-        pass
+    except Exception:  # diagnostics must never break a dispatch
+        _LOGGER.debug("Dispatch debug: emit %s failed", event, exc_info=True)
 
 
 def _record_expected_safely(
     appliance: Appliance,
     action: str,
     payload: Mapping[str, object],
 ) -> None:
     try:
         record_expected_update(appliance, action, payload)
-    except Exception:
-        pass
+    except Exception:  # correlation is best-effort
+        _LOGGER.debug("Dispatch debug: expected-update record failed", exc_info=True)

(requires a module-level _LOGGER = logging.getLogger(__name__))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/command_dispatch.py` around lines 26 - 41, Replace
the silent exception handling in _emit_safely and _record_expected_safely, plus
the other referenced diagnostic wrappers, with module-level logger debug calls
using _LOGGER.debug(..., exc_info=True). Keep all diagnostic failures suppressed
so command dispatch behavior remains unchanged, and initialize _LOGGER with
logging.getLogger(__name__).

Source: Linters/SAST tools

custom_components/addhon/client/transport/mqtt.py (1)

460-463: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Silent except Exception: pass hides a diagnostics regression; log at DEBUG instead.

observe_mqtt_update is already internally failure-safe (broad try/except + _log_failure), so anything escaping it is a real bug in the diagnostics module — and this bare pass makes it invisible. A DEBUG line keeps the callback thread protected while staying diagnosable, and clears Ruff S110/BLE001.

♻️ Suggested change
                 try:
                     observe_mqtt_update(appliance, observed_values)
-                except Exception:
-                    pass
+                except Exception as err:  # pragma: no cover - defensive
+                    # Diagnostics must never break the push path, but a failure
+                    # here means the module's own guard leaked: record it.
+                    _LOGGER.debug("MQTT: command correlation failed: %s", err)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/client/transport/mqtt.py` around lines 460 - 463,
Replace the bare exception suppression around observe_mqtt_update in the MQTT
update callback with a DEBUG-level log that records the escaped exception, while
preserving the callback thread’s failure-safe behavior and avoiding propagation.
Use the module’s existing logger and exception logging conventions.

Source: Linters/SAST tools

tests/test_log_identity_redaction.py (1)

466-484: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Wall-clock assertion is a latent CI flake.

The margin is large (~0.0007s vs the 0.2s bound), but a contended or emulated runner can still stall a single statement past 200ms, and the failure would be unrelated to the behavior under test. Consider taking the best of a few runs, or asserting the observable bound instead of elapsed time (e.g. counting _sort_key invocations via a patch, which is what "no full sort before trimming" actually means).

♻️ Cheap mitigation: best-of-three
-        with self.assertLogs(self._LOGGER_NAME, level="DEBUG") as captured:
-            started = time.monotonic()
-            emit_command_event("command_payload", {"payload": huge})
-            elapsed = time.monotonic() - started
+        with self.assertLogs(self._LOGGER_NAME, level="DEBUG") as captured:
+            elapsed = min(
+                self._time_emit(emit_command_event, huge) for _ in range(3)
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_log_identity_redaction.py` around lines 466 - 484, Remove the
wall-clock assertion from
test_command_event_bounds_a_very_large_mapping_quickly, which can flake on slow
or contended runners. Validate the bounded traversal behavior directly by
patching or instrumenting _sort_key and asserting it is not invoked for every
item before the payload is limited, while preserving the existing payload-size
and serialized-record bounds.
custom_components/addhon/diagnostics.py (1)

493-493: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reusing _FUTURE_MAX_VALUES as a character budget conflates two units.

_FUTURE_MAX_VALUES is documented as "max values per unhandled delta" (an item count), but here it is multiplied by 4 to bound a string length. A dedicated constant would keep the two bounds independently tunable.

♻️ Suggested tweak
+# Character budget for a single reported live value (a raw shadow scalar).
+_FUTURE_MAX_VALUE_CHARS = 80
-            unhandled_state[name] = text[:_FUTURE_MAX_VALUES * 4]
+            unhandled_state[name] = text[:_FUTURE_MAX_VALUE_CHARS]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/diagnostics.py` at line 493, Replace the
`_FUTURE_MAX_VALUES * 4` character bound in the unhandled-state assignment with
a dedicated constant for the maximum text length, keeping `_FUTURE_MAX_VALUES`
exclusively as the item-count limit. Define and name the new character-budget
constant consistently with the surrounding constants, and use it when slicing
`text`.
custom_components/addhon/light.py (1)

84-85: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Annotate the class-level set to silence RUF012.

-    _attr_supported_color_modes = {ColorMode.BRIGHTNESS}
+    _attr_supported_color_modes: ClassVar[set[ColorMode]] = {ColorMode.BRIGHTNESS}

Requires from typing import ClassVar.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/light.py` around lines 84 - 85, Import ClassVar from
typing and annotate the class-level _attr_supported_color_modes set as
ClassVar[set[ColorMode]] to satisfy RUF012, leaving _attr_color_mode unchanged.

Source: Linters/SAST tools

custom_components/addhon/air_purifier.py (1)

537-559: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sort __all__ to satisfy RUF022.

AP_CO_ALARM_RAW sits after AP_WRITABLE_MODES; if RUF022 is enforced in CI this fails lint.

♻️ Proposed ordering
 __all__ = [
     "AP_AIR_QUALITY_LABELS",
     "AP_AROMA_TO_OPTION",
     "AP_BRIGHTNESS_TO_LIGHT",
+    "AP_CO_ALARM_RAW",
+    "AP_ENTITY_PARAMS",
+    "AP_HANDLED_VALUES",
     "AP_LIGHT_TO_BRIGHTNESS",
     "AP_MODE_TO_PRESET",
     "AP_OPTION_TO_AROMA",
     "AP_PRESET_TO_MODE",
     "AP_WRITABLE_MODES",
-    "AP_CO_ALARM_RAW",
-    "AP_ENTITY_PARAMS",
-    "AP_HANDLED_VALUES",
     "AirPurifierCapabilities",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/air_purifier.py` around lines 537 - 559, Reorder the
entries in the module-level __all__ list alphabetically to satisfy RUF022,
specifically moving AP_CO_ALARM_RAW before AP_ENTITY_PARAMS and
AP_HANDLED_VALUES while preserving all existing exports.

Source: Linters/SAST tools

custom_components/addhon/number.py (1)

629-633: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Use air_purifier._raw() for AP state reads.

custom_active still compares against str(raw), while AP writes use _raw(value) for canonical codes, and other read paths also handle bare str(raw). Use the same exported raw normalizer here, or add a shared read-side raw helper for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/number.py` around lines 629 - 633, Update the
_custom_active property to normalize the _AROMA_ATTR value with the exported
air_purifier._raw() helper before comparing it with AP_CUSTOM_AROMA, replacing
the direct str(raw) comparison and preserving the existing None handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/scripts/release-policy.sh:
- Line 50: Synchronize the invalid-trigger-tag guidance in the release-intake
workflow with the validation message in die, ensuring it documents numbered beta
tags in the pr-vX.Y.Z-betaN form as well as unnumbered beta tags. Update the
workflow’s hard-coded expectation or reuse a shared message so both validation
paths remain consistent.

In `@custom_components/addhon/command_diagnostics.py`:
- Around line 267-271: Normalize observed MQTT scalar values with
diagnostics._scalar_text in the observed mapping, and apply the same helper when
record_expected_update stores expected values, so numerically equivalent
payloads use identical text representations and match correctly.

In `@custom_components/addhon/hon_client.py`:
- Around line 680-684: Update dispatch_patch_sync and the corresponding async
command-dispatch path to preserve the boolean result from
CommandDispatcher.dispatch; when dispatch returns False for a cloud rejection,
raise or propagate the existing localized command error instead of allowing
entity callers to complete normally, while keeping successful dispatch behavior
unchanged.

In `@custom_components/addhon/select.py`:
- Around line 1093-1114: Update HonAirPurifierAromaSelect.async_select_option to
reject writes when the appliance is powered off, before constructing or
dispatching the aroma patch, matching the guard behavior in
HonAirPurifierTimeNumber.async_set_native_value. Raise HomeAssistantError using
a new translation key for the stopped-power condition, and add matching English
and Italian entries in the translation files.

In `@custom_components/addhon/translations/it.json`:
- Around line 607-608: Update the Italian co_alarm name translation to use the
full wording “Indicazione di monossido di carbonio” while preserving the
existing experimental and certification disclaimer, matching the established co
sensor label.

In `@tests/contract_fixtures.py`:
- Around line 25-32: Normalize each case’s id to a string before duplicate
detection in the fixture validation loop: derive the normalized value once, use
it for the membership check, and store that same value in ids. Keep the existing
missing-field validation and duplicate-id error behavior unchanged.

In `@tests/test_air_purifier_entities.py`:
- Around line 490-492: Move the existing if __name__ == "__main__":
unittest.main() block from its current position to the end of
tests/test_air_purifier_entities.py, after all test classes and definitions, so
direct execution discovers every test.

---

Nitpick comments:
In @.gitignore:
- Around line 11-17: Narrow the .superpowers/ entry in .gitignore so tracked
campaign documentation under .superpowers/sdd/... remains discoverable by git
add. Ignore only the specific generated artifacts, or remove this repository
rule and rely on local/global ignore configuration.

In `@custom_components/addhon/air_purifier.py`:
- Around line 537-559: Reorder the entries in the module-level __all__ list
alphabetically to satisfy RUF022, specifically moving AP_CO_ALARM_RAW before
AP_ENTITY_PARAMS and AP_HANDLED_VALUES while preserving all existing exports.

In `@custom_components/addhon/client/transport/mqtt.py`:
- Around line 460-463: Replace the bare exception suppression around
observe_mqtt_update in the MQTT update callback with a DEBUG-level log that
records the escaped exception, while preserving the callback thread’s
failure-safe behavior and avoiding propagation. Use the module’s existing logger
and exception logging conventions.

In `@custom_components/addhon/command_dispatch.py`:
- Around line 278-311: Add a concise comment adjacent to the patch.prepare call
documenting that selector-free callbacks are the supported path and that no
selected-category callback mutation is currently performed. Do not change the
behavior of CommandPatch handling or parameter mutation.
- Around line 26-41: Replace the silent exception handling in _emit_safely and
_record_expected_safely, plus the other referenced diagnostic wrappers, with
module-level logger debug calls using _LOGGER.debug(..., exc_info=True). Keep
all diagnostic failures suppressed so command dispatch behavior remains
unchanged, and initialize _LOGGER with logging.getLogger(__name__).

In `@custom_components/addhon/diagnostics.py`:
- Line 493: Replace the `_FUTURE_MAX_VALUES * 4` character bound in the
unhandled-state assignment with a dedicated constant for the maximum text
length, keeping `_FUTURE_MAX_VALUES` exclusively as the item-count limit. Define
and name the new character-budget constant consistently with the surrounding
constants, and use it when slicing `text`.

In `@custom_components/addhon/light.py`:
- Around line 84-85: Import ClassVar from typing and annotate the class-level
_attr_supported_color_modes set as ClassVar[set[ColorMode]] to satisfy RUF012,
leaving _attr_color_mode unchanged.

In `@custom_components/addhon/number.py`:
- Around line 629-633: Update the _custom_active property to normalize the
_AROMA_ATTR value with the exported air_purifier._raw() helper before comparing
it with AP_CUSTOM_AROMA, replacing the direct str(raw) comparison and preserving
the existing None handling.

In `@tests/conftest.py`:
- Around line 142-149: Rename _install_fan_stubs to a name reflecting that it
installs shared platform stubs for fan, light, switch, select, sensor,
binary_sensor, number, and entity_platform, then update its call site and any
references consistently.

In `@tests/test_air_purifier_entities.py`:
- Around line 1762-1764: Remove the unused ON = None placeholder and its
accompanying comment from ExperimentalAirQualityLabelTest; leave the existing
_label and _experimental behavior unchanged.

In `@tests/test_log_identity_redaction.py`:
- Around line 466-484: Remove the wall-clock assertion from
test_command_event_bounds_a_very_large_mapping_quickly, which can flake on slow
or contended runners. Validate the bounded traversal behavior directly by
patching or instrumenting _sort_key and asserting it is not invoked for every
item before the payload is limited, while preserving the existing payload-size
and serialized-record bounds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 991e1a22-2fae-40e0-941a-f74ac12c000f

📥 Commits

Reviewing files that changed from the base of the PR and between 5526739 and 3459e10.

⛔ Files ignored due to path filters (3)
  • tests/fixtures/ap/schema.json is excluded by !tests/fixtures/**
  • tests/fixtures/contracts/air_purifier.json is excluded by !tests/fixtures/**
  • tests/fixtures/contracts/dispatcher.json is excluded by !tests/fixtures/**
📒 Files selected for processing (68)
  • .github/scripts/release-policy.sh
  • .gitignore
  • .superpowers/sdd/2026-07-27-air-purifier-support/OPEN-ITEMS.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/progress.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-1-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-10-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-11-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-12-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-13-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-2-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-3-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-4-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-5-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-6-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-7-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-8-report.md
  • .superpowers/sdd/2026-07-27-air-purifier-support/task-9-report.md
  • .superpowers/sdd/2026-07-27-dispatcher-aggregate-fixes/progress.md
  • .superpowers/sdd/2026-07-27-dispatcher-aggregate-fixes/task-1-report.md
  • .superpowers/sdd/2026-07-27-dispatcher-aggregate-fixes/task-2-report.md
  • .superpowers/sdd/2026-07-27-dispatcher-aggregate-fixes/task-3-report.md
  • custom_components/addhon/__init__.py
  • custom_components/addhon/air_purifier.py
  • custom_components/addhon/binary_sensor.py
  • custom_components/addhon/client/engine/appliance.py
  • custom_components/addhon/client/engine/commands.py
  • custom_components/addhon/client/interfaces.py
  • custom_components/addhon/client/transport/mqtt.py
  • custom_components/addhon/command_diagnostics.py
  • custom_components/addhon/command_dispatch.py
  • custom_components/addhon/config_flow.py
  • custom_components/addhon/const.py
  • custom_components/addhon/diagnostics.py
  • custom_components/addhon/fan.py
  • custom_components/addhon/hon_client.py
  • custom_components/addhon/light.py
  • custom_components/addhon/manifest.json
  • custom_components/addhon/number.py
  • custom_components/addhon/param_rollback.py
  • custom_components/addhon/select.py
  • custom_components/addhon/sensor.py
  • custom_components/addhon/switch.py
  • custom_components/addhon/translations/en.json
  • custom_components/addhon/translations/it.json
  • tests/conftest.py
  • tests/contract_fixtures.py
  • tests/test_ac_write_path.py
  • tests/test_air_purifier_contracts.py
  • tests/test_air_purifier_entities.py
  • tests/test_client_interfaces.py
  • tests/test_command_dispatch.py
  • tests/test_diagnostics.py
  • tests/test_engine_cluster.py
  • tests/test_entity_availability.py
  • tests/test_entity_translation_keys.py
  • tests/test_hon_client_realtime.py
  • tests/test_log_identity_redaction.py
  • tests/test_number_setpoints.py
  • tests/test_options_flow.py
  • tests/test_program_options.py
  • tests/test_release_policy.py
  • tests/test_sensor_per_type.py
  • tests/test_stub_hygiene.py
  • tests/test_switch_params.py
  • tests/test_tier2_sensors.py
  • tests/test_translations.py
  • tests/test_transport_mqtt.py
  • tests/test_wash_option_params.py

Comment thread .github/scripts/release-policy.sh Outdated
Comment thread custom_components/addhon/command_diagnostics.py
Comment thread custom_components/addhon/hon_client.py
Comment on lines +1093 to +1114
async def async_select_option(self, option: str) -> None:
if option not in self._attr_options:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_setpoint",
translation_placeholders={
"value": option,
"allowed": ", ".join(self._attr_options),
},
)
appliance = self._appliance
client = self._hon_client
if not appliance or not client:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="appliance_or_client_unavailable",
)
raw = AP_OPTION_TO_AROMA[option]
values: dict[str, object] = {"value": raw}
if raw == AP_CUSTOM_AROMA:
values["time_on"] = self._custom_time(_AROMA_TIME_ON_ATTR)
values["time_off"] = self._custom_time(_AROMA_TIME_OFF_ATTR)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

No power guard on the write path, unlike the sibling AP number entity.

available hides the select while the purifier is stopped, but a service call still reaches async_select_option, which then dispatches an aroma patch — exactly what the class docstring says must not happen. HonAirPurifierTimeNumber.async_set_native_value refuses instead of trusting the UI; mirror that here.

🛡️ Proposed guard
     async def async_select_option(self, option: str) -> None:
+        if not environment_available(self._attributes):
+            raise HomeAssistantError(
+                translation_domain=DOMAIN,
+                translation_key="appliance_not_running",
+            )
         if option not in self._attr_options:

Needs a matching translation key in translations/en.json / it.json.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def async_select_option(self, option: str) -> None:
if option not in self._attr_options:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_setpoint",
translation_placeholders={
"value": option,
"allowed": ", ".join(self._attr_options),
},
)
appliance = self._appliance
client = self._hon_client
if not appliance or not client:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="appliance_or_client_unavailable",
)
raw = AP_OPTION_TO_AROMA[option]
values: dict[str, object] = {"value": raw}
if raw == AP_CUSTOM_AROMA:
values["time_on"] = self._custom_time(_AROMA_TIME_ON_ATTR)
values["time_off"] = self._custom_time(_AROMA_TIME_OFF_ATTR)
async def async_select_option(self, option: str) -> None:
if not environment_available(self._attributes):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="appliance_not_running",
)
if option not in self._attr_options:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_setpoint",
translation_placeholders={
"value": option,
"allowed": ", ".join(self._attr_options),
},
)
appliance = self._appliance
client = self._hon_client
if not appliance or not client:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="appliance_or_client_unavailable",
)
raw = AP_OPTION_TO_AROMA[option]
values: dict[str, object] = {"value": raw}
if raw == AP_CUSTOM_AROMA:
values["time_on"] = self._custom_time(_AROMA_TIME_ON_ATTR)
values["time_off"] = self._custom_time(_AROMA_TIME_OFF_ATTR)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/select.py` around lines 1093 - 1114, Update
HonAirPurifierAromaSelect.async_select_option to reject writes when the
appliance is powered off, before constructing or dispatching the aroma patch,
matching the guard behavior in HonAirPurifierTimeNumber.async_set_native_value.
Raise HomeAssistantError using a new translation key for the stopped-power
condition, and add matching English and Italian entries in the translation
files.

Comment thread custom_components/addhon/translations/it.json Outdated
Comment thread tests/contract_fixtures.py Outdated
Comment thread tests/test_air_purifier_entities.py Outdated
@qodo-code-review

qodo-code-review Bot commented Jul 28, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Silent dispatch diagnostics failures ✓ Resolved 🐞 Bug ◔ Observability
Description
command_dispatch.py introduces helpers that catch and ignore exceptions from
emit_command_event()/record_expected_update(), which can silently drop command intent/payload/result
diagnostics. When that happens, command dispatch still proceeds, but the debug trail for
investigating rejected commands or shadow divergence can disappear.
Code

custom_components/addhon/command_dispatch.py[R26-41]

+def _emit_safely(event: str, fields: Mapping[str, object]) -> None:
+    try:
+        emit_command_event(event, fields)
+    except Exception:
+        pass
+
+
+def _record_expected_safely(
+    appliance: Appliance,
+    action: str,
+    payload: Mapping[str, object],
+) -> None:
+    try:
+        record_expected_update(appliance, action, payload)
+    except Exception:
+        pass
Relevance

●●● Strong

Past patterns wrap optional flows with try/except + warning; fully swallowing diagnostics errors is
atypical.

PR-#57
PR-#69
PR-#32

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new dispatcher defines two helper functions that swallow all exceptions from the diagnostics
functions without emitting any log/metric, which can make diagnostics loss silent.

custom_components/addhon/command_dispatch.py[26-41]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_emit_safely()` and `_record_expected_safely()` catch `Exception` and do nothing. This can hide failures in the diagnostics/instrumentation layer (and makes it harder to detect when the diagnostics pipeline stops producing events).

### Issue Context
These helpers are used throughout the dispatcher to emit `command_intent` / `command_payload` / `command_result` events and to record expected updates. The dispatcher should remain non-fatal if diagnostics fails, but failures should be visible at least at DEBUG.

### Fix Focus Areas
- custom_components/addhon/command_dispatch.py[26-41]

### Suggested fix
- Prefer removing these wrappers and calling `emit_command_event(...)` / `record_expected_update(...)` directly if those functions already handle their own exceptions.
- If you keep wrappers, log at DEBUG on failure (with `exc_info=True`) and include minimal safe context (event name / action), while avoiding sensitive payload logging.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Swallowed MQTT diagnostic errors ✓ Resolved 🐞 Bug ◔ Observability
Description
In mqtt._on_message(), exceptions from observe_mqtt_update() are caught and ignored, so failures in
the new command/shadow diagnostic instrumentation can be completely silent. This reduces
observability when diagnosing contract-check/shadow-divergence issues.
Code

custom_components/addhon/client/transport/mqtt.py[R460-463]

+                try:
+                    observe_mqtt_update(appliance, observed_values)
+                except Exception:
+                    pass
Relevance

●●● Strong

Repo typically logs caught exceptions (esp. MQTT paths); silent pass hurts diagnosability.

PR-#57
PR-#31
PR-#69

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The MQTT transport wraps the new diagnostics hook in a blanket exception handler that discards any
error without logging, making instrumentation failure invisible.

custom_components/addhon/client/transport/mqtt.py[431-463]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`observe_mqtt_update()` is invoked inside a blanket `try/except Exception: pass` in the MQTT message handler. If the diagnostics observer throws (now or after future changes), the error is silently discarded, making it hard to notice that command/shadow instrumentation is no longer working.

### Issue Context
This call is strictly diagnostic/instrumentation, so it should remain best-effort and must not break MQTT processing; however, failures should be visible at least at DEBUG.

### Fix Focus Areas
- custom_components/addhon/client/transport/mqtt.py[460-463]

### Suggested fix
- Replace the bare `except Exception: pass` with a debug-level log (with `exc_info=True`) and keep processing.
 - Example:
   ```py
   try:
       observe_mqtt_update(appliance, observed_values)
   except Exception:
       _LOGGER.debug("observe_mqtt_update failed", exc_info=True)
   ```
- Alternatively, if `observe_mqtt_update` already guarantees it never raises, remove the redundant `try/except` wrapper entirely.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread custom_components/addhon/client/transport/mqtt.py Outdated
Comment thread custom_components/addhon/command_dispatch.py Outdated
tis24dev added 12 commits July 28, 2026 11:04
`air_purifier._raw` was the module's canonicalization rule but stayed private,
so seven air-purifier read paths hand-rolled a weaker `str(raw)` instead:
fan.is_on and fan._raw_mode, light._raw_level, the lock/tone switch, the aroma
select, the aroma-timing number, and the eco binary through the shared platform
comparison. `environment_available` already used the rule, so the same
`onOffStatus` was resolved two different ways.

This is NOT a live bug fix. The engine's HonAttribute.value routes through
str_to_float, which folds bool, int and integral float onto an int before a
platform sees the value, so `str(raw)` matches the schema for every spelling the
cloud actually uses. The reachable divergence is a decimal-spelled numeric
string ("1.0"), which str_to_float keeps as a float; the worst case is the aroma
timings, which would hide AND refuse the write immediately after the user
selected Custom.

- rename `_raw` to `raw_text`, export it, and rewrite its docstring: it used to
  justify itself with the bool case, which the engine makes unreachable, and
  that is what made the hazard look bigger than it is.
- add `is_engaged` so the eco binary resolves its own value in this module
  instead of through the platform's generic `on_value` comparison, which is
  shared with every other appliance family.

The equivalent shared reads (binary_sensor.HonBinarySensor.is_on, sensor._mapped,
switch.HonSettingsSwitch.is_on) keep the older platform convention: they serve
eight appliance types whose behavior is pinned, and the engine protects them
identically.

Tests: ShadowSpellingTest drives all seven paths from real HonAttribute fixtures,
since a plain-string fixture cannot reproduce a divergence the engine creates.
RawTextRuleTest pins the helper's own branches. The structural guard is per
member via AST, not a whole-file grep, because three of these modules also serve
appliances that legitimately keep `str(raw)`. Reverting any one of the seven
sites fails both a behavioral and the structural test; a no-op control mutation
survives. 1575 passed.
`available` hid the aroma select and the two timing numbers while the purifier
was stopped, but neither write path re-checked it, so a service call still
dispatched. A hidden entity is not an unreachable one: a script keeps calling the
service, and the snapshot `available` reads can be a refresh behind the device.
The class docstring already stated the rule this now holds, that selecting a mode
must never implicitly start the appliance.

The timing numbers did have a write-path refusal, but on Custom rather than on
power, and that does not cover this: Custom is a SETTING the device can retain
while stopped, so aromaStatus=4 with onOffStatus=0 passed. The power check goes
first, since a stopped purifier outside Custom fails both and turning Custom on
would not help.

Unreported power stays a refusal. `environment_available` treats "not confirmed
on" as not running, which is the same rule the read side already applies to the
same attribute; letting the write path be more trusting than the read path is the
inconsistency, not the refusal.

The new key exposed a gap: nothing verified that a translation_key raised by the
code exists in the JSON at all. A missing one reaches the user as the raw key,
and only when the error fires. ExceptionKeyParityTest derives them by AST over
the whole component tree and checks both directions. Not by pattern: keyword
order is free, so translation_placeholders={"error": str(err)} before
translation_key hides the raise from any expression that cannot cross a bracket,
and a top-level-only scan would call a key that moved under client/ unused.
11 keys raised, 11 declared per language, exact match both ways.

Mutation: removing either guard, swapping the number's two checks, or deleting
the key from one language each fails; a no-op control survives. 1585 passed.
`test_air_purifier_entities.py` carried its guard at line 490 of 2561, with 54
module-level definitions below it, accumulated by appending a class per task.

The reported failure mode is not real: `python3 tests/test_air_purifier_entities.py`
crashes at import with "module 'homeassistant.exceptions' has no attribute
'HomeAssistantError'", because the module's own stub installer needs conftest's
base stubs, which only pytest puts in place. Nothing was silently skipped; under
pytest the guard is inert wherever it sits. What it was is a dead entry point that
reads as the end of the file and strands everything after it, with no signal to the
next reader appending a class.

Moved to the end. Whether a guard WORKS is a separate, pre-existing and much wider
question: a standalone sweep of the corpus shows many modules failing or crashing
on their stub bootstrap, and curing one of them as a rider here would leave the
others untouched. CI runs `python -m pytest tests/ -q`.

MainGuardPlacementTest fences it. The check is the placement one only, and it is
deliberately stricter than the defect: any trailing module-level STATEMENT counts,
not just a definition, since the rule is that the guard is last. The operator must
be `==` so an inverted `if __name__ != "__main__":` cannot be mistaken for a guard
that a real one then hides behind, and both operand orders are recognized. The
sweep asserts floors on modules read and guards seen, so an empty scan cannot pass
as a clean one. Its synthetic sources are assembled from a constant rather than
written literally, so this module's own text still carries exactly one real guard.

Mutation: counting only classes, dropping the operator check, dropping the operand
order, or pointing the sweep at nothing each fails; restoring the guard mid-file
fails two. 1590 passed.
The trigger format was written out in four places. Widening the regexes to accept
a numbered beta updated one of them, leaving release-intake.yml telling the
operator to push a shape that was no longer the only accepted one, and leaving
docs/release-workflow.md describing a pipeline that has not existed for several
releases.

- release-policy.sh owns the prose: RELEASE_TAG_FORMATS and PR_TAG_FORMATS, used
  by both die() messages and by the workflow.
- docs/release-workflow.md described pushing a bare vX.Y.Z tag by hand. That is
  the PROTECTED tag the automation creates itself on the squash commit; the
  operator pushes pr-vX.Y.Z. Rewritten around the two tags, with the numbered
  beta, the push-dev-first ordering, the squash-only requirement and the reason
  the policy is read from origin/main.

The interpolation is DEFAULTED, and must stay defaulted. The workflow body ships
with the tag while the policy is sourced from origin/main, so every release runs
a new body against the previous policy: a constant introduced and used in one
change does not exist yet when it first runs. Under `set -euo pipefail` that is a
fatal unbound variable, and it would have killed the invalid-trigger branch one
statement before delete_remote_tag, leaving the rejected trigger on origin. A
leftover trigger makes a re-push a no-op that never re-fires intake, so recovery
would have been a manual deletion. Reproduced against origin/main's policy before
fixing.

Tests. The prose is no longer read, it is EXERCISED: concrete tags are built out
of each constant and fed to the predicates, and each advertised trigger shape must
map onto the release shape advertised beside it. SplitSourceContractTest pins the
lag contract, both structurally (no workflow may read a policy variable without a
default) and end to end (the rejection branch, run against a policy stripped of
both constants, still reaches the cleanup). The operator doc's tag blocks must
equal the advertised shapes, and no workflow message may spell a format itself.

Mutation: dropping the default, deleting a constant, narrowing the regex under
stale prose, advertising a shape the regex rejects, hardcoding the format in the
workflow again, and adding or removing a shape in the doc each fail. 1598 passed.
The Italian binary read "Indicazione Monossido" while the sensor on the SAME
coLevel attribute read "Monossido di Carbonio", so the pair looked like two
different substances on one device page. English never diverged: both labels were
built from "Carbon monoxide".

SharedAttributeNamingTest fences it, with the pairs DERIVED from the description
tables rather than listed, so an entity that reuses an existing attribute is
covered the day it lands. That surfaced a third pair, `errors`, which is exempt
for a stated reason: the PROBLEM binary takes its name from the Home Assistant
device class, which is the platform's vocabulary and not this feature's to align.
ENUM is deliberately not an exemption, since it declares the value type and not a
name, and the carbon-monoxide binary is not exempt either because it carries no
device class at all, precisely so it is never presented as a certified detector.

The rule is EQUALITY of the referent, not overlap. Overlap was the first version,
and it passed with the bug restored: "Indicazione Monossido" and "Monossido di
Carbonio" share "monossido". A truncated referent is exactly the shape that reads
as two things, so nothing short of equality catches it.

That makes the noise list load-bearing, and the tempting way to silence a failure
is to add the missing noun to it. Referent SIZES are therefore compared across
languages, so hiding "carbonio" leaves Italian naming the substance with one word
where English uses two, and fails there instead. The sweep also pins which pairs
are exempt, so a future entity carrying a device class cannot drop its pair out
unnoticed.

What this cannot judge is whether the shared name is the right one: two labels
that are identically wrong pass by construction. Said so in the docstring, and
cross-referenced from the filter-label test, which keeps the overlap rule on
purpose because those two read DIFFERENT attributes and must stay distinguishable.

Mutation: the Italian truncation, the same truncation in English, a diverging
air-quality pair, a device class on the monoxide binary, splitting a pair by
attr_key, extending the noise list, and a new device-classed entity joining a
swept attribute each fail. 1600 passed.
The two sides of the correlation are spelled differently and neither is wrong.
What leaves the machine is a parameter's intern_value, typed str | float. What
comes back is the cloud's raw parValue, taken verbatim from the MQTT frame, so it
can be a string, a number or a bool, and the cloud may reformat it. A plain str()
on both sides calls "60" against "60.0" a missing key.

The report is the lesser half. `_match_coverage` uses the same comparison to pick
WHICH pending command a push confirms, so a spelling difference does not just
mis-report a field, it can hand the push to the wrong command. Both sides are
therefore canonicalized where they ENTER, so every comparison downstream is
consistent.

`comparable_text` lives in debug_utils, the leaf module command_diagnostics
already imports. Numbers compare numerically, everything else as trimmed text.
Bools are taken first because the rest goes through str(), and "True" is not
something a numeric parse accepts. A decimal comma is read as a decimal point for
the same reason `client.helpers.str_to_float` reads it: the cloud sends that
spelling and the engine already stored 5.5, so calling it a mismatch would
contradict the value the integration holds.

Deliberately more forgiving than `air_purifier.raw_text`, which prepares a value
to be WRITTEN and must never invent a spelling the schema does not declare.
Nothing here reaches the wire. Forgiving about spelling, though, never about
value: overflow spellings keep their raw text, because collapsing them onto "inf"
would make "1e400" and a 400-digit number compare equal.

Three things the verification pass corrected in this change. The expected side had
no test, and it is not defensive: a range parameter's intern_value really is a
float. The bool branch was documented as guarding float(True), which is never
evaluated since str() runs first; the real reason is the "True" literal. And the
overflow branch was pinned by a test that passed with it deleted, because "nan"
and "inf" render the same either way; it now uses "infinity" and "1e400", which
do not.

Mutation: un-normalizing either side, deleting the bool, overflow or decimal-comma
handling, and losing the right-pending-command correlation each fail; an
equivalent rewrite passes. 1611 passed.
`CommandDispatcher.dispatch` reports three outcomes: it raises, it returns True,
or it returns False having rolled the transaction back. `async_dispatch_patch`
discarded that False, so the entity went on to refresh and showed the user a
success on a write the hOn service never accepted.

But that branch is the one that never fires. A real refusal arrives as ApiError:
api.send_command returns a literal True or False, and
HonCommand._send_parameters raises on anything falsy. So the refusal that actually
happens reached the entity as a GENERIC failure carrying the untranslated literal
"Can't send command", which an Italian user read as "Comando non riuscito: Can't
send command", while a carefully worded localized string sat on the unreachable
branch. Both paths are handled now, and ApiError has a single raise site meaning
exactly this, so the mapping is exact rather than a guess.

Same defect in the diagnostics: outcome="cloud_rejected" was emitted only on the
dead branch, so every real refusal was recorded as a generic "error",
indistinguishable from a transport or preparation failure, and anyone counting
service-side refusals in a downloaded diagnostic got zero. A refusal is now
labelled as one however it arrives, and nothing else may carry the label.

The acceptance check is `is not True`, the same rule the dispatcher applies, and
by identity rather than equality: 1 equals True in Python, and a client answering
with an int is not a client confirming a write.

That identity is now pinned. `_run_on_hon_loop` is stubbed by every test that goes
near it, so nothing observed what the real executor hop returns, and the one
assertion that touched it used assertTrue, which passes for any truthy value. The
new test drives the real hop on a real background loop and asserts the value comes
back as the SAME object. It earns its place: an earlier revision of this branch
carried a stray edit turning that return into `1 if _v is True else _v`, which made
every successful purifier write raise "the service did not accept the command",
with the whole suite green. The edit is gone and the test now fails on it.

Mutation, whole suite: dropping the ApiError mapping fails 2; reverting the outcome
label fails 1; labelling every failure as a refusal fails 1; removing the
acceptance guard fails 9; loosening it to `is False`, `not accepted` or `!= True`
fails 1 each; coercing the loop hop's True fails 1; deleting the Italian key fails
5. 1621 passed.
Five reviewer nitpicks, all introduced by this campaign, each with the drift
fenced rather than just repaired.

- .gitignore excluded `/.superpowers/`, the tree whose campaign records this
  branch TRACKS. Every new task report was ignored silently and landed only when
  someone remembered `git add -f`; the committed ones stayed visible purely
  because a tracked file outranks .gitignore. Now the CONTENTS are excluded with
  sdd/ re-included, since git never descends into an excluded directory and a
  negation inside one can never re-include anything. Some working copies also
  carry an untracked `.superpowers/sdd/.gitignore` holding `*`, written by local
  tooling and not part of this repository, which still wins there; noted in place.

- conftest's `_install_fan_stubs` stubbed SEVEN platforms and its docstring listed
  five, having grown one per task. Renamed to `_install_entity_platform_stubs`,
  docstring corrected, and a test now asserts the docstring against the platforms
  the function actually stubs: a reader deciding whether their module needs its own
  stub was reading a list that had been wrong for weeks.

- A dead `ON = None` in the experimental air-quality test, whose comment described
  an approach never taken.

- diagnostics used `_FUTURE_MAX_VALUES * 4` to cap a string LENGTH, so a constant
  documented as a number of values read as "20 values" while meaning "80
  characters". Split into its own `_FUTURE_MAX_VALUE_CHARS`.

- air_purifier.__all__ had drifted out of order as each task appended to the end,
  and it was not the module's real surface: AP_CUSTOM_AROMA is imported by both the
  aroma select and the timing numbers while absent from the list. Sorted, the name
  added, and a test pins both the order and the direction that matters, that no
  module imports a name the module does not export. The reverse stays allowed: a
  constant may exist to state a rule, as AP_WRITABLE_MODES does.

1624 passed.
Four pre-existing reviewer nitpicks. None is a bug today; each is a place where a
real failure would have looked like a healthy state.

The contract fixture loader compared a case's RAW id against a set of normalized
strings, so a duplicate slipped whenever the spellings differed: "1" recorded
first, then a bare 1, matched nothing and collapsed onto the same entry. Two cases
then shared an id and the second silently shadowed the first in any id-keyed
lookup, across every contract matrix in the suite. Normalized once, then both
compared and stored, and the new test covers both orders plus a control.

The six diagnostics wrappers in command_dispatch and the MQTT correlation call
swallowed every exception with a bare `pass`. Swallowing is correct and stays:
diagnostics must never affect a command, and a broken diagnostic must not drop an
appliance state update. What was wrong is that it left no trace, so a correlation
that was dead for every single command was indistinguishable from one that simply
never matched. The module had no logger at all; it has one now, and the new test
asserts both halves, that the command still commits and that each wrapper that
fired recorded why.

The bounded-traversal test asserted an absolute 0.2s against a real measurement
well under a millisecond. That pinned nothing about the shape of the work and
would have gone red on a contended runner that merely stalled. It was also nearly
blind in the other direction: restoring the unbounded pre-limit sort measures
0.205s here, a 2.5 percent margin over the old threshold, so on a slightly faster
machine the regression it exists to catch would have passed. It now times the same
call on a small collection in the same process and compares the ratio, which
cancels machine speed and load: flat work stays within a wide factor while the
unbounded version is a thousandfold apart, and it fails on that mutation with 7x
of margin. The trimming assertions moved to their own test, since they were never
about timing.

1627 passed, three consecutive runs.
A mutation sweep over the whole unpushed delta found one survivor: removing the
character slice from the future-capability section entirely left all 1627 tests
green. The section is passive EVIDENCE, so a firmware answering with a long blob
must add a hint to the dump and never carry the blob into it, and nothing checked
that.

The bound is easy to lose because it had already been written twice: first as a
count constant times four, reading as "20 values" while meaning 80 characters, and
then split into its own `_FUTURE_MAX_VALUE_CHARS`. Neither spelling was covered.

The test derives the expected length FROM the constant, so the mechanism stays
pinned at whatever value it takes, and bounds the constant separately: a cap large
enough to carry the blob would satisfy the mechanism while defeating its purpose.
A control asserts a value that fits is not trimmed. The AP coordinator builder
takes attribute overrides now, which no existing caller notices.

Mutation: dropping the slice fails 1, widening the cap to 8000 fails 1.
1629 passed.
comparable_text promises the one thing a correlation cannot do without: forgiving
about spelling, never about value. It already guarded the overflow band, where
str() would render every too-large spelling as "inf" and make "1e400" and a
400-digit number compare EQUAL. Precision, though, is lost well before inf. A
float holds every integer only up to 2**53; above that str(int(number)) renders
the ROUNDED double, so 12345678901234567890 and 12345678901234567891 both land on
12345678901234567168 and _match_coverage would score a match that did not happen.

Same rule as the overflow branch, applied one step earlier: past 2**53 keep the
raw text. Below the bound nothing changes, so the reachable band, schema-declared
settings a few digits wide, keeps the numeric comparison the delta added it for.

Reachability today is nil, which is why this is a contract repair and not a bug
fix: record_expected_update is fed prepared.payload, built only from
active_parameters[key].intern_value, and transactionId, timestamp and macAddress
join the envelope later in client/transport/api.py, never that payload. The
docstring, however, stated the invariant absolutely, and a promise that holds only
below an undocumented bound is the kind a later caller relies on.

The test pins WHERE the bound is, not merely that large numbers survive: 2**53 + 1
has no float of its own and rounds onto 2**53, so that pair is the first one that
collides. Mutation evidence, tests/test_debug_utils_redact.py:
  guard removed (if False)      -> 1 failed
  bound widened to 2**63        -> 1 failed
  bound narrowed to 2**32       -> 1 failed
Full suite 1630 passed, 1 skipped, 7 xfailed, 319 subtests.
Two holes, both found by an adversarial pass over the whole delta rather than by
the commit that added the cap.

The control, test_a_short_unhandled_state_value_is_untouched, had a body identical
to the pre-existing test_future_capability_reports_an_unhandled_live_state: same
_ap_block() with no overrides, same {"machMode": "3"} assertion. Two identical
bodies over one deterministic fixture have provably equal discriminating power, so
no mutation could fail one and spare the other. It read as an independent control
while adding nothing.

The cap test fed a blob of one repeated character, so `blob.startswith(captured)`
was satisfied by ANY window of the right length. A slice that kept the correct
NUMBER of characters and the wrong ones was invisible.

Both now use a counting run where no character equals its neighbour, and the
control sits ON the bound: a value exactly _FUTURE_MAX_VALUE_CHARS long must come
back whole. A short value cannot tell a slice at the cap from one a character
either side of it, which is why the old control could not fail alone.

Mutation evidence against custom_components/addhon/diagnostics.py:498:
  text[1:cap + 1]  shifted window  -> 4 failed  (was invisible to the old pair)
  text[:cap - 1]                   -> 2 failed
  text[:cap + 1]                   -> 1 failed  (was invisible to the old pair)
  text            slice removed    -> 1 failed
Production code untouched. Full suite 1630 passed, 1 skipped, 7 xfailed,
319 subtests.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
custom_components/addhon/select.py (1)

1079-1087: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse raw_text instead of re-spelling the canonicalization here.

raw_text is already imported in this module and is the declared single rule for schema spelling; lines 1087 hand-roll the same integral-float collapse. Routing through it keeps one rule for the whole feature (and keeps this site aligned if raw_text ever grows a case).

♻️ Proposed refactor
             if low <= number <= high:
-                return str(int(number)) if number.is_integer() else str(number)
+                return raw_text(number)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/select.py` around lines 1079 - 1087, Update the
candidate normalization in the loop within the relevant select method to pass
the accepted numeric value through the imported raw_text helper instead of
manually converting integral floats and formatting other numbers. Preserve the
existing candidate filtering, float parsing, range validation, and return
behavior while using raw_text as the single canonicalization rule.
custom_components/addhon/diagnostics.py (1)

494-505: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider flagging a value that was actually trimmed.

truncated is currently driven only by _enum_deltas, so a state value clipped by _FUTURE_MAX_VALUE_CHARS is indistinguishable in the dump from a complete one.

♻️ Optional
     for name in sorted(handled):
         text = _scalar_text(attributes.get(name))
         if text is not None and text not in handled[name]:
+            if len(text) > _FUTURE_MAX_VALUE_CHARS:
+                truncated = True
             unhandled_state[name] = text[:_FUTURE_MAX_VALUE_CHARS]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/addhon/diagnostics.py` around lines 494 - 505, Update the
unhandled-state processing around _FUTURE_MAX_VALUE_CHARS so truncating any
state value also sets truncated to true. Preserve the existing clipped value in
state_values_unhandled and the current enum-delta truncation behavior.
tests/test_translations.py (1)

435-463: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The detector only sees raise HomeAssistantError(translation_key=...) inline.

A key passed to an exception that is constructed first and raised later (err = HomeAssistantError(...)raise err), or raised from a helper factory, is invisible here — which makes test_no_language_carries_an_unused_exception fail on a key that is genuinely used. Consider also walking ast.Call nodes whose func name ends in Error (not just Raise nodes), or noting the limitation in the docstring so the next reader knows why a live key reads as unused.

Also applies to: 482-488

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_translations.py` around lines 435 - 463, The _raised_keys detector
only records localized errors created directly inside raise statements, missing
exceptions constructed before raising or returned by helper factories. Extend
_raised_keys to inspect relevant ast.Call nodes, including error constructors
whose function name ends with “Error,” while preserving literal-key validation
and avoiding duplicate handling of inline raises; alternatively, explicitly
document this limitation if detection cannot be expanded.
tests/test_hon_client_realtime.py (1)

158-165: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Close the loop after joining the thread.

loop.stop() + join leaves the event loop object open, so its selector fds stay allocated for the rest of the session (and Python may emit a ResourceWarning).

♻️ Suggested cleanup
             thread = client._hon_thread
             if thread is not None:
                 thread.join(timeout=5)
+            if loop is not None:
+                loop.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_hon_client_realtime.py` around lines 158 - 165, Update the cleanup
in the finally block to close client._hon_loop after stopping it and joining
client._hon_thread. Ensure the loop is closed only when it exists, while
preserving the existing thread join timeout and shutdown sequence.
tests/test_stub_hygiene.py (1)

275-297: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nit: ast/re are already available at module scope, and the docstring extraction fails opaquely.

ast is imported at the top of this module (used by _main_guards), so the local imports are redundant. Also, if the installer ever loses its docstring or becomes async def, this test dies with ValueError/StopIteration rather than a readable assertion.

♻️ Optional
     `@staticmethod`
     def _installer_source() -> str:
-        import ast
-
         source = (TESTS_DIR / "conftest.py").read_text(encoding="utf-8")
         tree = ast.parse(source)
-        function = next(
+        function = next(
             node
             for node in tree.body
             if isinstance(node, ast.FunctionDef)
-            and node.name == "_install_entity_platform_stubs"
-        )
+            and node.name == "_install_entity_platform_stubs"
+        , None)
+        assert function is not None, "conftest lost _install_entity_platform_stubs"
         return ast.get_source_segment(source, function) or ""
 
     def test_the_docstring_lists_every_platform_it_stubs(self) -> None:
-        import re
-
         body = self._installer_source()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_stub_hygiene.py` around lines 275 - 297, Update
test_the_docstring_lists_every_platform_it_stubs and _installer_source to reuse
the module-level ast and re imports instead of importing them locally. Make
_installer_source explicitly assert that _install_entity_platform_stubs is found
and is a regular function with a docstring, producing clear assertion failures
before extracting the docstring; preserve the existing platform-list validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_air_purifier_entities.py`:
- Around line 2676-2678: Update the loop over self._ap_descriptions() to replace
the unused platform variable with an underscore placeholder, while preserving
the existing by_attribute population using description.attr_key and
description.key.

---

Nitpick comments:
In `@custom_components/addhon/diagnostics.py`:
- Around line 494-505: Update the unhandled-state processing around
_FUTURE_MAX_VALUE_CHARS so truncating any state value also sets truncated to
true. Preserve the existing clipped value in state_values_unhandled and the
current enum-delta truncation behavior.

In `@custom_components/addhon/select.py`:
- Around line 1079-1087: Update the candidate normalization in the loop within
the relevant select method to pass the accepted numeric value through the
imported raw_text helper instead of manually converting integral floats and
formatting other numbers. Preserve the existing candidate filtering, float
parsing, range validation, and return behavior while using raw_text as the
single canonicalization rule.

In `@tests/test_hon_client_realtime.py`:
- Around line 158-165: Update the cleanup in the finally block to close
client._hon_loop after stopping it and joining client._hon_thread. Ensure the
loop is closed only when it exists, while preserving the existing thread join
timeout and shutdown sequence.

In `@tests/test_stub_hygiene.py`:
- Around line 275-297: Update test_the_docstring_lists_every_platform_it_stubs
and _installer_source to reuse the module-level ast and re imports instead of
importing them locally. Make _installer_source explicitly assert that
_install_entity_platform_stubs is found and is a regular function with a
docstring, producing clear assertion failures before extracting the docstring;
preserve the existing platform-list validation.

In `@tests/test_translations.py`:
- Around line 435-463: The _raised_keys detector only records localized errors
created directly inside raise statements, missing exceptions constructed before
raising or returned by helper factories. Extend _raised_keys to inspect relevant
ast.Call nodes, including error constructors whose function name ends with
“Error,” while preserving literal-key validation and avoiding duplicate handling
of inline raises; alternatively, explicitly document this limitation if
detection cannot be expanded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f303809b-3eea-4b21-8c39-1c85f7931ddd

📥 Commits

Reviewing files that changed from the base of the PR and between 3459e10 and a207f1a.

📒 Files selected for processing (30)
  • .github/scripts/release-policy.sh
  • .github/workflows/release-intake.yml
  • .gitignore
  • custom_components/addhon/air_purifier.py
  • custom_components/addhon/binary_sensor.py
  • custom_components/addhon/client/transport/mqtt.py
  • custom_components/addhon/command_diagnostics.py
  • custom_components/addhon/command_dispatch.py
  • custom_components/addhon/debug_utils.py
  • custom_components/addhon/diagnostics.py
  • custom_components/addhon/fan.py
  • custom_components/addhon/light.py
  • custom_components/addhon/number.py
  • custom_components/addhon/select.py
  • custom_components/addhon/switch.py
  • custom_components/addhon/translations/en.json
  • custom_components/addhon/translations/it.json
  • docs/release-workflow.md
  • tests/conftest.py
  • tests/contract_fixtures.py
  • tests/test_air_purifier_entities.py
  • tests/test_command_dispatch.py
  • tests/test_debug_utils_redact.py
  • tests/test_diagnostics.py
  • tests/test_hon_client_realtime.py
  • tests/test_log_identity_redaction.py
  • tests/test_release_policy.py
  • tests/test_stub_hygiene.py
  • tests/test_translations.py
  • tests/test_transport_mqtt.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • custom_components/addhon/translations/en.json
  • custom_components/addhon/translations/it.json
  • .github/scripts/release-policy.sh
  • tests/conftest.py

Comment thread tests/test_air_purifier_entities.py
tis24dev added 2 commits July 30, 2026 12:16
greptile filed this as P1 and it was closed as accepted-by-design, on the grounds
that the purifier exposes discrete modes and deliberately does not declare
SET_SPEED. The first half is verifiable and true: _attr_supported_features carries
only PRESET_MODE, TURN_ON and TURN_OFF, and no percentage, speed_count or
percentage_step exists anywhere in the package. The second half does not follow.
Not advertising a speed justifies not implementing one; it does not justify
accepting a percentage and discarding it.

Dropped silently, a percentage is the worst of the three outcomes: the purifier
starts in the REMEMBERED mode, the service returns success, and the automation
reads as though the requested speed had been applied. Refusing costs a visible
error on a call that was never going to do what it asked, and it costs nothing on
any call Home Assistant itself makes, since the UI and the voice intents offer no
percentage for an entity without SET_SPEED.

The parameter stays in the signature because the service passes it positionally.

The second test is what keeps the refusal coherent: an entity that declared
SET_SPEED and then refused every percentage would be worse than either choice
alone, so the absence of the feature is now pinned rather than assumed.

Mutation evidence:
  refusal removed (if False)                    -> 1 failed
  SET_SPEED added to the declared features      -> 1 failed
  translation_key renamed to an undeclared one  -> 3 failed
Full suite 1632 passed, 1 skipped, 7 xfailed, 319 subtests.
The pair sweep at test_the_sweep_finds_the_pairs_it_is_meant_to reads only
description.attr_key and description.key, so the platform half of the tuple was
bound and never used. The sibling sweep above it does use both, which made the
difference easy to miss. Named _platform rather than a bare underscore, the
spelling the rest of the tree uses.

No linter in CI enforces this; it is a readability change.
@tis24dev
tis24dev merged commit 2d1fc71 into main Jul 30, 2026
12 checks passed
This was referenced Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant