Skip to content

fix(climate): gate unrouted WHO 4 #4#II and actuator status polls - #816

Draft
GreenGrassBlueOcean wants to merge 3 commits into
v2-phase1-architecturefrom
fix/who4-interface-routing-and-actuator-guards
Draft

GreenGrassBlueOcean wants to merge 3 commits into
v2-phase1-architecturefrom
fix/who4-interface-routing-and-actuator-guards

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What is the problem

Important

Merge-order blocker (held in Draft): Keep this PR in Draft until upstream protocol & oracle evidence PRs OpenWebNet-HA/OpenWebNet-Encyclopedia#52 and OpenWebNet-HA/own-firmware-oracle#65 are merged to main.

Rebased onto v2-phase1-architecture (f4a5a907, incorporating #794, #818, and #808), several WHO = 4 (thermoregulation) polling, outbound-write, event-echo, and WHERE grammar surfaces remained unsafe or misleading against the raw SCS bus frames recorded across all 12 emulated gateway firmwares in OpenWebNet-HA/own-firmware-oracle#65 (8e85216cd8a30e3b51d89a0217528f639ee2bcef):

  1. Routed #4#II WHO = 4 frames (*#4*Z#4#II##, *#4*#0#4#II##, *0##, *11##, *19##, *20##, *60##, outbound writes, and event-session Dimension 14 echoes) are not routed across F422 by any of the 12 emulated gateway firmwares:
    • #794 (b48e774e) introduced INTERFACE_ZONE_REQUEST_REFUSED and _interface_poll_refused, but relied on verdict=out on *#4*2#4#01*0## to exempt H4684, MH200, MH201, F455, and Generic, gated _interface_poll_refused in climate.py on self._temperature_poll_wanted and self._interface is not None, and left outbound writes (async_set_hvac_mode, async_set_temperature, async_set_fan_mode) and event-session Dimension 14 echoes (gateway_events.py) stripping #4#II onto local main-bus zone Z.
    • Inspecting the actual scs_out bytes in thermo-zone-requests.tsv (8e85216c) shows zero $06EC... / $06E4... interface-routed telegrams on any target:
      • H4684 (2.0.54) and MH200 (2.1.2) parse the leading integer before # and emit unrouted main-bus zone-2 queries ($0499023000\r, $0499023012\r, $049902300E\r, $049902300D\r on status; $0499023000\r on *0##; $0499023013\r on *19##), cross-contaminating 4-Z#4#II with local main-bus zone Z.
      • F450 (2.0.10), F454 (2.0.51), F459 (2.1.5), F460 (2.0.12), F461 (2.0.11), and MyHomeServer1 (2.82.6) truncate WHERE at # on *20## ($04A9023010\r / $004A9023010\r) and *60## ($06D102030308000000\r / $006D102...), cross-polling local main-bus zone Z.
      • Outbound climate writes (async_set_hvac_mode, async_set_temperature, async_set_fan_mode) built OWNHeatingCommand.*(self._where, ...) without self._interface, silently writing to local main-bus zone Z or #0 when triggered on a 4-Z#4#II / 4-#0#4#II entity; similarly, gateway_events.py stripped #4#II from incoming *#4*Z#4#II*#14*...## echoes and emitted unrouted *#4*Z## (or invalid *#4*0## on #0 / #0#1).
      • Tying _interface_poll_refused to self._temperature_poll_wanted (and excluding central #0#4#II or inline zone: "2#4#01") left F459 cross-polling *#4*Z#4#II*60## onto main-bus zone Z, left F460/F461/MyHomeServer1/Generic polling dimensionless *#4*Z#4#II## (silent + NACK -> false unresponsive repair issues), and exposed MH202 (1.0.24) while configured as Generic before SSDP/UPnP or WHO 13 model identification (*#4*2#4#01## misparses as zone 4, actuator 1 and crashes coso with SIGSEGV).
  2. Per-actuator and 3-token split addresses (Z#N, Z#0, 0#N, 0#0, 3#Z#N, Z#N#4#II) and 4-zone central unit channels (#0#1..#0#4):
    • On MH202 (1.0.24), replayed dimensionless *#4*2#1## and *#4*2#4#01## crash coso with SIGSEGV (verdict=crash), and static disassembly of libthermo.so (device_recognition at 0x18c4) + libplug_thermo_tr.so (thermo_open2scs+0x394 at 0x22b4, g_list_first(NULL)->data) shows non-# 3-token A#B#C (1 <= B <= 99, 1 <= C <= 9, e.g. 3#2#1) enters the same local-actuator branch.
    • Across all 12 targets, per-actuator *0##, *11##, *19##, and *60## are either rejected (nack, silent) or truncate at # onto local zone Z, whereas per-actuator valve status uses Dimension 20 (*#4*Z#N*20## or *#4*#0*20##).
    • 4-zone central unit channels #0#2..#0#4 (BTicino 4695) were not treated symmetrically with #0#1 in climate.py (build(), __init__, async_update), and known_keys / _zone_config stripped internal # characters via zone_number("#0#2") -> "02" (deduplicating local zone "02").

Related: #794, OpenWebNet-HA/OWNd#140, OpenWebNet-HA/own-firmware-oracle#65, OpenWebNet-HA/openwebnet-mcp#22, OpenWebNet-HA/OpenWebNet-Encyclopedia#52


What is the solution

  1. Add is_actuator_where(where) in custom_components/myhome/where_grammar.py:
    • Strips any valid trailing 1–2 digit #4#II interface suffix (rpartition("#4#") with 1 <= len(iface) <= 2) and identifies 2-token actuator / all-actuators / pump addresses (Z#N, Z#0, 0#N, 0#0 with 0 <= N <= 9) and 3-token split / actuator addresses (3#Z#N / A#B#C with 0 <= C <= 9, including #0#0, #0#9, Z#N#4#II, and 3#Z#N#4#II), while excluding plain zones (Z), routed zones (Z#4#II), bare #4#II, out-of-range actuator tails (2#10, #0#99, 2#4#100), and central units (0, #0, #0#1..#0#4, #0#4#II).
  2. Tighten inline #4#II normalization (discovery.py, climate.py), bound _ZONE_WHERE (gateway.py), and guard Dimension 14 echoes (gateway_events.py):
    • Split trailing #4#<1..2 digits> via rpartition(BUS_ROUTING) only when base is non-empty and 1 <= len(iface) <= 2 in Address.from_device_id, Address.from_config, and MyHOMEClimate.__init__ so inline zone: "2#4#01" (or 2#4#4#1) normalizes identically to where="2", interface="01" ("2#4#4#01") without turning bare "#4#01" into where="" or accepting 3-digit "2#4#100".
    • Bound _ZONE_WHERE in gateway.py to ^#?[0-9]{1,2}(?:#[0-9])?(?:#4#[0-9]{2})?$ (matching OWNd#140 0..99 zone and 0..9 actuator bounds) and normalize 1-digit interface suffixes via rpartition(BUS_ROUTING) in _validate_zone_where.
    • Replace the partial INTERFACE_ZONE_REQUEST_REFUSED frozenset from #794 with a fleet-wide True default in MyHOMEGatewayHandler.interface_zone_request_refused (delegating through _who4_target() while honoring explicit profile.supports_interface_zone_requests), and gate BUS_ROUTING in where in _send_zone_requests.
    • Guard the OWNHeatingCommand Dimension 14 handler in gateway_events.py so #4#II echoes are withheld when interface_zone_request_refused is True (or preserve #4#II when False), and #0 / #0#1..#0#4 / Z#N echoes never emit *#4*0## or cross-poll zone Z.
  3. Gate routed #4#II polls & writes, actuator polls, and #0#1..#0#4 4-zone central channels in custom_components/myhome/climate.py:
    • Compute _interface_poll_refused = ((self._interface is not None or BUS_ROUTING in self._full_where) and gateway.interface_zone_request_refused is True) across routed zones (Z#4#II), routed central units (#0#4#II), and malformed inline #4#... strings regardless of _temperature_poll_wanted.
    • Withhold dimensionless status (*#4*...##), temperature (*0##), fan (*11##), humidity (*60##), and _request_zone_dimension (*11##, *19##) when _interface_poll_refused is True (with a one-time DEBUG log when a poll was wanted), and refuse outbound writes (async_set_hvac_mode, async_set_temperature, async_set_fan_mode) via _refuse_interface_write() with a WARNING log instead of stripping #4#II onto local main-bus zone Z or #0.
    • Exclude is_actuator_where(self._where) from _poll_on_add, _polls_temperature, _enable_fan_mode, _enable_humidity, async_update, and _request_zone_dimension, and guard watch_zone_actuators with _ZONE_WHERE.match(self._full_where).
    • Treat #0#1..#0#4 (_FOUR_ZONE_CENTRAL) uniformly as Central Unit (4695) (_central = True, never polled) and guard known_keys / _zone_config against stripping internal # tokens (zone_number("#0#2") -> "02" no longer collides with local zone "02").
  4. Calibrate documentation and release notes (docs/configuration/climate.md, .github/release-notes/2.0.0b16.md):
    • Document #4#II poll and write withholding, the 3#4#1..3#4#9 1-digit shorthand normalization, and actuator/split poll guards.

State Diagram

flowchart TD
    Start["Climate poll / write / Dim 14 echo"] --> Routed{"#4#II routed AND gateway.interface_zone_request_refused?"}
    Routed -- "Yes (all 12 oracle models + Generic by default)" --> Withhold["Withhold WHO 4 polls (*#4*...##, *0..*60##), Dim 14 echo polls, and outbound writes (passive bus events still apply)"]
    Routed -- "No (main bus or profile override)" --> Actuator{"is_actuator_where(_where)? (Z#N, Z#0, 0#N, 3#Z#N)"}
    Actuator -- "Yes" --> GuardActuator["Suppress *#4*W## (MH202 SIGSEGV), *0##, *11##, *19##, *60##, and Dim 14 echo polls; allow 2-token *#4*Z#N*20## watch when unrouted"]
    Actuator -- "No" --> Central{"_central? (#0, 0, #0#1..#0#4)"}
    Central -- "Yes (#0#1..#0#4)" --> Skip4695["4695 channel: event-driven only, never poll status/dims"]
    Central -- "Yes (#0 / 0)" --> PollCentral["3550 central: allow *#4*#0## status request (skip Dim 14 *#4*0##)"]
    Central -- "No (zone Z in 1..99)" --> PollZone["Emit supported gateway polls (*0##, *11##, *19##, *20##, *60##, or *#4*Z##) and zone writes"]
Loading

Hardware Verification

  • N/A — verified against full-system QEMU gateway firmware emulation across all 12 firmware images in OpenWebNet-HA/own-firmware-oracle#65 (8e85216cd8a30e3b51d89a0217528f639ee2bcef).

How is it tested

  • tests/test_startup_requests_per_firmware.py:
    • test_interface_zone_is_not_polled_across_any_unrouted_gateway, test_ssdp_model_string_hl4684_is_keyed_as_h4684, & test_f455_zone_still_sends_nothing: verify across F450, F454, F459, MH202, H4684, HL4684, H/L4684, MH200, MH201, F460, F461, MyHomeServer1, F455, and Generic that routed #4#01 zones withhold startup/periodic polls (_poll_on_add is False), emit zero frames, and log once at DEBUG only when a poll would otherwise have been sent.
    • test_withheld_interface_poll_also_withholds_humidity_and_fan_cross_polls: parametrized across F459 (humidity=True), F453, F453AV, and MH200N (humidity=False), confirming both *60## and *11## cross-polls are withheld and logged once.
    • test_profile_flag_wins_over_the_interface_zone_default: verifies profile.supports_interface_zone_requests (True/False) overrides the fleet default for both *0## and status-only (F461) gateways.
  • tests/test_climate_actuator_status.py:
    • test_interface_zone_request_refused_defaults_true_across_fleet & test_interface_zone_request_refused_respects_profile_flag_and_delegation: verify the fleet-wide True default across all 12 oracle models + Generic and delegation via _who4_target().
    • test_an_invalid_zone_address_is_refused_at_once, test_routed_zone_is_withheld_by_default_and_asked_when_profile_opts_in, & test_a_one_digit_interface_is_normalized_to_two_digits_and_gated: verify bounded _ZONE_WHERE rejection (105, 001, 2#10, #4#1, #4#01, 3#2#1), rpartition normalization (2#4#4#1 -> 2#4#4#01), and _send_zone_requests #4#II gating.
    • test_routed_zone_on_generic_or_f461_never_polls_status_or_raises_unresponsive: verifies Generic and F461 emit zero frames on 4-2#4#01 without raising unresponsive repairs.
    • test_inline_routed_where_and_routed_central_withhold_polls_and_writes: verifies inline where="2#4#1" and routed central #0#4#01 emit zero polls across Generic, F461, F459, H4684, and MH202, log one-shot DEBUG for both entities, and refuse async_set_hvac_mode, async_set_temperature, and async_set_fan_mode with WARNING logs (allowing writes through when supports_interface_zone_requests=True).
    • test_is_actuator_where_distinguishes_actuators_from_central_and_routed & test_actuator_where_never_sends_status_or_zone_dimension_polls: verify is_actuator_where across all actuator/central/routed/out-of-range boundaries (#0#0, #0#9, #0#1..#0#4, #0#99, 2#10, 2#4#100, 3#4#1, "", "4-") and confirm zero *#4*W##, *0##, *11##, *19##, or *60## frames are emitted.
    • test_four_zone_central_channels_1_to_4_are_central_and_never_polled & test_four_zone_central_configured_channels_use_4695_model_and_skip_polls: verify #0#1..#0#4 get Central Unit (4695), _central=True, zero polls, and zero deduplication collision with local zone "02".
    • test_malformed_inline_routing_does_not_crash_added_to_hass_or_poll & test_gateway_events_dim14_echo_guards_routed_central_and_actuator_addresses: verify malformed #4# strings (2#4#100, 3#4#001, #4#01) do not crash async_added_to_hass and Dimension 14 event echoes do not cross-poll local zone Z or emit *#4*0##.
  • tests/test_platform_discovery.py:
    • test_address_forms_follow_openwebnet_where_conventions: verifies Address.from_device_id and Address.from_config split valid 2#4#4#01 / 2#4#1 while preserving malformed #4#01 and 2#4#100 unsplit.
  • Suite & static verification results:
    • pytest tests -q -o addopts="" --cov=custom_components/myhome --cov-report=term-missing: 5,039 passed, 100.0% coverage (16,879 statements, 0 missed).
    • Pinned OWNd==2.0.0b11 check: scripts/typing_ratchet.py (mypy --strict: 0 error(s) in 0 module(s); baseline 0) and 293 passed across the modified test modules.
    • ruff check custom_components tests scripts: All checks passed (0 violations).
    • scripts/verify_ha_standards.py: PASSED (54/54 Platinum quality scale rules satisfied/exempt).
    • scripts/sync_documentation.py --check: PASSED (0 broken links, oracle consistency verified).

Where are the traces

  • OpenWebNet-HA/own-firmware-oracle#65 (8e85216cd8a30e3b51d89a0217528f639ee2bcef):
    • findings/thermo-zone-requests.md and results/<product>/<version>/oracle/full/thermo-zone-requests.tsv across all 12 emulated gateway targets (F450 020010 with reset=each; F453 010100, F453AV 030014, F454 020051, F459 020105, F460 020012, F461 020011, H4684 020054, MH200 020102, MH200N 010108, MH202 010024, and MyHomeServer1 028206 with reset=0).

What does the Firmware Oracle / Encyclopedia say

  1. OpenWebNet Encyclopedia (WHO = 4, OpenWebNet-Encyclopedia#52):
    • functional/who-4-temperature-control/addressing.md and protocol/addressing.md separate published WHO = 4 #4#INTERFACE syntax from observed gateway translation plugin behavior: #4#INTERFACE forwarding is implemented per-WHO in gateway translation plugins rather than uniformly across subsystems, and per-actuator queries use Dimension 20 (*#4*Z#N*20## / *#4*#0*20##) rather than dimensionless *#4*Z#N## (ownkb:claim:c003279, ownkb:claim:c003280).
  2. Firmware Oracle (own-firmware-oracle#65, openwebnet-mcp#22):
    • Zero of the 12 emulated firmware images emit $06EC... / $06E4... local-bus-routed SCS frames on any tested WHO = 4 #4#II request (2#4#01, 2#4#12, *0##, *11##, *19##, *20##, *60##, 2#4#12*20##, 2#1#4#01*20##).
    • Replayed oracle verdict=crash rows on MH202 010024: dimensionless *#4*2#1## and *#4*2#4#01## terminate coso with SIGSEGV in libplug_thermo_tr.so (thermo_open2scs+0x394 at 0x22b4, g_list_first(NULL)->data).
    • Static binary disassembly generalization on MH202 010024: libthermo.so (device_recognition at 0x18c4) classifies non-# 3-token A#B#C with 1 <= B <= 99 and 1 <= C <= 9 (such as 3#2#1) into the same local-actuator code path (actuator B#C), so is_actuator_where guards 3-token A#B#C alongside 2-token Z#N.

Checklist

  • PR Description: Follows the structured sections above (What is the problem, What is the solution, State Diagram, How is it tested, Where are the traces, What does the Firmware Oracle / Encyclopedia say).
  • Trace-Backed (if protocol/device change): Behavior is verified against real OpenWebNet bus traces or gateway firmware fixtures rather than guessed.
  • V2 Architecture: New or modified entities implement handle_event() and do not manually subscribe via async_dispatcher_connect.
  • Tests & Linters: pytest, ruff, and scripts/verify_ha_standards.py pass.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@GreenGrassBlueOcean
GreenGrassBlueOcean marked this pull request as draft October 26, 2026 16:15
@GreenGrassBlueOcean
GreenGrassBlueOcean force-pushed the fix/who4-interface-routing-and-actuator-guards branch from 32f4e6b to 2e3c5ee Compare October 26, 2026 16:46
gdluck pushed a commit to gdluck/HomeAssistant-MyHOME that referenced this pull request Oct 26, 2026
…grammar (OpenWebNet-HA#792 follow-up)"

This reverts commit 26c4e63f3b0c3c2bd1dc26dd1b0f1b0b2c7e0ba1 at the
maintainer's request on OpenWebNet-HA#821: the validator change had a `.partition`
bug on an actuator `michnovka#4` behind a one-digit interface (`2#4#4#1`), it
lags OWNd#148's bounds, and OpenWebNet-HA#816 already rewrites the same lines (with
`.rpartition` and bounded digits); the `.fullmatch` catch for "2\n" and
the OWNd#148 bounds will be folded into OpenWebNet-HA#816 instead. A revert rather
than a history rewrite so the branch keeps its pushed commits; the tree
equals the branch without that commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants