From 7b47972da18e081639d7bb017590ba4a3074c1c9 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 18:35:02 +0300 Subject: [PATCH 1/7] chore(profiles): derive the discovery rows from the OWNd profile (prep for OWNd #142) scripts/update_gateway_profiles.py carried a static `queried_whos` list per discovery row next to the derived one, and the oracle check compared the two, so an OWNd profile change (OWNd #142 drops WHO 16 from the F461 and the MyHomeServer1) turned the check and the docs test red until the list was edited by hand. The rows now come only from the installed OWNd profile over STARTUP_DISCOVERY_WHOS, the rule the startup gate in gateway.py applies, and the sound wording of a row follows `supports_who(16)`. Green against the pinned OWNd today; after OWNd #142 lands and the pin is bumped, `sync_documentation.py --update` regenerates the two rows and nothing else needs an edit. Tests pin both shapes with a profile double. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 1 + scripts/update_gateway_profiles.py | 83 +++++++++++++++++------------ tests/test_gateway_profiles.py | 85 +++++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 36 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index cff879365..4a95d39af 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -31,6 +31,7 @@ See the cover docs for the details and the known limits. - **Diagnostics**: the gateway profile block reports the OWNd profile's real `model_name`, `display_name`, `event_keepalive_interval` and `keepalive_summary` instead of `Generic` / 90 s for every gateway. - **Setup**: when `myhome.yaml` has gateway sections but none under this gateway's MAC, a warning names the entry MAC and the section keys that match no configured gateway (#106). - **Setup**: a gateway added manually is asked who it is once the password is accepted (`*#13**15##`, then `*#1013**1##` on the shared code 200, the MAC only from models known to answer `*#13**12##`); a typed placeholder MAC or model is corrected from the reply. +- **Docs tooling**: the "Profile Subsystems (advertised)" column of the startup discovery table in `docs/getting-started/hardware-compatibility.md` is derived from the installed OWNd profile (`supports_who`) for every row, the same rule the startup gate applies, so an OWNd profile change (such as dropping WHO 16 from the F461 / MyHomeServer1) regenerates the docs at the pin bump (`sync_documentation.py --update`). - **Expose Home Assistant to the bus**: a declarative `expose` engine projects HA entities and the clock onto free SCS addresses: virtual WHO 1 / WHO 2 actuators that a wall control can drive (mirroring HA state back to its LEDs), WHO 4 virtual temperature probes, WHO 18 virtual energy meters and the WHO 13 bus clock. Custom actions run as Home Assistant scripts (#750). - **Covers**: `class:` in `myhome.yaml` now reaches WHO 2 covers (a `curtain` is no longer shown as a shutter; #39); Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739). - **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); `where: "0004"` in `myhome.yaml` is resolved to `04`, the spelling the gateways accept and report, with a one-time warning, and registry entries created under the 4-digit spelling are moved to it; the duplicate entity and device that bus discovery had created under `04` are removed, their area, labels, icon and custom name carried over to the configured ones where those have none, and a light that bus discovery had created at the address of a WHO 1 switch or impulse cover goes with its lock / unlock buttons; the `A` / `PL` state attributes of such a light, switch or cover read `0` / `4` instead of `00` / `04`, as on bus-discovered entities (#147; the `0809` of #135 is a point of area 8, not of area 0, and stays as typed); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749). diff --git a/scripts/update_gateway_profiles.py b/scripts/update_gateway_profiles.py index b21334fee..9f9aebeb9 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -177,89 +177,85 @@ }, ] -# Authoritative startup discovery capabilities calibrated against OWNd.profiles and firmware_oracle.json. -# ``queried_whos`` is what the OWNd profile advertises (recomputed from the profile and checked by -# verify_profiles_against_oracle); ``sweep_omits`` lists the general requests of those WHOs the -# startup sweep leaves out anyway because the gateway firmware drops them before the bus -# (GENERAL_ZONE_REQUEST_REFUSED / SOUND_STATUS_REQUEST_REFUSED in custom_components/myhome/gateway.py; -# tests/test_startup_requests_per_firmware.py checks these lists against the sweep itself). +# Startup discovery rows. The WHOs a row advertises are not listed here: they are derived from +# the installed OWNd profile (``profile.supports_who``) over STARTUP_DISCOVERY_WHOS, exactly as +# the runtime gate in gateway.py decides what to send, so the table follows an OWNd profile +# change at the pin bump without an edit here. ``sweep_omits`` lists the general requests of +# those WHOs the startup sweep leaves out anyway because the gateway firmware drops them before +# the bus (GENERAL_ZONE_REQUEST_REFUSED / SOUND_STATUS_REQUEST_REFUSED in +# custom_components/myhome/gateway.py; tests/test_startup_requests_per_firmware.py checks these +# lists against the sweep itself); a request of a WHO the installed profile does not advertise +# is not an omission (nothing is sent for that WHO at all) and is left out of the generated row. +# ``notes`` describes the row; where a profile may list or drop WHO 16 between OWNd releases, +# ``notes_without_who16`` is used instead when the installed profile does not advertise WHO 16. GENERAL_ZONE_STATUS = "*#4*0##" SOUND_STATUS = "*#16*0*5##" GATEWAY_DISCOVERY_METADATA: list[dict[str, object]] = [ { "model": "**MH202**", "profile_name": "MH202", - "queried_whos": [1, 2, 4, 5, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS, SOUND_STATUS], "notes": "Burglar Alarm (`*#5*0##`) and Energy (`*#18*51*...`) queries. WHO 4 and WHO 16 are advertised, but the `010024` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle), so the sweep leaves both out and each climate zone asks `*#4**0##` itself.", }, { "model": "**H4890 / AM4890**", "profile_name": "H4890", - "queried_whos": [1, 2, 4, 5, 16, 18], "sweep_omits": [], "notes": "Supports Burglar Alarm, Sound (`*#16*0*5##`), and Energy meter 51 queries.", }, { "model": "**F454**", "profile_name": "F454", - "queried_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Full lighting, cover, sound (`bt_difson` firmware-verified), and energy meter 51 queries. The `020051` firmware drops `*#4*0##` (own-firmware-oracle), so each climate zone asks `*#4**0##` itself. Alarm query omitted.", }, { "model": "**F452 / F453 / F453AV**", "profile_name": "F453AV", - "queried_whos": [1, 2, 4, 16, 18], "sweep_omits": [], "notes": "Lighting, cover, climate, sound (`bt_difson` firmware-verified on F453/F453AV), and energy meter 51 queries. Alarm query omitted.", }, { "model": "**F461**", "profile_name": "F461", - "queried_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS, SOUND_STATUS], "notes": "Lighting, cover, and energy meter 51 queries. The `020011` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle, confirmed live on 2.0.11), so the sweep leaves both out; zones are polled with `*#4*##`. Alarm query omitted.", + "notes_without_who16": "Lighting, cover, and energy meter 51 queries. Sound is WHO 22 only (no `bt_difson`; every WHO 16 frame refused, own-firmware-oracle `020011`, confirmed live on 2.0.11). The firmware also drops `*#4*0##`, so the sweep leaves it out; zones are polled with `*#4*##`. Alarm query omitted.", }, { "model": "**F455**", "profile_name": "F455", - "queried_whos": [1, 2, 4, 18], "sweep_omits": [], "notes": "Entry-level gateway: lighting, cover, climate, and energy meter 51 queries. Sound (WHO 16/22) and Alarm (WHO 5) omitted per installer manual.", }, { "model": "**MyHomeServer1**", "profile_name": "MyHomeServer1", - "queried_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS, SOUND_STATUS], "notes": "Lighting, cover, and energy meter 51 queries. The firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle `028206`, confirmed live on 3.87.13), so the sweep leaves both out; zones are polled with `*#4*##`. Alarm query omitted.", + "notes_without_who16": "Lighting, cover, and energy meter 51 queries. Sound is WHO 22 only (no `bt_difson`; every WHO 16 frame refused, own-firmware-oracle `028206`, confirmed live on 3.87.13). The firmware also drops `*#4*0##`, so the sweep leaves it out; zones are polled with `*#4*##`. Alarm query omitted.", }, { "model": "**MH201**", "profile_name": "MH201", - "queried_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Lighting, cover, climate, sound (unmeasured class default), and energy meter 51 queries. Not in own-firmware-oracle; a physical MH201 3.3.6 returned no zone frame to `*#4*0##` although its zones report (`tests/fixtures/traces/issue_466`), so the sweep leaves it out and each climate zone asks `*#4**0##` itself. Alarm query omitted.", }, { "model": "**MH200** *(Legacy)*", "profile_name": "MH200", - "queried_whos": [1, 2, 4, 16], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Lighting, cover, climate, and sound queries (`bt_difson` hardware and firmware-verified). The `020102` firmware drops `*#4*0##` (own-firmware-oracle), so its climate zones ask `*#4**0##` themselves. Alarm and energy queries omitted.", }, { "model": "**MH200N**", "profile_name": "MH200N", - "queried_whos": [1, 2, 4, 16], "sweep_omits": [], "notes": "Lighting, cover, climate, and sound queries (`bt_difson` hardware and firmware-verified). The `010108` firmware forwards `*#4*0##` (own-firmware-oracle), unlike the MH200. Alarm and energy queries omitted.", }, { "model": "**H4684 / H/L4684**", "profile_name": "H4684", - "queried_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Lighting, cover, climate, sound, and energy meter 51 queries (`bt_multi` WHO 22 also enabled on firmware >= `6.0.0`). Both `020054` (own-firmware-oracle) and live `6.0.11` (`tests/fixtures/traces/issue_466`) drop `*#4*0##`, so each climate zone asks `*#4**0##` itself; the SSDP spellings `H/L4684` and `HL4684` resolve to `H4684Profile`. Alarm query omitted.", }, @@ -448,10 +444,27 @@ def build_options_block() -> str: return f"{OPTIONS_START_MARKER}\n{table}\n{OPTIONS_END_MARKER}" +def discovery_whos_for(profile: object) -> list[int]: + """The startup discovery WHOs the runtime sends to a gateway with ``profile``. + + Mirrors ``MyHOMEGatewayHandler._profile_supports_who``: a WHO is queried only when + the OWNd profile advertises it. + """ + supports = getattr(profile, "supports_who", None) + if not callable(supports): + return [] + return [w for w in STARTUP_DISCOVERY_WHOS if bool(supports(w))] + + def calibrate_discovery_with_ownd( discovery_metadata: list[dict[str, object]] | None = None, ) -> list[dict[str, object]]: - """Dynamically calibrate startup discovery WHOs against OWNd.profiles.""" + """Derive the startup discovery rows from OWNd.profiles. + + ``queried_whos`` (the advertised column), the ``sweep_omits`` that still apply and the + sound wording follow the installed profile. Without an importable OWNd the rows carry no + ``queried_whos`` (the table cannot be generated from guesses). + """ try: from OWNd.profiles import get_gateway_profile except ImportError: @@ -460,15 +473,19 @@ def calibrate_discovery_with_ownd( calibrated = copy.deepcopy( GATEWAY_DISCOVERY_METADATA if discovery_metadata is None else discovery_metadata ) - if get_gateway_profile is not None: - for row in calibrated: - prof_name = row.get("profile_name") - if prof_name: - prof = get_gateway_profile(str(prof_name)) - if prof is not None: - row["queried_whos"] = [ - w for w in STARTUP_DISCOVERY_WHOS if prof.supports_who(w) - ] + for row in calibrated: + prof_name = row.get("profile_name") + prof = get_gateway_profile(str(prof_name)) if prof_name and get_gateway_profile is not None else None + queried = discovery_whos_for(prof) if prof is not None else [] + row["queried_whos"] = queried + # A general request of a WHO the profile does not advertise is not an omission of the + # sweep (nothing is sent for that WHO at all), so it leaves the generated row. + row["sweep_omits"] = [ + frame for frame in cast_list(row.get("sweep_omits")) if _frame_who(str(frame)) in queried + ] + without_who16 = row.pop("notes_without_who16", None) + if prof is not None and without_who16 and 16 not in queried: + row["notes"] = without_who16 return calibrated @@ -482,7 +499,7 @@ def generate_gateway_discovery_table() -> str: for row in rows: model = row["model"] whos = row["queried_whos"] - who_str = "WHO " + ", ".join(str(w) for w in whos) if isinstance(whos, list) else str(whos) + who_str = "WHO " + ", ".join(str(w) for w in whos) if whos else "unknown (OWNd profile not available)" sent = "WHO " + ", ".join(str(w) for w in sweep_whos(row)) omitted = cast_list(row.get("sweep_omits")) if omitted: @@ -587,12 +604,10 @@ def _verdict(product: str, frame: str) -> tuple[str, str] | None: for row in GATEWAY_DISCOVERY_METADATA: prof_name = str(row.get("profile_name", "")) prof = get_gateway_profile(prof_name) - expected_whos = [w for w in STARTUP_DISCOVERY_WHOS if prof.supports_who(w)] - if row.get("queried_whos") != expected_whos: - violations.append( - f"Discovery row {row.get('model')} queried_whos={row.get('queried_whos')} " - f"disagrees with {prof_name} profile {expected_whos}." - ) + if prof is None: + violations.append(f"Discovery row {row.get('model')} names an OWNd profile that does not exist: {prof_name}.") + continue + expected_whos = discovery_whos_for(prof) for frame in cast_list(row.get("sweep_omits")): if str(frame) not in (GENERAL_ZONE_STATUS, SOUND_STATUS) or _frame_who(str(frame)) not in expected_whos: violations.append( diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index de4f0d763..27a570890 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -162,19 +162,39 @@ def test_calibrate_metadata_with_ownd(): assert f453av["upnp"] == "✅ Port 49153" +def _sound_row_cells(profile_name: str) -> str: + """The advertised and sent cells of the F461 / MyHomeServer1 row for the installed OWNd profile. + + Both firmwares drop ``*#4*0##`` and refuse every WHO 16 frame; whether WHO 16 is still + *advertised* (and ``*#16*0*5##`` therefore an omission of the sweep) depends on the + installed OWNd profile. + """ + from OWNd.profiles import get_gateway_profile + + if get_gateway_profile(profile_name).supports_who(16): + return "WHO 1, 2, 4, 16, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`, `*#16*0*5##`)" + return "WHO 1, 2, 4, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`)" + + def test_generate_gateway_discovery_table_and_verify_oracle(): - """Verify startup discovery table generation and oracle verification.""" + """The discovery table follows the installed OWNd profiles and the oracle check passes.""" block = ugp.build_discovery_block() assert ugp.DISCOVERY_START_MARKER in block assert ugp.DISCOVERY_END_MARKER in block + # Rows whose profile has not changed between OWNd releases: the literal cells. assert "| **F454** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 16, 18 (firmware drops `*#4*0##`) |" in block assert "| **F452 / F453 / F453AV** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 4, 16, 18 |" in block - assert "| **F461** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`, `*#16*0*5##`) |" in block assert "| **F455** | WHO 1, 2, 4, 18 | WHO 1, 2, 4, 18 |" in block assert "| **MH200** *(Legacy)* | WHO 1, 2, 4, 16 | WHO 1, 2, 16 (firmware drops `*#4*0##`) |" in block assert "| **MH200N** | WHO 1, 2, 4, 16 | WHO 1, 2, 4, 16 |" in block assert "| **MH201** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 16, 18 (firmware drops `*#4*0##`) |" in block assert "| **H4684 / H/L4684** | WHO 1, 2, 4, 16, 18 | WHO 1, 2, 16, 18 (firmware drops `*#4*0##`) |" in block + # Rows that follow the OWNd profile (WHO 16 on the F461 / MyHomeServer1 is being dropped + # in OWNd): derived from the installed profile, so the test is right before and after. + assert f"| **F461** | {_sound_row_cells('F461')} |" in block + assert f"| **MyHomeServer1** | {_sound_row_cells('MyHomeServer1')} |" in block + # No row hard-codes its WHOs; the generator gets them from the profile. + assert all("queried_whos" not in row for row in ugp.GATEWAY_DISCOVERY_METADATA) assert ugp.verify_profiles_against_oracle() == [] @@ -192,6 +212,67 @@ def test_oracle_check_rejects_a_sweep_omission_the_profile_does_not_advertise(mo assert "**MH202** sweep_omits='*#5*0##'" in violations +def test_discovery_whos_follow_the_runtime_gate(): + """``discovery_whos_for`` mirrors ``MyHOMEGatewayHandler._profile_supports_who`` over the discovery WHOs.""" + from OWNd.profiles import get_gateway_profile + + for name in ("F454", "F455", "F461", "MH200N", "MH202", "MyHomeServer1"): + prof = get_gateway_profile(name) + assert ugp.discovery_whos_for(prof) == [w for w in ugp.STARTUP_DISCOVERY_WHOS if prof.supports_who(w)] + assert ugp.discovery_whos_for(None) == [] + assert ugp.discovery_whos_for(object()) == [] + + +class _ProfileWithoutWho16: + """An OWNd profile double for the F461 / MyHomeServer1 once OWNd drops WHO 16 from them.""" + + def supports_who(self, who: int) -> bool: + return who in (1, 2, 4, 18, 22) + + +def test_discovery_row_drops_who16_when_the_profile_does(monkeypatch: pytest.MonkeyPatch): + """With a profile that no longer lists WHO 16 the F461 row, its sweep omissions and its sound wording follow. + + ``*#16*0*5##`` is no longer an omission of the sweep (nothing is sent for WHO 16 at all), + so the generated row stops naming it; ``*#4*0##`` is still left out by the firmware. + """ + import OWNd.profiles as ownd_profiles + + real = ownd_profiles.get_gateway_profile + + def _patched(name: str, *args, **kwargs): + if name in ("F461", "MyHomeServer1"): + return _ProfileWithoutWho16() + return real(name, *args, **kwargs) + + monkeypatch.setattr(ownd_profiles, "get_gateway_profile", _patched) + rows = {row["model"]: row for row in ugp.calibrate_discovery_with_ownd()} + for model in ("**F461**", "**MyHomeServer1**"): + assert rows[model]["queried_whos"] == [1, 2, 4, 18] + assert rows[model]["sweep_omits"] == ["*#4*0##"] + assert ugp.sweep_whos(rows[model]) == [1, 2, 18] + assert "WHO 22 only" in str(rows[model]["notes"]) + assert "notes_without_who16" not in rows[model] + assert rows["**F454**"]["queried_whos"] == [1, 2, 4, 16, 18] + assert rows["**F454**"]["sweep_omits"] == ["*#4*0##"] + assert "bt_difson" in str(rows["**F454**"]["notes"]) + assert rows["**MH202**"]["sweep_omits"] == ["*#4*0##", "*#16*0*5##"] + block = ugp.build_discovery_block() + assert ( + "| **F461** | WHO 1, 2, 4, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`) | " + "Lighting, cover, and energy meter 51 queries. Sound is WHO 22 only" + ) in block + assert "| **MyHomeServer1** | WHO 1, 2, 4, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`) | " in block + + +def test_discovery_table_without_ownd_does_not_guess(monkeypatch: pytest.MonkeyPatch): + """Without an importable OWNd the rows carry no WHOs instead of a stale hard-coded list.""" + monkeypatch.setitem(sys.modules, "OWNd.profiles", None) + rows = ugp.calibrate_discovery_with_ownd() + assert all(row["queried_whos"] == [] for row in rows) + assert "unknown (OWNd profile not available)" in ugp.generate_gateway_discovery_table() + + def test_oracle_check_rejects_patched_profile_instance(): """A profile instance rewritten after import fails the check instead of verifying the rewrite.""" from OWNd.profiles import get_gateway_profile From cbedb110ecce1110679f74b8372abbbc889f882d Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 00:18:25 +0300 Subject: [PATCH 2/7] fix(profiles): pinned no-OWNd fallback so the discovery table is identical with and without OWNd (CI follow-up) The quality-scale, test-coverage and docs CI jobs run scripts/verify_ha_standards.py and scripts/sync_documentation.py --check without OWNd installed ("[OWND] Client library verification skipped (OWNd not in path: No module named 'OWNd')"). The first push derived the startup discovery rows from the installed OWNd profile only and printed "unknown (OWNd profile not available)" otherwise, so those jobs regenerated a table that differed from the committed docs/getting-started/hardware-compatibility.md: "[RULE_DOCS_ANTI_DRIFT] Gateway Discovery table out of sync". Each discovery row now carries pinned_whos, the row the pinned OWNd 2.0.0b11 yields; it is used only when OWNd is not importable, so the generated table is byte-identical with and without OWNd. With OWNd importable the row is still derived from profile.supports_who over STARTUP_DISCOVERY_WHOS, as the startup gate does, and notes_without_who16 applies whenever the row does not carry WHO 16. verify_profiles_against_oracle() flags a row whose profile_name resolves to the generic profile (get_gateway_profile() never returns None). Tests: without OWNd every row carries exactly its pinned_whos and the generated block equals the committed one; with OWNd the derived block equals the committed one (installed profile == committed table == pinned_whos); pinned_whos is ignored when OWNd is importable. Fail-before on the first-push script: 3 of 18 fail; all pass after. sync_documentation.py --check passes in a venv without OWNd and with OWNd master (f04d8aa) on PYTHONPATH. Against the OWNd #142 branch (95dc8a5) the two docs-equality tests report the drift that the pin bump resolves with --update together with pinned_whos. Oracle re-check at own-firmware-oracle main 72759e8 (results/mcp_index.json, schema 1.2.0, 529 inputs): F461 020011 and MyHomeServer1 028206 still answer nack/silent to all 86 WHO 16 inputs (*#16*0*5## sound-who16-source.tsv line 16, *#16*101*5## line 28), unchanged from 53f9514. Refs OpenWebNet-HA/OWNd#142. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- scripts/update_gateway_profiles.py | 80 ++++++++++++++++--------- tests/test_gateway_profiles.py | 93 ++++++++++++++++++++++++++++-- 3 files changed, 141 insertions(+), 34 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 4a95d39af..41d995796 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -31,7 +31,7 @@ See the cover docs for the details and the known limits. - **Diagnostics**: the gateway profile block reports the OWNd profile's real `model_name`, `display_name`, `event_keepalive_interval` and `keepalive_summary` instead of `Generic` / 90 s for every gateway. - **Setup**: when `myhome.yaml` has gateway sections but none under this gateway's MAC, a warning names the entry MAC and the section keys that match no configured gateway (#106). - **Setup**: a gateway added manually is asked who it is once the password is accepted (`*#13**15##`, then `*#1013**1##` on the shared code 200, the MAC only from models known to answer `*#13**12##`); a typed placeholder MAC or model is corrected from the reply. -- **Docs tooling**: the "Profile Subsystems (advertised)" column of the startup discovery table in `docs/getting-started/hardware-compatibility.md` is derived from the installed OWNd profile (`supports_who`) for every row, the same rule the startup gate applies, so an OWNd profile change (such as dropping WHO 16 from the F461 / MyHomeServer1) regenerates the docs at the pin bump (`sync_documentation.py --update`). +- **Docs tooling**: the "Profile Subsystems (advertised)" column of the startup discovery table in `docs/getting-started/hardware-compatibility.md` is derived from the installed OWNd profile (`supports_who`) for every row, the same rule the startup gate applies, so an OWNd profile change (such as dropping WHO 16 from the F461 / MyHomeServer1) regenerates the docs at the pin bump (`sync_documentation.py --update`, together with the `pinned_whos` fallback the CI jobs without OWNd use). - **Expose Home Assistant to the bus**: a declarative `expose` engine projects HA entities and the clock onto free SCS addresses: virtual WHO 1 / WHO 2 actuators that a wall control can drive (mirroring HA state back to its LEDs), WHO 4 virtual temperature probes, WHO 18 virtual energy meters and the WHO 13 bus clock. Custom actions run as Home Assistant scripts (#750). - **Covers**: `class:` in `myhome.yaml` now reaches WHO 2 covers (a `curtain` is no longer shown as a shutter; #39); Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739). - **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); `where: "0004"` in `myhome.yaml` is resolved to `04`, the spelling the gateways accept and report, with a one-time warning, and registry entries created under the 4-digit spelling are moved to it; the duplicate entity and device that bus discovery had created under `04` are removed, their area, labels, icon and custom name carried over to the configured ones where those have none, and a light that bus discovery had created at the address of a WHO 1 switch or impulse cover goes with its lock / unlock buttons; the `A` / `PL` state attributes of such a light, switch or cover read `0` / `4` instead of `00` / `04`, as on bus-discovered entities (#147; the `0809` of #135 is a point of area 8, not of area 0, and stays as typed); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749). diff --git a/scripts/update_gateway_profiles.py b/scripts/update_gateway_profiles.py index 9f9aebeb9..b040d135e 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -177,47 +177,57 @@ }, ] -# Startup discovery rows. The WHOs a row advertises are not listed here: they are derived from -# the installed OWNd profile (``profile.supports_who``) over STARTUP_DISCOVERY_WHOS, exactly as -# the runtime gate in gateway.py decides what to send, so the table follows an OWNd profile -# change at the pin bump without an edit here. ``sweep_omits`` lists the general requests of -# those WHOs the startup sweep leaves out anyway because the gateway firmware drops them before -# the bus (GENERAL_ZONE_REQUEST_REFUSED / SOUND_STATUS_REQUEST_REFUSED in -# custom_components/myhome/gateway.py; tests/test_startup_requests_per_firmware.py checks these -# lists against the sweep itself); a request of a WHO the installed profile does not advertise -# is not an omission (nothing is sent for that WHO at all) and is left out of the generated row. -# ``notes`` describes the row; where a profile may list or drop WHO 16 between OWNd releases, -# ``notes_without_who16`` is used instead when the installed profile does not advertise WHO 16. +# Startup discovery rows. With an importable OWNd the WHOs a row advertises are derived from +# the installed profile (``profile.supports_who``) over STARTUP_DISCOVERY_WHOS, exactly as the +# runtime gate in gateway.py decides what to send, so the table follows an OWNd profile change +# at the pin bump. ``pinned_whos`` is the row the pinned OWNd (the ``OWNd==`` pin in +# manifest.json) yields: it is used only when OWNd is not importable (the quality-scale, +# test-coverage and docs CI jobs run the docs check without it), so the generated table is the +# same with and without OWNd. At a pin bump that changes a profile, update ``pinned_whos`` +# together with ``sync_documentation.py --update``; ``tests/test_gateway_profiles.py`` pins the +# fallback to the committed table. ``sweep_omits`` lists the general requests of those WHOs the +# startup sweep leaves out anyway because the gateway firmware drops them before the bus +# (GENERAL_ZONE_REQUEST_REFUSED / SOUND_STATUS_REQUEST_REFUSED in custom_components/myhome/gateway.py; +# tests/test_startup_requests_per_firmware.py checks these lists against the sweep itself, and +# verify_profiles_against_oracle checks them against ``pinned_whos``); a request of a WHO the row +# does not advertise is not an omission (nothing is sent for that WHO at all) and is left out of +# the generated row. ``notes`` describes the row; where a profile may list or drop WHO 16 between +# OWNd releases, ``notes_without_who16`` is used instead when the row does not carry WHO 16. GENERAL_ZONE_STATUS = "*#4*0##" SOUND_STATUS = "*#16*0*5##" GATEWAY_DISCOVERY_METADATA: list[dict[str, object]] = [ { "model": "**MH202**", "profile_name": "MH202", + "pinned_whos": [1, 2, 4, 5, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS, SOUND_STATUS], "notes": "Burglar Alarm (`*#5*0##`) and Energy (`*#18*51*...`) queries. WHO 4 and WHO 16 are advertised, but the `010024` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle), so the sweep leaves both out and each climate zone asks `*#4**0##` itself.", }, { "model": "**H4890 / AM4890**", "profile_name": "H4890", + "pinned_whos": [1, 2, 4, 5, 16, 18], "sweep_omits": [], "notes": "Supports Burglar Alarm, Sound (`*#16*0*5##`), and Energy meter 51 queries.", }, { "model": "**F454**", "profile_name": "F454", + "pinned_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Full lighting, cover, sound (`bt_difson` firmware-verified), and energy meter 51 queries. The `020051` firmware drops `*#4*0##` (own-firmware-oracle), so each climate zone asks `*#4**0##` itself. Alarm query omitted.", }, { "model": "**F452 / F453 / F453AV**", "profile_name": "F453AV", + "pinned_whos": [1, 2, 4, 16, 18], "sweep_omits": [], "notes": "Lighting, cover, climate, sound (`bt_difson` firmware-verified on F453/F453AV), and energy meter 51 queries. Alarm query omitted.", }, { "model": "**F461**", "profile_name": "F461", + "pinned_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS, SOUND_STATUS], "notes": "Lighting, cover, and energy meter 51 queries. The `020011` firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle, confirmed live on 2.0.11), so the sweep leaves both out; zones are polled with `*#4*##`. Alarm query omitted.", "notes_without_who16": "Lighting, cover, and energy meter 51 queries. Sound is WHO 22 only (no `bt_difson`; every WHO 16 frame refused, own-firmware-oracle `020011`, confirmed live on 2.0.11). The firmware also drops `*#4*0##`, so the sweep leaves it out; zones are polled with `*#4*##`. Alarm query omitted.", @@ -225,12 +235,14 @@ { "model": "**F455**", "profile_name": "F455", + "pinned_whos": [1, 2, 4, 18], "sweep_omits": [], "notes": "Entry-level gateway: lighting, cover, climate, and energy meter 51 queries. Sound (WHO 16/22) and Alarm (WHO 5) omitted per installer manual.", }, { "model": "**MyHomeServer1**", "profile_name": "MyHomeServer1", + "pinned_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS, SOUND_STATUS], "notes": "Lighting, cover, and energy meter 51 queries. The firmware drops `*#4*0##` and `*#16*0*5##` (own-firmware-oracle `028206`, confirmed live on 3.87.13), so the sweep leaves both out; zones are polled with `*#4*##`. Alarm query omitted.", "notes_without_who16": "Lighting, cover, and energy meter 51 queries. Sound is WHO 22 only (no `bt_difson`; every WHO 16 frame refused, own-firmware-oracle `028206`, confirmed live on 3.87.13). The firmware also drops `*#4*0##`, so the sweep leaves it out; zones are polled with `*#4*##`. Alarm query omitted.", @@ -238,24 +250,28 @@ { "model": "**MH201**", "profile_name": "MH201", + "pinned_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Lighting, cover, climate, sound (unmeasured class default), and energy meter 51 queries. Not in own-firmware-oracle; a physical MH201 3.3.6 returned no zone frame to `*#4*0##` although its zones report (`tests/fixtures/traces/issue_466`), so the sweep leaves it out and each climate zone asks `*#4**0##` itself. Alarm query omitted.", }, { "model": "**MH200** *(Legacy)*", "profile_name": "MH200", + "pinned_whos": [1, 2, 4, 16], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Lighting, cover, climate, and sound queries (`bt_difson` hardware and firmware-verified). The `020102` firmware drops `*#4*0##` (own-firmware-oracle), so its climate zones ask `*#4**0##` themselves. Alarm and energy queries omitted.", }, { "model": "**MH200N**", "profile_name": "MH200N", + "pinned_whos": [1, 2, 4, 16], "sweep_omits": [], "notes": "Lighting, cover, climate, and sound queries (`bt_difson` hardware and firmware-verified). The `010108` firmware forwards `*#4*0##` (own-firmware-oracle), unlike the MH200. Alarm and energy queries omitted.", }, { "model": "**H4684 / H/L4684**", "profile_name": "H4684", + "pinned_whos": [1, 2, 4, 16, 18], "sweep_omits": [GENERAL_ZONE_STATUS], "notes": "Lighting, cover, climate, sound, and energy meter 51 queries (`bt_multi` WHO 22 also enabled on firmware >= `6.0.0`). Both `020054` (own-firmware-oracle) and live `6.0.11` (`tests/fixtures/traces/issue_466`) drop `*#4*0##`, so each climate zone asks `*#4**0##` itself; the SSDP spellings `H/L4684` and `HL4684` resolve to `H4684Profile`. Alarm query omitted.", }, @@ -462,8 +478,9 @@ def calibrate_discovery_with_ownd( """Derive the startup discovery rows from OWNd.profiles. ``queried_whos`` (the advertised column), the ``sweep_omits`` that still apply and the - sound wording follow the installed profile. Without an importable OWNd the rows carry no - ``queried_whos`` (the table cannot be generated from guesses). + sound wording follow the installed profile. Without an importable OWNd the rows carry + ``pinned_whos`` (what the pinned OWNd yields), so the generated table is the same either + way as long as the pin and the fallback agree; nothing else is guessed. """ try: from OWNd.profiles import get_gateway_profile @@ -474,17 +491,21 @@ def calibrate_discovery_with_ownd( GATEWAY_DISCOVERY_METADATA if discovery_metadata is None else discovery_metadata ) for row in calibrated: - prof_name = row.get("profile_name") - prof = get_gateway_profile(str(prof_name)) if prof_name and get_gateway_profile is not None else None - queried = discovery_whos_for(prof) if prof is not None else [] + pinned = row.pop("pinned_whos", []) + without_who16 = row.pop("notes_without_who16", None) + if get_gateway_profile is None: + queried = [int(w) for w in pinned] if isinstance(pinned, list) else [] + else: + prof_name = row.get("profile_name") + prof = get_gateway_profile(str(prof_name)) if prof_name else None + queried = discovery_whos_for(prof) if prof is not None else [] row["queried_whos"] = queried - # A general request of a WHO the profile does not advertise is not an omission of the + # A general request of a WHO the row does not advertise is not an omission of the # sweep (nothing is sent for that WHO at all), so it leaves the generated row. row["sweep_omits"] = [ frame for frame in cast_list(row.get("sweep_omits")) if _frame_who(str(frame)) in queried ] - without_who16 = row.pop("notes_without_who16", None) - if prof is not None and without_who16 and 16 not in queried: + if without_who16 and 16 not in queried: row["notes"] = without_who16 return calibrated @@ -499,7 +520,7 @@ def generate_gateway_discovery_table() -> str: for row in rows: model = row["model"] whos = row["queried_whos"] - who_str = "WHO " + ", ".join(str(w) for w in whos) if whos else "unknown (OWNd profile not available)" + who_str = "WHO " + ", ".join(str(w) for w in whos) if whos else "none" sent = "WHO " + ", ".join(str(w) for w in sweep_whos(row)) omitted = cast_list(row.get("sweep_omits")) if omitted: @@ -603,16 +624,21 @@ def _verdict(product: str, frame: str) -> tuple[str, str] | None: for row in GATEWAY_DISCOVERY_METADATA: prof_name = str(row.get("profile_name", "")) - prof = get_gateway_profile(prof_name) - if prof is None: - violations.append(f"Discovery row {row.get('model')} names an OWNd profile that does not exist: {prof_name}.") - continue - expected_whos = discovery_whos_for(prof) + if isinstance(get_gateway_profile(prof_name), GenericGatewayProfile): + violations.append( + f"Discovery row {row.get('model')} names an OWNd profile that does not exist: " + f"{prof_name} (resolves to the generic profile)." + ) + pinned = [int(w) for w in cast_list(row.get("pinned_whos"))] + if [w for w in pinned if w not in STARTUP_DISCOVERY_WHOS]: + violations.append( + f"Discovery row {row.get('model')} pinned_whos={pinned} names a WHO the startup sweep never asks for." + ) for frame in cast_list(row.get("sweep_omits")): - if str(frame) not in (GENERAL_ZONE_STATUS, SOUND_STATUS) or _frame_who(str(frame)) not in expected_whos: + if str(frame) not in (GENERAL_ZONE_STATUS, SOUND_STATUS) or _frame_who(str(frame)) not in pinned: violations.append( f"Discovery row {row.get('model')} sweep_omits={frame!r} is not a general request " - f"of an advertised WHO of the {prof_name} profile." + f"of a WHO the row advertises (pinned_whos={pinned})." ) return violations diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index 27a570890..66e573519 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -193,8 +193,10 @@ def test_generate_gateway_discovery_table_and_verify_oracle(): # in OWNd): derived from the installed profile, so the test is right before and after. assert f"| **F461** | {_sound_row_cells('F461')} |" in block assert f"| **MyHomeServer1** | {_sound_row_cells('MyHomeServer1')} |" in block - # No row hard-codes its WHOs; the generator gets them from the profile. + # No row hard-codes its WHOs; the generator gets them from the profile (``pinned_whos`` + # is the no-OWNd fallback only, and every row carries one). assert all("queried_whos" not in row for row in ugp.GATEWAY_DISCOVERY_METADATA) + assert all(isinstance(row.get("pinned_whos"), list) for row in ugp.GATEWAY_DISCOVERY_METADATA) assert ugp.verify_profiles_against_oracle() == [] @@ -265,12 +267,91 @@ def _patched(name: str, *args, **kwargs): assert "| **MyHomeServer1** | WHO 1, 2, 4, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`) | " in block -def test_discovery_table_without_ownd_does_not_guess(monkeypatch: pytest.MonkeyPatch): - """Without an importable OWNd the rows carry no WHOs instead of a stale hard-coded list.""" +def test_discovery_table_without_ownd_uses_the_pinned_rows(monkeypatch: pytest.MonkeyPatch): + """Without an importable OWNd every row carries ``pinned_whos`` (the pinned OWNd rows), nothing else.""" monkeypatch.setitem(sys.modules, "OWNd.profiles", None) - rows = ugp.calibrate_discovery_with_ownd() - assert all(row["queried_whos"] == [] for row in rows) - assert "unknown (OWNd profile not available)" in ugp.generate_gateway_discovery_table() + rows = {row["model"]: row for row in ugp.calibrate_discovery_with_ownd()} + pinned = {row["model"]: row["pinned_whos"] for row in ugp.GATEWAY_DISCOVERY_METADATA} + assert rows.keys() == pinned.keys() + for model, row in rows.items(): + assert row["queried_whos"] == pinned[model] + assert "pinned_whos" not in row + assert "notes_without_who16" not in row + # The pinned OWNd 2.0.0b11 still lists WHO 16 on the F461 / MyHomeServer1 (OWNd #142 pending). + assert rows["**F461**"]["queried_whos"] == [1, 2, 4, 16, 18] + assert rows["**MyHomeServer1**"]["queried_whos"] == [1, 2, 4, 16, 18] + assert "none" not in ugp.generate_gateway_discovery_table() + + +def test_discovery_table_without_ownd_matches_the_committed_docs(monkeypatch: pytest.MonkeyPatch): + """The no-OWNd fallback regenerates the committed table byte for byte. + + The quality-scale, test-coverage and docs CI jobs run ``sync_documentation.py --check`` + without OWNd installed, so the fallback and the committed table must agree; at a pin bump + that changes a profile, ``pinned_whos`` is updated together with ``--update``. + """ + monkeypatch.setitem(sys.modules, "OWNd.profiles", None) + doc = (ugp.REPO_ROOT / "docs" / "getting-started" / "hardware-compatibility.md").read_text( + encoding="utf-8" + ) + start = doc.index(ugp.DISCOVERY_START_MARKER) + end = doc.index(ugp.DISCOVERY_END_MARKER) + len(ugp.DISCOVERY_END_MARKER) + assert ugp.build_discovery_block() == doc[start:end] + + +def test_discovery_table_with_ownd_matches_the_committed_docs(): + """With the installed OWNd the derived table equals the committed one, row for row. + + Together with the no-OWNd test above this pins ``installed profile == committed table == + pinned_whos`` for the pinned OWNd. Against an OWNd whose profiles differ from the pin + (OWNd #142 drops WHO 16 from the F461 / MyHomeServer1; until the pin bump) only the rows + whose profile disagrees with ``pinned_whos`` may differ (they are what + ``sync_documentation.py --update`` regenerates at the pin bump, which ``--check`` reports + until then); every other row stays byte-identical, so the derivation cannot move a row + whose profile did not change. + """ + from OWNd.profiles import get_gateway_profile + + doc = (ugp.REPO_ROOT / "docs" / "getting-started" / "hardware-compatibility.md").read_text( + encoding="utf-8" + ) + start = doc.index(ugp.DISCOVERY_START_MARKER) + end = doc.index(ugp.DISCOVERY_END_MARKER) + len(ugp.DISCOVERY_END_MARKER) + committed = doc[start:end].splitlines() + generated = ugp.build_discovery_block().splitlines() + rows_off_the_pin = { + str(row["model"]) + for row in ugp.GATEWAY_DISCOVERY_METADATA + if ugp.discovery_whos_for(get_gateway_profile(str(row["profile_name"]))) != row["pinned_whos"] + } + assert len(generated) == len(committed) + for got, want in zip(generated, committed, strict=True): + model = got.split(" | ")[0].lstrip("| ") + if model in rows_off_the_pin: + assert got != want, model + else: + assert got == want + if not rows_off_the_pin: + assert ugp.build_discovery_block() == doc[start:end] + + +def test_pinned_rows_are_ignored_when_ownd_is_importable(monkeypatch: pytest.MonkeyPatch): + """With OWNd importable the installed profile wins over ``pinned_whos``.""" + metadata = [ + { + "model": "**F461**", + "profile_name": "F461", + "pinned_whos": [1], + "notes": "sound", + "notes_without_who16": "no sound", + } + ] + from OWNd.profiles import get_gateway_profile + + (row,) = ugp.calibrate_discovery_with_ownd(metadata) + assert row["queried_whos"] == ugp.discovery_whos_for(get_gateway_profile("F461")) + assert row["queried_whos"] != [1] + assert row["notes"] == ("sound" if 16 in row["queried_whos"] else "no sound") def test_oracle_check_rejects_patched_profile_instance(): From 4ad7c8427ca977a8acfc08ab44133a85bc5e27d1 Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 06:03:53 +0200 Subject: [PATCH 3/7] test(profiles): pin the validator and the no-OWNd fallback at the pin bump (rebase on #794) After the rebase the discovery rows carry both #794's `sweep_omits` (the general requests the firmware drops) and this branch's `pinned_whos` (the no-OWNd fallback), while the advertised column is derived from the installed OWNd profile. Two tests pin the interplay at the OWNd #142 pin bump: - `verify_profiles_against_oracle()` checks `sweep_omits` against `pinned_whos`, so once the F461 / MyHomeServer1 rows drop WHO 16 a leftover `*#16*0*5##` is flagged (and a `pinned_whos` entry the startup sweep never asks for is flagged too); the F455 / unknown-frame cases of #794 are unchanged. - Without an importable OWNd, `pinned_whos` without WHO 16 yields the `WHO 1, 2, 4, 18` row, the reduced `(firmware drops `*#4*0##`)` sent column and the WHO 22 wording, so the CI jobs that run the docs check without OWNd regenerate the same table as the pinned OWNd. Co-Authored-By: Claude Fable 5.1 --- tests/test_gateway_profiles.py | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index 66e573519..4dac3a2c3 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -354,6 +354,48 @@ def test_pinned_rows_are_ignored_when_ownd_is_importable(monkeypatch: pytest.Mon assert row["notes"] == ("sound" if 16 in row["queried_whos"] else "no sound") +def test_oracle_check_rejects_a_stale_sweep_omission_at_the_pin_bump(monkeypatch: pytest.MonkeyPatch): + """Once ``pinned_whos`` drops WHO 16 (the OWNd #142 pin bump) a leftover ``*#16*0*5##`` is flagged. + + ``sweep_omits`` is checked against ``pinned_whos`` (what the row advertises on the pinned + OWNd), not against whatever OWNd is on ``PYTHONPATH``; a ``pinned_whos`` entry outside the + startup sweep is flagged too. + """ + rows = copy.deepcopy(ugp.GATEWAY_DISCOVERY_METADATA) + f461 = next(row for row in rows if row["profile_name"] == "F461") + assert f461["sweep_omits"] == ["*#4*0##", "*#16*0*5##"] + f461["pinned_whos"] = [1, 2, 4, 18] # the pin bump edit, without the sweep_omits edit + mhs1 = next(row for row in rows if row["profile_name"] == "MyHomeServer1") + mhs1["pinned_whos"] = [1, 2, 4, 18, 22] # WHO 22 is never a startup sweep request + monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", rows) + violations = ugp.verify_profiles_against_oracle() + assert [v for v in violations if "**F461** sweep_omits='*#16*0*5##'" in v] + assert [v for v in violations if "**F461** sweep_omits='*#4*0##'" in v] == [] + assert [v for v in violations if "**MyHomeServer1** pinned_whos=[1, 2, 4, 18, 22]" in v] + assert [v for v in violations if "**MyHomeServer1** sweep_omits='*#16*0*5##'" in v] + assert len(violations) == 3 + + +def test_discovery_table_without_ownd_follows_the_pinned_rows_at_the_pin_bump(monkeypatch: pytest.MonkeyPatch): + """After the pin bump the CI jobs without OWNd generate the WHO 22 wording from ``pinned_whos`` alone.""" + monkeypatch.setitem(sys.modules, "OWNd.profiles", None) + rows = copy.deepcopy(ugp.GATEWAY_DISCOVERY_METADATA) + for row in rows: + if row["profile_name"] in ("F461", "MyHomeServer1"): + row["pinned_whos"] = [1, 2, 4, 18] + calibrated = {row["model"]: row for row in ugp.calibrate_discovery_with_ownd(rows)} + for model in ("**F461**", "**MyHomeServer1**"): + assert calibrated[model]["queried_whos"] == [1, 2, 4, 18] + assert calibrated[model]["sweep_omits"] == ["*#4*0##"] + assert "WHO 22 only" in str(calibrated[model]["notes"]) + assert calibrated["**MH202**"]["queried_whos"] == [1, 2, 4, 5, 16, 18] + assert calibrated["**MH202**"]["sweep_omits"] == ["*#4*0##", "*#16*0*5##"] + monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", rows) + block = ugp.build_discovery_block() + assert "| **F461** | WHO 1, 2, 4, 18 | WHO 1, 2, 18 (firmware drops `*#4*0##`) | Lighting, cover, and energy meter 51 queries. Sound is WHO 22 only" in block + assert "| **MH202** | WHO 1, 2, 4, 5, 16, 18 | WHO 1, 2, 5, 18 (firmware drops `*#4*0##`, `*#16*0*5##`) |" in block + + def test_oracle_check_rejects_patched_profile_instance(): """A profile instance rewritten after import fails the check instead of verifying the rewrite.""" from OWNd.profiles import get_gateway_profile From 86392bdbd271e438e0152fb7c839b1c3bdf6e12a Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 06:20:28 +0200 Subject: [PATCH 4/7] fix(profiles): a pin may differ from the installed profile only by WHO 16 where the row declares it (review follow-up) `verify_profiles_against_oracle()` checked `sweep_omits` against `pinned_whos` but never `pinned_whos` against a profile, so a mistyped pin (an F455 row given WHO 16 and a matching `*#16*0*5##` omission, or a row that lost WHO 4) passed the check and the no-OWNd table would have followed it. Now a pin that differs from the installed profile is flagged, except by WHO 16 on a row that carries `notes_without_who16` (the F461 / MyHomeServer1 switch of OWNd #142, the one change the fallback is meant to absorb between the pinned release and the next), so the check is green with the pinned OWNd and with the OWNd #142 checkout alike and catches a stale pin at the pin bump together with the stale omission. Hardening from the same review: `pinned_whos` that is not a list of WHO numbers is a violation instead of a `ValueError` / `TypeError`, and such a row (or a profile lookup that yields nothing) generates `none` cells with no omissions and the plain notes rather than an unfinished sent cell and an unjustified WHO 22 wording. Co-Authored-By: Claude Fable 5.1 --- scripts/update_gateway_profiles.py | 38 ++++++++++++++++---- tests/test_gateway_profiles.py | 57 ++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/scripts/update_gateway_profiles.py b/scripts/update_gateway_profiles.py index b040d135e..d3c491881 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -295,6 +295,14 @@ def cast_list(value: object) -> list[object]: return list(value) if isinstance(value, (list, tuple)) else [] +def pinned_whos(row: dict[str, object]) -> list[int] | None: + """Return ``row["pinned_whos"]`` as WHO numbers, or ``None`` when it is not a list of them.""" + pinned = row.get("pinned_whos") + if not isinstance(pinned, (list, tuple)) or not all(isinstance(w, int) and not isinstance(w, bool) for w in pinned): + return None + return list(pinned) + + def extract_gateway_models_from_const(const_path: Path = CONST_PY) -> list[str]: """Parse SUPPORTED_GATEWAY_MODELS from custom_components/myhome/const.py using AST.""" if not const_path.exists(): @@ -491,10 +499,11 @@ def calibrate_discovery_with_ownd( GATEWAY_DISCOVERY_METADATA if discovery_metadata is None else discovery_metadata ) for row in calibrated: - pinned = row.pop("pinned_whos", []) + pinned = pinned_whos(row) or [] + row.pop("pinned_whos", None) without_who16 = row.pop("notes_without_who16", None) if get_gateway_profile is None: - queried = [int(w) for w in pinned] if isinstance(pinned, list) else [] + queried = pinned else: prof_name = row.get("profile_name") prof = get_gateway_profile(str(prof_name)) if prof_name else None @@ -505,7 +514,7 @@ def calibrate_discovery_with_ownd( row["sweep_omits"] = [ frame for frame in cast_list(row.get("sweep_omits")) if _frame_who(str(frame)) in queried ] - if without_who16 and 16 not in queried: + if queried and without_who16 and 16 not in queried: row["notes"] = without_who16 return calibrated @@ -521,7 +530,7 @@ def generate_gateway_discovery_table() -> str: model = row["model"] whos = row["queried_whos"] who_str = "WHO " + ", ".join(str(w) for w in whos) if whos else "none" - sent = "WHO " + ", ".join(str(w) for w in sweep_whos(row)) + sent = "WHO " + ", ".join(str(w) for w in sweep_whos(row)) if whos else "none" omitted = cast_list(row.get("sweep_omits")) if omitted: sent += " (firmware drops " + ", ".join(f"`{frame}`" for frame in omitted) + ")" @@ -624,16 +633,33 @@ def _verdict(product: str, frame: str) -> tuple[str, str] | None: for row in GATEWAY_DISCOVERY_METADATA: prof_name = str(row.get("profile_name", "")) - if isinstance(get_gateway_profile(prof_name), GenericGatewayProfile): + prof = get_gateway_profile(prof_name) + if isinstance(prof, GenericGatewayProfile): violations.append( f"Discovery row {row.get('model')} names an OWNd profile that does not exist: " f"{prof_name} (resolves to the generic profile)." ) - pinned = [int(w) for w in cast_list(row.get("pinned_whos"))] + pinned = pinned_whos(row) + if pinned is None: + violations.append( + f"Discovery row {row.get('model')} pinned_whos={row.get('pinned_whos')!r} is not a list of WHO numbers." + ) + pinned = [] if [w for w in pinned if w not in STARTUP_DISCOVERY_WHOS]: violations.append( f"Discovery row {row.get('model')} pinned_whos={pinned} names a WHO the startup sweep never asks for." ) + # The pin may differ from the installed profile only by WHO 16, and only on a row that + # declares the WHO 16 switch (``notes_without_who16``): the one profile change the + # fallback absorbs between the pinned release and the next (OWNd #142). Any other + # difference is a stale or mistyped pin. + expected_whos = discovery_whos_for(prof) + tolerated = {16} if row.get("notes_without_who16") else set() + if (set(pinned) ^ set(expected_whos)) - tolerated: + violations.append( + f"Discovery row {row.get('model')} pinned_whos={pinned} disagrees with the installed " + f"{prof_name} profile {expected_whos}." + ) for frame in cast_list(row.get("sweep_omits")): if str(frame) not in (GENERAL_ZONE_STATUS, SOUND_STATUS) or _frame_who(str(frame)) not in pinned: violations.append( diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index 4dac3a2c3..20b3e1f7a 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -363,7 +363,7 @@ def test_oracle_check_rejects_a_stale_sweep_omission_at_the_pin_bump(monkeypatch """ rows = copy.deepcopy(ugp.GATEWAY_DISCOVERY_METADATA) f461 = next(row for row in rows if row["profile_name"] == "F461") - assert f461["sweep_omits"] == ["*#4*0##", "*#16*0*5##"] + f461["sweep_omits"] = ["*#4*0##", "*#16*0*5##"] # the pinned-release omissions f461["pinned_whos"] = [1, 2, 4, 18] # the pin bump edit, without the sweep_omits edit mhs1 = next(row for row in rows if row["profile_name"] == "MyHomeServer1") mhs1["pinned_whos"] = [1, 2, 4, 18, 22] # WHO 22 is never a startup sweep request @@ -373,7 +373,60 @@ def test_oracle_check_rejects_a_stale_sweep_omission_at_the_pin_bump(monkeypatch assert [v for v in violations if "**F461** sweep_omits='*#4*0##'" in v] == [] assert [v for v in violations if "**MyHomeServer1** pinned_whos=[1, 2, 4, 18, 22]" in v] assert [v for v in violations if "**MyHomeServer1** sweep_omits='*#16*0*5##'" in v] - assert len(violations) == 3 + assert [v for v in violations if "**MyHomeServer1** pinned_whos=[1, 2, 4, 18, 22] disagrees with the installed" in v] + assert len(violations) == 4 + + +def test_oracle_check_rejects_a_pin_that_disagrees_with_the_installed_profile(monkeypatch: pytest.MonkeyPatch): + """``pinned_whos`` may differ from the installed profile only by WHO 16, and only where the row declares it. + + A pin that gains a WHO the profile does not advertise (with or without a matching + ``sweep_omits``) or loses one it does is a stale or mistyped pin and is flagged; the F461 / + MyHomeServer1 rows, which carry ``notes_without_who16``, may list WHO 16 or not whatever the + installed OWNd says about it (before and after OWNd #142). A pin that is not a list of WHO + numbers is flagged instead of raising. + """ + rows = copy.deepcopy(ugp.GATEWAY_DISCOVERY_METADATA) + f455 = next(row for row in rows if row["profile_name"] == "F455") + f455["pinned_whos"] = [1, 2, 4, 16, 18] # the F455 profile has no WHO 16, and no notes_without_who16 + f455["sweep_omits"] = ["*#16*0*5##"] + f454 = next(row for row in rows if row["profile_name"] == "F454") + f454["pinned_whos"] = [1, 2, 16, 18] # lost WHO 4, which the profile advertises + mh202 = next(row for row in rows if row["profile_name"] == "MH202") + mh202["pinned_whos"] = "1, 2, 4" + for name in ("F461", "MyHomeServer1"): + next(row for row in rows if row["profile_name"] == name)["pinned_whos"] = [1, 2, 4, 18] + monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", rows) + violations = ugp.verify_profiles_against_oracle() + assert [v for v in violations if "**F455** pinned_whos=[1, 2, 4, 16, 18] disagrees with the installed F455 profile [1, 2, 4, 18]." in v] + assert [v for v in violations if "**F455** sweep_omits=" in v] == [] # 16 is in the (wrong) pin; the pin itself is flagged + assert [v for v in violations if "**F454** pinned_whos=[1, 2, 16, 18] disagrees with the installed F454 profile [1, 2, 4, 16, 18]." in v] + assert [v for v in violations if "**MH202** pinned_whos='1, 2, 4' is not a list of WHO numbers." in v] + assert [v for v in violations if "**MH202** sweep_omits=" in v] # an unreadable pin advertises nothing + assert [v for v in violations if "**F461**" in v or "**MyHomeServer1**" in v] == [ + v for v in violations if "sweep_omits='*#16*0*5##'" in v and ("**F461**" in v or "**MyHomeServer1**" in v) + ] + + +def test_discovery_row_with_an_unreadable_pin_and_no_ownd_is_empty(monkeypatch: pytest.MonkeyPatch): + """Without OWNd an unreadable ``pinned_whos`` yields an empty row: ``none`` cells, no omissions, the plain notes.""" + monkeypatch.setitem(sys.modules, "OWNd.profiles", None) + metadata = [ + { + "model": "**F461**", + "profile_name": "F461", + "pinned_whos": "1, 2, 4, 18", + "sweep_omits": ["*#4*0##", "*#16*0*5##"], + "notes": "sound", + "notes_without_who16": "no sound", + } + ] + (row,) = ugp.calibrate_discovery_with_ownd(metadata) + assert row["queried_whos"] == [] + assert row["sweep_omits"] == [] + assert row["notes"] == "sound" + monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", metadata) + assert "| **F461** | none | none | sound |" in ugp.generate_gateway_discovery_table() def test_discovery_table_without_ownd_follows_the_pinned_rows_at_the_pin_bump(monkeypatch: pytest.MonkeyPatch): From 4473c476441e6578817ca00c333eb2f8ed86432a Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 06:29:02 +0200 Subject: [PATCH 5/7] fix(profiles): reject a repeated or reordered pin and render the fallback in sweep order (review follow-up) A `pinned_whos` such as `[1, 1, 2, 4, 18]` or `[2, 1, 4, 18]` compared equal to the profile as a set and, without OWNd, was rendered as written. The oracle check now requires the pin in STARTUP_DISCOVERY_WHOS order without repeats (the order the sweep sends, the order `discovery_whos_for` yields), and the no-OWNd fallback renders the pin in that order. Co-Authored-By: Claude Fable 5.1 --- scripts/update_gateway_profiles.py | 12 +++++++++++- tests/test_gateway_profiles.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/update_gateway_profiles.py b/scripts/update_gateway_profiles.py index d3c491881..53cd2fa21 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -303,6 +303,11 @@ def pinned_whos(row: dict[str, object]) -> list[int] | None: return list(pinned) +def in_sweep_order(whos: list[int]) -> list[int]: + """Return ``whos`` in the order of STARTUP_DISCOVERY_WHOS, without repeats (what the sweep sends).""" + return [w for w in STARTUP_DISCOVERY_WHOS if w in whos] + + def extract_gateway_models_from_const(const_path: Path = CONST_PY) -> list[str]: """Parse SUPPORTED_GATEWAY_MODELS from custom_components/myhome/const.py using AST.""" if not const_path.exists(): @@ -503,7 +508,7 @@ def calibrate_discovery_with_ownd( row.pop("pinned_whos", None) without_who16 = row.pop("notes_without_who16", None) if get_gateway_profile is None: - queried = pinned + queried = in_sweep_order(pinned) else: prof_name = row.get("profile_name") prof = get_gateway_profile(str(prof_name)) if prof_name else None @@ -649,6 +654,11 @@ def _verdict(product: str, frame: str) -> tuple[str, str] | None: violations.append( f"Discovery row {row.get('model')} pinned_whos={pinned} names a WHO the startup sweep never asks for." ) + elif pinned != in_sweep_order(pinned): + violations.append( + f"Discovery row {row.get('model')} pinned_whos={pinned} is not in startup sweep order " + f"{in_sweep_order(pinned)} (a repeated or reordered WHO)." + ) # The pin may differ from the installed profile only by WHO 16, and only on a row that # declares the WHO 16 switch (``notes_without_who16``): the one profile change the # fallback absorbs between the pinned release and the next (OWNd #142). Any other diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index 20b3e1f7a..741d7352c 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -394,6 +394,10 @@ def test_oracle_check_rejects_a_pin_that_disagrees_with_the_installed_profile(mo f454["pinned_whos"] = [1, 2, 16, 18] # lost WHO 4, which the profile advertises mh202 = next(row for row in rows if row["profile_name"] == "MH202") mh202["pinned_whos"] = "1, 2, 4" + h4890 = next(row for row in rows if row["profile_name"] == "H4890") + h4890["pinned_whos"] = [1, 1, 2, 4, 5, 16, 18] # a repeated WHO + mh201 = next(row for row in rows if row["profile_name"] == "MH201") + mh201["pinned_whos"] = [2, 1, 4, 16, 18] # reordered for name in ("F461", "MyHomeServer1"): next(row for row in rows if row["profile_name"] == name)["pinned_whos"] = [1, 2, 4, 18] monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", rows) @@ -403,6 +407,9 @@ def test_oracle_check_rejects_a_pin_that_disagrees_with_the_installed_profile(mo assert [v for v in violations if "**F454** pinned_whos=[1, 2, 16, 18] disagrees with the installed F454 profile [1, 2, 4, 16, 18]." in v] assert [v for v in violations if "**MH202** pinned_whos='1, 2, 4' is not a list of WHO numbers." in v] assert [v for v in violations if "**MH202** sweep_omits=" in v] # an unreadable pin advertises nothing + assert [v for v in violations if "**H4890 / AM4890** pinned_whos=[1, 1, 2, 4, 5, 16, 18] is not in startup sweep order [1, 2, 4, 5, 16, 18]" in v] + assert [v for v in violations if "**MH201** pinned_whos=[2, 1, 4, 16, 18] is not in startup sweep order [1, 2, 4, 16, 18]" in v] + assert [v for v in violations if ("**H4890 / AM4890**" in v or "**MH201**" in v) and "disagrees" in v] == [] assert [v for v in violations if "**F461**" in v or "**MyHomeServer1**" in v] == [ v for v in violations if "sweep_omits='*#16*0*5##'" in v and ("**F461**" in v or "**MyHomeServer1**" in v) ] @@ -427,6 +434,12 @@ def test_discovery_row_with_an_unreadable_pin_and_no_ownd_is_empty(monkeypatch: assert row["notes"] == "sound" monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", metadata) assert "| **F461** | none | none | sound |" in ugp.generate_gateway_discovery_table() + # A repeated or reordered pin (which the oracle check flags) is still rendered in sweep order. + metadata[0]["pinned_whos"] = [18, 1, 1, 4, 2] + (row,) = ugp.calibrate_discovery_with_ownd(metadata) + assert row["queried_whos"] == [1, 2, 4, 18] + assert row["sweep_omits"] == ["*#4*0##"] + assert row["notes"] == "no sound" def test_discovery_table_without_ownd_follows_the_pinned_rows_at_the_pin_bump(monkeypatch: pytest.MonkeyPatch): From 1e346c3649b05b9390d7d04ff5b5db6f7d7419be Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 06:34:46 +0200 Subject: [PATCH 6/7] test(profiles): keep the pin-bump tests right across the pin bump A pin-bump rehearsal (pinned_whos of the F461 / MyHomeServer1 rows set to [1, 2, 4, 18], `*#16*0*5##` removed from their sweep_omits, `sync_documentation.py --update`) left two tests red for no reason of the code: one asserted the pre-bump WHO 16 literal of the two rows (the committed-docs equality test pins that already), the other assumed the pre-bump sweep_omits of the MyHomeServer1 row. Both now state their own precondition, so the pin bump edits the rows, the docs and nothing in this file. Co-Authored-By: Claude Fable 5.1 --- tests/test_gateway_profiles.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index 741d7352c..1349be83d 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -277,9 +277,9 @@ def test_discovery_table_without_ownd_uses_the_pinned_rows(monkeypatch: pytest.M assert row["queried_whos"] == pinned[model] assert "pinned_whos" not in row assert "notes_without_who16" not in row - # The pinned OWNd 2.0.0b11 still lists WHO 16 on the F461 / MyHomeServer1 (OWNd #142 pending). - assert rows["**F461**"]["queried_whos"] == [1, 2, 4, 16, 18] - assert rows["**MyHomeServer1**"]["queried_whos"] == [1, 2, 4, 16, 18] + # Whether the F461 / MyHomeServer1 rows carry WHO 16 is the pin's business (the committed + # docs pin it, see the next test), not this test's: it stays right across the pin bump. + assert all(row["queried_whos"] for row in rows.values()) assert "none" not in ugp.generate_gateway_discovery_table() @@ -366,6 +366,7 @@ def test_oracle_check_rejects_a_stale_sweep_omission_at_the_pin_bump(monkeypatch f461["sweep_omits"] = ["*#4*0##", "*#16*0*5##"] # the pinned-release omissions f461["pinned_whos"] = [1, 2, 4, 18] # the pin bump edit, without the sweep_omits edit mhs1 = next(row for row in rows if row["profile_name"] == "MyHomeServer1") + mhs1["sweep_omits"] = ["*#4*0##", "*#16*0*5##"] # the pinned-release omissions mhs1["pinned_whos"] = [1, 2, 4, 18, 22] # WHO 22 is never a startup sweep request monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", rows) violations = ugp.verify_profiles_against_oracle() From fd207928a099bce9d227b57eb9391cdf536c269c Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 13:20:24 +0200 Subject: [PATCH 7/7] test(profiles): a discovery row that resolves to the generic profile is flagged (review follow-up) Negative test for the GenericGatewayProfile guard of verify_profiles_against_oracle(): a row whose profile_name is "Generic" or an unknown model string reports "resolves to the generic profile" for that row and for no other. Fails with the previous `is None` check, passes with the guard. Co-Authored-By: Claude Fable 5.1 --- tests/test_gateway_profiles.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index 1349be83d..8865c6296 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -416,6 +416,27 @@ def test_oracle_check_rejects_a_pin_that_disagrees_with_the_installed_profile(mo ] +@pytest.mark.parametrize("profile_name", ["Generic", "BTicino Nonesuch 9000"]) +def test_oracle_check_rejects_a_row_that_resolves_to_the_generic_profile(monkeypatch: pytest.MonkeyPatch, profile_name): + """A row whose ``profile_name`` is ``Generic`` or names no OWNd profile is flagged, not silently accepted. + + ``get_gateway_profile`` answers ``GenericGatewayProfile`` for both; the check must say which row + points at a profile that does not exist instead of deriving an empty advertised column from it. + """ + rows = copy.deepcopy(ugp.GATEWAY_DISCOVERY_METADATA) + f455 = next(row for row in rows if row["profile_name"] == "F455") + f455["profile_name"] = profile_name + monkeypatch.setattr(ugp, "GATEWAY_DISCOVERY_METADATA", rows) + violations = ugp.verify_profiles_against_oracle() + assert [ + v + for v in violations + if "**F455**" in v and f"{profile_name} (resolves to the generic profile)." in v + ] + # the other rows still resolve: no generic-profile violation is reported for them + assert [v for v in violations if "resolves to the generic profile" in v and "**F455**" not in v] == [] + + def test_discovery_row_with_an_unreadable_pin_and_no_ownd_is_empty(monkeypatch: pytest.MonkeyPatch): """Without OWNd an unreadable ``pinned_whos`` yields an empty row: ``none`` cells, no omissions, the plain notes.""" monkeypatch.setitem(sys.modules, "OWNd.profiles", None)