From 57ab1336c2e26101711b803ea4921323f044ad51 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:44:33 +0200 Subject: [PATCH 1/2] fix(docs): enforce WHO dispatch coverage in sync check and map event-only WHO 0/9 - Call verify_who_catalog_coverage() during sync_documentation() so that --check fails if a registered WHO subsystem is missing from WHO_DEFINITIONS. - Check both _COMMAND_DISPATCH and _EVENT_DISPATCH in verify_who_catalog_coverage(), reporting whether missing WHOs are in commands, events, or both. - Add dedicated WHO_DEFINITIONS entries for event-only subsystems WHO 0 (Scenarios) and WHO 9 (Auxiliary), and remove OWNScenarioEvent from WHO 15. - Update README.md WHO catalog table to reflect WHO 0 and WHO 9. - Add unit tests verifying registry gap detection and sentinel failure modes. --- README.md | 4 +- scripts/sync_documentation.py | 83 +++++++++++++++++++++++++------- tests/test_sync_documentation.py | 73 +++++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3facaf1..a29ca11 100755 --- a/README.md +++ b/README.md @@ -75,13 +75,15 @@ OWNd parses OpenWebNet frames and dispatches typed commands and events across th | WHO | Subsystem | Description & Capabilities | Event / Command Classes | |:---:|:---|:---|:---| +| **0** | Scenarios | Basic scenario execution (1–32) from control panels, scenario module programming and memory lock states | `OWNScenarioEvent` | | **1** | Lighting | On/off switching, dimming level (0–100%), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries | `OWNLightingCommand`, `OWNLightingEvent` | | **2** | Automation | Shutters, blinds, motorized curtains, tilt angles, short & full replies | `OWNAutomationCommand`, `OWNAutomationEvent` | | **3** | Load Control | Load shedding status, circuit priority management | `OWNCommand`, `OWNEvent` | | **4** | Thermoregulation / Climate | Multi-zone temperature readouts, target adjustments, HVAC modes (Heat/Cool/Auto/Off), local offsets, fan coil speeds, valve states, Central Unit 3550/4695 master coordination | `OWNHeatingCommand`, `OWNHeatingEvent` | | **5** | Burglar Alarm | Zone status, system arming / disarming states | `OWNAlarmCommand`, `OWNAlarmEvent` | +| **9** | Auxiliary | Auxiliary channel status (aux 1–9) for remote relays and inter-system signaling | `OWNAuxEvent` | | **13** | Gateway Diagnostics & Clock | Gateway date/time synchronization, timezone offsets, firmware metadata | `OWNGatewayCommand`, `OWNGatewayEvent` | -| **15** | CEN Scenarios | Scenario control, pushbutton push/release/extended press events, strongly typed command builders | `OWNCenCommand`, `OWNCENEvent`, `OWNScenarioEvent` | +| **15** | CEN Scenarios | Scenario control, pushbutton push/release/extended press events, strongly typed command builders | `OWNCenCommand`, `OWNCENEvent` | | **16** / **22** | Sound Diffusion | Multi-source selection, zone activation, volume adjustment, F441 matrix | `OWNSoundCommand`, `OWNSoundEvent`, `OWNAVCommand` | | **17** | Scenario Programmer | MH200N / MH202 scenario activation and state monitoring | `OWNSceneCommand`, `OWNSceneEvent` | | **18** | Energy Management | Active power (W), hourly/daily/monthly consumption (kWh), Stop & Go breaker diagnostics | `OWNEnergyCommand`, `OWNEnergyEvent` | diff --git a/scripts/sync_documentation.py b/scripts/sync_documentation.py index 67ccd10..960c867 100644 --- a/scripts/sync_documentation.py +++ b/scripts/sync_documentation.py @@ -85,6 +85,12 @@ class WhoCatalogDefinition: WHO_DEFINITIONS: tuple[WhoCatalogDefinition, ...] = ( + WhoCatalogDefinition( + who="**0**", + name="Scenarios", + description="Basic scenario execution (1–32) from control panels, scenario module programming and memory lock states", + dispatch_who_keys=(0,), + ), WhoCatalogDefinition( who="**1**", name="Lighting", @@ -116,6 +122,12 @@ class WhoCatalogDefinition: description="Zone status, system arming / disarming states", dispatch_who_keys=(5,), ), + WhoCatalogDefinition( + who="**9**", + name="Auxiliary", + description="Auxiliary channel status (aux 1–9) for remote relays and inter-system signaling", + dispatch_who_keys=(9,), + ), WhoCatalogDefinition( who="**13**", name="Gateway Diagnostics & Clock", @@ -127,7 +139,6 @@ class WhoCatalogDefinition: name="CEN Scenarios", description="Scenario control, pushbutton push/release/extended press events, strongly typed command builders", dispatch_who_keys=(15,), - explicit_classes=("OWNScenarioEvent",), ), WhoCatalogDefinition( who="**16** / **22**", @@ -250,9 +261,10 @@ def build_who_catalog_table() -> str: def verify_who_catalog_coverage() -> list[str]: - """Verify that all WHO subsystems registered in _COMMAND_DISPATCH are accounted for.""" + """Verify that all WHO subsystems registered in dispatch registries are accounted for.""" from OWNd.message.base import ( _COMMAND_DISPATCH, + _EVENT_DISPATCH, _ensure_all_subsystems_registered, ) @@ -262,16 +274,25 @@ def verify_who_catalog_coverage() -> list[str]: covered_who.update(defn.dispatch_who_keys) # WHO 25 is handled with explicit classes because its dispatcher is a private helper function + all_registered = set(_COMMAND_DISPATCH.keys()) | set(_EVENT_DISPATCH.keys()) missing_who = [ - who for who in sorted(_COMMAND_DISPATCH.keys()) + who for who in sorted(all_registered) if who not in covered_who and who != 25 ] - if missing_who: - return [ - f"WHO {who} registered in _COMMAND_DISPATCH but not mapped in WHO_DEFINITIONS." - for who in missing_who - ] - return [] + violations: list[str] = [] + for who in missing_who: + in_cmd = who in _COMMAND_DISPATCH + in_evt = who in _EVENT_DISPATCH + if in_cmd and in_evt: + registry = "_COMMAND_DISPATCH and _EVENT_DISPATCH" + elif in_cmd: + registry = "_COMMAND_DISPATCH" + else: + registry = "_EVENT_DISPATCH" + violations.append( + f"WHO {who} registered in {registry} but not mapped in WHO_DEFINITIONS." + ) + return violations # ───────────────────────────────────────────────────────────────────────────── @@ -436,7 +457,14 @@ def sync_documentation( else: messages.append("Gateway Profiles table is in sync.") - # 2. WHO Subsystems Catalog Table + # 2. WHO Subsystems Catalog Table & Coverage Check + coverage_violations = verify_who_catalog_coverage() + if coverage_violations: + for violation in coverage_violations: + messages.append(f"WHO catalog gap: {violation}") + else: + messages.append("WHO catalog dispatch coverage is complete.") + who_table = build_who_catalog_table() working_content, who_changed = _replace_marker_block( working_content, @@ -497,7 +525,12 @@ def sync_documentation( messages.append(f"Normalized line endings to LF in {readme_path.name}.") content_changed = working_content != original_content - is_in_sync = (not content_changed) and (not has_crlf) and (not svg_drifted) + is_in_sync = ( + (not content_changed) + and (not has_crlf) + and (not svg_drifted) + and (not coverage_violations) + ) diff_str = "" if content_changed: @@ -586,23 +619,39 @@ def main(argv: list[str] | None = None) -> int: for msg in messages: print(f"- {msg}") + has_catalog_gaps = any(msg.startswith("WHO catalog gap:") for msg in messages) + if args.check: if not in_sync: print("\nError: Documentation is out of date!", file=sys.stderr) if diff: print(diff, file=sys.stderr) - print( - f"\nRun 'python scripts/sync_documentation.py' to synchronize {args.readme.name}.", - file=sys.stderr, - ) + if has_catalog_gaps: + print( + f"\nRun 'python scripts/sync_documentation.py' after defining missing WHO subsystems in WHO_DEFINITIONS.", + file=sys.stderr, + ) + else: + print( + f"\nRun 'python scripts/sync_documentation.py' to synchronize {args.readme.name}.", + file=sys.stderr, + ) return 1 print(f"\nDocumentation in {args.readme.name} is fully synchronized.") return 0 if in_sync: print(f"\nDocumentation in {args.readme.name} is already up to date.") - else: - print(f"\nSuccessfully synchronized documentation in {args.readme.name}.") + return 0 + + if has_catalog_gaps: + print( + "\nError: Incomplete documentation synchronization — unmapped WHO subsystems in WHO_DEFINITIONS.", + file=sys.stderr, + ) + return 1 + + print(f"\nSuccessfully synchronized documentation in {args.readme.name}.") return 0 diff --git a/tests/test_sync_documentation.py b/tests/test_sync_documentation.py index 734af01..cb151c7 100644 --- a/tests/test_sync_documentation.py +++ b/tests/test_sync_documentation.py @@ -66,6 +66,18 @@ def test_build_who_catalog_table() -> None: # Verify WHO 4 includes OWNHeatingCommand and OWNHeatingEvent assert "`OWNHeatingCommand`, `OWNHeatingEvent`" in table + # Verify WHO 0 includes OWNScenarioEvent + assert "| **0** | Scenarios |" in table + assert "`OWNScenarioEvent`" in table + + # Verify WHO 9 includes OWNAuxEvent + assert "| **9** | Auxiliary |" in table + assert "`OWNAuxEvent`" in table + + # Verify WHO 15 includes CEN classes without OWNScenarioEvent + assert "| **15** | CEN Scenarios |" in table + assert "`OWNCenCommand`, `OWNCENEvent`" in table + # Verify WHO 25 includes CEN+ and DryContact classes assert "`OWNCenPlusCommand`, `OWNCENPlusEvent`, `OWNDryContactCommand`, `OWNDryContactEvent`" in table @@ -388,11 +400,70 @@ def test_sync_documentation_crlf_detection(tmp_path: Path) -> None: def test_who_catalog_dispatch_coverage() -> None: - """Verify all registered WHO command parsers are covered in WHO_DEFINITIONS.""" + """Verify all registered WHO command and event parsers are covered in WHO_DEFINITIONS.""" violations = verify_who_catalog_coverage() assert not violations, f"WHO catalog definition gaps found: {violations}" +def test_who_catalog_dispatch_coverage_detects_unmapped_registries(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify verify_who_catalog_coverage catches missing WHOs in commands, events, or both.""" + import OWNd.message.base as base_mod + + # Clean initial state + assert verify_who_catalog_coverage() == [] + + # 1. Fake command parser for WHO 998 + fake_cmd_dispatch = dict(base_mod._COMMAND_DISPATCH) + fake_cmd_dispatch[998] = lambda frame: base_mod.OWNCommand(frame) + monkeypatch.setattr(base_mod, "_COMMAND_DISPATCH", fake_cmd_dispatch) + + violations_cmd = verify_who_catalog_coverage() + assert any("WHO 998 registered in _COMMAND_DISPATCH but not mapped in WHO_DEFINITIONS" in v for v in violations_cmd) + + # 2. Fake event parser for WHO 999 + fake_evt_dispatch = dict(base_mod._EVENT_DISPATCH) + fake_evt_dispatch[999] = lambda frame: base_mod.OWNEvent(frame) + monkeypatch.setattr(base_mod, "_EVENT_DISPATCH", fake_evt_dispatch) + + violations_evt = verify_who_catalog_coverage() + assert any("WHO 999 registered in _EVENT_DISPATCH but not mapped in WHO_DEFINITIONS" in v for v in violations_evt) + + # 3. Fake command and event parser for WHO 997 + fake_cmd_dispatch[997] = lambda frame: base_mod.OWNCommand(frame) + fake_evt_dispatch[997] = lambda frame: base_mod.OWNEvent(frame) + violations_both = verify_who_catalog_coverage() + assert any("WHO 997 registered in _COMMAND_DISPATCH and _EVENT_DISPATCH but not mapped in WHO_DEFINITIONS" in v for v in violations_both) + + +def test_sync_documentation_fails_on_catalog_gap(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify sync_documentation and CLI --check flag catalog gaps even without README text drift.""" + import OWNd.message.base as base_mod + + repo_root = Path(__file__).resolve().parent.parent + readme_path = repo_root / "README.md" + + # Register an unmapped event-only WHO (e.g. 999) + fake_evt_dispatch = dict(base_mod._EVENT_DISPATCH) + fake_evt_dispatch[999] = lambda frame: base_mod.OWNEvent(frame) + monkeypatch.setattr(base_mod, "_EVENT_DISPATCH", fake_evt_dispatch) + + # sync_documentation in check mode must return in_sync=False + in_sync, diff, messages = sync_documentation( + readme_path=readme_path, + coverage_xml_path=None, + check_only=True, + ) + assert in_sync is False + assert any("WHO catalog gap:" in m for m in messages) + assert any("WHO 999 registered in _EVENT_DISPATCH" in m for m in messages) + + # main(["--check"]) must exit 1 + assert main(["--check"]) == 1 + + # main([]) in update mode must also exit 1 on catalog gaps + assert main(["--readme", str(readme_path)]) == 1 + + def test_sync_documentation_missing_coverage_graceful(tmp_path: Path) -> None: """Verify that absent coverage.xml does not break check mode if markers are present.""" gw_table = build_gateway_profiles_table() From f33ebf3fd1c4b5406e7f348d591178fe42f18a44 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:51:25 +0200 Subject: [PATCH 2/2] fix(docs): refuse in-place writes on catalog gaps and isolate registry tests - Refuse disk writes to README.md and coverage.svg when catalog coverage gaps exist, preventing partial inconsistent updates. - Set dispatch_who_keys=(25,) on WHO 25 definition, eliminating the hard-coded exclusion in verify_who_catalog_coverage(). - Isolate monkeypatched test cases in test_who_catalog_dispatch_coverage_detects_unmapped_registries. - Add explicit assertion that update mode preserves drifted files and refuses write on gaps. --- scripts/sync_documentation.py | 23 ++++++--- tests/test_sync_documentation.py | 86 +++++++++++++++++++++----------- 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/scripts/sync_documentation.py b/scripts/sync_documentation.py index 960c867..3ed4acd 100644 --- a/scripts/sync_documentation.py +++ b/scripts/sync_documentation.py @@ -163,7 +163,7 @@ class WhoCatalogDefinition: who="**25**", name="CEN+ & Dry Contacts", description="32-button keypads, rotary knob encoders (CW/CCW), dry contacts, PIR sensors, strongly typed command builders", - dispatch_who_keys=(), + dispatch_who_keys=(25,), explicit_classes=( "OWNCenPlusCommand", "OWNCENPlusEvent", @@ -273,11 +273,10 @@ def verify_who_catalog_coverage() -> list[str]: for defn in WHO_DEFINITIONS: covered_who.update(defn.dispatch_who_keys) - # WHO 25 is handled with explicit classes because its dispatcher is a private helper function all_registered = set(_COMMAND_DISPATCH.keys()) | set(_EVENT_DISPATCH.keys()) missing_who = [ who for who in sorted(all_registered) - if who not in covered_who and who != 25 + if who not in covered_who ] violations: list[str] = [] for who in missing_who: @@ -508,8 +507,13 @@ def sync_documentation( if svg_drifted: messages.append(f"{coverage_svg_path.name} drifted from {coverage_xml_path.name} ({rate_round}%).") if not check_only: - coverage_svg_path.write_text(expected_svg, encoding="utf-8", newline="\n") - messages.append(f"Updated {coverage_svg_path.name} to {rate_round}%.") + if coverage_violations: + messages.append( + f"Refused to update {coverage_svg_path.name} while WHO catalog coverage gaps exist." + ) + else: + coverage_svg_path.write_text(expected_svg, encoding="utf-8", newline="\n") + messages.append(f"Updated {coverage_svg_path.name} to {rate_round}%.") else: messages.append(f"{coverage_svg_path.name} is in sync ({rate_round}%).") else: @@ -545,8 +549,13 @@ def sync_documentation( diff_str = "".join(diff_lines) if not check_only and (content_changed or has_crlf): - readme_path.write_text(working_content, encoding="utf-8", newline="\n") - messages.append(f"Updated {readme_path.name} in place.") + if coverage_violations: + messages.append( + f"Refused to update {readme_path.name} while WHO catalog coverage gaps exist." + ) + else: + readme_path.write_text(working_content, encoding="utf-8", newline="\n") + messages.append(f"Updated {readme_path.name} in place.") return is_in_sync, diff_str, messages diff --git a/tests/test_sync_documentation.py b/tests/test_sync_documentation.py index cb151c7..df9d632 100644 --- a/tests/test_sync_documentation.py +++ b/tests/test_sync_documentation.py @@ -410,33 +410,44 @@ def test_who_catalog_dispatch_coverage_detects_unmapped_registries(monkeypatch: import OWNd.message.base as base_mod # Clean initial state + clean_cmds = dict(base_mod._COMMAND_DISPATCH) + clean_evts = dict(base_mod._EVENT_DISPATCH) assert verify_who_catalog_coverage() == [] - # 1. Fake command parser for WHO 998 - fake_cmd_dispatch = dict(base_mod._COMMAND_DISPATCH) - fake_cmd_dispatch[998] = lambda frame: base_mod.OWNCommand(frame) - monkeypatch.setattr(base_mod, "_COMMAND_DISPATCH", fake_cmd_dispatch) - - violations_cmd = verify_who_catalog_coverage() - assert any("WHO 998 registered in _COMMAND_DISPATCH but not mapped in WHO_DEFINITIONS" in v for v in violations_cmd) - - # 2. Fake event parser for WHO 999 - fake_evt_dispatch = dict(base_mod._EVENT_DISPATCH) - fake_evt_dispatch[999] = lambda frame: base_mod.OWNEvent(frame) - monkeypatch.setattr(base_mod, "_EVENT_DISPATCH", fake_evt_dispatch) - - violations_evt = verify_who_catalog_coverage() - assert any("WHO 999 registered in _EVENT_DISPATCH but not mapped in WHO_DEFINITIONS" in v for v in violations_evt) - - # 3. Fake command and event parser for WHO 997 - fake_cmd_dispatch[997] = lambda frame: base_mod.OWNCommand(frame) - fake_evt_dispatch[997] = lambda frame: base_mod.OWNEvent(frame) - violations_both = verify_who_catalog_coverage() - assert any("WHO 997 registered in _COMMAND_DISPATCH and _EVENT_DISPATCH but not mapped in WHO_DEFINITIONS" in v for v in violations_both) - - -def test_sync_documentation_fails_on_catalog_gap(monkeypatch: pytest.MonkeyPatch) -> None: - """Verify sync_documentation and CLI --check flag catalog gaps even without README text drift.""" + # 1. Isolated command-only registration + cmd_only = dict(clean_cmds) + cmd_only[998] = lambda frame: base_mod.OWNCommand(frame) + monkeypatch.setattr(base_mod, "_COMMAND_DISPATCH", cmd_only) + monkeypatch.setattr(base_mod, "_EVENT_DISPATCH", dict(clean_evts)) + assert verify_who_catalog_coverage() == [ + "WHO 998 registered in _COMMAND_DISPATCH but not mapped in WHO_DEFINITIONS." + ] + + # 2. Isolated event-only registration + evt_only = dict(clean_evts) + evt_only[999] = lambda frame: base_mod.OWNEvent(frame) + monkeypatch.setattr(base_mod, "_COMMAND_DISPATCH", dict(clean_cmds)) + monkeypatch.setattr(base_mod, "_EVENT_DISPATCH", evt_only) + assert verify_who_catalog_coverage() == [ + "WHO 999 registered in _EVENT_DISPATCH but not mapped in WHO_DEFINITIONS." + ] + + # 3. Isolated dual registration (both command and event dispatch) + both_cmd = dict(clean_cmds) + both_cmd[997] = lambda frame: base_mod.OWNCommand(frame) + both_evt = dict(clean_evts) + both_evt[997] = lambda frame: base_mod.OWNEvent(frame) + monkeypatch.setattr(base_mod, "_COMMAND_DISPATCH", both_cmd) + monkeypatch.setattr(base_mod, "_EVENT_DISPATCH", both_evt) + assert verify_who_catalog_coverage() == [ + "WHO 997 registered in _COMMAND_DISPATCH and _EVENT_DISPATCH but not mapped in WHO_DEFINITIONS." + ] + + +def test_sync_documentation_fails_on_catalog_gap_and_refuses_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify sync_documentation and CLI refuse writing and exit 1 on catalog gaps.""" import OWNd.message.base as base_mod repo_root = Path(__file__).resolve().parent.parent @@ -447,7 +458,7 @@ def test_sync_documentation_fails_on_catalog_gap(monkeypatch: pytest.MonkeyPatch fake_evt_dispatch[999] = lambda frame: base_mod.OWNEvent(frame) monkeypatch.setattr(base_mod, "_EVENT_DISPATCH", fake_evt_dispatch) - # sync_documentation in check mode must return in_sync=False + # 1. sync_documentation in check mode must return in_sync=False in_sync, diff, messages = sync_documentation( readme_path=readme_path, coverage_xml_path=None, @@ -457,11 +468,28 @@ def test_sync_documentation_fails_on_catalog_gap(monkeypatch: pytest.MonkeyPatch assert any("WHO catalog gap:" in m for m in messages) assert any("WHO 999 registered in _EVENT_DISPATCH" in m for m in messages) - # main(["--check"]) must exit 1 + # 2. sync_documentation in update mode must refuse writing when catalog gaps exist + drifted_content = readme_path.read_text(encoding="utf-8").replace( + WHO_END_MARKER, f"| **999** | Fake | Description | `FakeEvent` |\n{WHO_END_MARKER}" + ) + temp_readme = tmp_path / "README_drift.md" + temp_readme.write_text(drifted_content, encoding="utf-8") + + in_sync_update, _, update_msgs = sync_documentation( + readme_path=temp_readme, + coverage_xml_path=None, + check_only=False, + ) + assert in_sync_update is False + assert any("Refused to update" in m for m in update_msgs) + # File content must NOT have been modified + assert temp_readme.read_text(encoding="utf-8") == drifted_content + + # 3. CLI --check must exit 1 assert main(["--check"]) == 1 - # main([]) in update mode must also exit 1 on catalog gaps - assert main(["--readme", str(readme_path)]) == 1 + # 4. CLI update mode must also exit 1 on catalog gaps + assert main(["--readme", str(temp_readme)]) == 1 def test_sync_documentation_missing_coverage_graceful(tmp_path: Path) -> None: