Skip to content

Releases

v0.4.11 — fix options-page crash (HA config_entry now read-only)

Choose a tag to compare

@Limecooler Limecooler released this 18 May 18:38

Found in production logs during the thorough re-test pass. The moment the user opens the integration's options page in HA, the framework raises:

```
AttributeError: property 'config_entry' of 'AprilaireOptionsFlowHandler' object has no setter
```

Root cause

Modern HA (2024+) promoted `OptionsFlow.config_entry` to a read-only property that the framework injects automatically. Custom `init` constructors that assign to it now fail. Same change that landed across most core integrations during the HA 2024+ refactor — we just hadn't kept up.

Fix

  • `AprilaireConfigFlow.async_get_options_flow` no longer passes `config_entry` to the handler constructor.
  • `AprilaireOptionsFlowHandler` drops its custom `init` entirely. Reads `self.config_entry.options` and `self.config_entry.data` via the inherited property. The in-progress form state (`self.options`) is now lazily initialized in `async_step_init` rather than pre-cached.

Why CI didn't catch this

The existing `test_options_flow_shows_form` test (which routes through HA's full options-flow init path) was passing — `MockConfigEntry` from pytest-homeassistant-custom-component evidently allowed the property assignment, so the bug only manifested on a real HA instance. Added an explicit `AprilaireOptionsFlowHandler()` no-arg-constructor regression test that catches the signature mismatch directly.

586 tests pass.

Upgrade impact

Anyone who's seen the options gear icon crash will be able to use it again. No behavior change otherwise.

v0.4.10 — preserve available on targeted publish

Choose a tag to compare

@Limecooler Limecooler released this 18 May 18:33

Caught immediately by the thorough ha-mcp re-test on the live bus — right after firing `climate.set_temperature` on Billy Bedroom, the entity dropped to `unavailable` instead of staying `cool`.

Root cause

`device.get_state()` returns `device._state` only. The `available` flag lives on the device as a separate attribute, not in `_state`. The v0.4.8 `_publish_single_device_state` was overwriting `coordinator.data[device_id]` with the bare `_state` dict, blowing away the `available` key that the climate entity reads to decide whether to render at all.

The bulk-poll path (`_async_update_data`) handles this correctly by merging `available` in explicitly after the state copy; the targeted-publish path I added in v0.4.8 forgot to mirror the same merge.

Fix

`_publish_single_device_state` now merges:

  • `available` from `device.available`
  • Drops `from_cache` (stale once we've successfully talked to the device)

Why the v0.4.8 tests missed it

The existing coordinator test stub returned `{"heat_setpoint": 72.0, "available": True}` from `get_state()` — which isn't realistic; real `device.get_state()` only ever returns `_state` keys. The test passed the bug right through. Test stub updated to match reality, plus a new explicit assertion that `available` is preserved after the publish.

Verification

Will re-run the full ha-mcp test plan once HACS pulls v0.4.10 and HA restarts.

585 tests pass.

v0.4.9 — switches route through coordinator (found by ha-mcp test)

Choose a tag to compare

@Limecooler Limecooler released this 18 May 18:21

Found via end-to-end ha-mcp test: after running `switch.turn_on` on the Billy Bedroom fan override entity, the HA state stayed at `off` forever — the switch toggle "looked dead". The bus write went out fine, but the UI never reflected it.

Root cause

Both `AprilaireFanOverrideSwitch` and `AprilaireNetworkOverrideSwitch` called the device's setter directly (`self._device.async_set_fan_mode(...)`, `self._device.async_set_hold(...)`). Those methods update `device._state` on success — but never touch `coordinator.data`. The switch's `is_on` reads from `coordinator.data`, so the toggle remained False until the next ~5 minute poll cycle.

Climate already had this fixed in v0.4.8 by routing through `coordinator.async_set_*` (which calls `_publish_single_device_state` on success). The switches missed the same treatment.

Fix

  • Switch `_async_turn_on_impl` / `_async_turn_off_impl` now call `coordinator.async_set_fan_mode` / `coordinator.async_set_hold`.
  • New `coordinator.async_set_hold(device_id, on)` wraps `device.async_set_hold` and publishes a targeted single-device state update on success.

Verified end-to-end with ha-mcp on the live 11-device bus

  • Changed `climate.aprilaire_7` (Billy Bedroom) cool setpoint from 76 → 78 via `climate.set_temperature` service.
  • HA's state attribute `temperature` updated to 78 immediately.
  • All other devices' `last_reported` timestamps remained at the previous regular cycle (18:13:00) — no full bulk poll fired, proving v0.4.8's targeted publish works exactly as intended.
  • v0.4.9's switch routing applies the same pattern to the fan override + network override switches.

Tests

4 existing switch tests updated to assert the coordinator method is called (not the device directly). +2 covering `async_set_hold`'s targeted publish and missing-method handling. 584 pass.

v0.4.8 — targeted single-device update instead of full refresh on set

Choose a tag to compare

@Limecooler Limecooler released this 18 May 17:52

User question, answered: "When I change the temperature on Billy bedroom, it immediately re-polls every device for all details. Is this the proper implementation?" No — and the user is right.

What was happening

v0.4.7 made the refresh conditional on success, but a successful setpoint change was still calling `coordinator.async_request_refresh()` which triggers a full `DataUpdateCoordinator` refresh: every essential SN0 query (TEMP/MODE/FAN/HVAC/HOLD/SH/SC) against all 11 devices, ~30 seconds of bus time. Just to learn the new value of one setpoint we just set ourselves and already know.

What's fixed

Targeted single-device publish.

  • New `coordinator._publish_single_device_state(device)` pulls the device's already-updated `_state` into `coordinator.data[device_id]` and calls `async_update_listeners()`. HA UI sees the new value immediately.
  • `async_set_heat_setpoint` / `async_set_cool_setpoint` / `async_set_hvac_mode` / `async_set_fan_mode` all do this on success now.
  • `climate.async_set_temperature` / `async_set_hvac_mode` / `async_set_fan_mode` no longer call `async_request_refresh()` at all. The next scheduled bulk poll picks up any drift on its normal ~5½-minute schedule.

Bus impact

Setpoint changes are now ~265 ms of bus traffic (the one `SN SH=` or `SC=` write) instead of ~30 s of redundant polling per click. Concretely on an 11-device bus: every climate UI interaction was costing 9 commands × ~3 s each; now it costs 1 command.

Tests

+3 covering:

  • The targeted-publish path updates `data` and notifies exactly once.
  • Failed sets don't touch `data` and don't notify (so the UI never briefly shows a value the thermostat doesn't actually have).
  • The climate "never trigger full refresh on set" contract (both success and failure).

582 pass.

v0.4.7 — fix stale cache + no refresh on failure + skip dead sensors

Choose a tag to compare

@Limecooler Limecooler released this 18 May 17:42

After upgrading to v0.4.6, the user reported Billy Bedroom (and every other thermostat) still rejected setpoint changes with the same "Cannot set temperature on humidity controller" error, and every failed attempt kicked off an immediate full re-poll of all 11 devices.

Three fixes in this release

1. Stale capability cache made the v0.4.6 fix invisible

v0.4.5 and earlier persisted `controller_type` as the int `0` via the buggy `int(value)` in `_parse_controller_type`. v0.4.6 fixed the parser to store a string — but the loader (`_try_hydrate_from_capability_cache`) read the cached int back unchanged. So the comparison `capabilities["controller_type"] != "0"` was still `0 != "0"` → True for every user with a pre-v0.4.6 cache.

Fix: coerce non-str `controller_type` to `str` when loading from the cache. Also defensively coerce at the comparison site so any future drift is self-healing.

2. Failed `set_temperature` was triggering a full re-poll

`climate.async_set_temperature` unconditionally called `coordinator.async_request_refresh()` at the end, whether the underlying set succeeded or not. So every controller-type-mismatch error kicked off a ~30-second 9-command bulk cycle on the bus, drowning out subsequent commands and creating a thrashing pattern.

Fix: coordinator setters (`async_set_heat_setpoint` / `async_set_cool_setpoint`) now return `bool`. Climate only requests a refresh when the set actually stuck.

3. HUM/OT now skipped for devices that have no sensor wired

The 8870 firmware returns `HUM=--%` / `OT=--F` when no sensor is wired. With 11 thermostats all returning `--`, the integration was spending ~6 seconds of bus time per cycle (~12 minutes/day) on dead queries.

Fix: per-device `_humidity_supported` / `_outdoor_temp_supported` flags. Flip to False when the sentinel arrives, True on a real reading. The coordinator's `SN0 HUM?` / `SN0 OT?` globals only fire when at least one device is supported-or-unknown; the per-device fallback poll skips them for individual unsupported devices. If a sensor is later wired and broadcasts a real value, the flag flips back True and querying resumes automatically.

Tests

+5 covering:

  • Cache normalization for int→str `controller_type`
  • Climate skip-refresh on failure
  • Climate refresh-on-success
  • Bulk-poll skip when all devices unsupported
  • Bulk-poll continues when any device is supported-or-unknown

581 pass.

v0.4.6 — fix setpoint setting + family of producer/consumer bugs

Choose a tag to compare

@Limecooler Limecooler released this 18 May 17:24

Triggering bug: the climate entity couldn't set the setpoint on Billy Bedroom (SN7) with a spurious `"Cannot set temperature on humidity controller"` error in the log. The error actually fires for ANY thermostat once init runs.

Root cause: stringly-typed mismatch

  • `CONTROLLER_TYPE_TEMP = "0"` is a string constant.
  • `device._state["controller_type"]` defaults to that string.
  • But `_parse_controller_type` was doing `int(value)` and storing the int — so after init's `CT?` query landed, the equality `controller_type != "0"` was always True, blocking every `set_temperature` call on every device.

Same family, three more bugs

System Error binary sensor stuck "on" for every device

`dict.get(key, default)` returns the default only when the key is missing, not when the value IS `None`. `error_status` defaults to `None` (device.py:136) and stays `None` until the optional ERROR poll runs — and with `monitor_alarms=False` (the default), it never does. So `None != "000000"` evaluated True and the sensor reported problem=on for every thermostat permanently.

Sensor `self._device` always None

`coordinator.devices` is keyed by int (the RS-485 address), but `sensor.py` passed `device_id` in as a str — so `coordinator.devices.get(device_id)` always missed. Every temperature/humidity/outdoor-temp sensor silently dropped the device's location name and firmware version from the HA device registry.

Constant Backlight switch entirely non-functional

The 8870 firmware only supports `BLTON` (one-shot 10-second pulse, per programmer's manual DP 10005756). There is no constant on/off state — yet the switch entity treated it as if there were. The `constant_backlight` state key was never written, and `async_set_constant_backlight` doesn't exist on the device — both turn-on and turn-off impls silently no-op'd. Removed the SwitchEntity. The `aprilaire_8870.set_backlight` service still works for the one-shot pulse.

Latent hazard removed

`binary_sensor.py:74` accessed `device.is_heat_pump` as a direct attribute, but the real value lives in `device.capabilities["is_heat_pump"]`. Unreachable today because `entry.runtime_data.devices` is empty at platform-setup time; the moment that ordering changes, the old code would have `AttributeError`'d. Switched to a `capabilities.get()` lookup that works either way.

Tests

+3 covering the controller_type-after-parse-CT path, the None-default `error_status` false positive, and the sensor int-id lookup. 576 pass.

Upgrade impact

  • Setpoint changes work again on every thermostat.
  • System Error binary sensors stop reporting permanent false positives.
  • HA device registry picks up location names and firmware versions for the temperature/humidity/outdoor-temp sensors.
  • "Constant Backlight" entity disappears from HA. If you had it in dashboards or automations, they were silently broken anyway. Use the `aprilaire_8870.set_backlight` service for the one-shot pulse instead.

v0.4.5 — drop unsupported COS flags + serialize bus

Choose a tag to compare

@Limecooler Limecooler released this 18 May 17:12

Live-log review of v0.4.4 surfaced two related problems on the 11-thermostat bus.

8870 supports COS c1-c12 only

v0.4.4 enabled c14 (alarms) and c19 (errors) by default. Every `SN c14=ON` / `c19=ON` write timed out with "No response received" warnings, then so did each readback and per-device retry. With 11 devices × 4 commands (write + verify, both for c14 and c19) × ~3s each, this added 2+ minutes of bus-occupying timeouts to startup. Removed from `DEFAULT_COS_FLAGS`. Alarm/error state is still picked up by the optional per-device poll when `monitor_alarms` is enabled.

Bus interleaving between concurrent send paths

Both `async_send_global_command` and `async_send_command_with_response` released `_send_lock` immediately after writing the request, then awaited the response future without holding any lock. The bulk SN0 response window is up to 9 seconds (TDMA 265ms × 32 slots per the install manual). During that window the COS background setup's per-device retry path was firing `SN` writes into the bus, colliding with responses that were still being TDMA-streamed back to a different concurrent SN0.

Fix: hold `_send_lock` across the FULL request + response cycle in both send paths. The bus is now genuinely serialized — coordinator polls, COS background setup, and service calls all queue rather than racing.

Tests

+1 explicitly verifies a concurrent `_send_lock` acquirer waits until the global's response window closes. Existing COS tests updated for the 5-flag default. 576 pass.

v0.4.4 — reliable COS flag enable

Choose a tag to compare

@Limecooler Limecooler released this 18 May 16:59

Problem

The v0.4.3 log review surfaced a real asymmetry in COS broadcast traffic on the 11-thermostat bus:

  • 20 unsolicited HVAC (C1) broadcasts in 95 minutes — flowing well.
  • Only 4 unsolicited temperature (C2) broadcasts in the same window, despite ~14 polled whole-degree temperature transitions.

Smoking gun: thermostat SN4 oscillated 76↔77°F eight times and broadcast zero `T=` messages.

Root cause

The v0.4.0 bulk COS setup wrote each flag once via `SN0 c=ON` and trusted whatever echoed back inside a 2-second window. Devices whose echo arrived late, was dropped on the bus, or never came were silently assumed to have accepted the flag — but a missed write means the firmware doesn't actually have the C-flag enabled, so broadcasts never arrive from that device. There was no retry, no readback, no per-device verification.

Fix

Per-flag bulk write → bulk readback → per-device retry:

  1. `SN0 c=ON` (bulk write, same as before)
  2. `SN0 c?` (new — bulk readback to see what actually stuck)
  3. For any address that didn't echo `=ON`: one per-device write + readback via `device.protocol` (which reuses the future-correlation path the v0.4.2 case-insensitive command regex fixed for `c1..c8`).

`_cos_flags` is now authoritative — an empty set means broadcasts won't arrive from that device. Previously the code fabricated a "looks enabled" set even when nothing was actually echoed.

Expected impact

On an 11-device bus: C2 (and every other default flag) becomes reliably enabled at startup. Whole-degree temperature changes broadcast within ~1 second of the firmware detecting them, instead of being missed entirely until the next bulk poll up to 5½ min later.

Setup cost: ~7 extra SN0 readbacks plus a small handful of per-device retries — single-digit seconds at startup, no ongoing runtime cost.

Tests

+2 new tests covering the retry-on-OFF-echo path and the give-up-and-log path. 575 pass.

Considered and skipped

Re-polling individual devices on HVAC change. Once C2 is reliably enabled, every degree change broadcasts on its own. An HVAC-triggered poll would add ~330 extra single-device commands per hour to an actively cycling bus while being strictly redundant with the C2 broadcast it would race against.

v0.4.3 — fix binary-sensor crash from v0.4.2

Choose a tag to compare

@Limecooler Limecooler released this 18 May 14:08

Bug fix

v0.4.2 corrected the int-vs-str device_id lookup in binary_sensor.py. That made the inner state lookup actually return data — which exposed two latent bugs:

Crash on every coordinator update

device.py initializes hvac_status to None, and dict.get(key, default) only returns the default when the key is missing, not when the value is None. So None[index] raised TypeError: 'NoneType' object is not subscriptable on every coordinator update for the heating/cooling/fan/emergency-heat sensors:

File "/config/custom_components/aprilaire_8870/binary_sensor.py", line 216, in is_on
TypeError: 'NoneType' object is not subscriptable

Wrong relay indices

HVAC_RELAY_INDICES was sized for a legacy short format (G-Y-W-Y-W-B-O-, 14 chars), but real 8870 firmware sends the long format with stage-digit suffixes (G-Y1-W1-Y2-W2-B+O-, 18 chars). Every index past G/fan pointed at a digit character, so heating/cooling/emergency-heat sensors silently reported False even when the relays were on.

Fix

Replaced HVAC_RELAY_INDICES with the same find-then-check-sign helper that climate.async_hvac_action already uses (climate.py:236-244). The helper is robust to None, empty, short, or long formats. Test fixtures switched to the long format that matches what real devices return.

Tests

+1 regression test exercising all four affected sensor classes with hvac_status=None. 573 pass.

Upgrade urgency

Recommended for anyone who upgraded to v0.4.2 — the crashes don't break the integration's polling, but they spam the log and could obscure other warnings.

v0.4.2 — fan switch + COS verification + binary sensors

Choose a tag to compare

@Limecooler Limecooler released this 18 May 13:59

Bug fixes

Live-log triage of an 11-thermostat bus surfaced a family of silent producer/consumer mismatches that pre-date v0.4.

Fan override switch (the reported bug)

switch.py passes uppercase "ON" / "AUTO" to device.async_set_fan_mode, but the device's mode_map used lowercase keys ("on", "auto"). Every toggle hit the "Invalid fan mode" branch and returned False without ever sending the command to the thermostat. The integration could see thermostat-side changes (via COS broadcasts) but couldn't push them.

Fix: async_set_fan_mode is now case-insensitive and accepts both HA format ("on"/"auto"/"circulate") and wire format ("ON"/"AUTO"/"CIRC"). The wire-format value is stored in _state so bulk-poll echoes and switch.is_on (which compares against "ON") stay in sync.

COS verification false-negative

connection._COMMAND_RE was uppercase-only, but COS flags are conventionally lowercase (c1..c8). Lowercase requests fell through to send-only with no response future, so async_verify_cos and per-flag async_enable_cos always saw None. Logs showed "COS active on only 0/11 devices — staying on fallback scan interval" even though broadcasts WERE flowing.

Fix: the regex is now case-insensitive. Address-keyed future routing doesn't care about command case.

Binary sensors silently broken since v0.1

All binary sensors keyed self._device.device_id (int) against the coordinator's str-keyed data dict — always empty lookup. Plus:

  • Filter alarm compared a bool to the string "ON" — always False.
  • System error and network override read keys "error" / "hold" but the coordinator stores "error_status" / "hold_status".

Fix: all of the above, plus the int-vs-str pattern that sensor.py and switch.py already use correctly. Defensive padding on error_status so an unexpected length doesn't IndexError.

Preventive: HVAC mode setter case-insensitive

async_set_hvac_mode had the same lowercase-only mode_map shape as the broken fan setter. Today's callers only pass HA enum values (lowercase) so it works, but a direct caller passing "HEAT" would silently break the same way.

Docs

The docs/ folder is now referenced from README.md under References → Local docs, so the programmer's manuals the integration was developed against (DP 10005756, 8800 manual, install guide, etc.) are easy to find.

Tests

+7 new tests covering uppercase fan input from the switch, wire-format fan/HVAC aliases, lowercase command-code routing through the regex, bool/key-rename binary sensor fixes, and defensive short-string error_status handling. 572 tests pass.