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..3ed4acd 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**", @@ -152,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", @@ -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, ) @@ -261,17 +273,25 @@ 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(_COMMAND_DISPATCH.keys()) - if who not in covered_who and who != 25 + who for who in sorted(all_registered) + if who not in covered_who ] - 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 +456,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, @@ -480,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: @@ -497,7 +529,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: @@ -512,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 @@ -586,23 +628,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..df9d632 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,98 @@ 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 + clean_cmds = dict(base_mod._COMMAND_DISPATCH) + clean_evts = dict(base_mod._EVENT_DISPATCH) + assert verify_who_catalog_coverage() == [] + + # 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 + 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) + + # 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, + 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) + + # 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 + + # 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: """Verify that absent coverage.xml does not break check mode if markers are present.""" gw_table = build_gateway_profiles_table()