Fix thermostat fan modes to respect fanModeSequence - #708
Conversation
|
To regenerate device diagnostics files (to see what this change would affect in real devices), run the following: python -m tools.regenerate_diagnosticsIt looks like four devices (in the testing DB) change their exposed fan modes. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #708 +/- ##
=======================================
Coverage 97.15% 97.16%
=======================================
Files 55 55
Lines 10481 10512 +31
=======================================
+ Hits 10183 10214 +31
Misses 298 298 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Should resolve #226 |
|
I noticed that one of the test devices was my thermostat. I can confirm this shows up properly:
But the fan mode doesn't really do anything for my specific thermostat even when switching the fan mode from "auto" to "on", it doesn't actually support fan mode independent from heat and cool. This is probably just a device-specific bug. @dmulcahey @TheJulianJES Do you have a device to test this change with? |
|
I'll have a closer look later, but I think the issue is still present in Matter: https://github.com/home-assistant/core/blob/9ddefaaacd1df9da4f5a4f673eea2348b524d0ac/homeassistant/components/matter/climate.py#L121-L159 (assuming it's even the same issue here, which it might not be...) |
|
Hi, I wanted to add a real-world data point in support of this PR. I've been running a custom ESP32-C6 Zigbee bridge that exposes a Midea split AC unit to Home Assistant via ZHA using standard clusters (Thermostat 0x0201, Fan Control 0x0202, Temperature Measurement 0x0402). The device reports With the current ZHA code:
Both of these issues are exactly what this PR addresses. I'd be happy to test a build with this change applied against my device if that would help move things forward. Thanks for working on this. |
|
The specific concern I brought above is that "fan only" support isn't reliably signaled by devices. My thermostat (and I imagine many of the others in our testing database) show up as supporting "fan only" but do not actually support it. There is, as far as I can tell, no way to tell from the ZCL alone that a device supports "fan only". We may need an opt-in mechanism via quirks, unfortunately. |
|
Added opt-in via quirks |
|
@puddly Can you take a look if latest commit is in line with what you suggested? |
|
Is there anything I can help with to get this merged? Or is it purely waiting for someone to merge it? I'm also developing a esp32h2 based air conditioning controller and am hitting the exact same situation Arol described above when running the stable ZHA. |
|
Fixed merge conflicts |
|
I think as an opt-in setting via quirk feature ID this is totally fine, since it's disabled by default. @TheJulianJES thoughts? |
TheJulianJES
left a comment
There was a problem hiding this comment.
I think using an "exposed feature" via quirks is indeed the best we can do here for the FAN_ONLY mode.
| modes = SEQ_OF_OPERATION.get(self._ctrl_sequence_of_oper, [HVACMode.OFF]) | ||
| if ( | ||
| self._fan_cluster is not None | ||
| and THERMOSTAT_FAN_ONLY_HVAC in self._device.exposes_features |
There was a problem hiding this comment.
I'm not saying we should, but if we wanted to, we could also have another entity class that overrides the fist one (so likely using a feature group) and then matches on the exposed feature. There, we'd only add FAN_ONLY to the list of possible modes. It would keep the base ZCL class clean of the exposed feature, but I think either is fine.
zigpy-review-bot
left a comment
There was a problem hiding this comment.
Right fix for a real bug — reading fan_mode_sequence instead of hardcoding [auto, on] is clearly correct per ZCL R8 §6.4.2.1.2 (Table 6-42), and the quirk-gated opt-in for FAN_ONLY matches what was asked for in the discussion and follows the existing exposes_feature prior art (SIREN_BASIC in zhaquirks/develco/*.py). One blocker and a few must-address items before this can merge.
Blocker
Fan Control cluster changes never reach Home Assistant. Thermostat.on_add() (zha/application/platforms/climate/__init__.py:549-563) registers the attribute-event callbacks on self._cluster (Thermostat, 0x0201) only. Both new getters read self._fan_cluster (Fan, 0x0202), which has no subscription — so nothing calls maybe_emit_state_changed_event() when the fan mode changes.
This isn't theoretical: ZHA already binds the Fan cluster and configures reporting on fan_mode (climate/__init__.py:353-366, min 5 s / max 900 s / reportable_change 1), so those reports do arrive and update the zigpy cache — they're just dropped on the floor. I verified it against this head: a fan_mode report on the Fan cluster flips entity.fan_mode from auto to high with 0 STATE_CHANGED emissions, and async_set_fan_mode() writes the attribute and returns without emitting either. On dev the same report also emits 0, but there fan_mode is derived from running_state — a Thermostat-cluster attribute that is subscribed — so the value only ever changed at a moment a state event was already firing. This PR moves the value onto an event stream nobody listens to.
User-visible effect: the test-plan item "Current fan mode reflects the actual fan_mode attribute" holds when you read the property directly, but HA keeps showing the old fan mode until some unrelated thermostat attribute happens to report — up to the reporting interval, or indefinitely on a quiet device.
The fix is small — extend the on_add() loop to the fan cluster:
def on_add(self) -> None:
"""Run when entity is added."""
super().on_add()
clusters = [self._cluster]
if self._fan_cluster is not None:
clusters.append(self._fan_cluster)
for cluster in clusters:
for event_type in (
AttributeReadEvent,
AttributeReportedEvent,
AttributeUpdatedEvent,
AttributeWrittenEvent,
):
self._on_remove_callbacks.append(
cluster.on_event(event_type.event_type, self.handle_attribute_updated)
)(_handle_attribute_updated is keyed on ATTR_OCCP_* names only before it emits, so fan attributes fall straight through to maybe_emit_state_changed_event() — no further changes needed. Worth a test asserting the emission, since nothing currently would catch this.)
Must-address
-
fan_modecan return a value that isn't infan_modes— the PR's own regenerated snapshot demonstrates it.tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json(fan_mode_sequence = 0x02,fan_mode = 0x04) now reportsfan_modes: [low, medium, high, auto]alongsidefan_mode: "on". HA rejects aset_fan_modefor anything outsidefan_modes(_valid_mode_or_raise,homeassistant/components/climate/__init__.py:584) and the frontend renders the selector with no matching option, so the user is left looking at a fan mode they cannot re-select. Please decide the reconciliation deliberately — e.g. union the reportedfan_modeintofan_modes, or clamp the reported mode to the sequence. -
FanMode.OffandFanMode.Smartare silently reported asauto.ZCL_TO_FAN_MODEis built by invertingFAN_MODE_TO_ZCL, which covers only 5 of the 7 non-reserved values in ZCL R8 §6.4.2.1.1 (Table 6-41);Off(0x00) andSmart(0x06) fall through to theFAN_AUTOdefault. Verified against this head: a device reportingFanMode.Offshows up in HA asauto, which is the opposite of what it means. It compounds with item 1 — withfan_mode_sequence = 0x00(Low/Med/High),fan_modescontains noautoat all, so the fallback is out of range by construction.FAN_OFFalready exists inconst.py:35; please mapFanMode.Offto it, and returnNonerather thanFAN_AUTOfor anything still unmapped. -
The tests don't cover the headline behavior. The three added tests exercise the
FAN_ONLYopt-in and the defensivezcl_mode is Nonebranch. Nothing assertsfan_modesderived from a reportedfan_mode_sequence,fan_moderead back from the Fan cluster, or thatlow/medium/highwriteFanMode.Low/Medium/High— i.e. the first, third, fourth and fifth test-plan checkboxes. The green Codecov patch comes from the four regenerated device snapshots exercising those lines incidentally, which is coverage without assertions. Please add explicit tests (send_attributes_reporton the fan cluster intests/test_climate.pyis all it takes).
Optional
- The unknown-sequence fallback flipped from
[FAN_AUTO, FAN_ON]to[FAN_ON, FAN_AUTO], which is visible in thecentralite-systems-3156105andzen-within-zen-01snapshots. The PR body saysfan_mode_sequence = 0x04devices are "unchanged" — they're reordered, which changes the dropdown order for every existing user of those devices. Harmless, but if it wasn't intentional it's free to restore.
Re: the hvac_modes design thread (TheJulianJES, inline on line 688)
Asked whether to keep the feature check in the base entity or split it into a feature-matched subclass — my read is keep it inline as written, with one caveat worth stating out loud.
The caveat: climate/__init__.py:688 is the first place in the codebase where a platform entity reads device.exposes_features directly. Until now that set was consumed exclusively by discovery.py:229-239 (match.exposed_features / not_exposed_features), so the subclass route isn't hypothetical — the matching machinery for it already exists and would keep the ZCL base class free of quirk knowledge.
But the cost doesn't scale with the benefit here. A Thermostat subclass that exists only to append one enum member to one property buys a registry entry and a second entity class, and swapping which class a device discovers as brings its own unique_id/continuity questions for anyone who later adds the quirk. The three-line guard is self-documenting and stays local to the property it affects. I'd revisit it if a second or third feature check lands in this entity — at that point the subclass earns its keep.
Separately: nothing in zigpy/zha-device-handlers references THERMOSTAT_FAN_ONLY_HVAC yet, so FAN_ONLY is inert on merge. That's the intended shape of an opt-in, but worth flagging that the DIY ESP32 bridges reported in this thread would each need a quirk (or a custom one) before they see any change — a companion quirks PR would make the feature reachable.
Verified (8 checks)
- State emission, base vs head. Ran an identical scratch test against
ccc36515andcb51c852: a Fan-clusterfan_modereport emits 0STATE_CHANGEDon both, but only on this head does the reported value change as a result (auto→on). A Thermostat-clusterrunning_statereport emits 1 on both. async_set_fan_modeemission. 0STATE_CHANGEDafter the write completes.- Out-of-range
fan_mode. Reproduced from the snapshot values:fan_mode_sequence = 2+fan_mode = 4→fan_modes = ['low','medium','high','auto'],fan_mode = 'on'. - Off / Smart fallback. Devices reporting
FanMode.Off(0) andFanMode.Smart(6) both surface as'auto'; withfan_mode_sequence = 0the list is['low','medium','high'], so'auto'isn't in it. fan_mode_sequenceavailability. Initially suspectedread_on_startup=Falsemeant the attribute was never fetched — it isn't;cluster_config.py:245-270reads those withallow_cache=True, so a freshly paired device does read it (falling back to[on, auto]only when the read fails). Not an issue.- ZCL. R8 §6.4.2.1.1 Table 6-41 (FanMode values) and §6.4.2.1.2 Table 6-42 (FanModeSequence) —
SEQ_FAN_MODESmatches Table 6-42 exactly. - Local suite + tooling.
tests/test_climate.py65 passed;mypy zha/clean in the worktree venv (real deps, not the dependency-less pre-commit env);ruff checkclean. CI green across 3.12/3.13/3.14. - Second opinion (Copilot, GPT-5.5). Independently raised the state-staleness and the Off/Smart fallback; both excerpts verified against the cited lines. No other findings.
zigpy-review-bot
left a comment
There was a problem hiding this comment.
All three items from the previous round are properly fixed — the Fan cluster is now wired into on_add(), ZCL_TO_FAN_MODE is spelled out explicitly with Off mapped and Smart deliberately omitted, out-of-range modes return None, and the new tests cover the headline behavior end to end. One shipstopper is left: CI is red, and the cause is a one-line ordering decision that also happens to be the leftover half of last round's optional item.
Blocker
CI fails on all three Python versions — run 31402877952, two snapshot tests:
test_devices_from_files[centralite-systems-3156105-0x1418468c.json]
test_devices_from_files[zen-within-zen-01-0x0000021f.json]
Both devices report fan_mode_sequence = 0x04, so they take SEQ_FAN_MODES[FanModeSequence.On_Auto] — which is [FAN_ON, FAN_AUTO], the reverse of the [FAN_AUTO, FAN_ON] these snapshots were generated with on dev. Reproduced locally (2 failed, 871 passed); python -m tools.regenerate_diagnostics produces exactly "auto", "on" → "on", "auto" in those two files and nothing else.
Last round's optional item 4 was about this ordering flip. The fallback was restored to [FAN_AUTO, FAN_ON], but the fallback is not the path these devices take — real On/Auto devices report the sequence and hit the SEQ_FAN_MODES entry, which still reads [FAN_ON, FAN_AUTO]. So the reorder that item flagged is still shipping, and the snapshots now disagree with the code.
The cheapest fix closes both at once: make the On_Auto entry [FAN_AUTO, FAN_ON]. CI goes green with no snapshot churn, and every existing user of a 0x04 device keeps the dropdown order they have today. (The alternative — regenerating the two snapshots and accepting the reorder — also turns CI green, but is a gratuitous UI change for existing installs.)
Must-address
The running_state fallback in fan_mode isn't clamped to fan_modes. The new branch above it correctly returns None when the reported mode is out of range, but if fan_mode isn't in the cache the property falls through to the old heuristic and returns FAN_AUTO/FAN_ON unchecked. Verified on this head: with fan_mode uncached and fan_mode_sequence = 0x00, fan_modes is ['low', 'medium', 'high'] while fan_mode returns 'auto' — the exact out-of-range state the clamp was added to prevent. That's reachable whenever the startup read of fan_mode fails or hasn't landed but the sequence has. Please either run the fallback through the same in self.fan_modes check, or drop the heuristic now that the real attribute is read.
Optional
FanMode.Off can never be surfaced. Off (0x00) is a valid FanMode for any device (ZCL R8 §6.4.2.1.1, Table 6-41), but no FanModeSequence value includes it (Table 6-42), so SEQ_FAN_MODES never contains FAN_OFF and the clamp turns every Off report into None. Verified across all five sequences: a device reporting Off shows as unknown in HA rather than "off". Not a regression — off was unreachable before this PR too — and test_fan_mode_off_mapped_correctly locks in the current behavior deliberately, so this is only worth revisiting if a device turns up that parks its fan at Off.
Notes, not asks
- The design thread on
hvac_modes(TheJulianJES,climate/__init__.py:695) is still open. My read is unchanged from last round: keep the inline feature check; aThermostatsubclass that exists only to append one enum member doesn't pay for the registry entry and the entity-class swap it implies. THERMOSTAT_FAN_ONLY_HVACstill has no consumer inzigpy/zha-device-handlersand no open PR referencing it, soFAN_ONLYremains inert on merge. That is the intended shape of an opt-in, but the DIY bridges reported in this thread each need a quirk before they see any change.
Verified (7 checks)
- Prior blocker (state emission).
on_add()now subscribes the fan cluster;test_fan_mode_state_emission_on_fan_cluster_reportasserts the emission. Confirmed the fan attribute names don't collide with any Thermostat-cluster name, so_handle_attribute_updatedfalls straight through tomaybe_emit_state_changed_event(). - Prior must-address 1 (out-of-range
fan_mode). Resolved by clamping toNone; theatlantic-group-adapter-zigbee-fujitsusnapshot (fan_mode_sequence = 0x02,fan_mode = 0x04) now reportsfan_mode: nullinstead of an unselectable"on". - Prior must-address 2 (
Off/Smart).ZCL_TO_FAN_MODEis now explicit rather than an inversion;Off→FAN_OFF,Smartomitted with a spec citation. fan_modesis no longer acached_property. Necessary — it's part ofClimateState, and HA-core re-derives_attr_fan_modesfrom state on every change (components/zha/entity.py:161→climate.py:118-125), so a latefan_mode_sequencereport does propagate.- ZCL.
SEQ_FAN_MODESmatches R8 §6.4.2.1.2 Table 6-42 exactly, key for key. - Local suite + tooling.
tests/test_climate.py82 passed;tests/test_discover.py2 failed / 871 passed (the two above);mypy zha/clean in the worktree venv (real deps, not the dependency-less pre-commit env);ruff checkclean. - Second opinion (Copilot, GPT-5.5). One finding, independently arriving at the
On_Autoordering change and its HA-visible effect; excerpt verified at the cited line. Nothing else.
zigpy-review-bot
left a comment
There was a problem hiding this comment.
Request changes — everything from the last round is fixed; one item left, and it's visible in the PR's own regenerated snapshot.
Fixed since 2026-08-10 (re-verified at 76deb4a): FanModeSequence.On_Auto is back to [auto, on], so centralite-systems-3156105 and zen-within-zen-01 regenerate unchanged and CI is green on 3.12/3.13/3.14. The running_state fallback now runs through the same in self.fan_modes clamp. The test gap is closed — sequence-derived fan_modes, fan_mode read back from the Fan cluster, and low/medium/high writes are all asserted now.
Must address — the clamp nulls out a real device's fan mode
tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json caches fan_mode = 0x04 (On) together with fan_mode_sequence = 0x02 (Low_Med_High_Auto). SEQ_FAN_MODES maps that sequence to [low, medium, high, auto], so on fails the mode in self.fan_modes check at zha/application/platforms/climate/__init__.py:612 and fan_mode returns None. The PR's own snapshot records it: "fan_mode": "auto" → "fan_mode": null.
In HA that means climate.fan_mode is None while ClimateEntityFeature.FAN_MODE is set — the fan dropdown renders with nothing selected on a device whose fan is genuinely running on On. The same list rejects the way back: async_set_fan_mode("on") returns early at line 833, so the user can't select it either.
The device is out of spec — ZCL R8 §6.4.2.1.2 Table 6-42 doesn't include On in the Low/Med/High/Auto sequence — but §6.4.2.1.1 Table 6-41 makes On (0x04) a valid FanMode value regardless of the sequence, and this is a device in the test DB that a user in this thread reported testing the PR against, so it isn't a hypothetical.
Suggested shape — union the reported mode into fan_modes rather than discarding it:
@property
def fan_modes(self) -> list[str] | None:
"""Return supported FAN modes."""
if self._fan_cluster is None:
return None
seq = self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode_sequence.name)
modes = list(SEQ_FAN_MODES.get(seq, [FAN_AUTO, FAN_ON]))
current = ZCL_TO_FAN_MODE.get(
self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode.name)
)
if current is not None and current not in modes:
modes.append(current)
return modesThat keeps the invariant the clamp was added for (fan_mode is always in fan_modes), turns the Atlantic snapshot back into "fan_mode": "on" instead of null, makes on selectable again, and leaves the three spec-conforming devices byte-identical. FanMode.Smart still maps to nothing and still yields None, which is the right answer for it. The list(...) copy also stops fan_modes handing out the shared module-level list object from SEQ_FAN_MODES.
Behavior note (no action needed)
No quirk in zigpy/zha-device-handlers references THERMOSTAT_FAN_ONLY_HVAC yet, so HVACMode.FAN_ONLY ships inert until a quirks-side PR opts a device in. That's the intended opt-in design, but worth flagging for the people in this thread running their own ESP32 Zigbee firmware — a custom device with no quirk won't pick it up.
Verified (7 checks)
- Reviewed at
76deb4ain an isolated worktree + venv.pytest tests/test_climate.py: 83 passed.mypy zha/inside that venv (real deps, unlike the CI hook's dependency-free env): no errors.ruff check: clean. CI green on 3.12/3.13/3.14. - Surveyed all 54 device snapshots for a Fan cluster (0x0202). Four have a thermostat entity:
atlantic-group-adapter-zigbee-fujitsu(fan_mode0x04 / seq 0x02),enktro-acmidea(0x05 / 0x02),centralite-systems-3156105(0x05 / 0x04),zen-within-zen-01(0x05 / 0x04). Only the Atlantic one hits the mismatch above; the other three are correct under this PR. - Confirmed the
On_Autoordering fix: nocentralite/zen-withinsnapshot churn in the diff, andtest_devices_from_filespasses. - Confirmed the
running_statefallback is now clamped (lines 616-628), covered bytest_fan_mode_running_state_fallback_clamped. read_on_startup=Falseonfan_mode_sequenceis not a problem for the new derivation:initialize_cluster_configsreads cache-preferring attributes withallow_cache=True(notonly_cache), so a freshly-paired device still reads it off the wire once.- Checked the new Fan-cluster event subscription in
on_addagainst everyhandle_attribute_updatedoverride —MoesThermostat,BecaThermostatandZONNSMARTThermostatdispatch on the attribute nameoperation_preset, which can't collide withfan_mode/fan_mode_sequencedespite the overlapping attribute IDs, andSinopeTechnologiesThermostat.on_addcallssuper().on_add(). - Second opinion from GitHub Copilot (GPT-5.5, read-only): one finding, independently the same clamp regression on the Atlantic device. Nothing else.
zigpy-review-bot
left a comment
There was a problem hiding this comment.
Comment — the fan_modes union does exactly what it was meant to, and every item from the previous rounds is now closed. CI is green on 3.12/3.13/3.14, the full suite passes locally, and regenerate_diagnostics produces zero churn at 1fd4370. One new item arrived with the union commit; it's a one-line fix, so I'm flagging rather than blocking.
Fixed since 76deb4a: atlantic-group-adapter-zigbee-fujitsu regenerates as "fan_mode": "on" with on appended to fan_modes, so the state is accurate and the mode is selectable again. The list(...) copy stops fan_modes handing out the shared SEQ_FAN_MODES list object, and dropping functools.cached_property (which dev has on this property) is what lets the union actually track reports instead of freezing at first access.
Must address — off becomes selectable but can never be written
ZCL_TO_FAN_MODE maps FanMode.Off (0x00) to "off", so a device reporting that mode now gets "off" unioned into fan_modes and shown in the HA fan dropdown. FAN_MODE_TO_ZCL has no "off" entry, so async_set_fan_mode("off") clears the fan_mode not in self.fan_modes gate at line 842 and then dead-ends at line 848 — No ZCL mapping for fan mode 'off' in the log, nothing on the wire.
Verified on this head, with fan_mode_sequence = Low_Med_High_Auto and a reported FanMode.Off:
fan_modes = ['low', 'medium', 'high', 'auto', 'off']
fan_mode = 'off'
write_attributes calls after async_set_fan_mode('off') = []
This was unreachable before the union commit — off appears in no SEQ_FAN_MODES list, so the fan_modes gate rejected it first. Adding FAN_OFF: FanMode.Off, to FAN_MODE_TO_ZCL closes it, and Off is a writable value of the fan_mode attribute per ZCL R8 §6.4.2.1.1 (Table 6-41), so there's no spec objection to sending it.
test_fan_mode_off_mapped_correctly asserts the read side (FanState.OFF in entity.fan_modes) but not the write side, which is why the suite stays green over this. Worth extending it with an async_set_fan_mode("off") assertion once the mapping is added.
Behavior note — the union is scoped to the currently-reported mode (no action requested)
The union only holds while the device is actively reporting the out-of-sequence mode. Verified: the Atlantic shape gives ['low', 'medium', 'high', 'auto', 'on'] while On, but after a Low report it drops to ['low', 'medium', 'high', 'auto'] and async_set_fan_mode("on") is rejected again — so a user who switches away from on can't get back to it. On dev that device's list is the hardcoded [auto, on], where on is always offered.
Net it's still clearly better than dev for that device (low/medium/high were previously unreachable), and this is the shape I suggested last round, so I'm not asking for a change — just making sure the trade-off is a deliberate choice rather than a surprise later.
Re: a second entity class + feature group for FAN_ONLY (raised by TheJulianJES on zha/application/platforms/climate/__init__.py:711)
I'd keep the inline check that's here. The THERMOSTAT_FAN feature group is already carrying this entity's priority ladder: the base Thermostat registers at feature_priority=(PlatformFeatureGroup.THERMOSTAT_FAN, 1) (line 242) and seven manufacturer subclasses register at priority 2. A ThermostatFanOnly(Thermostat) gated on exposed_features={THERMOSTAT_FAN_ONLY_HVAC} would need to outrank the base but lose to the manufacturer classes, and there is no slot between 1 and 2. Put it at 2 and it ties with the manufacturer match — selected_matches = matches_by_priority[highest_priority] (zha/application/discovery.py:323) returns the whole tied list, so a Zen/Moes/Beca/Centralite device that opted in would get two climate entities. Making it work means either renumbering the ladder or adding a fan-only variant of each of the seven subclasses.
The SIREN_BASIC precedent (zha/application/platforms/siren.py:335) composes cleanly because it pairs with not_exposed_features on the competing select entities and has no manufacturer subclass ladder to fight. Here the three-condition check at lines 708-714 costs five lines and works with every subclass for free, so the cost/benefit points the other way.
Verified (8 checks)
- Reviewed at
1fd4370in an isolated worktree + venv. Full suite: 1385 passed.tests/test_climate.py: 83 passed.mypy zha/inside that venv (real deps, unlike the CI hook's dependency-free env): no issues in 57 source files.ruff checkandruff format --check: clean. CI green on 3.12/3.13/3.14. python -m tools.regenerate_diagnosticsat this head produces an empty diff — the committed snapshots match what the code actually generates.- Re-surveyed every device snapshot: four expose a
fan_modeslist —atlantic-group-adapter-zigbee-fujitsu(on/[low, medium, high, auto, on]),enktro-acmidea(auto/[low, medium, high, auto]),centralite-systems-3156105andzen-within-zen-01(bothauto/[auto, on]). Last round's Atlanticnullis gone and the other three are unchanged fromdev. - Confirmed the
offdead-end by probe (output quoted above) and confirmed it is new to the union commit, not pre-existing. - Confirmed the union's mode-scoping by probe (the
on→low→on-unreachable sequence quoted above). - Confirmed
fan_modesis a plain@propertyon this branch.devdeclares itfunctools.cached_property; had that survived, the union would have frozen at first access and the fix would have been inert after the first state read. THERMOSTAT_FAN_ONLY_HVACstill has no consumer inzigpy/zha-device-handlers(greppeddev), soHVACMode.FAN_ONLYships inert until a quirks-side PR opts a device in. That's the intended design, but worth restating for the people in this thread running their own ESP32 Zigbee firmware — a custom device with no quirk won't pick it up.- Second opinion from GitHub Copilot (GPT-5.5, read-only): one finding, independently the same
offdead-end with the same reasoning. Nothing else.
zigpy-review-bot
left a comment
There was a problem hiding this comment.
Approve — the FAN_OFF → FanMode.Off write mapping from the last round is in, along with the test extension, and every item from the previous rounds is now closed. CI is green on 3.12/3.13/3.14, the full suite passes locally, regenerate_diagnostics produces an empty diff at 18dadc3, and mypy/ruff are clean.
Fixed since 1fd4370: FAN_MODE_TO_ZCL gains FAN_OFF: FanMode.Off, so the off entry the union puts into fan_modes is now actually writable instead of dead-ending at the No ZCL mapping for fan mode 'off' warning. test_fan_mode_off_mapped_correctly asserts the write side as well as the read side.
Verified by probe at this head, on a Low_Med_High_Auto device:
reported=Auto -> fan_modes=['low','medium','high','auto'] fan_mode='auto' set('off') -> no write (rejected by the fan_modes gate)
reported=Off -> fan_modes=['low','medium','high','auto','off'] fan_mode='off' set('off') -> write_attributes({'fan_mode': FanMode.Off})
reported=Auto -> fan_modes=['low','medium','high','auto'] fan_mode='auto'
reported=Smart -> fan_modes=['low','medium','high','auto'] fan_mode=None
Behavior note — off is reachable only while the device reports it (no action requested)
This is the same mode-scoping caveat as the on case I flagged last round, so it isn't an ask. Because no FanModeSequence value contains Off, off enters fan_modes only through the union — that is, only while the device is already reporting FanMode.Off. So in practice the new write mapping only ever fires as a redundant re-write of the mode the device is already in; "turn the fan off" from low/auto stays unavailable.
That is not a regression (dev's hardcoded [auto, on] never offered off either), and it is defensible on spec: ZCL R8 §6.4.2.1.2 describes FanModeSequence as "the possible fan speeds that the thermostat can set", and Table 6-42 lists no sequence containing Off — so unconditionally offering off would expose a mode no device advertises. The mapping is still worth having exactly as added, because it stops HA from rendering a dropdown option that silently does nothing.
If a device ever turns up that genuinely accepts Off outside its advertised sequence, the follow-up is a one-liner (append FAN_OFF unconditionally), but it would change the dropdown for every existing fan-capable thermostat and churn the four snapshots — not something to fold into this PR.
Notes carried over, unchanged
THERMOSTAT_FAN_ONLY_HVACstill has no consumer inzigpy/zha-device-handlers(re-greppedorigin/dev), soHVACMode.FAN_ONLYships inert until a quirks-side PR opts a device in. That is the intended shape of an opt-in, but the people in this thread running their own ESP32/ESP32-C6 Zigbee firmware each need a quirk before they see any change.- The design thread on
hvac_modes(TheJulianJES,climate/__init__.py:711) is still open. My position from last round is unchanged: the inline feature check is the cheaper option here, because aThermostatFanOnlysubclass has no free slot in theTHERMOSTAT_FANpriority ladder between the base class (priority 1) and the seven manufacturer subclasses (priority 2).
Verified (8 checks)
- Reviewed at
18dadc3in an isolated worktree + venv. Full suite: 1385 passed.tests/test_climate.py: 83 passed.mypy zha/inside that venv (real deps, unlike the CI hook's dependency-free env): no issues in 57 source files.ruff checkandruff format --check: clean. CI green on 3.12/3.13/3.14. python -m tools.regenerate_diagnosticsat this head produces an empty diff — the committed snapshots match what the code actually generates.- The delta since
1fd4370is exactly one source line (FAN_OFF: FanMode.OffinFAN_MODE_TO_ZCL) plus the test extension and its docstring. No other behavior change to re-verify. - Re-surveyed every device snapshot: four expose a
fan_modeslist —atlantic-group-adapter-zigbee-fujitsu(on/[low, medium, high, auto, on]),enktro-acmidea(auto/[low, medium, high, auto]),centralite-systems-3156105andzen-within-zen-01(bothauto/[auto, on]). Unchanged from last round; the latter two still matchdev. - Probe above covers the read and write of
off, its disappearance once the device moves to another mode, andFanMode.Smartstill yieldingNonewithout being unioned in. - ZCL R8 §6.4.2.1.1 Table 6-41 (FanMode values, access RW) and §6.4.2.1.2 Table 6-42 (FanModeSequence) re-read for the
Offquestion above. - Confirmed
functoolsis still used elsewhere inclimate/__init__.pyafter thecached_propertyremoval, and thatDevice.exposes_featuresis the right accessor for thehvac_modesopt-in check. - Second opinion from GitHub Copilot (GPT-5.5, read-only): one finding — the same observation that
offis absent from every sequence-derivedfan_modes, raised as must-address. I verified the cited code and agree with the mechanism, but not with the severity: the behavior predates this PR and noFanModeSequenceadvertisesOff, so I've recorded it as the behavior note above rather than an ask.


Summary
Thermostatentity to read thefan_mode_sequenceattribute from the ZCL Fan Control cluster (0x0202) instead of hardcoding fan modes to[auto, on]. Devices now correctly expose Low, Medium, High, and Auto fan speeds based on the ZCL spec (Table 6-20).fan_modegetter to read the actualfan_modeattribute from the Fan Control cluster handler instead of guessing from the thermostat'srunning_statebitmap.async_set_fan_modeto map all supported fan mode strings (low, medium, high, on, auto) to their correspondingFanModeZCL enum values.HVACMode.FAN_ONLYas an available HVAC mode when a Fan Control cluster is present on the endpoint, sinceSEQ_OF_OPERATION(derived fromcontrolSequenceOfOperation) never includes it per ZCL spec.Problem
The
Thermostatentity hardcodes fan modes to[auto, on]and completely ignores thefan_mode_sequenceattribute (attribute 0x0001) from the Fan Control cluster. Per ZCL 8 (Table 6-20),FanModeSequenceTypedefines which fan modes a device supports:Devices reporting e.g.
fan_mode_sequence = 0x02(Low/Medium/High/Auto) were stuck with only Auto/On in the UI.Additionally, the thermostat never exposes
HVACMode.FAN_ONLYeven when the device supportsSystemMode.Fan_only(0x07). The mappingsHVAC_MODE_2_SYSTEMandSYSTEM_MODE_2_HVACalready handleFAN_ONLY <-> Fan_only, buthvac_modesderives its list solely fromSEQ_OF_OPERATIONwhich never includes it, sincecontrolSequenceOfOperationonly covers cooling/heating per the ZCL spec.Changes
zha/application/platforms/climate/const.pyFanModefrom zigpySEQ_FAN_MODESdict mappingfan_mode_sequencevalues (0x00–0x04) to fan mode string listsFAN_MODE_TO_ZCLdict mapping fan mode strings toFanModeenum valuesZCL_TO_FAN_MODEreverse mapping dictzha/application/platforms/climate/__init__.pyfan_modes: Readfan_mode_sequencefrom the fan cluster handler, look up modes viaSEQ_FAN_MODES, fall back to[on, auto]for unknown sequencesfan_mode: Read actualfan_modeattribute from the fan cluster handler, map back viaZCL_TO_FAN_MODE, fall back torunning_stateheuristic when unavailableasync_set_fan_mode: UseFAN_MODE_TO_ZCLmapping instead of hardcodedOn/Autobranchhvac_modes: AppendHVACMode.FAN_ONLYwhen a fan cluster handler is presentBackwards compatibility
fan_mode_sequence = 0x04still get[on, auto](unchanged)fan_mode_sequencevalues fall back to[on, auto]HVAC_MODE_2_SYSTEM,SYSTEM_MODE_2_HVAC, orasync_set_hvac_modeTest plan
fan_mode_sequence = 0x02exposes: Low, Medium, High, Autofan_mode_sequence = 0x04(or unknown) exposes: On, Auto (backwards compatible)FanMode.Low(0x01) to the deviceFanMode.High(0x03) to the devicefan_modeattribute from the Fan Control clusterSystemMode.Fan_only(0x07) to the thermostatAffected devices
Any thermostat with a Fan Control cluster (0x0202) that reports
fan_mode_sequence != 0x04, such as Tuya HVAC thermostats with cooling/heating/fan modes (e.g._TZE204_mpbki2zm).