diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index cff87936..41d99579 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`, 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 b21334fe..53cd2fa2 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -177,89 +177,101 @@ }, ] -# 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 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). +# 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", - "queried_whos": [1, 2, 4, 5, 16, 18], + "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", - "queried_whos": [1, 2, 4, 5, 16, 18], + "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", - "queried_whos": [1, 2, 4, 16, 18], + "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", - "queried_whos": [1, 2, 4, 16, 18], + "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", - "queried_whos": [1, 2, 4, 16, 18], + "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.", }, { "model": "**F455**", "profile_name": "F455", - "queried_whos": [1, 2, 4, 18], + "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", - "queried_whos": [1, 2, 4, 16, 18], + "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.", }, { "model": "**MH201**", "profile_name": "MH201", - "queried_whos": [1, 2, 4, 16, 18], + "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", - "queried_whos": [1, 2, 4, 16], + "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", - "queried_whos": [1, 2, 4, 16], + "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", - "queried_whos": [1, 2, 4, 16, 18], + "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.", }, @@ -283,6 +295,19 @@ 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 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(): @@ -448,10 +473,28 @@ 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 + ``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 except ImportError: @@ -460,15 +503,24 @@ 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: + for row in calibrated: + 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 = in_sweep_order(pinned) + else: 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) - ] + 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 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 + ] + if queried and without_who16 and 16 not in queried: + row["notes"] = without_who16 return calibrated @@ -482,8 +534,8 @@ 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) - sent = "WHO " + ", ".join(str(w) for w in sweep_whos(row)) + 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)) if whos else "none" omitted = cast_list(row.get("sweep_omits")) if omitted: sent += " (firmware drops " + ", ".join(f"`{frame}`" for frame in omitted) + ")" @@ -587,17 +639,42 @@ 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: + 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 = 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." + ) + 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 + # 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')} queried_whos={row.get('queried_whos')} " - f"disagrees with {prof_name} profile {expected_whos}." + 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 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 de4f0d76..8865c629 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -162,19 +162,41 @@ 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 (``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() == [] @@ -192,6 +214,276 @@ 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_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 = {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 + # 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() + + +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_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") + 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() + 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 [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" + 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) + 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 "**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) + ] + + +@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) + 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() + # 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): + """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