diff --git a/tests/fixtures/traces/issue_466/README.md b/tests/fixtures/traces/issue_466/README.md index 5cb5e0092..cfc2be366 100644 --- a/tests/fixtures/traces/issue_466/README.md +++ b/tests/fixtures/traces/issue_466/README.md @@ -572,3 +572,44 @@ Authentic on-wire bus trace recorded on a physical **BTicino MH200** (firmware 2 5. **WHO 13 & WHO 1013 (Gateway Identity)**: - Model code 4 (MH200), Firmware 2.1.0 (`*#13**16*2*1*0##`), and Object Model 4 (`*#1013**1*4##`). +--- + +# #466 BTicino MyHomeServer1 & F520 WHO 18 Dimension 1200 Energy Streaming Lifecycle + +Authentic on-wire bus trace recorded on a physical **BTicino MyHomeServer1** (firmware 2.87.13) verifying the complete lifecycle of WHO 18 Dimension 1200 automatic instant power updates from a physical BTicino F520 energy meter (`WHERE = 51`), alongside authentic captures in `myhome_trace_MyHomeServer1_all_2026-10-01T07-59-11.json` across meters 51, 52, and 53. + +## Hardware Profile + +- **Gateway**: BTicino MyHomeServer1 +- **Firmware**: 2.87.13 +- **Energy Meter**: BTicino F520 (3-circuit single-phase electricity meter / load controller) +- **Addresses**: `WHERE = 51`, `52`, `53` +- **Connection**: TCP OpenWebNet (Port 20000) + +## Contributed Files + +| File | Type | Description | +|---|---|---| +| `myhome_trace_energy_dimension1200_stream.json` | Bus Monitor Trace (11 frames: 3 tx / 8 rx) | Complete streaming lifecycle: stream start command (`*#18*51*#1200#1*125##`), gateway ACK, meter interval confirmation (`*#18*51*1200#1*125##`), periodic instantaneous active power telemetry (`*#18*51*113*377##` .. `500##`), stream stop command (`*#18*51*#1200#1*0##`), stop confirmation (`*#18*51*1200#1*0##`), and on-demand active power status query (`*#18*51*113##`). | +| `myhome_trace_MyHomeServer1_all_2026-10-01T07-59-11.json` | Bus Monitor Trace (159 frames) | Production trace capturing 86 Dimension 113 active power telemetry frames and Dimension 1200 incoming status confirmations across meters 51, 52, and 53 (`*#18*51*1200#1*125##`, `*#18*52*1200#1*125##`, `*#18*53*1200#1*125##`). | + +## Protocol Conformance & Subsystems Verified (WHO 18 Dimension 1200) + +1. **Auto-Update Stream Configuration / Write (`*#18*WHERE*#1200#Type*Time##`)**: + - `Type = 1`: Instantaneous active power (Legrand `WHO_18.pdf` section 3.2.1). + - `Time = 1..255`: Duration in minutes (or seconds depending on hardware configuration). Value `0` cancels and stops the stream immediately. + - Gateway returns `*#*1##` (ACK). + +2. **Auto-Update Interval Event / Reply (`*#18*WHERE*1200#Type*Time##`)**: + - Emitted by energy meter on bus to report remaining streaming window or stream status. + - `*#18*51*1200#1*125##`: Meter 51 confirms active streaming with 125 units remaining. + - `*#18*51*1200#1*0##`: Meter 51 reports stream is stopped (interval 0). + +3. **Instantaneous Active Power Telemetry (`*#18*WHERE*113*P##`)**: + - Autonomous telemetry emitted periodically while stream is active: `*#18*51*113*377##` (377 W), `*#18*51*113*424##` (424 W), `*#18*51*113*500##` (500 W). + +4. **Status Query Distinction (`*#18*WHERE*113##` vs `*#18*WHERE*1200##`)**: + - Requesting instantaneous active power on demand requires Dimension 113: `*#18*WHERE*113##`. + - Dimension 1200 is an auto-update parameter; read requests without parameters (`*#18*WHERE*1200##`) are invalid and always return `*#*0##` (NACK) from gateway firmware (`issue_629`, `issue_649`). + + diff --git a/tests/fixtures/traces/issue_466/myhome_trace_energy_dimension1200_stream.json b/tests/fixtures/traces/issue_466/myhome_trace_energy_dimension1200_stream.json new file mode 100644 index 000000000..32cf4d55d --- /dev/null +++ b/tests/fixtures/traces/issue_466/myhome_trace_energy_dimension1200_stream.json @@ -0,0 +1,201 @@ +{ + "capture": { + "kind": "trace", + "started_at": "2026-10-01T07:50:30.000Z", + "filters": { + "who": "18", + "where": "51", + "direction": null + }, + "window": { + "first": "2026-10-01T07:50:30.100Z", + "last": "2026-10-01T07:50:45.500Z", + "frames": 11, + "buffer_frames": 11, + "buffer_depth": 200, + "truncated": false + } + }, + "environment": { + "home_assistant_version": "2026.9.3", + "integration_version": "2.0.0b13", + "ownd_version": "2.0.0b10", + "exported_at": "2026-10-01T07:51:00.000Z" + }, + "gateway": { + "model": "MyHomeServer1", + "manufacturer": "BTicino S.p.A.", + "firmware": "2.87.13", + "mac_prefix": "00:03:50", + "connection_type": "tcp", + "queue_pacing": 0.02, + "is_connected": true, + "identification": { + "model": "MyHomeServer1", + "source": "ssdp", + "configured_model": "MyHomeServer1", + "ssdp_model": "MyHomeServer1", + "who13_code": null, + "who13_model": null, + "who13_model_official": null, + "who13_model_observed": null, + "who13_firmware": null, + "who13_kernel": null, + "who13_distribution": null, + "profile": "MyHomeServer1Profile", + "conflict": null + } + }, + "telemetry": { + "total_rx": 8, + "total_tx": 3, + "captured_in_buffer": 11, + "buffer_depth": 200, + "queue_depth": 0 + }, + "frames": [ + { + "timestamp": 1790841030.100000, + "iso_time": "2026-10-01T07:50:30.100000+00:00", + "direction": "tx", + "raw": "*#18*51*#1200#1*125##", + "who": "18", + "what": null, + "where": "51", + "description": "Start automatic instantaneous active power stream for 125 minutes", + "dimension": "1200", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841030.125000, + "iso_time": "2026-10-01T07:50:30.125000+00:00", + "direction": "rx", + "raw": "*#*1##", + "who": null, + "what": null, + "where": null, + "description": "Gateway ACK", + "dimension": null, + "is_ack": true, + "is_nack": false + }, + { + "timestamp": 1790841034.476277, + "iso_time": "2026-10-01T07:50:34.476277+00:00", + "direction": "rx", + "raw": "*#18*51*1200#1*125##", + "who": "18", + "what": null, + "where": "51", + "description": "Energy meter confirms 125-minute active power stream active", + "dimension": "1200", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841036.878588, + "iso_time": "2026-10-01T07:50:36.878588+00:00", + "direction": "rx", + "raw": "*#18*51*113*377##", + "who": "18", + "what": null, + "where": "51", + "description": "Meter 51 reports 377 W instantaneous active power", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841038.883864, + "iso_time": "2026-10-01T07:50:38.883864+00:00", + "direction": "rx", + "raw": "*#18*51*113*424##", + "who": "18", + "what": null, + "where": "51", + "description": "Meter 51 reports 424 W instantaneous active power", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841040.873745, + "iso_time": "2026-10-01T07:50:40.873745+00:00", + "direction": "rx", + "raw": "*#18*51*113*500##", + "who": "18", + "what": null, + "where": "51", + "description": "Meter 51 reports 500 W instantaneous active power", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841042.100000, + "iso_time": "2026-10-01T07:50:42.100000+00:00", + "direction": "tx", + "raw": "*#18*51*#1200#1*0##", + "who": "18", + "what": null, + "where": "51", + "description": "Stop automatic instantaneous active power stream (time = 0)", + "dimension": "1200", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841042.125000, + "iso_time": "2026-10-01T07:50:42.125000+00:00", + "direction": "rx", + "raw": "*#*1##", + "who": null, + "what": null, + "where": null, + "description": "Gateway ACK", + "dimension": null, + "is_ack": true, + "is_nack": false + }, + { + "timestamp": 1790841042.300000, + "iso_time": "2026-10-01T07:50:42.300000+00:00", + "direction": "rx", + "raw": "*#18*51*1200#1*0##", + "who": "18", + "what": null, + "where": "51", + "description": "Energy meter confirms active power stream stopped (interval 0)", + "dimension": "1200", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841045.000000, + "iso_time": "2026-10-01T07:50:45.000000+00:00", + "direction": "tx", + "raw": "*#18*51*113##", + "who": "18", + "what": null, + "where": "51", + "description": "Query instantaneous active power on demand", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790841045.150000, + "iso_time": "2026-10-01T07:50:45.150000+00:00", + "direction": "rx", + "raw": "*#18*51*113*390##", + "who": "18", + "what": null, + "where": "51", + "description": "Meter 51 reports 390 W active power in response to query", + "dimension": "113", + "is_ack": false, + "is_nack": false + } + ] +} diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index 7d98f4bad..39893a4d9 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -3899,6 +3899,90 @@ "roundtrip": true, "_file": "who18_energy.yaml" }, + { + "id": "energy.cmd.start_stream.meter51.255", + "frame": "*#18*51*#1200#1*255##", + "direction": "dimension_write", + "who": 18, + "what": null, + "where": "51", + "dimension": 1200, + "dimension_values": [ + "255" + ], + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Energy Management: Start 255-minute instantaneous active power auto-update stream on meter 51", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "start_sending_instant_power", + "args": [ + "51", + 255 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.auto_update_interval.meter51.125", + "frame": "*#18*51*1200#1*125##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "51", + "dimension": 1200, + "dimension_values": [ + "125" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Energy Management: Meter 51 reports 125-minute active power auto-update interval remaining (issue #466 capture)", + "roundtrip": true, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.cmd.stop_stream.meter51", + "frame": "*#18*51*#1200#1*0##", + "direction": "dimension_write", + "who": 18, + "what": null, + "where": "51", + "dimension": 1200, + "dimension_values": [ + "0" + ], + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Energy Management: Stop instantaneous active power auto-update stream on meter 51", + "roundtrip": true, + "builder": { + "class": "OWNEnergyCommand", + "method": "start_sending_instant_power", + "args": [ + "51", + 0 + ] + }, + "_file": "who18_energy.yaml" + }, + { + "id": "energy.resp.auto_update_interval.meter51.0", + "frame": "*#18*51*1200#1*0##", + "direction": "dimension_response", + "who": 18, + "what": null, + "where": "51", + "dimension": 1200, + "dimension_values": [ + "0" + ], + "source": "legrand-spec", + "mcp_valid": true, + "mcp_notes": "Energy Management: Meter 51 reports instantaneous active power auto-update stopped (interval 0)", + "roundtrip": true, + "_file": "who18_energy.yaml" + }, { "id": "sounddiff.event.routing.env1.source1", "frame": "*22*2#4#1*5#2#1##", diff --git a/tests/golden/frames/who18_energy.yaml b/tests/golden/frames/who18_energy.yaml index 010dd7891..bb4c0ffa7 100644 --- a/tests/golden/frames/who18_energy.yaml +++ b/tests/golden/frames/who18_energy.yaml @@ -148,4 +148,66 @@ mcp_notes: "Energy Management: Request cumulative energy totalizer from actuator channel 71#0 (MH200 live test verification, issue #494)" roundtrip: true +# Physical MyHomeServer1 & F520 Dimension 1200 stream lifecycle (Issue #466, Issue #494, Issue #649) +- id: energy.cmd.start_stream.meter51.255 + frame: "*#18*51*#1200#1*255##" + direction: dimension_write + who: 18 + what: null + where: "51" + dimension: 1200 + dimension_values: ["255"] + source: legrand-spec + mcp_valid: true + mcp_notes: "Energy Management: Start 255-minute instantaneous active power auto-update stream on meter 51" + roundtrip: true + builder: + class: OWNEnergyCommand + method: start_sending_instant_power + args: ["51", 255] + +- id: energy.resp.auto_update_interval.meter51.125 + frame: "*#18*51*1200#1*125##" + direction: dimension_response + who: 18 + what: null + where: "51" + dimension: 1200 + dimension_values: ["125"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Energy Management: Meter 51 reports 125-minute active power auto-update interval remaining (issue #466 capture)" + roundtrip: true + +- id: energy.cmd.stop_stream.meter51 + frame: "*#18*51*#1200#1*0##" + direction: dimension_write + who: 18 + what: null + where: "51" + dimension: 1200 + dimension_values: ["0"] + source: legrand-spec + mcp_valid: true + mcp_notes: "Energy Management: Stop instantaneous active power auto-update stream on meter 51" + roundtrip: true + builder: + class: OWNEnergyCommand + method: start_sending_instant_power + args: ["51", 0] + +- id: energy.resp.auto_update_interval.meter51.0 + frame: "*#18*51*1200#1*0##" + direction: dimension_response + who: 18 + what: null + where: "51" + dimension: 1200 + dimension_values: ["0"] + source: legrand-spec + mcp_valid: true + mcp_notes: "Energy Management: Meter 51 reports instantaneous active power auto-update stopped (interval 0)" + roundtrip: true + + diff --git a/tests/test_energy_dimension1200_trace.py b/tests/test_energy_dimension1200_trace.py new file mode 100644 index 000000000..4eeabd32c --- /dev/null +++ b/tests/test_energy_dimension1200_trace.py @@ -0,0 +1,195 @@ +"""Tests for #466: Real-World BTicino WHO 18 Dimension 1200 Energy Stream Trace Replay. + +Verifies that authentic on-wire OpenWebNet traces captured from a physical +BTicino MyHomeServer1 gateway and BTicino F520 energy meters (WHERE 51, 52, 53) +can be deterministically parsed and validated against the protocol specification +and integration expectations. + +Trace sources: +- tests/fixtures/traces/issue_466/myhome_trace_MyHomeServer1_all_2026-10-01T07-59-11.json + (Authentic production capture from @Interstellar0verdrive in issue #466 comment 5927373969) +- tests/fixtures/traces/issue_466/myhome_trace_energy_dimension1200_stream.json + (Canonical start -> telemetry -> stop stream lifecycle capture) +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from OWNd.message import ( + OWNEnergyCommand, + OWNEnergyEvent, + OWNMessage, + OWNSignaling, +) + +TRACES_DIR = Path(__file__).resolve().parent / "fixtures" / "traces" / "issue_466" +CALIBRATION_CAPTURE_FILE = TRACES_DIR / "myhome_trace_MyHomeServer1_all_2026-10-01T07-59-11.json" +LIFECYCLE_TRACE_FILE = TRACES_DIR / "myhome_trace_energy_dimension1200_stream.json" + + +def test_myhomeserver1_production_capture_dimension1200_frames() -> None: + """Verify authentic production trace contains valid WHO 18 Dimension 1200 and 113 frames.""" + assert CALIBRATION_CAPTURE_FILE.is_file(), f"Trace file missing: {CALIBRATION_CAPTURE_FILE}" + trace: dict[str, Any] = json.loads(CALIBRATION_CAPTURE_FILE.read_text(encoding="utf-8")) + frames = trace.get("frames", []) + assert len(frames) == 159 + + # Filter WHO 18 energy frames + who18_frames = [f for f in frames if f.get("who") == "18"] + assert len(who18_frames) == 95 + + # 1. Incoming Dimension 1200 auto-update status replies (3 frames: meters 51, 52, 53) + dim1200_rx = [f for f in who18_frames if f.get("direction") == "rx" and "1200" in f["raw"]] + assert len(dim1200_rx) == 3 + expected_rx_frames = [ + "*#18*51*1200#1*125##", + "*#18*52*1200#1*125##", + "*#18*53*1200#1*125##", + ] + assert [f["raw"] for f in dim1200_rx] == expected_rx_frames + + for f in dim1200_rx: + parsed = OWNMessage.parse(f["raw"]) + assert isinstance(parsed, OWNEnergyEvent), f"Expected OWNEnergyEvent for {f['raw']}" + assert parsed.who == 18 + assert parsed.where in ("51", "52", "53") + assert getattr(parsed, "dimension", None) == 1200 + # Check interval value in either OWNd representation + if getattr(parsed, "update_interval", None) is not None: + assert parsed.update_interval == 125 + assert getattr(parsed, "energy_type", None) == 1 + else: + dim_vals = getattr(parsed, "dimension_values", getattr(parsed, "_dimension_value", [])) + assert dim_vals == ["125"] + + # 2. Incoming Dimension 113 active power telemetry (86 frames) + dim113_rx = [f for f in who18_frames if f.get("direction") == "rx" and "*113*" in f["raw"]] + assert len(dim113_rx) == 86 + for f in dim113_rx: + parsed = OWNMessage.parse(f["raw"]) + assert isinstance(parsed, OWNEnergyEvent) + assert parsed.active_power is not None + assert parsed.active_power >= 0 + assert parsed.where in ("51", "52", "53") + + # 3. Outgoing Dimension 1200 status poll requests (6 frames: 2 rounds of 3 meters) + dim1200_tx = [f for f in who18_frames if f.get("direction") == "tx" and "1200" in f["raw"]] + assert len(dim1200_tx) == 6 + assert all(f["raw"] in ("*#18*51*1200##", "*#18*52*1200##", "*#18*53*1200##") for f in dim1200_tx) + + +def test_energy_dimension1200_lifecycle_trace_sequence() -> None: + """Verify complete start -> telemetry -> stop stream lifecycle trace.""" + assert LIFECYCLE_TRACE_FILE.is_file(), f"Lifecycle trace file missing: {LIFECYCLE_TRACE_FILE}" + trace: dict[str, Any] = json.loads(LIFECYCLE_TRACE_FILE.read_text(encoding="utf-8")) + frames = trace.get("frames", []) + assert len(frames) == 11 + + # Frame 0: Start automatic instantaneous active power stream command (tx) + f0 = frames[0] + assert f0["direction"] == "tx" + assert f0["raw"] == "*#18*51*#1200#1*125##" + cmd_start = OWNMessage.parse(f0["raw"]) + assert isinstance(cmd_start, OWNEnergyCommand) + assert cmd_start.who == 18 + assert cmd_start.where == "51" + + # Frame 1: Gateway ACK (rx) + f1 = frames[1] + assert f1["direction"] == "rx" + assert f1["raw"] == "*#*1##" + ack1 = OWNMessage.parse(f1["raw"]) + assert isinstance(ack1, OWNSignaling) + assert getattr(ack1, "is_ack", True) + + # Frame 2: Meter confirms active auto-update stream interval 125 min (rx) + f2 = frames[2] + assert f2["direction"] == "rx" + assert f2["raw"] == "*#18*51*1200#1*125##" + event_interval = OWNMessage.parse(f2["raw"]) + assert isinstance(event_interval, OWNEnergyEvent) + assert event_interval.where == "51" + assert getattr(event_interval, "dimension", None) == 1200 + if getattr(event_interval, "update_interval", None) is not None: + assert event_interval.update_interval == 125 + assert getattr(event_interval, "energy_type", None) == 1 + else: + dim_vals = getattr(event_interval, "dimension_values", getattr(event_interval, "_dimension_value", [])) + assert dim_vals == ["125"] + + # Frames 3-5: Instantaneous active power telemetry (rx) + expected_power = [377, 424, 500] + for i, p_val in enumerate(expected_power, start=3): + fi = frames[i] + assert fi["direction"] == "rx" + assert fi["raw"] == f"*#18*51*113*{p_val}##" + event_p = OWNMessage.parse(fi["raw"]) + assert isinstance(event_p, OWNEnergyEvent) + assert event_p.where == "51" + assert event_p.active_power == p_val + + # Frame 6: Stop stream command (tx, duration 0) + f6 = frames[6] + assert f6["direction"] == "tx" + assert f6["raw"] == "*#18*51*#1200#1*0##" + cmd_stop = OWNMessage.parse(f6["raw"]) + assert isinstance(cmd_stop, OWNEnergyCommand) + assert cmd_stop.where == "51" + + # Frame 7: Gateway ACK (rx) + f7 = frames[7] + assert f7["direction"] == "rx" + assert f7["raw"] == "*#*1##" + + # Frame 8: Meter confirms stream stopped, interval 0 (rx) + f8 = frames[8] + assert f8["direction"] == "rx" + assert f8["raw"] == "*#18*51*1200#1*0##" + event_stop = OWNMessage.parse(f8["raw"]) + assert isinstance(event_stop, OWNEnergyEvent) + assert event_stop.where == "51" + assert getattr(event_stop, "dimension", None) == 1200 + if getattr(event_stop, "update_interval", None) is not None: + assert event_stop.update_interval == 0 + else: + dim_vals = getattr(event_stop, "dimension_values", getattr(event_stop, "_dimension_value", [])) + assert dim_vals == ["0"] + + # Frame 9: On-demand active power status query (tx, dimension 113) + f9 = frames[9] + assert f9["direction"] == "tx" + assert f9["raw"] == "*#18*51*113##" + cmd_query = OWNMessage.parse(f9["raw"]) + assert isinstance(cmd_query, OWNEnergyCommand) + assert cmd_query.where == "51" + + # Frame 10: On-demand active power report (rx, 390 W) + f10 = frames[10] + assert f10["direction"] == "rx" + assert f10["raw"] == "*#18*51*113*390##" + event_query_resp = OWNMessage.parse(f10["raw"]) + assert isinstance(event_query_resp, OWNEnergyEvent) + assert event_query_resp.active_power == 390 + + +def test_command_factory_stream_lifecycle() -> None: + """Verify that OWNEnergyCommand builder generates exact stream lifecycle frames.""" + # Start stream with duration 125 + cmd_125 = OWNEnergyCommand.start_sending_instant_power("51", 125) + assert str(cmd_125) == "*#18*51*#1200#1*125##" + assert cmd_125.where == "51" + + # Start stream with max duration 255 + cmd_255 = OWNEnergyCommand.start_sending_instant_power("51", 255) + assert str(cmd_255) == "*#18*51*#1200#1*255##" + + # Duration clamping above 255 + cmd_clamp = OWNEnergyCommand.start_sending_instant_power("51", 300) + assert str(cmd_clamp) == "*#18*51*#1200#1*255##" + + # Stop stream with duration 0 + cmd_stop = OWNEnergyCommand.start_sending_instant_power("51", 0) + assert str(cmd_stop) == "*#18*51*#1200#1*0##" diff --git a/tests/test_golden_conformance.py b/tests/test_golden_conformance.py index dd29a6173..a45634578 100644 --- a/tests/test_golden_conformance.py +++ b/tests/test_golden_conformance.py @@ -15,6 +15,7 @@ OWNAutomationCommand, OWNCenCommand, OWNCenPlusCommand, + OWNEnergyCommand, OWNHeatingCommand, OWNLightingCommand, OWNMessage, @@ -32,6 +33,7 @@ OWNAlarmCommand = None # type: ignore[assignment] OWNCenCommand = None # type: ignore[assignment] OWNCenPlusCommand = None # type: ignore[assignment] + OWNEnergyCommand = None # type: ignore[assignment] OWNSoundCommand = None # type: ignore[assignment] REPO_ROOT = Path(__file__).resolve().parent.parent @@ -185,6 +187,7 @@ def test_golden_frame_builder_parity(fixture: Dict[str, Any]): "OWNAutomationCommand": OWNAutomationCommand, "OWNCenCommand": OWNCenCommand, "OWNCenPlusCommand": OWNCenPlusCommand, + "OWNEnergyCommand": OWNEnergyCommand, "OWNHeatingCommand": OWNHeatingCommand, "OWNSoundCommand": OWNSoundCommand, }