From c38de00319ba09b947cac84b1869fa7c819041c6 Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 19:50:41 +0200 Subject: [PATCH 1/4] fix(config_flow): resolve the corrected model's profile through _profile_for_model (#804 follow-up) _async_apply_gateway_identity called get_gateway_profile(model, gateway.model_number): OWNd's signature is (model_name, firmware_version), and model_number is always None on the manual path, so the second argument was inert and read as if a model number were a firmware. The runtime resolves the same correction through _profile_for_model (#818), which also maps the SSDP spellings; the flow now uses it, without a firmware, which is not known before the first connection. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/config_flow_discovery.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/config_flow_discovery.py b/custom_components/myhome/config_flow_discovery.py index 3a3ac84c..8928ee3a 100644 --- a/custom_components/myhome/config_flow_discovery.py +++ b/custom_components/myhome/config_flow_discovery.py @@ -22,7 +22,6 @@ from homeassistant.helpers import device_registry as dr from OWNd.connection import OWNCommandSession from OWNd.message import OWNGatewayCommand, OWNGatewayDiagnosticCommand -from OWNd.profiles import get_gateway_profile from voluptuous import All, Coerce, In, Range, Required from .const import ( @@ -38,7 +37,7 @@ DOMAIN, LOGGER, ) -from .gateway import command_session_default +from .gateway import _profile_for_model, command_session_default from .identity import ( UNTRUSTED_MODEL_LABELS, CodeReading, @@ -654,7 +653,9 @@ async def _async_apply_gateway_identity(self, gateway: typing.Any) -> str | None configured, ) gateway.model_name = gateway.model = model - gateway.profile = get_gateway_profile(model, gateway.model_number) + # The firmware is not known yet (the runtime re-resolves with it at connect time); + # the same helper as the runtime, so SSDP spellings map the same way. + gateway.profile = _profile_for_model(model) gateway._log_id = f"[{model} gateway - {gateway.host}]" # pylint: disable=protected-access return resolution.source From c34e44f30fd65f372583dc39ddf128d37c59c82f Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 19:51:01 +0200 Subject: [PATCH 2/4] fix(legacy_yaml): say what moved when only a scope cover's members were canonicalised (#798 follow-up) canonicalise_imported_platforms appended "plat: old -> new" for every rewritten device, also when the key was unchanged and only the `members` list of a scope cover held a 4-digit area-0 address: the INFO line then read "1 point address(es) moved to the canonical spelling (cover: 2-1 -> 2-1)", a move that moved nothing, and dropped what did change. The line now reads "cover: 2-1 members ['0004', '12'] -> ['04', '12']" for that case; a moved key is reported as before. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/legacy_yaml.py | 8 ++++++-- tests/test_canonical_point_where.py | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/custom_components/myhome/legacy_yaml.py b/custom_components/myhome/legacy_yaml.py index 45374790..d5fd9ada 100644 --- a/custom_components/myhome/legacy_yaml.py +++ b/custom_components/myhome/legacy_yaml.py @@ -123,7 +123,8 @@ def canonicalise_imported_platforms( ``where``, the ``members`` and the device key are rewritten in a new copy (the stored one is not touched). When the copy already has a device under the canonical key, that one stays and the 4-digit twin is dropped with a WARNING. Returns the new - copy and one ``plat: old -> new`` line per rewritten device. + copy and one line per rewritten device: ``plat: old -> new`` for a moved key, or + ``plat: key members [old] -> [new]`` when only a scope cover's members moved. """ rewritten: list[str] = [] result: dict[str, dict[str, Any]] = {} @@ -156,7 +157,10 @@ def canonicalise_imported_platforms( ) continue new_devices[new_id] = new_cfg - rewritten.append(f"{plat}: {d_id} -> {new_id}") + if new_id != d_id: + rewritten.append(f"{plat}: {d_id} -> {new_id}") + else: + rewritten.append(f"{plat}: {d_id} members {members} -> {new_members}") result[plat] = new_devices return result, rewritten diff --git a/tests/test_canonical_point_where.py b/tests/test_canonical_point_where.py index f05f193f..dc9087dd 100644 --- a/tests/test_canonical_point_where.py +++ b/tests/test_canonical_point_where.py @@ -365,6 +365,14 @@ def test_imported_copy_is_rewritten_to_the_canonical_spelling(caplog) -> None: assert again == result and rewritten_again == [] +def test_imported_copy_reports_a_members_only_rewrite_as_such() -> None: + """A scope cover under a canonical key whose `members` hold `0004`: the line names the members, not a key move.""" + snapshot = {"cover": {"2-1": {"who": "2", "where": "1", "members": ["0004", "12"], "name": "Area 1", "entities": {}}}} + result, rewritten = canonicalise_imported_platforms(snapshot) + assert result["cover"]["2-1"]["members"] == ["04", "12"] + assert rewritten == ["cover: 2-1 members ['0004', '12'] -> ['04', '12']"] + + def test_imported_copy_keeps_the_canonical_device_on_a_collision(caplog) -> None: """A copy holding both `1-0004` and `1-04` keeps `1-04`; the twin is dropped, not overwritten.""" snapshot = {"light": { From 4f011efe8ec47b29ccf068e732701ff78aae98fb Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 19:51:01 +0200 Subject: [PATCH 3/4] fix(legacy_yaml): _same_mac compares dr.format_mac results directly (#801 follow-up) The `except Exception` fallback of _same_mac was unreachable and its docstring described a path that does not exist: HA's dr.format_mac never raises (a value that is no MAC comes back unchanged), and a malformed section key never reaches _log_mac_mismatch because validate.config_schema (`Optional(CONF_MAC): MacAddress()`) rejects it first with the generic "Failed to parse myhome.yaml ...: Invalid MAC address" ERROR, the very outcome the docstring claimed the fallback avoided. The two tests passed only by monkeypatching format_mac to raise. The helper now compares the two format_mac results, the docstring says why no fallback is needed, and the two monkeypatch tests are replaced by one on real spellings (hyphens, dots, junk against itself) and one showing that a 'not-a-mac' section key is one schema ERROR with no WARNING and no mismatch check. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/legacy_yaml.py | 12 ++---- tests/test_legacy_yaml.py | 49 +++++++++---------------- 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/custom_components/myhome/legacy_yaml.py b/custom_components/myhome/legacy_yaml.py index d5fd9ada..8663fbe8 100644 --- a/custom_components/myhome/legacy_yaml.py +++ b/custom_components/myhome/legacy_yaml.py @@ -231,15 +231,11 @@ def _apply_imported_copy( def _same_mac(first: Any, second: Any) -> bool: """Whether two MAC spellings (colons, hyphens, dots, raw hex) name the same gateway. - Diagnostic path only: when ``dr.format_mac`` rejects a value the two are compared - as plain strings, so a malformed section key keeps the orphan WARNING instead of - raising into the generic "Failed to parse" error of ``_load_live_yaml``. + ``dr.format_mac`` never raises: a value that is no MAC comes back as it went in, so two + equal malformed strings still compare equal. Such a section key never gets this far + anyway: ``validate.config_schema`` rejects it first with the "Failed to parse" error. """ - first_text, second_text = str(first), str(second) - try: - return dr.format_mac(first_text) == dr.format_mac(second_text) - except Exception: - return first_text == second_text + return dr.format_mac(str(first)) == dr.format_mac(str(second)) def _log_mac_mismatch(hass: HomeAssistant, entry: ConfigEntry, keys: list[Any]) -> None: diff --git a/tests/test_legacy_yaml.py b/tests/test_legacy_yaml.py index 8cbf8a90..543189e7 100644 --- a/tests/test_legacy_yaml.py +++ b/tests/test_legacy_yaml.py @@ -7,7 +7,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from custom_components.myhome.const import ( CONF_FILE_PATH, @@ -468,50 +467,38 @@ async def test_legacy_yaml_other_gateway_section_spelled_differently_does_not_wa assert _myhome_warnings(caplog) == [] -async def test_legacy_yaml_mac_mismatch_keeps_warning_when_format_mac_raises( +def test_same_mac_compares_spellings_and_never_raises(): + """`dr.format_mac` returns a non-MAC unchanged, so no fallback is needed: equal junk is equal, a MAC is not junk.""" + assert _same_mac("00-03-50-99-99-99", "00:03:50:99:99:99") is True + assert _same_mac("0003.5099.9999", "00:03:50:99:99:99") is True + assert _same_mac("not-a-mac", "not-a-mac") is True + assert _same_mac("not-a-mac", "00:03:50:99:99:99") is False + assert _same_mac(None, "None") is True + + +async def test_legacy_yaml_malformed_section_key_is_a_parse_error_not_a_mismatch( mock_hass, base_entry, tmp_path, caplog ): - """A key dr.format_mac rejects is still reported at WARNING, not as a parse ERROR.""" + """A section key that is no MAC is refused by the schema before the mismatch check could run.""" yaml_file = tmp_path / "myhome.yaml" yaml_file.write_text( - "'00:03:50:00:00:00':\n light:\n hallway:\n where: '21'\n name: 'Hallway'\n", + "'not-a-mac':\n light:\n hallway:\n where: '21'\n name: 'Hallway'\n", encoding="utf-8", ) - mock_hass.config_entries.async_entries.return_value = [ - base_entry, - _other_entry("00:03:50:99:99:99"), - ] + mock_hass.config_entries.async_entries.return_value = [base_entry] configured_platforms = _make_configured_platforms() - real_format_mac = dr.format_mac - - def _raising_format_mac(value: str) -> str: - if value != base_entry.data[CONF_MAC]: - raise ValueError(f"malformed MAC {value!r}") - return real_format_mac(value) with ( caplog.at_level("WARNING", logger="custom_components.myhome"), - patch("custom_components.myhome.legacy_yaml.dr.format_mac", _raising_format_mac), + patch("custom_components.myhome.legacy_yaml._log_mac_mismatch") as mismatch, ): await load_legacy_myhome_yaml(mock_hass, base_entry, configured_platforms) + mismatch.assert_not_called() assert configured_platforms["light"] == {} - assert not [r for r in caplog.records if r.levelname == "ERROR"] - warnings = _myhome_warnings(caplog) - assert len(warnings) == 1, warnings - assert "00:03:50:81:22:33" in warnings[0] - assert "00:03:50:00:00:00" in warnings[0] - - -def test_same_mac_falls_back_to_string_comparison_when_format_mac_raises(): - """The helper compares raw strings when dr.format_mac raises, and never raises itself.""" - - def _raising_format_mac(value: str) -> str: - raise ValueError(f"malformed MAC {value!r}") - - with patch("custom_components.myhome.legacy_yaml.dr.format_mac", _raising_format_mac): - assert _same_mac("not-a-mac", "not-a-mac") is True - assert _same_mac("not-a-mac", "00:03:50:99:99:99") is False + errors = [r.getMessage() for r in caplog.records if r.levelname == "ERROR"] + assert len(errors) == 1 and "Invalid MAC address" in errors[0] + assert _myhome_warnings(caplog) == [] async def test_legacy_yaml_mac_mismatch_skips_other_entry_without_mac( From ccda545380f2d015956941a356be91a89e213d5b Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 19:51:01 +0200 Subject: [PATCH 4/4] docs(profiles): discovery_whos_for is the runtime gate's rule, not its mirror at the edges (#808 follow-up) The docstring said the script "mirrors MyHOMEGatewayHandler._profile_supports_who"; the runtime gate fails open (every WHO when supports_who is missing or raises), the script fails closed ([] when missing) and propagates when supports_who raises, and test_discovery_whos_follow_the_runtime_gate pins the divergent discovery_whos_for(object()) == []. Real OWNd profiles agree. The docstring and the test's say so. Co-Authored-By: Claude Fable 5.1 --- scripts/update_gateway_profiles.py | 6 ++++-- tests/test_gateway_profiles.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/update_gateway_profiles.py b/scripts/update_gateway_profiles.py index 53cd2fa2..56cf23d4 100644 --- a/scripts/update_gateway_profiles.py +++ b/scripts/update_gateway_profiles.py @@ -476,8 +476,10 @@ def build_options_block() -> str: 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. + The same rule as ``MyHOMEGatewayHandler._profile_supports_who`` for a real OWNd + profile: a WHO is queried only when the profile advertises it. The two differ at the + edges: an object without ``supports_who`` yields no rows here (the table lists what a + profile advertises), while the runtime gate assumes everything and queries every WHO. """ supports = getattr(profile, "supports_who", None) if not callable(supports): diff --git a/tests/test_gateway_profiles.py b/tests/test_gateway_profiles.py index 8865c629..09e2cd2b 100644 --- a/tests/test_gateway_profiles.py +++ b/tests/test_gateway_profiles.py @@ -215,7 +215,7 @@ def test_oracle_check_rejects_a_sweep_omission_the_profile_does_not_advertise(mo def test_discovery_whos_follow_the_runtime_gate(): - """``discovery_whos_for`` mirrors ``MyHOMEGatewayHandler._profile_supports_who`` over the discovery WHOs.""" + """``discovery_whos_for`` follows ``_profile_supports_who`` for real profiles; without ``supports_who`` it yields no rows (the runtime gate assumes everything).""" from OWNd.profiles import get_gateway_profile for name in ("F454", "F455", "F461", "MH200N", "MH202", "MyHomeServer1"):