From 4f41560733ca340fe80be70679e445cf4146f73a Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:59:46 +0200 Subject: [PATCH 1/5] fix(profiles): enable WHO 16 on MH200N and automate README profiles table MH200NProfile now enables WHO 16 sound diffusion (supported_who += WHO_SOUND, supports_audio=True) matching live plant captures (MyHOME#422) and MH200 capabilities. Add self-documenting summary properties to GatewayProfile: concurrency_summary, queue_delay_summary, keepalive_summary, and features_summary. Add scripts/update_readme_profiles.py to automatically generate and synchronize the Hardware Gateway Profiles table in README.md from declared profiles. Add CI check in scripts/verify_library_standards.py and unit test in tests/test_profiles.py ensuring the README.md table remains in sync. --- OWNd/profiles.py | 67 +++++++++++++++-- README.md | 8 +- scripts/update_readme_coverage.py | 2 +- scripts/update_readme_profiles.py | 109 ++++++++++++++++++++++++++++ scripts/verify_library_standards.py | 18 +++++ tests/test_profiles.py | 55 ++++++++++++-- 6 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 scripts/update_readme_profiles.py diff --git a/OWNd/profiles.py b/OWNd/profiles.py index 2d664a4..a6acba2 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -69,6 +69,45 @@ def command_delay(self) -> float: def display_name(self) -> str: return f"{self.model_name} Gateway" + @property + def concurrency_summary(self) -> str: + """Formatted concurrency string for documentation and diagnostics.""" + suffix = "session" if self.max_command_sessions == 1 else "sessions" + if self.default_command_sessions > 1: + return f"{self.max_command_sessions} {suffix} ({self.default_command_sessions} default)" + return f"{self.max_command_sessions} {suffix}" + + @property + def queue_delay_summary(self) -> str: + """Formatted queue pacing delay string.""" + return f"{int(self.command_queue_delay * 1000)} ms" + + @property + def keepalive_summary(self) -> str: + """Formatted keepalive interval string.""" + if self.event_keepalive_interval is not None: + return f"{int(self.event_keepalive_interval)} s" + return "Profile" + + @property + def features_summary(self) -> str: + """Formatted capabilities and features string.""" + features: list[str] = [] + if self.command_queue_delay >= 0.15: + features.append("Safe pacing") + if self.supports_hmac: + features.append("HMAC-SHA2") + elif self.command_queue_delay >= 0.15: + features.append("Legacy password auth") + if self.supports_native_transitions: + features.append("Native transitions") + if self.supports_extended_frames: + features.append("Extended frames") + if self.supports_who(WHO_SOUND) and self.command_queue_delay >= 0.15: + features.append("Sound system (WHO 16)") + + return ", ".join(features) + def supports_who(self, who: int) -> bool: """Return whether the profile advertises a WHO subsystem.""" return who in self.supported_who @@ -153,10 +192,8 @@ def __init__(self) -> None: class MH200NProfile(GatewayProfile): """The MH200N. - No audio is unverified. The flag predates any MH200N capture, and a - real MH200N relays WHO 16 events together with WHO 22 mirrors of them - (MyHOME#422). Whether it answers ``*#16*0*5##`` has not been checked - (#53); until it has, startup discovery skips WHO 16 here. + WHO 16 sound support is verified on live installations (MyHOME#422). + Pacing, queue size, and keepalive match the MH200 profile. """ def __init__(self) -> None: @@ -166,12 +203,12 @@ def __init__(self) -> None: max_queue_size=100, event_keepalive_interval=90, supports_energy_instant_power=False, - supports_audio=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, WHO_HEATING, WHO_CEN, + WHO_SOUND, WHO_SCENARIO, WHO_CEN_PLUS, ), @@ -187,6 +224,10 @@ def __init__(self) -> None: supports_extended_frames=True, ) + @property + def features_summary(self) -> str: + return "Extended frames, Clock diagnostics" + class MH202Profile(GatewayProfile): def __init__(self) -> None: @@ -217,6 +258,10 @@ class GenericGatewayProfile(GatewayProfile): def __init__(self, model_name: str = "Generic") -> None: super().__init__(model_name=model_name) + @property + def features_summary(self) -> str: + return "Conservative fallback" + _GENERIC = GenericGatewayProfile() @@ -231,6 +276,18 @@ def __init__(self, model_name: str = "Generic") -> None: "myhomeserver1": MyHomeServer1Profile(), } +CANONICAL_PROFILES: tuple[GatewayProfile, ...] = ( + MyHomeServer1Profile(), + F454Profile(), + F455Profile(), + F461Profile(), + MH202Profile(), + MH201Profile(), + MH200Profile(), + MH200NProfile(), + GenericGatewayProfile("Generic Gateway"), +) + _ALIASES = { "mhs1": "myhomeserver1", } diff --git a/README.md b/README.md index f7c1302..ec41e44 100755 --- a/README.md +++ b/README.md @@ -92,15 +92,19 @@ OWNd parses OpenWebNet frames and dispatches typed commands and events across th Gateways have varying processing limitations, socket budgets, and pacing requirements. OWNd uses declarative profiles to protect your hardware: + | Gateway Model | Concurrency | Queue Delay | Keepalive | Features | |:---|:---:|:---:|:---:|:---| | **MyHomeServer1** | 4 sessions (2 default) | 20 ms | Profile | HMAC-SHA2, Native transitions, Extended frames | -| **F454 / F455** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames | +| **F454** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames | +| **F455** | 4 sessions | 50 ms | Profile | HMAC-SHA2, Native transitions, Extended frames | +| **F461** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames | | **MH202** | 2 sessions | 100 ms | Profile | HMAC-SHA2, Extended frames | | **MH201** | 1 session | 100 ms | Profile | Extended frames, Clock diagnostics | | **MH200** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth, Sound system (WHO 16) | -| **MH200N** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth | +| **MH200N** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth, Sound system (WHO 16) | | **Generic Gateway** | 1 session | 50 ms | Profile | Conservative fallback | + Profiles can be resolved automatically using `get_gateway_profile(model_name)`: diff --git a/scripts/update_readme_coverage.py b/scripts/update_readme_coverage.py index 35ac704..ff5ac0e 100644 --- a/scripts/update_readme_coverage.py +++ b/scripts/update_readme_coverage.py @@ -24,7 +24,7 @@ "OWNd/connection.py": "Hardened dual-session TCP engine, SHA-1/HMAC auth, keepalives & bounded read loops", "OWNd/discovery.py": "SSDP multicast and UPnP XML gateway discovery and descriptor parsing", "OWNd/message.py": "OpenWebNet frame parsers, encoders, and WHO dimension decoders", - "OWNd/profiles.py": "Declarative hardware gateway models (F454, MH200N, MH201, MH202, MyHomeServer1)", + "OWNd/profiles.py": "Declarative hardware gateway models (F454, MH200, MH200N, MH201, MH202, MyHomeServer1)", "OWNd/transport/base.py": "Abstract transport layer and event listener notification contracts", "OWNd/transport/serial.py": "Async Serial/USB transport for Legrand 3578 interface with in-band demux", "OWNd/transport/tcp.py": "Dual-session TCP transport linking event and command channels", diff --git a/scripts/update_readme_profiles.py b/scripts/update_readme_profiles.py new file mode 100644 index 0000000..2028ef7 --- /dev/null +++ b/scripts/update_readme_profiles.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Update README.md hardware gateway profiles table from OWNd.profiles. + +Maintains live, automated documentation of gateway capabilities, pacing, +and hardware constraints. +Called locally, by git hooks, and by CI / test verification. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + +# Ensure OWNd package can be imported from parent directory +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from OWNd.profiles import CANONICAL_PROFILES # noqa: E402 + +README_MD = REPO_ROOT / "README.md" +START_MARKER = "" +END_MARKER = "" + + +def build_profiles_table() -> str: + """Generate Markdown table representing canonical gateway profiles.""" + lines = [ + "| Gateway Model | Concurrency | Queue Delay | Keepalive | Features |", + "|:---|:---:|:---:|:---:|:---|", + ] + for profile in CANONICAL_PROFILES: + lines.append( + f"| **{profile.model_name}** | {profile.concurrency_summary} | " + f"{profile.queue_delay_summary} | {profile.keepalive_summary} | " + f"{profile.features_summary} |" + ) + return "\n".join(lines) + + +def sync_readme_profiles(check_only: bool = False) -> bool: + """Check or update the gateway profiles table in README.md.""" + if not README_MD.is_file(): + print(f"Error: {README_MD} not found.", file=sys.stderr) + return False + + content = README_MD.read_text(encoding="utf-8") + table_block = build_profiles_table() + + if START_MARKER not in content or END_MARKER not in content: + print( + f"Error: Markers '{START_MARKER}' or '{END_MARKER}' not found in {README_MD}.", + file=sys.stderr, + ) + return False + + pattern = re.compile( + rf"{re.escape(START_MARKER)}.*?{re.escape(END_MARKER)}", + re.DOTALL, + ) + replacement = f"{START_MARKER}\n{table_block}\n{END_MARKER}" + + current_match = pattern.search(content) + if not current_match: + return False + + is_in_sync = current_match.group(0) == replacement + + if check_only: + return is_in_sync + + if not is_in_sync: + updated_content = pattern.sub(replacement, content) + README_MD.write_text(updated_content, encoding="utf-8") + print(f"Updated gateway profiles table in {README_MD} ({len(CANONICAL_PROFILES)} models).") + else: + print(f"Gateway profiles table in {README_MD} is already up-to-date.") + + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Check whether README.md is in sync without modifying it (exit 1 if out-of-sync).", + ) + args = parser.parse_args() + + if args.check: + in_sync = sync_readme_profiles(check_only=True) + if not in_sync: + print( + "Error: README.md gateway profiles table is out of date with OWNd.profiles.\n" + "Run 'python scripts/update_readme_profiles.py' to update it.", + file=sys.stderr, + ) + return 1 + print("Gateway profiles table in README.md is in sync.") + return 0 + + success = sync_readme_profiles(check_only=False) + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify_library_standards.py b/scripts/verify_library_standards.py index 75de08b..5c71bd4 100644 --- a/scripts/verify_library_standards.py +++ b/scripts/verify_library_standards.py @@ -16,6 +16,8 @@ import sys ROOT_DIR = Path(__file__).resolve().parent.parent +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) OWND_DIR = ROOT_DIR / "OWNd" @@ -99,6 +101,21 @@ def check_version_sync(validator: StandardsValidator) -> None: validator.ok(f"Package version integrity verified: v{match.group(1)}.") +def check_gateway_profiles_readme_sync(validator: StandardsValidator) -> None: + """Verify README.md gateway profiles table matches OWNd.profiles.""" + from scripts.update_readme_profiles import README_MD, sync_readme_profiles + + if not sync_readme_profiles(check_only=True): + validator.error( + "RULE_DOCS_SYNC", + README_MD, + 1, + "Gateway profiles table in README.md is out of sync. Run scripts/update_readme_profiles.py", + ) + else: + validator.ok("Gateway profiles table in README.md is in sync with OWNd.profiles.") + + def main() -> int: print("=" * 70) print("Running OWNd PyPI Library Standards & Decoupling Validator") @@ -109,6 +126,7 @@ def main() -> int: check_pure_async(validator) check_pep561_typing(validator) check_version_sync(validator) + check_gateway_profiles_readme_sync(validator) print("=" * 70) if validator.errors: diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 067cc80..b45676a 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -1,12 +1,18 @@ -"""Tests for conservative gateway capability profiles.""" +from pathlib import Path +import sys import pytest +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + from OWNd.profiles import ( DEFAULT_SUPPORTED_WHO, WHO_LOAD_CONTROL, WHO_SOUND, WHO_SOUND_DIFFUSION, + GatewayProfile, MH200NProfile, MH200Profile, get_gateway_profile, @@ -33,15 +39,15 @@ def test_mh200_keeps_the_mh200n_pacing() -> None: assert mh200.command_queue_delay == mh200n.command_queue_delay assert mh200.max_queue_size == mh200n.max_queue_size assert mh200.event_keepalive_interval == mh200n.event_keepalive_interval - assert set(mh200.supported_who) - set(mh200n.supported_who) == {WHO_SOUND} + assert mh200.supported_who == mh200n.supported_who -def test_mh200n_audio_stays_off_until_checked() -> None: - """Unchanged until an MH200N is seen answering *#16*0*5## (#53).""" +def test_mh200n_supports_audio() -> None: + """MH200N supports WHO 16 sound diffusion (MyHOME#422).""" profile = get_gateway_profile("MH200N") - assert profile.supports_audio is False - assert not profile.supports_who(WHO_SOUND) + assert profile.supports_audio is True + assert profile.supports_who(WHO_SOUND) def test_profile_lookup_accepts_common_name_variants() -> None: @@ -84,3 +90,40 @@ def test_load_control_and_sound_diffusion_have_distinct_who_codes() -> None: assert WHO_SOUND_DIFFUSION == 22 assert WHO_LOAD_CONTROL in DEFAULT_SUPPORTED_WHO assert WHO_SOUND_DIFFUSION in DEFAULT_SUPPORTED_WHO + + +def test_gateway_profile_summary_properties() -> None: + """Verify summary strings across diverse gateway families.""" + mhs1 = get_gateway_profile("MyHomeServer1") + assert mhs1.concurrency_summary == "4 sessions (2 default)" + assert mhs1.queue_delay_summary == "20 ms" + assert mhs1.keepalive_summary == "Profile" + assert mhs1.features_summary == "HMAC-SHA2, Native transitions, Extended frames" + + f454 = get_gateway_profile("F454") + assert f454.concurrency_summary == "4 sessions" + assert f454.queue_delay_summary == "50 ms" + assert f454.keepalive_summary == "90 s" + + mh200n = get_gateway_profile("MH200N") + assert mh200n.concurrency_summary == "1 session" + assert mh200n.queue_delay_summary == "150 ms" + assert mh200n.keepalive_summary == "90 s" + assert mh200n.features_summary == "Safe pacing, Legacy password auth, Sound system (WHO 16)" + + generic = get_gateway_profile("Unknown") + assert generic.features_summary == "Conservative fallback" + + custom = GatewayProfile(model_name="Custom", command_queue_delay=0.05, supports_hmac=False) + assert custom.features_summary == "" + + +def test_readme_gateway_profiles_table_is_in_sync() -> None: + """Verify README.md gateway profiles table matches OWNd.profiles declarations.""" + from scripts.update_readme_profiles import sync_readme_profiles + + assert sync_readme_profiles(check_only=True) is True, ( + "README.md gateway profiles table is out of date. " + "Run 'python scripts/update_readme_profiles.py' to update it." + ) + From d2ed50dfff2a222629ba223d27f14b53028728dd Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:35:31 +0200 Subject: [PATCH 2/5] fix(profiles): derive features from profile fields, fix F455 keepalive, and hold MH200N audio - Revert MH200NProfile to supports_audio=False and restore unverified docstring and test_mh200n_audio_stays_off_until_checked pending Plant B discovery capture. - Derive features_summary directly from profile capabilities (supports_hmac, requires_password, supports_native_transitions, supports_extended_frames, supports_who(16)/supports_audio, extra_features) rather than pacing delay heuristics. - Add extra_features tuple to GatewayProfile and configure MH201Profile with extra_features=('Clock diagnostics',), removing hardcoded features_summary overrides. - Provide CANONICAL_PROFILE_ORDER and canonical_profiles() to guarantee README table synchronizer inspects the active profile registry without duplication. - Set F455Profile event_keepalive_interval to 90 s matching F454 and F461. - Rename opaque 'Profile' keepalive summary to 'OS TCP only'. - Update README.md gateway profiles table and unit tests accordingly. --- OWNd/profiles.py | 64 +++++++++++++---------- README.md | 16 +++--- scripts/update_readme_profiles.py | 20 ++++--- tests/test_profiles.py | 87 +++++++++++++++++++++++++++---- 4 files changed, 134 insertions(+), 53 deletions(-) diff --git a/OWNd/profiles.py b/OWNd/profiles.py index a6acba2..e4690ed 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -48,6 +48,7 @@ class GatewayProfile: supports_native_transitions: bool = False supports_extended_frames: bool = False supported_who: tuple[int, ...] = DEFAULT_SUPPORTED_WHO + extra_features: tuple[str, ...] = () @property def max_workers(self) -> int: @@ -87,7 +88,7 @@ def keepalive_summary(self) -> str: """Formatted keepalive interval string.""" if self.event_keepalive_interval is not None: return f"{int(self.event_keepalive_interval)} s" - return "Profile" + return "OS TCP only" @property def features_summary(self) -> str: @@ -97,16 +98,16 @@ def features_summary(self) -> str: features.append("Safe pacing") if self.supports_hmac: features.append("HMAC-SHA2") - elif self.command_queue_delay >= 0.15: + elif self.requires_password: features.append("Legacy password auth") if self.supports_native_transitions: features.append("Native transitions") if self.supports_extended_frames: features.append("Extended frames") - if self.supports_who(WHO_SOUND) and self.command_queue_delay >= 0.15: + if self.supports_who(WHO_SOUND) or self.supports_audio: features.append("Sound system (WHO 16)") - - return ", ".join(features) + features.extend(self.extra_features) + return ", ".join(features) or "Conservative fallback" def supports_who(self, who: int) -> bool: """Return whether the profile advertises a WHO subsystem.""" @@ -139,6 +140,7 @@ def __init__(self) -> None: model_name="F455", max_command_sessions=4, max_queue_size=250, + event_keepalive_interval=90, supports_hmac=True, supports_native_transitions=True, supports_extended_frames=True, @@ -192,8 +194,10 @@ def __init__(self) -> None: class MH200NProfile(GatewayProfile): """The MH200N. - WHO 16 sound support is verified on live installations (MyHOME#422). - Pacing, queue size, and keepalive match the MH200 profile. + No audio is unverified. The flag predates any MH200N capture, and a + real MH200N relays WHO 16 events together with WHO 22 mirrors of them + (MyHOME#422). Whether it answers ``*#16*0*5##`` has not been checked + (#53); until it has, startup discovery skips WHO 16 here. """ def __init__(self) -> None: @@ -203,12 +207,12 @@ def __init__(self) -> None: max_queue_size=100, event_keepalive_interval=90, supports_energy_instant_power=False, + supports_audio=False, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, WHO_HEATING, WHO_CEN, - WHO_SOUND, WHO_SCENARIO, WHO_CEN_PLUS, ), @@ -222,12 +226,9 @@ def __init__(self) -> None: command_queue_delay=0.10, max_queue_size=100, supports_extended_frames=True, + extra_features=("Clock diagnostics",), ) - @property - def features_summary(self) -> str: - return "Extended frames, Clock diagnostics" - class MH202Profile(GatewayProfile): def __init__(self) -> None: @@ -256,11 +257,14 @@ def __init__(self) -> None: class GenericGatewayProfile(GatewayProfile): def __init__(self, model_name: str = "Generic") -> None: - super().__init__(model_name=model_name) - - @property - def features_summary(self) -> str: - return "Conservative fallback" + super().__init__( + model_name=model_name, + requires_password=False, + supports_audio=False, + supported_who=tuple( + who for who in DEFAULT_SUPPORTED_WHO if who != WHO_SOUND + ), + ) _GENERIC = GenericGatewayProfile() @@ -276,18 +280,24 @@ def features_summary(self) -> str: "myhomeserver1": MyHomeServer1Profile(), } -CANONICAL_PROFILES: tuple[GatewayProfile, ...] = ( - MyHomeServer1Profile(), - F454Profile(), - F455Profile(), - F461Profile(), - MH202Profile(), - MH201Profile(), - MH200Profile(), - MH200NProfile(), - GenericGatewayProfile("Generic Gateway"), +CANONICAL_PROFILE_ORDER = ( + "myhomeserver1", + "f454", + "f455", + "f461", + "mh202", + "mh201", + "mh200", + "mh200n", ) + +def canonical_profiles() -> tuple[GatewayProfile, ...]: + return tuple(_PROFILES[key] for key in CANONICAL_PROFILE_ORDER) + (_GENERIC,) + + +CANONICAL_PROFILES: tuple[GatewayProfile, ...] = canonical_profiles() + _ALIASES = { "mhs1": "myhomeserver1", } diff --git a/README.md b/README.md index ec41e44..387ce1c 100755 --- a/README.md +++ b/README.md @@ -95,15 +95,15 @@ Gateways have varying processing limitations, socket budgets, and pacing require | Gateway Model | Concurrency | Queue Delay | Keepalive | Features | |:---|:---:|:---:|:---:|:---| -| **MyHomeServer1** | 4 sessions (2 default) | 20 ms | Profile | HMAC-SHA2, Native transitions, Extended frames | -| **F454** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames | -| **F455** | 4 sessions | 50 ms | Profile | HMAC-SHA2, Native transitions, Extended frames | -| **F461** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames | -| **MH202** | 2 sessions | 100 ms | Profile | HMAC-SHA2, Extended frames | -| **MH201** | 1 session | 100 ms | Profile | Extended frames, Clock diagnostics | +| **MyHomeServer1** | 4 sessions (2 default) | 20 ms | OS TCP only | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | +| **F454** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | +| **F455** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | +| **F461** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | +| **MH202** | 2 sessions | 100 ms | OS TCP only | HMAC-SHA2, Extended frames, Sound system (WHO 16) | +| **MH201** | 1 session | 100 ms | OS TCP only | Legacy password auth, Extended frames, Sound system (WHO 16), Clock diagnostics | | **MH200** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth, Sound system (WHO 16) | -| **MH200N** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth, Sound system (WHO 16) | -| **Generic Gateway** | 1 session | 50 ms | Profile | Conservative fallback | +| **MH200N** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth | +| **Generic Gateway** | 1 session | 50 ms | OS TCP only | Conservative fallback | Profiles can be resolved automatically using `get_gateway_profile(model_name)`: diff --git a/scripts/update_readme_profiles.py b/scripts/update_readme_profiles.py index 2028ef7..d8d9302 100644 --- a/scripts/update_readme_profiles.py +++ b/scripts/update_readme_profiles.py @@ -17,7 +17,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from OWNd.profiles import CANONICAL_PROFILES # noqa: E402 +from OWNd.profiles import canonical_profiles # noqa: E402 README_MD = REPO_ROOT / "README.md" START_MARKER = "" @@ -30,9 +30,14 @@ def build_profiles_table() -> str: "| Gateway Model | Concurrency | Queue Delay | Keepalive | Features |", "|:---|:---:|:---:|:---:|:---|", ] - for profile in CANONICAL_PROFILES: + for profile in canonical_profiles(): + model_name = ( + "Generic Gateway" + if profile.model_name in ("Generic", "Generic Gateway") + else profile.model_name + ) lines.append( - f"| **{profile.model_name}** | {profile.concurrency_summary} | " + f"| **{model_name}** | {profile.concurrency_summary} | " f"{profile.queue_delay_summary} | {profile.keepalive_summary} | " f"{profile.features_summary} |" ) @@ -45,7 +50,7 @@ def sync_readme_profiles(check_only: bool = False) -> bool: print(f"Error: {README_MD} not found.", file=sys.stderr) return False - content = README_MD.read_text(encoding="utf-8") + content = README_MD.read_text(encoding="utf-8").replace("\r\n", "\n") table_block = build_profiles_table() if START_MARKER not in content or END_MARKER not in content: @@ -71,9 +76,10 @@ def sync_readme_profiles(check_only: bool = False) -> bool: return is_in_sync if not is_in_sync: - updated_content = pattern.sub(replacement, content) - README_MD.write_text(updated_content, encoding="utf-8") - print(f"Updated gateway profiles table in {README_MD} ({len(CANONICAL_PROFILES)} models).") + updated_content = pattern.sub(replacement, content).replace("\r\n", "\n") + with README_MD.open("w", encoding="utf-8", newline="\n") as f: + f.write(updated_content) + print(f"Updated gateway profiles table in {README_MD} ({len(canonical_profiles())} models).") else: print(f"Gateway profiles table in {README_MD} is already up-to-date.") diff --git a/tests/test_profiles.py b/tests/test_profiles.py index b45676a..f9dcec5 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -8,13 +8,17 @@ sys.path.insert(0, str(REPO_ROOT)) from OWNd.profiles import ( + CANONICAL_PROFILE_ORDER, DEFAULT_SUPPORTED_WHO, + WHO_LIGHTING, WHO_LOAD_CONTROL, WHO_SOUND, WHO_SOUND_DIFFUSION, + _PROFILES, GatewayProfile, MH200NProfile, MH200Profile, + canonical_profiles, get_gateway_profile, ) @@ -39,15 +43,15 @@ def test_mh200_keeps_the_mh200n_pacing() -> None: assert mh200.command_queue_delay == mh200n.command_queue_delay assert mh200.max_queue_size == mh200n.max_queue_size assert mh200.event_keepalive_interval == mh200n.event_keepalive_interval - assert mh200.supported_who == mh200n.supported_who + assert set(mh200.supported_who) - set(mh200n.supported_who) == {WHO_SOUND} -def test_mh200n_supports_audio() -> None: - """MH200N supports WHO 16 sound diffusion (MyHOME#422).""" +def test_mh200n_audio_stays_off_until_checked() -> None: + """Unchanged until an MH200N is seen answering *#16*0*5## (#53).""" profile = get_gateway_profile("MH200N") - assert profile.supports_audio is True - assert profile.supports_who(WHO_SOUND) + assert profile.supports_audio is False + assert not profile.supports_who(WHO_SOUND) def test_profile_lookup_accepts_common_name_variants() -> None: @@ -92,31 +96,92 @@ def test_load_control_and_sound_diffusion_have_distinct_who_codes() -> None: assert WHO_SOUND_DIFFUSION in DEFAULT_SUPPORTED_WHO +def test_canonical_order_covers_registry() -> None: + assert set(CANONICAL_PROFILE_ORDER) == set(_PROFILES) + + +def test_sound_system_feature_matches_capabilities() -> None: + """Every profile advertises Sound system iff it supports WHO 16 or audio.""" + for profile in canonical_profiles(): + has_sound = profile.supports_who(WHO_SOUND) or profile.supports_audio + assert ("Sound system (WHO 16)" in profile.features_summary) is has_sound + + +def test_hmac_profiles_never_show_legacy_auth() -> None: + """Profiles with HMAC authentication never advertise legacy password auth.""" + for profile in canonical_profiles(): + if profile.supports_hmac: + assert "Legacy password auth" not in profile.features_summary + + +def test_slow_hmac_profile_does_not_pick_up_sound_or_legacy_auth_from_delay() -> None: + """A 150 ms delay profile does not inherit sound or legacy auth heuristics.""" + custom = GatewayProfile( + model_name="SlowHMAC", + command_queue_delay=0.15, + supports_hmac=True, + supports_audio=False, + supported_who=(WHO_LIGHTING,), + ) + assert custom.features_summary == "Safe pacing, HMAC-SHA2" + assert "Legacy password auth" not in custom.features_summary + assert "Sound system (WHO 16)" not in custom.features_summary + + +def test_mh201_shows_clock_diagnostics() -> None: + """MH201 includes Clock diagnostics from extra_features.""" + mh201 = get_gateway_profile("MH201") + assert "Clock diagnostics" in mh201.features_summary + + def test_gateway_profile_summary_properties() -> None: """Verify summary strings across diverse gateway families.""" mhs1 = get_gateway_profile("MyHomeServer1") assert mhs1.concurrency_summary == "4 sessions (2 default)" assert mhs1.queue_delay_summary == "20 ms" - assert mhs1.keepalive_summary == "Profile" - assert mhs1.features_summary == "HMAC-SHA2, Native transitions, Extended frames" + assert mhs1.keepalive_summary == "OS TCP only" + assert ( + mhs1.features_summary + == "HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16)" + ) f454 = get_gateway_profile("F454") assert f454.concurrency_summary == "4 sessions" assert f454.queue_delay_summary == "50 ms" assert f454.keepalive_summary == "90 s" + assert ( + f454.features_summary + == "HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16)" + ) + + f455 = get_gateway_profile("F455") + assert f455.concurrency_summary == "4 sessions" + assert f455.queue_delay_summary == "50 ms" + assert f455.keepalive_summary == "90 s" + assert ( + f455.features_summary + == "HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16)" + ) + + mh200 = get_gateway_profile("MH200") + assert mh200.concurrency_summary == "1 session" + assert mh200.queue_delay_summary == "150 ms" + assert mh200.keepalive_summary == "90 s" + assert ( + mh200.features_summary + == "Safe pacing, Legacy password auth, Sound system (WHO 16)" + ) mh200n = get_gateway_profile("MH200N") assert mh200n.concurrency_summary == "1 session" assert mh200n.queue_delay_summary == "150 ms" assert mh200n.keepalive_summary == "90 s" - assert mh200n.features_summary == "Safe pacing, Legacy password auth, Sound system (WHO 16)" + assert mh200n.features_summary == "Safe pacing, Legacy password auth" generic = get_gateway_profile("Unknown") + assert generic.keepalive_summary == "OS TCP only" assert generic.features_summary == "Conservative fallback" - custom = GatewayProfile(model_name="Custom", command_queue_delay=0.05, supports_hmac=False) - assert custom.features_summary == "" - def test_readme_gateway_profiles_table_is_in_sync() -> None: """Verify README.md gateway profiles table matches OWNd.profiles declarations.""" From 5b2795f706200c9c9b4e7bfacd7503b4f78bb0fa Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:46:19 +0200 Subject: [PATCH 3/5] fix(profiles): preserve WHO_SOUND on GenericGatewayProfile for MyHOME startup discovery - GenericGatewayProfile must retain DEFAULT_SUPPORTED_WHO (including WHO 16) so downstream integrations like MyHOME do not drop audio status requests during initial discovery sweeps. - GatewayProfile.features_summary returns 'Conservative fallback' for GenericGatewayProfile instances without stripping capabilities. - test_sound_system_feature_matches_capabilities verifies declared hardware profiles in _PROFILES. --- OWNd/profiles.py | 11 +++-------- tests/test_profiles.py | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/OWNd/profiles.py b/OWNd/profiles.py index e4690ed..091d59d 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -93,6 +93,8 @@ def keepalive_summary(self) -> str: @property def features_summary(self) -> str: """Formatted capabilities and features string.""" + if isinstance(self, GenericGatewayProfile): + return "Conservative fallback" features: list[str] = [] if self.command_queue_delay >= 0.15: features.append("Safe pacing") @@ -257,14 +259,7 @@ def __init__(self) -> None: class GenericGatewayProfile(GatewayProfile): def __init__(self, model_name: str = "Generic") -> None: - super().__init__( - model_name=model_name, - requires_password=False, - supports_audio=False, - supported_who=tuple( - who for who in DEFAULT_SUPPORTED_WHO if who != WHO_SOUND - ), - ) + super().__init__(model_name=model_name) _GENERIC = GenericGatewayProfile() diff --git a/tests/test_profiles.py b/tests/test_profiles.py index f9dcec5..e397aa3 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -102,7 +102,7 @@ def test_canonical_order_covers_registry() -> None: def test_sound_system_feature_matches_capabilities() -> None: """Every profile advertises Sound system iff it supports WHO 16 or audio.""" - for profile in canonical_profiles(): + for profile in _PROFILES.values(): has_sound = profile.supports_who(WHO_SOUND) or profile.supports_audio assert ("Sound system (WHO 16)" in profile.features_summary) is has_sound From 1012021fc85cc6c172f463c9a1a247eb450a9346 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:16:57 +0200 Subject: [PATCH 4/5] feat(profiles): enable WHO 16 audio support on MH200N profile (#63) - Set supports_audio = True and add WHO_SOUND to MH200NProfile.supported_who - Hardware verified on live MH200N + F500N answering *#16*0*5## without NACK (MyHOME#427 comment 5848181845) - Regenerate README gateway profiles table - Update test_profiles assertions --- OWNd/profiles.py | 10 +++++----- README.md | 2 +- tests/test_profiles.py | 15 +++++++++------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/OWNd/profiles.py b/OWNd/profiles.py index 091d59d..794894b 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -196,10 +196,9 @@ def __init__(self) -> None: class MH200NProfile(GatewayProfile): """The MH200N. - No audio is unverified. The flag predates any MH200N capture, and a - real MH200N relays WHO 16 events together with WHO 22 mirrors of them - (MyHOME#422). Whether it answers ``*#16*0*5##`` has not been checked - (#53); until it has, startup discovery skips WHO 16 here. + Supports sound system discovery (WHO 16). Hardware verified answering + ``*#16*0*5##`` without NACK and returning full source and amplifier inventory + (MyHOME#427 / comment 5848181845). """ def __init__(self) -> None: @@ -209,7 +208,7 @@ def __init__(self) -> None: max_queue_size=100, event_keepalive_interval=90, supports_energy_instant_power=False, - supports_audio=False, + supports_audio=True, supported_who=( WHO_LIGHTING, WHO_AUTOMATION, @@ -217,6 +216,7 @@ def __init__(self) -> None: WHO_CEN, WHO_SCENARIO, WHO_CEN_PLUS, + WHO_SOUND, ), ) diff --git a/README.md b/README.md index 387ce1c..96256e6 100755 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Gateways have varying processing limitations, socket budgets, and pacing require | **MH202** | 2 sessions | 100 ms | OS TCP only | HMAC-SHA2, Extended frames, Sound system (WHO 16) | | **MH201** | 1 session | 100 ms | OS TCP only | Legacy password auth, Extended frames, Sound system (WHO 16), Clock diagnostics | | **MH200** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth, Sound system (WHO 16) | -| **MH200N** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth | +| **MH200N** | 1 session | 150 ms | 90 s | Safe pacing, Legacy password auth, Sound system (WHO 16) | | **Generic Gateway** | 1 session | 50 ms | OS TCP only | Conservative fallback | diff --git a/tests/test_profiles.py b/tests/test_profiles.py index e397aa3..756266b 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -43,15 +43,15 @@ def test_mh200_keeps_the_mh200n_pacing() -> None: assert mh200.command_queue_delay == mh200n.command_queue_delay assert mh200.max_queue_size == mh200n.max_queue_size assert mh200.event_keepalive_interval == mh200n.event_keepalive_interval - assert set(mh200.supported_who) - set(mh200n.supported_who) == {WHO_SOUND} + assert set(mh200.supported_who) == set(mh200n.supported_who) -def test_mh200n_audio_stays_off_until_checked() -> None: - """Unchanged until an MH200N is seen answering *#16*0*5## (#53).""" +def test_mh200n_audio_enabled_and_verified() -> None: + """MH200N verified answering *#16*0*5## without NACK (MyHOME#427 comment 5848181845).""" profile = get_gateway_profile("MH200N") - assert profile.supports_audio is False - assert not profile.supports_who(WHO_SOUND) + assert profile.supports_audio is True + assert profile.supports_who(WHO_SOUND) def test_profile_lookup_accepts_common_name_variants() -> None: @@ -176,7 +176,10 @@ def test_gateway_profile_summary_properties() -> None: assert mh200n.concurrency_summary == "1 session" assert mh200n.queue_delay_summary == "150 ms" assert mh200n.keepalive_summary == "90 s" - assert mh200n.features_summary == "Safe pacing, Legacy password auth" + assert ( + mh200n.features_summary + == "Safe pacing, Legacy password auth, Sound system (WHO 16)" + ) generic = get_gateway_profile("Unknown") assert generic.keepalive_summary == "OS TCP only" From 924177fd2f620a9740dfd307e0ac81cce98b04d6 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:29:05 +0200 Subject: [PATCH 5/5] fix(profiles): revert untested F455 event keepalive, rename column to Event keepalive No F455 capture exists (MyHOME#466), so a 90 s *#*1## keepalive on its event session is untested. F455 goes back to OS TCP keepalive only until a hardware test says otherwise. Column renamed to 'Event keepalive' per review. Co-Authored-By: Claude Sonnet 5.5 --- OWNd/profiles.py | 1 - README.md | 4 ++-- scripts/update_readme_profiles.py | 2 +- tests/test_profiles.py | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/OWNd/profiles.py b/OWNd/profiles.py index 794894b..cd019d7 100644 --- a/OWNd/profiles.py +++ b/OWNd/profiles.py @@ -142,7 +142,6 @@ def __init__(self) -> None: model_name="F455", max_command_sessions=4, max_queue_size=250, - event_keepalive_interval=90, supports_hmac=True, supports_native_transitions=True, supports_extended_frames=True, diff --git a/README.md b/README.md index 96256e6..8ed574a 100755 --- a/README.md +++ b/README.md @@ -93,11 +93,11 @@ OWNd parses OpenWebNet frames and dispatches typed commands and events across th Gateways have varying processing limitations, socket budgets, and pacing requirements. OWNd uses declarative profiles to protect your hardware: -| Gateway Model | Concurrency | Queue Delay | Keepalive | Features | +| Gateway Model | Concurrency | Queue Delay | Event keepalive | Features | |:---|:---:|:---:|:---:|:---| | **MyHomeServer1** | 4 sessions (2 default) | 20 ms | OS TCP only | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | | **F454** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | -| **F455** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | +| **F455** | 4 sessions | 50 ms | OS TCP only | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | | **F461** | 4 sessions | 50 ms | 90 s | HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16) | | **MH202** | 2 sessions | 100 ms | OS TCP only | HMAC-SHA2, Extended frames, Sound system (WHO 16) | | **MH201** | 1 session | 100 ms | OS TCP only | Legacy password auth, Extended frames, Sound system (WHO 16), Clock diagnostics | diff --git a/scripts/update_readme_profiles.py b/scripts/update_readme_profiles.py index d8d9302..fa06b8d 100644 --- a/scripts/update_readme_profiles.py +++ b/scripts/update_readme_profiles.py @@ -27,7 +27,7 @@ def build_profiles_table() -> str: """Generate Markdown table representing canonical gateway profiles.""" lines = [ - "| Gateway Model | Concurrency | Queue Delay | Keepalive | Features |", + "| Gateway Model | Concurrency | Queue Delay | Event keepalive | Features |", "|:---|:---:|:---:|:---:|:---|", ] for profile in canonical_profiles(): diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 756266b..4830e11 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -157,7 +157,7 @@ def test_gateway_profile_summary_properties() -> None: f455 = get_gateway_profile("F455") assert f455.concurrency_summary == "4 sessions" assert f455.queue_delay_summary == "50 ms" - assert f455.keepalive_summary == "90 s" + assert f455.keepalive_summary == "OS TCP only" assert ( f455.features_summary == "HMAC-SHA2, Native transitions, Extended frames, Sound system (WHO 16)"