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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ OWNd parses OpenWebNet frames and dispatches typed commands and events across th
<!-- START_WHO_CATALOG_TABLE -->
| 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` |
Expand Down
106 changes: 82 additions & 24 deletions scripts/sync_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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**",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
)

Expand All @@ -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


# ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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


Expand Down
101 changes: 100 additions & 1 deletion tests/test_sync_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
Loading