Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions OWNd/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -166,14 +207,15 @@ 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,
WHO_HEATING,
WHO_CEN,
WHO_SCENARIO,
WHO_CEN_PLUS,
WHO_SOUND,
),
)

Expand All @@ -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",),
)


Expand Down Expand Up @@ -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",
}
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
<!-- START_GATEWAY_PROFILES_TABLE -->
| 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 |
<!-- END_GATEWAY_PROFILES_TABLE -->

Profiles can be resolved automatically using `get_gateway_profile(model_name)`:

Expand Down
2 changes: 1 addition & 1 deletion scripts/update_readme_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
115 changes: 115 additions & 0 deletions scripts/update_readme_profiles.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- START_GATEWAY_PROFILES_TABLE -->"
END_MARKER = "<!-- END_GATEWAY_PROFILES_TABLE -->"


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())
18 changes: 18 additions & 0 deletions scripts/verify_library_standards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
Loading
Loading