diff --git a/OWNd/profiles.py b/OWNd/profiles.py index 2d664a4..cd019d7 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: @@ -69,6 +70,47 @@ 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 "OS TCP only" + + @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") + if self.supports_hmac: + features.append("HMAC-SHA2") + 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) or self.supports_audio: + features.append("Sound system (WHO 16)") + 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.""" return who in self.supported_who @@ -153,10 +195,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: @@ -166,7 +207,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, @@ -174,6 +215,7 @@ def __init__(self) -> None: WHO_CEN, WHO_SCENARIO, WHO_CEN_PLUS, + WHO_SOUND, ), ) @@ -185,6 +227,7 @@ def __init__(self) -> None: command_queue_delay=0.10, max_queue_size=100, supports_extended_frames=True, + extra_features=("Clock diagnostics",), ) @@ -231,6 +274,24 @@ def __init__(self, model_name: str = "Generic") -> None: "myhomeserver1": MyHomeServer1Profile(), } +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 f7c1302..8ed574a 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 | + +| Gateway Model | Concurrency | Queue Delay | Event 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 | -| **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 | 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 | | **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 | -| **Generic Gateway** | 1 session | 50 ms | Profile | Conservative fallback | +| **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 | + 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..fa06b8d --- /dev/null +++ b/scripts/update_readme_profiles.py @@ -0,0 +1,115 @@ +#!/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 | Event keepalive | Features |", + "|:---|:---:|:---:|:---:|:---|", + ] + for profile in canonical_profiles(): + model_name = ( + "Generic Gateway" + if profile.model_name in ("Generic", "Generic Gateway") + else profile.model_name + ) + lines.append( + f"| **{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").replace("\r\n", "\n") + 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).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.") + + 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..4830e11 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -1,14 +1,24 @@ -"""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 ( + 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, ) @@ -33,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: @@ -84,3 +94,104 @@ 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_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 _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 + + +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 == "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 == "OS TCP only" + 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)" + ) + + generic = get_gateway_profile("Unknown") + assert generic.keepalive_summary == "OS TCP only" + assert generic.features_summary == "Conservative fallback" + + +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." + ) +