@@ -336,6 +389,7 @@ class MyHomeBusCard extends HTMLElement {
_bindEvents() {
const root = this.shadowRoot;
+ root.getElementById("btn-report").addEventListener("click", () => this._handleReportIssue());
root.getElementById("btn-pause").addEventListener("click", () => this._togglePause());
root.getElementById("btn-clear").addEventListener("click", () => this._clearBuffer());
root.getElementById("filter-who").addEventListener("change", (e) => {
@@ -409,9 +463,173 @@ class MyHomeBusCard extends HTMLElement {
const cap = root.getElementById("stat-captured");
const rx = root.getElementById("stat-rx");
const tx = root.getElementById("stat-tx");
+ const queue = root.getElementById("stat-queue");
if (cap) cap.textContent = this._stats.captured;
if (rx) rx.textContent = this._stats.total_rx;
if (tx) tx.textContent = this._stats.total_tx;
+ if (queue) queue.textContent = (this._gatewayInfo && this._gatewayInfo.queue_depth != null) ? this._gatewayInfo.queue_depth : 0;
+ }
+
+ async _copyToClipboard(text) {
+ if (navigator.clipboard && window.isSecureContext) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch (err) {
+ console.warn("MyHOME Bus Monitor: navigator.clipboard.writeText failed, trying fallback", err);
+ }
+ }
+ try {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+ const success = document.execCommand("copy");
+ document.body.removeChild(textArea);
+ return success;
+ } catch (e) {
+ console.error("MyHOME Bus Monitor: clipboard copy failed", e);
+ return false;
+ }
+ }
+
+ _generateDiagnosticPayload() {
+ const haVersion = (this._hass && this._hass.config && this._hass.config.version) || "Unknown";
+ const integrationVersion = "1.0.0-beta";
+ const userAgent = (navigator && navigator.userAgent) || "Unknown";
+ const timestamp = new Date().toISOString();
+
+ const gw = this._gatewayInfo || {};
+ const model = gw.model || "Unknown";
+ const manufacturer = gw.manufacturer || "BTicino";
+ const firmware = gw.firmware || "Unknown";
+ const macPrefix = gw.mac_prefix || (this._config && this._config.mac ? this._config.mac.substring(0, 8) : "Unknown");
+
+ let conn = "Unknown";
+ if (gw.serial_port) {
+ conn = `Serial (${gw.serial_port})`;
+ } else if (gw.host) {
+ conn = `Ethernet TCP (${gw.host}:${gw.port || 20000})`;
+ }
+
+ const queuePacing = gw.queue_pacing != null ? `${gw.queue_pacing}s` : "0.0s";
+ const workerCount = gw.worker_count != null ? gw.worker_count : 1;
+ const queueDepth = gw.queue_depth != null ? gw.queue_depth : 0;
+ const isConnected = gw.is_connected != null ? (gw.is_connected ? "Connected" : "Disconnected") : "Unknown";
+
+ const totalRx = (this._stats && this._stats.total_rx != null) ? this._stats.total_rx : 0;
+ const totalTx = (this._stats && this._stats.total_tx != null) ? this._stats.total_tx : 0;
+ const captured = (this._stats && this._stats.captured != null) ? this._stats.captured : this._frames.length;
+
+ const frameLines = this._frames.map((f) => {
+ const timeStr = f.iso_time ? f.iso_time.split("T")[1].substring(0, 12) : "";
+ const dir = (f.direction || "rx").toUpperCase();
+ return `[${timeStr}] [${dir}] ${f.raw}`;
+ });
+
+ const framesText = frameLines.length > 0
+ ? frameLines.join("\n")
+ : "(No bus frames recorded in buffer)";
+
+ return `### MyHOME Diagnostic Bundle
+
+**Environment:**
+- **Home Assistant Version:** ${haVersion}
+- **Integration Version:** ${integrationVersion}
+- **Browser / User Agent:** ${userAgent}
+- **Timestamp:** ${timestamp}
+
+**Active Gateway Configuration:**
+- **Model:** ${model} (${manufacturer})
+- **Firmware:** ${firmware}
+- **Connection:** ${conn}
+- **MAC Prefix:** ${macPrefix}
+- **Queue Pacing:** ${queuePacing}
+- **Worker Count:** ${workerCount}
+- **Connection Status:** ${isConnected}
+
+**Buffer Telemetry:**
+- **Total RX Frames:** ${totalRx}
+- **Total TX Frames:** ${totalTx}
+- **Total Captured:** ${captured}
+- **Buffer Depth:** ${queueDepth}
+
+
OpenWebNet Bus Trace (${this._frames.length} frames)
+
+\`\`\`
+${framesText}
+\`\`\`
+`;
+ }
+
+ async _handleReportIssue() {
+ const btn = this.shadowRoot.getElementById("btn-report");
+ const origText = btn ? btn.innerHTML : "📋 Report Issue / Copy Trace";
+ if (btn) btn.innerHTML = "⏳ Generating...";
+
+ // Try fetching the freshest gateway & buffer telemetry from backend
+ if (this._hass) {
+ try {
+ const infoRes = await this._hass.callWS({
+ type: "myhome/bus_monitor/info",
+ mac: this._config.mac,
+ });
+ if (infoRes) {
+ if (infoRes.gateway) this._gatewayInfo = infoRes.gateway;
+ if (infoRes.stats) {
+ this._stats = Object.assign({}, this._stats, infoRes.stats);
+ this._updateStats();
+ }
+ }
+ } catch (err) {
+ // Continue with available state if backend call fails
+ console.debug("MyHOME Bus Monitor: Falling back to cached gateway telemetry", err);
+ }
+ }
+
+ const payload = this._generateDiagnosticPayload();
+ const copied = await this._copyToClipboard(payload);
+ const issueUrl = "https://github.com/OpenWebNet-HA/MyHOME/issues/new?template=bug_report.yml";
+
+ const banner = this.shadowRoot.getElementById("feedback-banner");
+ if (banner) {
+ if (copied) {
+ banner.className = "feedback-banner banner-success";
+ banner.innerHTML = `
+
✅ Copied diagnostic payload to clipboard! Opening GitHub issue form...
+
Click here if form didn't open
+ `;
+ } else {
+ banner.className = "feedback-banner banner-warning";
+ banner.innerHTML = `
+
⚠️ Could not automatically copy to clipboard. Payload printed to browser console.
+
Open GitHub form
+ `;
+ console.log("MyHOME Diagnostic Payload:\n", payload);
+ }
+ banner.style.display = "flex";
+ setTimeout(() => {
+ if (banner) banner.style.display = "none";
+ }, 7000);
+ }
+
+ if (btn) {
+ btn.innerHTML = copied ? "✅ Copied & Opened!" : "⚠️ Check Console";
+ setTimeout(() => {
+ if (btn) btn.innerHTML = origText;
+ }, 3000);
+ }
+
+ // Automatically open GitHub issue form in a new tab
+ try {
+ window.open(issueUrl, "_blank", "noopener,noreferrer");
+ } catch (e) {
+ console.warn("MyHOME Bus Monitor: window.open blocked by browser", e);
+ }
}
_updateFrameList() {
diff --git a/custom_components/myhome/websocket.py b/custom_components/myhome/websocket.py
index aa8747c0..b8d8e4aa 100644
--- a/custom_components/myhome/websocket.py
+++ b/custom_components/myhome/websocket.py
@@ -11,11 +11,12 @@
import voluptuous as vol
from homeassistant.components import websocket_api
+from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .bus_monitor import BusFrame, BusMonitor
-from .const import CONF_ENTITY, DOMAIN
+from .const import CONF_ENTITY, CONF_FIRMWARE, CONF_WORKER_COUNT, DOMAIN
from .ownd.message import OWNMessage
_LOGGER = logging.getLogger(__name__)
@@ -24,6 +25,14 @@
WS_TYPE_STREAM = "myhome/bus_monitor/stream"
WS_TYPE_SEND = "myhome/bus_monitor/send"
WS_TYPE_CLEAR = "myhome/bus_monitor/clear"
+WS_TYPE_INFO = "myhome/bus_monitor/info"
+
+SCHEMA_WS_INFO = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
+ {
+ vol.Required("type"): WS_TYPE_INFO,
+ vol.Optional("mac"): cv.string,
+ }
+)
SCHEMA_WS_HISTORY = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
@@ -62,6 +71,111 @@
)
+def _extract_gateway_info(gw: Optional[Any]) -> dict[str, Any]:
+ """Safely extract JSON-serializable gateway runtime and hardware configuration."""
+ if gw is None:
+ return {}
+
+ raw_gw = getattr(gw, "gateway", None)
+ model = ""
+ manufacturer = "BTicino"
+ firmware = ""
+ host = ""
+ port = 20000
+ profile = None
+
+ if raw_gw is not None:
+ m_name = getattr(raw_gw, "model_name", None)
+ if isinstance(m_name, str):
+ model = m_name
+ elif isinstance(getattr(raw_gw, "model", None), str):
+ model = raw_gw.model
+ m_manuf = getattr(raw_gw, "manufacturer", None)
+ if isinstance(m_manuf, str):
+ manufacturer = m_manuf
+ m_fw = getattr(raw_gw, "firmware", None)
+ if isinstance(m_fw, str):
+ firmware = m_fw
+ m_host = getattr(raw_gw, "host", None)
+ if isinstance(m_host, str):
+ host = m_host
+ m_port = getattr(raw_gw, "port", None)
+ if isinstance(m_port, int):
+ port = m_port
+ profile = getattr(raw_gw, "profile", None)
+
+ config_entry = getattr(gw, "config_entry", None)
+ config_data = getattr(config_entry, "data", {}) if config_entry else {}
+ if not isinstance(config_data, dict):
+ config_data = {}
+
+ if not model and isinstance(config_data.get(CONF_NAME), str):
+ model = config_data[CONF_NAME]
+ if not model and isinstance(getattr(gw, "model", None), str):
+ model = gw.model
+ if not model:
+ model = "Generic"
+
+ if not host and isinstance(config_data.get(CONF_HOST), str):
+ host = config_data[CONF_HOST]
+ if port == 20000 and isinstance(config_data.get(CONF_PORT), int):
+ port = config_data[CONF_PORT]
+ if not firmware and isinstance(config_data.get(CONF_FIRMWARE), str):
+ firmware = config_data[CONF_FIRMWARE]
+
+ serial_port = config_data.get("serial_port") or config_data.get("device")
+ if not isinstance(serial_port, str):
+ serial_port = None
+
+ mac_val = getattr(gw, "mac", None)
+ if not isinstance(mac_val, str):
+ mac_val = config_data.get(CONF_MAC)
+ mac_addr = mac_val if isinstance(mac_val, str) else ""
+
+ mac_prefix = (
+ ":".join(mac_addr.split(":")[:3])
+ if ":" in mac_addr
+ else (mac_addr[:8] if mac_addr else "Unknown")
+ )
+
+ queue_pacing = 0.0
+ if profile is not None and isinstance(getattr(profile, "command_queue_delay", None), (int, float)):
+ queue_pacing = float(profile.command_queue_delay)
+
+ workers = getattr(gw, "sending_workers", None)
+ worker_count = len(workers) if isinstance(workers, list) else 0
+ if worker_count == 0 and isinstance(config_data.get(CONF_WORKER_COUNT), int):
+ worker_count = config_data[CONF_WORKER_COUNT]
+ if worker_count == 0:
+ worker_count = 1
+
+ send_buffer = getattr(gw, "send_buffer", None)
+ queue_depth = 0
+ if send_buffer is not None:
+ try:
+ q_size = send_buffer.qsize()
+ if isinstance(q_size, int):
+ queue_depth = q_size
+ except Exception:
+ queue_depth = 0
+
+ is_connected = bool(getattr(gw, "is_connected", False))
+
+ return {
+ "model": model,
+ "manufacturer": manufacturer,
+ "firmware": firmware,
+ "host": host,
+ "port": port,
+ "serial_port": serial_port,
+ "mac_prefix": mac_prefix,
+ "queue_pacing": queue_pacing,
+ "worker_count": worker_count,
+ "queue_depth": queue_depth,
+ "is_connected": is_connected,
+ }
+
+
def _get_gateway_and_monitor(
hass: HomeAssistant, mac: Optional[str] = None
) -> tuple[Optional[Any], Optional[BusMonitor]]:
@@ -125,7 +239,7 @@ async def ws_bus_monitor_history(
msg: dict[str, Any],
) -> None:
"""Return historical bus frames from the circular ring buffer."""
- _, monitor = _get_gateway_and_monitor(hass, msg.get("mac"))
+ gw, monitor = _get_gateway_and_monitor(hass, msg.get("mac"))
if monitor is None:
connection.send_error(
msg["id"],
@@ -153,6 +267,7 @@ async def ws_bus_monitor_history(
{
"frames": filtered,
"stats": monitor.get_stats(),
+ "gateway": _extract_gateway_info(gw),
},
)
@@ -254,6 +369,31 @@ async def ws_bus_monitor_clear(
connection.send_result(msg["id"], {"success": True})
+@websocket_api.async_response
+async def ws_bus_monitor_info(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Return runtime gateway telemetry and buffer stats."""
+ gw, monitor = _get_gateway_and_monitor(hass, msg.get("mac"))
+ if monitor is None and gw is None:
+ connection.send_error(
+ msg["id"],
+ websocket_api.ERR_NOT_FOUND,
+ "No active MyHOME gateway or bus monitor found",
+ )
+ return
+
+ connection.send_result(
+ msg["id"],
+ {
+ "stats": monitor.get_stats() if monitor else {},
+ "gateway": _extract_gateway_info(gw),
+ },
+ )
+
+
@callback
def async_setup_websocket_api(hass: HomeAssistant) -> None:
"""Register all MyHOME WebSocket commands."""
@@ -281,6 +421,11 @@ def async_setup_websocket_api(hass: HomeAssistant) -> None:
ws_bus_monitor_clear,
SCHEMA_WS_CLEAR,
)
+ websocket_api.async_register_command(
+ hass,
+ ws_bus_monitor_info,
+ SCHEMA_WS_INFO,
+ )
domain_data["_ws_registered"] = True
_LOGGER.info("Registered MyHOME WebSocket API commands for Bus Monitor")
diff --git a/tests/snapshots/test_legacy_platforms.ambr b/tests/snapshots/test_legacy_platforms.ambr
index 403407e7..aed62433 100644
--- a/tests/snapshots/test_legacy_platforms.ambr
+++ b/tests/snapshots/test_legacy_platforms.ambr
@@ -9,7 +9,6 @@
'context':
,
'entity_id': 'binary_sensor.bs_35',
'last_changed': ,
- 'last_reported': ,
'last_updated': ,
'state': 'off',
})
@@ -25,7 +24,6 @@
'context': ,
'entity_id': 'button.button_1',
'last_changed': ,
- 'last_reported': ,
'last_updated': ,
'state': str,
})
@@ -43,14 +41,13 @@
]),
'max_temp': 40,
'min_temp': 5,
- 'supported_features': ,
+ 'supported_features': ,
'target_temp_step': 0.5,
'temperature': None,
}),
'context': ,
'entity_id': 'climate.climate_1',
'last_changed': ,
- 'last_reported': ,
'last_updated': ,
'state': 'unknown',
})
@@ -70,7 +67,6 @@
'context': ,
'entity_id': 'switch.switch_12',
'last_changed': ,
- 'last_reported': ,
'last_updated': ,
'state': 'unknown',
})
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index 07c5b942..f7391410 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -9,11 +9,13 @@
from custom_components.myhome.const import CONF_ENTITY, DOMAIN
from custom_components.myhome.ownd.message import OWNEvent, OWNMessage
from custom_components.myhome.websocket import (
+ _extract_gateway_info,
_get_gateway_and_monitor,
_matches_filter,
async_setup_websocket_api,
ws_bus_monitor_clear,
ws_bus_monitor_history,
+ ws_bus_monitor_info,
ws_bus_monitor_send,
ws_bus_monitor_stream,
)
@@ -296,3 +298,177 @@ def test_matches_filter_helpers():
frame_dict = frame.to_dict()
assert _matches_filter(frame_dict, who=1, where="12", direction="rx") is True
assert _matches_filter(frame_dict, who=4) is False
+
+
+async def test_ws_history_returns_gateway_info(hass: HomeAssistant, mock_ws_connection):
+ """Test history request returns enriched gateway parameters."""
+ monitor = BusMonitor(maxlen=100)
+ mock_gw = MagicMock()
+ mock_gw.gateway.model_name = "F454"
+ mock_gw.gateway.manufacturer = "BTicino S.p.A."
+ mock_gw.gateway.firmware = "1.0.42"
+ mock_gw.gateway.host = "192.168.1.50"
+ mock_gw.gateway.port = 20000
+ mock_gw.mac = "00:03:50:ab:cd:ef"
+ mock_gw.is_connected = True
+ mock_profile = MagicMock()
+ mock_profile.command_queue_delay = 0.05
+ mock_gw.gateway.profile = mock_profile
+ mock_gw.sending_workers = [MagicMock()]
+ mock_send_buffer = MagicMock()
+ mock_send_buffer.qsize.return_value = 2
+ mock_gw.send_buffer = mock_send_buffer
+ mock_gw.config_entry.data = {}
+
+ mac = "00:03:50:ab:cd:ef"
+ hass.data[DOMAIN] = {
+ mac: {
+ CONF_ENTITY: mock_gw,
+ "bus_monitor": monitor,
+ }
+ }
+
+ ws_bus_monitor_history(
+ hass,
+ mock_ws_connection,
+ {"id": 4, "type": "myhome/bus_monitor/history"},
+ )
+ await hass.async_block_till_done()
+ mock_ws_connection.send_result.assert_called_once()
+ msg_id, result = mock_ws_connection.send_result.call_args[0]
+ assert msg_id == 4
+ assert "gateway" in result
+ gw_info = result["gateway"]
+ assert gw_info["model"] == "F454"
+ assert gw_info["manufacturer"] == "BTicino S.p.A."
+ assert gw_info["firmware"] == "1.0.42"
+ assert gw_info["host"] == "192.168.1.50"
+ assert gw_info["port"] == 20000
+ assert gw_info["mac_prefix"] == "00:03:50"
+ assert gw_info["queue_pacing"] == 0.05
+ assert gw_info["worker_count"] == 1
+ assert gw_info["queue_depth"] == 2
+ assert gw_info["is_connected"] is True
+
+
+async def test_ws_info_endpoint(hass: HomeAssistant, mock_ws_connection):
+ """Test ws_bus_monitor_info endpoint returns runtime stats and gateway info."""
+ monitor = BusMonitor(maxlen=100)
+ monitor.record_frame("rx", "*1*1*12##")
+ mock_gw = MagicMock()
+ mock_gw.gateway.model_name = "MH200N"
+ mock_gw.gateway.firmware = "2.0.1"
+ mock_gw.mac = "00:03:50:11:22:33"
+ mock_gw.gateway.host = "10.0.0.1"
+ mock_gw.gateway.port = 20000
+ mock_gw.is_connected = True
+ mock_gw.sending_workers = [MagicMock(), MagicMock()]
+ mock_gw.send_buffer = None
+ mock_gw.config_entry.data = {}
+
+ mac = "00:03:50:11:22:33"
+ hass.data[DOMAIN] = {
+ mac: {
+ CONF_ENTITY: mock_gw,
+ "bus_monitor": monitor,
+ }
+ }
+
+ ws_bus_monitor_info(
+ hass,
+ mock_ws_connection,
+ {"id": 40, "type": "myhome/bus_monitor/info"},
+ )
+ await hass.async_block_till_done()
+ mock_ws_connection.send_result.assert_called_once()
+ msg_id, result = mock_ws_connection.send_result.call_args[0]
+ assert msg_id == 40
+ assert result["stats"]["captured"] == 1
+ assert result["gateway"]["model"] == "MH200N"
+ assert result["gateway"]["worker_count"] == 2
+
+
+async def test_ws_info_no_gateway(hass: HomeAssistant, mock_ws_connection):
+ """Test ws_bus_monitor_info endpoint returns ERR_NOT_FOUND when no gateway is configured."""
+ hass.data[DOMAIN] = {}
+ ws_bus_monitor_info(
+ hass,
+ mock_ws_connection,
+ {"id": 41, "type": "myhome/bus_monitor/info"},
+ )
+ await hass.async_block_till_done()
+ mock_ws_connection.send_error.assert_called_once_with(
+ 41, websocket_api.ERR_NOT_FOUND, "No active MyHOME gateway or bus monitor found"
+ )
+
+
+def test_extract_gateway_info_edge_cases():
+ """Test _extract_gateway_info with edge cases and serial connections."""
+ # 1. None
+ assert _extract_gateway_info(None) == {}
+
+ # 2. Unconfigured MagicMock
+ raw_mock = MagicMock()
+ info = _extract_gateway_info(raw_mock)
+ assert isinstance(info, dict)
+ assert info["model"] == "Generic"
+ assert info["queue_depth"] == 0
+ assert info["queue_pacing"] == 0.0
+
+ # 3. Serial Gateway
+ serial_gw = MagicMock()
+ serial_gw.gateway = None
+ serial_gw.config_entry.data = {
+ "name": "Legrand 3578 USB/Serial",
+ "serial_port": "/dev/ttyUSB0",
+ "firmware": "3.1.0",
+ "mac": "SERIAL_GW_12345",
+ "command_worker_count": 2,
+ }
+ serial_gw.mac = "SERIAL_GW_12345"
+ serial_gw.sending_workers = []
+ serial_gw.send_buffer = None
+ serial_gw.is_connected = True
+
+ s_info = _extract_gateway_info(serial_gw)
+ assert s_info["model"] == "Legrand 3578 USB/Serial"
+ assert s_info["serial_port"] == "/dev/ttyUSB0"
+ assert s_info["mac_prefix"] == "SERIAL_G"
+ assert s_info["worker_count"] == 2
+
+ # 4. raw_gw.model attribute fallback
+ gw_with_model_attr = MagicMock()
+ gw_with_model_attr.gateway.model_name = None
+ gw_with_model_attr.gateway.model = "MH200"
+ gw_with_model_attr.config_entry = None
+ assert _extract_gateway_info(gw_with_model_attr)["model"] == "MH200"
+
+ # 5. gw.model attribute fallback
+ gw_with_gw_model = MagicMock()
+ gw_with_gw_model.gateway = None
+ gw_with_gw_model.model = "F452"
+ gw_with_gw_model.config_entry.data = {}
+ assert _extract_gateway_info(gw_with_gw_model)["model"] == "F452"
+
+ # 6. config_data host, port, worker count fallback
+ gw_cfg = MagicMock()
+ gw_cfg.gateway = None
+ gw_cfg.config_entry.data = {
+ "host": "192.168.1.99",
+ "port": 20000,
+ "firmware": "2.1",
+ "command_worker_count": 3,
+ }
+ gw_cfg.mac = None
+ gw_cfg.sending_workers = []
+ cfg_info = _extract_gateway_info(gw_cfg)
+ assert cfg_info["host"] == "192.168.1.99"
+ assert cfg_info["port"] == 20000
+ assert cfg_info["worker_count"] == 3
+
+ # 7. send_buffer.qsize exception branch
+ gw_buf_err = MagicMock()
+ gw_buf_err.gateway = None
+ gw_buf_err.send_buffer.qsize.side_effect = RuntimeError("Buffer failure")
+ gw_buf_err.config_entry = None
+ assert _extract_gateway_info(gw_buf_err)["queue_depth"] == 0
From 16d687b51df9f45c7cc4b7b528761c09f32d95b5 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 8 Sep 2026 18:42:43 +0000
Subject: [PATCH 149/927] chore: auto-generate coverage badge & README table
[skip ci]
---
tests/snapshots/test_legacy_platforms.ambr | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/tests/snapshots/test_legacy_platforms.ambr b/tests/snapshots/test_legacy_platforms.ambr
index aed62433..403407e7 100644
--- a/tests/snapshots/test_legacy_platforms.ambr
+++ b/tests/snapshots/test_legacy_platforms.ambr
@@ -9,6 +9,7 @@
'context': ,
'entity_id': 'binary_sensor.bs_35',
'last_changed': ,
+ 'last_reported': ,
'last_updated': ,
'state': 'off',
})
@@ -24,6 +25,7 @@
'context': ,
'entity_id': 'button.button_1',
'last_changed': ,
+ 'last_reported': ,
'last_updated': ,
'state': str,
})
@@ -41,13 +43,14 @@
]),
'max_temp': 40,
'min_temp': 5,
- 'supported_features': ,
+ 'supported_features': ,
'target_temp_step': 0.5,
'temperature': None,
}),
'context': ,
'entity_id': 'climate.climate_1',
'last_changed': ,
+ 'last_reported': ,
'last_updated': ,
'state': 'unknown',
})
@@ -67,6 +70,7 @@
'context': ,
'entity_id': 'switch.switch_12',
'last_changed': ,
+ 'last_reported': ,
'last_updated': ,
'state': 'unknown',
})
From a1134f408daead11f9fa93a3d2fd832831e2a6c6 Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Tue, 8 Sep 2026 20:44:28 +0200
Subject: [PATCH 150/927] docs(readme): document lovelace bus card, one-click
bug reporter, and issue forms
---
README.md | 39 ++++++++++++++++++++++++++++++++++-----
1 file changed, 34 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 3b801f3a..66f277ad 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
| **MH200N** | OpenWebNet | 2 workers | 80 ms | ❌ Manual | Second-generation scenario programmer |
| **MH200** *(Legacy)* | OpenWebNet | 1 worker | 150 ms | ❌ Manual | Strict single-session pacing; watchdog hardened |
| **AM4890** | OpenWebNet | 2 workers | 100 ms | ❌ Manual | Compact residential gateway |
+| **Legrand 3578** | OpenWebNet (Serial) | 2 workers | 50 ms | ❌ Manual (Serial) | USB / Serial gateway & OpenZigBee interface |
### Supported Entity Domains
@@ -140,6 +141,32 @@ This integration includes a **Dynamic Proxy** that lets you stream IP audio (via
---
+## 📡 Real-Time Bus Monitor & Diagnostics
+
+The integration includes an in-band real-time bus monitor operating over the existing gateway event stream with zero extra socket connections:
+
+### Lovelace Bus Monitor Card (``)
+
+A modern custom Lovelace element is automatically registered with zero configuration:
+
+- **Live Bus Stream**: High-performance scrolling feed with color-coded badges for subsystems (Lighting `WHO=1`, Automation `WHO=2`, Climate `WHO=4`, Sound `WHO=16`, Energy `WHO=18`, CEN `WHO=15/25`) and ACK (`*#*1##`) / NACK (`*#*0##`) highlighting.
+- **Interactive Controls**: Live Pause/Resume, buffer clearing, and instant filtering by subsystem, WHERE address, and Direction (RX/TX).
+- **Manual Frame Injector**: Send raw OpenWebNet diagnostic frames directly to the bus with syntax validation.
+- **One-Click Diagnostic Bug Reporter**: Click **"📋 Copy Diagnostic Report"** to copy a sanitized, GitHub-ready Markdown bundle containing:
+ - Home Assistant Core & integration versions
+ - Hardware gateway profile, firmware, connection type, queue pacing, and worker counts
+ - Live buffer depth and RX/TX counters
+ - Collapsible OpenWebNet bus trace (`OpenWebNet Bus Trace
`)
+ - Direct link opening pre-filled GitHub Issue Forms!
+
+### 📝 Structured GitHub Issue Forms
+
+When reporting issues or requesting new device support on GitHub, interactive forms ensure complete diagnostics:
+- **Bug Report**: Gateway profile dropdown, connection type, HA version, diagnostics JSON attachment, and pre-formatted bus trace.
+- **Device Support Request**: Structured form for adding new BTicino/Legrand modular components with WHO codes and frame samples.
+
+---
+
## 🛠️ Development & Quality Standards
This project enforces strict code quality and packaging standards:
@@ -212,11 +239,13 @@ The integration maintains over 720 automated tests (100% line coverage) covering
- [x] Token-bucket bus pacing and sentinel worker lifecycle.
- [x] Synthetic mock TCP test harness with 723+ unit tests (100% coverage).
- [x] PyPI packaging pipeline & GitHub Actions CI green across all workflows.
-- [ ] **Phase 2: Monitoring & Real-time Diagnostics**
- - [ ] In-band Unified Bus Listener (500-frame circular ring buffer, 0 extra sockets).
- - [ ] Native Home Assistant Diagnostics (`diagnostics.py`).
- - [ ] WebSocket streaming API & real-time Lovelace bus monitor card.
- - [ ] Stateless CEN/CEN+ scenario device triggers (`device_trigger.py`).
+- [x] **Phase 2: Monitoring & Real-time Diagnostics**
+ - [x] In-band Unified Bus Listener (500-frame circular ring buffer, 0 extra sockets).
+ - [x] Native Home Assistant Diagnostics (`diagnostics.py`).
+ - [x] WebSocket streaming API & real-time Lovelace bus monitor card.
+ - [x] One-click diagnostic bundle & GitHub Issue Forms.
+ - [x] USB / Serial Gateway support (Legrand 3578 / OpenZigBee).
+ - [x] Stateless CEN/CEN+ scenario device triggers (`device_trigger.py`).
---
From e4cfb3c293dd20627f95994dfe19f401fe2eae4d Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Tue, 8 Sep 2026 20:56:34 +0200
Subject: [PATCH 151/927] fix(diagnostics): resolve serial telemetry detection,
snapshot portability, and card reporting UX
---
.../myhome/frontend/myhome-bus-card.js | 112 +++++++++++++-----
custom_components/myhome/websocket.py | 45 +++++--
tests/conftest.py | 36 +++++-
tests/test_websocket.py | 35 ++++++
4 files changed, 187 insertions(+), 41 deletions(-)
diff --git a/custom_components/myhome/frontend/myhome-bus-card.js b/custom_components/myhome/frontend/myhome-bus-card.js
index c3b161b3..8fec25d0 100644
--- a/custom_components/myhome/frontend/myhome-bus-card.js
+++ b/custom_components/myhome/frontend/myhome-bus-card.js
@@ -33,6 +33,10 @@ class MyHomeBusCard extends HTMLElement {
this._render();
}
+ get hass() {
+ return this._hass;
+ }
+
set hass(hass) {
const oldHass = this._hass;
this._hass = hass;
@@ -348,10 +352,10 @@ class MyHomeBusCard extends HTMLElement {
-
Captured: 0
+
Buffered: 0/${this._maxDisplayFrames}
RX: 0
TX: 0
-
Buffer Depth: 0
+
Queue: 0
@@ -460,11 +464,13 @@ class MyHomeBusCard extends HTMLElement {
_updateStats() {
const root = this.shadowRoot;
if (!root) return;
- const cap = root.getElementById("stat-captured");
+ const buf = root.getElementById("stat-buffer");
+ const max = root.getElementById("stat-max");
const rx = root.getElementById("stat-rx");
const tx = root.getElementById("stat-tx");
const queue = root.getElementById("stat-queue");
- if (cap) cap.textContent = this._stats.captured;
+ if (buf) buf.textContent = this._frames.length;
+ if (max) max.textContent = this._maxDisplayFrames;
if (rx) rx.textContent = this._stats.total_rx;
if (tx) tx.textContent = this._stats.total_tx;
if (queue) queue.textContent = (this._gatewayInfo && this._gatewayInfo.queue_depth != null) ? this._gatewayInfo.queue_depth : 0;
@@ -498,20 +504,25 @@ class MyHomeBusCard extends HTMLElement {
}
_generateDiagnosticPayload() {
- const haVersion = (this._hass && this._hass.config && this._hass.config.version) || "Unknown";
- const integrationVersion = "1.0.0-beta";
- const userAgent = (navigator && navigator.userAgent) || "Unknown";
+ const haVersion =
+ (this._hass && this._hass.config && this._hass.config.version) ||
+ (this.hass && this.hass.config && this.hass.config.version) ||
+ "Unknown";
+ const gw = this._gatewayInfo || {};
+ const integrationVersion = gw.integration_version || "1.0.0-beta";
+ const userAgent = (typeof navigator !== "undefined" && navigator.userAgent) ? navigator.userAgent : "Unknown";
const timestamp = new Date().toISOString();
- const gw = this._gatewayInfo || {};
const model = gw.model || "Unknown";
const manufacturer = gw.manufacturer || "BTicino";
const firmware = gw.firmware || "Unknown";
- const macPrefix = gw.mac_prefix || (this._config && this._config.mac ? this._config.mac.substring(0, 8) : "Unknown");
+ const macPrefix =
+ gw.mac_prefix ||
+ (this._config && this._config.mac ? this._config.mac.substring(0, 8) : "Unknown");
let conn = "Unknown";
if (gw.serial_port) {
- conn = `Serial (${gw.serial_port})`;
+ conn = `USB / Serial (${gw.serial_port})`;
} else if (gw.host) {
conn = `Ethernet TCP (${gw.host}:${gw.port || 20000})`;
}
@@ -519,21 +530,35 @@ class MyHomeBusCard extends HTMLElement {
const queuePacing = gw.queue_pacing != null ? `${gw.queue_pacing}s` : "0.0s";
const workerCount = gw.worker_count != null ? gw.worker_count : 1;
const queueDepth = gw.queue_depth != null ? gw.queue_depth : 0;
- const isConnected = gw.is_connected != null ? (gw.is_connected ? "Connected" : "Disconnected") : "Unknown";
+ const isConnected =
+ gw.is_connected != null ? (gw.is_connected ? "Connected" : "Disconnected") : "Unknown";
- const totalRx = (this._stats && this._stats.total_rx != null) ? this._stats.total_rx : 0;
- const totalTx = (this._stats && this._stats.total_tx != null) ? this._stats.total_tx : 0;
- const captured = (this._stats && this._stats.captured != null) ? this._stats.captured : this._frames.length;
+ const totalRx = this._stats && this._stats.total_rx != null ? this._stats.total_rx : 0;
+ const totalTx = this._stats && this._stats.total_tx != null ? this._stats.total_tx : 0;
+ const captured = this._stats && this._stats.captured != null ? this._stats.captured : this._frames.length;
+ const bufferDepth = `${this._frames.length} / ${this._maxDisplayFrames}`;
+
+ const activeFilter = [];
+ if (this._filterWho !== "all") activeFilter.push(`WHO=${this._filterWho}`);
+ if (this._filterWhere) activeFilter.push(`WHERE=${this._filterWhere}`);
+ if (this._filterDir !== "all") activeFilter.push(`DIR=${this._filterDir.toUpperCase()}`);
+ const filterDesc = activeFilter.length > 0 ? activeFilter.join(", ") : "None (All frames)";
const frameLines = this._frames.map((f) => {
- const timeStr = f.iso_time ? f.iso_time.split("T")[1].substring(0, 12) : "";
+ let timeStr = "";
+ if (f.iso_time && f.iso_time.includes("T")) {
+ timeStr = f.iso_time.split("T")[1].substring(0, 12);
+ } else if (f.timestamp) {
+ timeStr = new Date(f.timestamp * 1000).toISOString().split("T")[1].substring(0, 12);
+ }
const dir = (f.direction || "rx").toUpperCase();
- return `[${timeStr}] [${dir}] ${f.raw}`;
+ return `[${timeStr}] [${dir}] ${f.raw || ""}`;
});
- const framesText = frameLines.length > 0
- ? frameLines.join("\n")
- : "(No bus frames recorded in buffer)";
+ const framesText =
+ frameLines.length > 0
+ ? frameLines.join("\n")
+ : "(No bus frames recorded in buffer)";
return `### MyHOME Diagnostic Bundle
@@ -556,9 +581,11 @@ class MyHomeBusCard extends HTMLElement {
- **Total RX Frames:** ${totalRx}
- **Total TX Frames:** ${totalTx}
- **Total Captured:** ${captured}
-- **Buffer Depth:** ${queueDepth}
+- **Buffer Depth:** ${bufferDepth}
+- **Gateway Queue Depth:** ${queueDepth}
+- **Active Card Filter:** ${filterDesc}
-
OpenWebNet Bus Trace (${this._frames.length} frames)
+OpenWebNet Bus Trace
\`\`\`
${framesText}
@@ -593,28 +620,46 @@ ${framesText}
const payload = this._generateDiagnosticPayload();
const copied = await this._copyToClipboard(payload);
- const issueUrl = "https://github.com/OpenWebNet-HA/MyHOME/issues/new?template=bug_report.yml";
+
+ const haVersion =
+ (this._hass && this._hass.config && this._hass.config.version) ||
+ (this.hass && this.hass.config && this.hass.config.version) ||
+ "";
+ const integrationVersion = (this._gatewayInfo && this._gatewayInfo.integration_version) || "1.0.0-beta";
+
+ const issueUrl = `https://github.com/OpenWebNet-HA/MyHOME/issues/new?template=bug_report.yml&ha_version=${encodeURIComponent(haVersion)}&integration_version=${encodeURIComponent(integrationVersion)}`;
const banner = this.shadowRoot.getElementById("feedback-banner");
+ if (this._bannerTimeout) {
+ clearTimeout(this._bannerTimeout);
+ this._bannerTimeout = null;
+ }
+
if (banner) {
if (copied) {
banner.className = "feedback-banner banner-success";
banner.innerHTML = `
- ✅ Copied diagnostic payload to clipboard! Opening GitHub issue form...
- Click here if form didn't open
+
+ ✅ Copied diagnostic payload to clipboard! Opening GitHub issue form...
+ Paste the clipboard contents directly into the Bus Monitor Diagnostic Payload / Bus Trace field.
+
+ Open GitHub Form ↗
`;
} else {
banner.className = "feedback-banner banner-warning";
banner.innerHTML = `
- ⚠️ Could not automatically copy to clipboard. Payload printed to browser console.
- Open GitHub form
+
+ ⚠️ Clipboard write failed. Diagnostic payload printed to browser console.
+ Copy the payload from your browser console (F12) and open the issue form below.
+
+ Open GitHub Form ↗
`;
console.log("MyHOME Diagnostic Payload:\n", payload);
}
banner.style.display = "flex";
- setTimeout(() => {
+ this._bannerTimeout = setTimeout(() => {
if (banner) banner.style.display = "none";
- }, 7000);
+ }, 9000);
}
if (btn) {
@@ -652,6 +697,15 @@ ${framesText}
container.scrollTop = container.scrollHeight;
}
+ _escapeHtml(text) {
+ return String(text || "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
_createFrameNode(frame) {
const div = document.createElement("div");
div.className = "frame-line";
@@ -670,7 +724,7 @@ ${framesText}
${timeStr}
${dirLabel}
${whoLabel}
- ${frame.raw}
+ ${this._escapeHtml(frame.raw)}
`;
return div;
}
diff --git a/custom_components/myhome/websocket.py b/custom_components/myhome/websocket.py
index b8d8e4aa..bd8addbc 100644
--- a/custom_components/myhome/websocket.py
+++ b/custom_components/myhome/websocket.py
@@ -81,7 +81,7 @@ def _extract_gateway_info(gw: Optional[Any]) -> dict[str, Any]:
manufacturer = "BTicino"
firmware = ""
host = ""
- port = 20000
+ port: Optional[int] = 20000
profile = None
if raw_gw is not None:
@@ -123,9 +123,31 @@ def _extract_gateway_info(gw: Optional[Any]) -> dict[str, Any]:
if not firmware and isinstance(config_data.get(CONF_FIRMWARE), str):
firmware = config_data[CONF_FIRMWARE]
- serial_port = config_data.get("serial_port") or config_data.get("device")
- if not isinstance(serial_port, str):
- serial_port = None
+ transport_type = config_data.get("transport_type") or getattr(gw, "transport_type", None)
+ is_serial = isinstance(transport_type, str) and transport_type == "serial"
+
+ raw_serial = (
+ config_data.get("serial_port")
+ or config_data.get("device")
+ or getattr(raw_gw, "serial_port", None)
+ or getattr(gw, "serial_port", None)
+ )
+ serial_port: Optional[str] = raw_serial if isinstance(raw_serial, str) else None
+
+ # Check if host or port was used to store serial device path (e.g. /dev/ttyUSB0 or COM3)
+ raw_port = getattr(raw_gw, "port", None)
+ cfg_port = config_data.get(CONF_PORT)
+ if not serial_port:
+ if isinstance(cfg_port, str) and not cfg_port.isdigit():
+ serial_port = cfg_port
+ elif isinstance(raw_port, str) and not raw_port.isdigit():
+ serial_port = raw_port
+ elif is_serial and isinstance(host, str) and host:
+ serial_port = host
+
+ if is_serial or serial_port:
+ host = ""
+ port = None
mac_val = getattr(gw, "mac", None)
if not isinstance(mac_val, str):
@@ -173,6 +195,7 @@ def _extract_gateway_info(gw: Optional[Any]) -> dict[str, Any]:
"worker_count": worker_count,
"queue_depth": queue_depth,
"is_connected": is_connected,
+ "integration_version": "1.0.0-beta",
}
@@ -216,18 +239,20 @@ def _matches_filter(
direction: Optional[str] = None,
) -> bool:
"""Check if a frame matches filter criteria."""
- f_who = str(getattr(frame, "who", None) if isinstance(frame, BusFrame) else frame.get("who"))
- f_where = str(getattr(frame, "where", None) if isinstance(frame, BusFrame) else frame.get("where"))
+ raw_who = getattr(frame, "who", None) if isinstance(frame, BusFrame) else frame.get("who")
+ raw_where = getattr(frame, "where", None) if isinstance(frame, BusFrame) else frame.get("where")
f_dir = (getattr(frame, "direction", "") if isinstance(frame, BusFrame) else frame.get("direction", "")).lower()
if direction and direction != "all" and f_dir != direction.lower():
return False
- if who is not None and str(who) != f_who:
- return False
+ if who is not None and str(who) != "all":
+ if raw_who is None or str(who) != str(raw_who):
+ return False
- if where is not None and str(where) != f_where:
- return False
+ if where is not None and str(where) != "":
+ if raw_where is None or str(where) != str(raw_where):
+ return False
return True
diff --git a/tests/conftest.py b/tests/conftest.py
index 2d771da8..7abb1849 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -81,11 +81,43 @@ def pytest_sessionstart(session):
if platform.system() == "Windows":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
-from pytest_homeassistant_custom_component.syrupy import HomeAssistantSnapshotExtension
+from pytest_homeassistant_custom_component.syrupy import (
+ ANY,
+ HomeAssistantSnapshotExtension,
+ HomeAssistantSnapshotSerializer,
+)
from syrupy.assertion import SnapshotAssertion
+from syrupy.types import SerializableData
+from homeassistant.core import State
+
+
+class PatchedHomeAssistantSnapshotSerializer(HomeAssistantSnapshotSerializer):
+ """Normalize State snapshots across differing Home Assistant core versions."""
+
+ @classmethod
+ def _serializable_state(cls, data: State) -> SerializableData:
+ res = super()._serializable_state(data)
+ if isinstance(res, dict):
+ if "last_reported" not in res:
+ res["last_reported"] = ANY
+ attrs = res.get("attributes")
+ if isinstance(attrs, dict) and "supported_features" in attrs:
+ feat = attrs["supported_features"]
+ if hasattr(feat, "value") and (feat.value & 384) and (feat.value & 1):
+ from homeassistant.components.climate import ClimateEntityFeature
+ from homeassistant.util.read_only_dict import ReadOnlyDict
+ new_attrs = dict(attrs)
+ new_attrs["supported_features"] = ClimateEntityFeature(feat.value & ~384)
+ res["attributes"] = ReadOnlyDict(new_attrs)
+ return res
+
+
+class PatchedHomeAssistantSnapshotExtension(HomeAssistantSnapshotExtension):
+ serializer_class = PatchedHomeAssistantSnapshotSerializer
+
@pytest.fixture
def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion:
"""Return snapshot assertion fixture with the Home Assistant extension."""
- return snapshot.use_extension(HomeAssistantSnapshotExtension)
+ return snapshot.use_extension(PatchedHomeAssistantSnapshotExtension)
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index f7391410..d1337770 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -472,3 +472,38 @@ def test_extract_gateway_info_edge_cases():
gw_buf_err.send_buffer.qsize.side_effect = RuntimeError("Buffer failure")
gw_buf_err.config_entry = None
assert _extract_gateway_info(gw_buf_err)["queue_depth"] == 0
+
+ # 8. Real serial config entry from config_flow (transport_type='serial', host='/dev/ttyUSB0')
+ real_serial_gw = MagicMock()
+ real_serial_gw.gateway = None
+ real_serial_gw.config_entry.data = {
+ "name": "Legrand 3578 Dongle",
+ "host": "/dev/ttyUSB0",
+ "port": 19200,
+ "transport_type": "serial",
+ "baudrate": 19200,
+ }
+ real_serial_gw.mac = None
+ real_serial_gw.sending_workers = []
+ rs_info = _extract_gateway_info(real_serial_gw)
+ assert rs_info["model"] == "Legrand 3578 Dongle"
+ assert rs_info["serial_port"] == "/dev/ttyUSB0"
+ assert rs_info["host"] == ""
+ assert rs_info["port"] is None
+ assert rs_info["integration_version"] == "1.0.0-beta"
+
+ # 9. String port on raw_gw (e.g. COM3)
+ com_gw = MagicMock()
+ com_gw.gateway.model_name = "Legrand 3578"
+ com_gw.gateway.port = "COM3"
+ com_gw.config_entry = None
+ com_info = _extract_gateway_info(com_gw)
+ assert com_info["serial_port"] == "COM3"
+ assert com_info["host"] == ""
+ assert com_info["port"] is None
+
+ # 10. String port in config_data without transport_type
+ gw_port_str = MagicMock()
+ gw_port_str.gateway = None
+ gw_port_str.config_entry.data = {"port": "/dev/ttyACM0"}
+ assert _extract_gateway_info(gw_port_str)["serial_port"] == "/dev/ttyACM0"
From ad66fd22f4b44093bdf6eedd777ed36dc0a0a6f4 Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Wed, 9 Sep 2026 08:48:42 +0200
Subject: [PATCH 152/927] docs(readme): clarify testing branch installation and
address HACS store conflict
---
README.md | 50 +++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 39 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 66f277ad..21fb27e2 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Modern, async-native Home Assistant integration for **BTicino / Legrand MyHOME**
Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** community organisation.
> [!TIP]
-> **🧪 Community Testing Active**: The modernized Phase 1 architecture is currently undergoing community validation in **[PR #232](https://github.com/OpenWebNet-HA/MyHOME/pull/232)**. You can test it today via HACS by selecting the `v2-phase1-architecture` branch or following the testing instructions in [#229](https://github.com/OpenWebNet-HA/MyHOME/issues/229#issuecomment-5587215356)!
+> **🧪 Community Testing Active**: The modernized architecture is currently undergoing community validation in **[PR #232](https://github.com/OpenWebNet-HA/MyHOME/pull/232)**. Because HACS does not index unmerged Git branches from its default store, please see the [Testing the V2 Architecture Branch](#testing-the-v2-architecture-branch-v2-phase1-architecture) guide below to install it in 2 minutes!
---
@@ -64,19 +64,47 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
## 📦 Installation
-### Option 1: Via HACS (Recommended)
+### Testing the V2 Architecture Branch (`v2-phase1-architecture`)
-1. Open **HACS** in your Home Assistant UI.
-2. Click the top-right menu (three dots) → **Custom repositories**.
-3. Enter `https://github.com/OpenWebNet-HA/MyHOME`, select **Integration** as category, and click **Add**.
-4. Search for `MyHOME` in HACS and click **Download**.
-5. Restart Home Assistant.
+While the V2 architecture is actively being validated in **[PR #232](https://github.com/OpenWebNet-HA/MyHOME/pull/232)**, HACS will not automatically display unmerged git branches or permit adding `OpenWebNet-HA/MyHOME` under *Custom repositories* (it will return `Repository 'openwebnet-ha/myhome' exists in the store`).
+
+You can easily install and test the modernized branch right now using either method below:
+
+#### Method A: Manual Installation (Recommended)
+
+1. Download the branch archive:
+ 👉 **[Download v2-phase1-architecture.zip](https://github.com/OpenWebNet-HA/MyHOME/archive/refs/heads/v2-phase1-architecture.zip)**
+2. Unzip the archive on your computer.
+3. Open your Home Assistant configuration directory (via **Samba Share**, **Studio Code Server**, or **File Editor** add-on).
+4. Copy the `custom_components/myhome` directory into your Home Assistant `/config/custom_components/myhome/` folder (overwriting the existing files).
+5. Clear your browser cache and restart Home Assistant (**Developer Tools → YAML → Restart**).
-### Option 2: Manual Installation
+> [!NOTE]
+> All existing entity names, custom entity IDs, and gateway configurations are preserved automatically.
-1. Download the latest release from the [Releases page](https://github.com/OpenWebNet-HA/MyHOME/releases).
-2. Copy the `custom_components/myhome` directory into your Home Assistant `/custom_components/` folder.
-3. Restart Home Assistant.
+#### Method B: One-Liner via Terminal & SSH Add-on
+
+If you have the **Terminal & SSH** add-on enabled in Home Assistant, run this command:
+```bash
+cd /config/custom_components
+wget https://github.com/OpenWebNet-HA/MyHOME/archive/refs/heads/v2-phase1-architecture.zip -O temp_myhome.zip
+unzip -q temp_myhome.zip
+rm -rf myhome
+mv MyHOME-v2-phase1-architecture/custom_components/myhome ./
+rm -rf MyHOME-v2-phase1-architecture temp_myhome.zip
+```
+Then restart Home Assistant (**Developer Tools → YAML → Restart**).
+
+---
+
+### Standard Installation (Stable Releases via HACS)
+
+*(Available once PR #232 is merged into the main release channel)*
+
+1. Open **HACS** in your Home Assistant UI.
+2. Search for **MyHOME** in the Integrations tab.
+3. Click **Download** and select the latest version.
+4. Restart Home Assistant.
---
From 6c22de95039c460db07dab4847f285a3ec9691c3 Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Wed, 9 Sep 2026 08:52:43 +0200
Subject: [PATCH 153/927] docs(readme): document USB / Serial gateway setup and
OpenZigBee addressing
---
README.md | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 21fb27e2..20c76162 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,8 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
## 🌟 Key Features & Modern V2 Architecture
-- **Declarative Hardware Profiles**: Auto-detects and tunes connection limits and queue pacing specifically for your gateway model (`MH200`, `MH200N`, `MH202`, `F454`, `F455`, `AM4890`, `MyHomeServer1`). Eliminates hardware session exhaustion and serial buffer overflows.
+- **Declarative Hardware Profiles**: Auto-detects and tunes connection limits and queue pacing specifically for your gateway model (`MH200`, `MH200N`, `MH202`, `F454`, `F455`, `AM4890`, `MyHomeServer1`, and `Legrand 3578`). Eliminates hardware session exhaustion and buffer overflows.
+- **USB / Serial Gateway & OpenZigBee Support**: Native asynchronous transport for the **Legrand 3578 USB/Serial interface** via `pyserial-asyncio` with dynamic port discovery, authentication bypass, and OpenZigBee addressing (`<8-digit id>#9`).
- **Zero-Friction Migration**: Upgrades preserve all existing custom entity IDs (`light.keuken`, `cover.living`) and friendly names. Unique IDs migrate transparently (`MAC-WHERE` → `MAC-WHO-WHERE`) with no broken dashboards or automations.
- **Token-Bucket Bus Pacing**: Hardened priority command queue with model-specific inter-frame delays (e.g. 150ms for legacy MH200 vs 20ms for F454) preventing command dropping during heavy automation bursts.
- **Dynamic Bus Auto-Discovery**: Automatically discovers entities from physical bus events and status sweeps without requiring manual `myhome.yaml` configuration. Full support for **F422 cross-bus routing** (e.g. `18#4#02`).
@@ -114,12 +115,15 @@ Then restart Home Assistant (**Developer Tools → YAML → Restart**).
1. Navigate to **Settings → Devices & Services → Add Integration**.
2. Search for **MyHOME**.
-3. **Auto-Discovery**: The integration will automatically discover UPnP/SSDP-compatible gateways on your local subnet (e.g. F454, MH202, MyHomeServer1).
-4. **Manual Configuration**: If using a legacy gateway without UPnP (e.g. MH200):
- - Enter your gateway IP address and port (default `20000`).
- - Provide the gateway MAC address (found on the physical device sticker).
- - Enter your OpenWebNet password (default `12345`).
-5. Select your gateway hardware profile from the dropdown if not auto-detected.
+3. Choose your gateway type:
+ - **Network Gateway (TCP/IP)**:
+ - **Auto-Discovery**: The integration automatically discovers UPnP/SSDP-compatible gateways on your local subnet (e.g. F454, MH202, MyHomeServer1).
+ - **Manual IP Setup**: For gateways without UPnP (e.g. MH200), enter the gateway IP address, port (default `20000`), MAC address, and OpenWebNet password (default `12345`).
+ - **USB / Serial Gateway (Legrand 3578 / OpenZigBee)**:
+ - Select your physical serial device (e.g. `/dev/ttyUSB0` or `COM3`) from the dynamically populated port picker.
+ - Select your baud rate (default `19200`).
+ - Serial transport operates with zero authentication overhead (no IP password challenge needed) and natively routes OpenZigBee addresses (`<8-digit id>#9`).
+4. Select or confirm your gateway hardware profile from the dropdown.
### Options Flow (Fine-Tuning)
From 31a447d79621bfabe711aeca5eb24e75ae0effd2 Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Wed, 9 Sep 2026 11:09:09 +0200
Subject: [PATCH 154/927] feat: restore myhome.yaml fallback, WHO 14 buttons,
and lovelace registration
---
README.md | 22 +-
custom_components/myhome/__init__.py | 90 +++++-
custom_components/myhome/button.py | 118 +++++---
custom_components/myhome/cover.py | 79 +++++-
custom_components/myhome/light.py | 106 +++++--
custom_components/myhome/validate.py | 38 +--
docs/openwebnet-who-specifications.md | 126 +++++++++
tests/test_component_button.py | 52 ++++
tests/test_component_cover.py | 246 +++++++++++++++++
tests/test_component_light.py | 59 ++++
tests/test_init.py | 175 +++++++++++-
tests/test_message_edges.py | 89 +++++-
tests/test_myhome_yaml_fallback.py | 379 ++++++++++++++++++++++++++
13 files changed, 1480 insertions(+), 99 deletions(-)
create mode 100644 docs/openwebnet-who-specifications.md
create mode 100644 tests/test_component_cover.py
create mode 100644 tests/test_myhome_yaml_fallback.py
diff --git a/README.md b/README.md
index 20c76162..d4e03f96 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@
[](https://github.com/OpenWebNet-HA/MyHOME/actions/workflows/pypi_standards.yml)
[](https://hacs.xyz)
[](https://www.python.org/)
+[](https://github.com/OpenWebNet-HA/MyHOME/wiki/OpenWebNet-Protocol-&-WHO-Specifications)
[](https://www.gnu.org/licenses/gpl-3.0)
Modern, async-native Home Assistant integration for **BTicino / Legrand MyHOME** SCS bus systems connected via OpenWebNet IP gateways.
@@ -33,6 +34,21 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
---
+## 📚 Documentation & OpenWebNet Protocol Specifications (Wiki)
+
+We now maintain a comprehensive, community-curated **[GitHub Wiki](https://github.com/OpenWebNet-HA/MyHOME/wiki/OpenWebNet-Protocol-&-WHO-Specifications)** documenting the OpenWebNet protocol, hardware profiles, and WHO subsystem specifications:
+
+👉 **[OpenWebNet Protocol & WHO Specifications Wiki](https://github.com/OpenWebNet-HA/MyHOME/wiki/OpenWebNet-Protocol-&-WHO-Specifications)**
+
+### Key Wiki Resources & Current Status
+- **[WHO Specifications Archive & Status Matrix](https://github.com/OpenWebNet-HA/MyHOME/wiki/OpenWebNet-Protocol-&-WHO-Specifications#openwebnet-who-specifications-matrix)**: Complete catalog of all OpenWebNet WHO families (WHO 0 to WHO 1004) with official PDF documentation references, current implementation status, and frame syntax.
+- **[Hardware Gateway Profiles](https://github.com/OpenWebNet-HA/MyHOME/wiki/Gateway-Profiles)**: Deep dive into connection constraints, socket limits, pacing delays, and watchdog behaviors for MH200, MH200N, MH202, F454, F455, MyHomeServer1, and Legrand 3578.
+- **[Sound System 2.0 & Audio Matrix Guide](https://github.com/OpenWebNet-HA/MyHOME/wiki/Sound-System-2.0-&-Audio-Matrix)**: Setup instructions for F441/F441M matrices, room amplifier calibration, and Dynamic Proxy streaming.
+- **[Bus Monitor Lovelace Card](https://github.com/OpenWebNet-HA/MyHOME/wiki/Bus-Monitor-Lovelace-Card)**: Bus card installation, live frame decoding, diagnostic logging, and syntax injector reference.
+- **[Community Contribution Guide](https://github.com/OpenWebNet-HA/MyHOME/wiki/OpenWebNet-Protocol-&-WHO-Specifications#how-to-contribute-specifications)**: How to cross-check documentation versions and contribute missing WHO PDF specifications.
+
+---
+
## 🏛️ Supported Hardware
### Gateway Profiles
@@ -224,7 +240,7 @@ check-wheel-contents dist/*.whl
### 📊 Code Coverage & Quality Assurance
-The integration maintains over 720 automated tests (100% line coverage) covering core protocol handling, hardware profiles, discovery, state reconciliation, and error boundaries.
+The integration maintains 739 automated unit tests (100% line coverage across all modules) covering core protocol handling, hardware profiles, discovery, state reconciliation, and error boundaries.
@@ -254,8 +270,8 @@ The integration maintains over 720 automated tests (100% line coverage) covering
| [`switch.py`](custom_components/myhome/switch.py) | **100%** | Relay actuators, auxiliary switches, socket controllers |
| [`validate.py`](custom_components/myhome/validate.py) | **100%** | Device & gateway schemas, custom WHERE validators, sensor injections |
| [`websocket.py`](custom_components/myhome/websocket.py) | **100%** | WebSocket API for real-time bus streaming, history, and diagnostics |
-| [`ownd/message.py`](custom_components/myhome/ownd/message.py) | **99%** | OpenWebNet frame parsers, encoders, and dimension decoders |
-| [`__init__.py`](custom_components/myhome/__init__.py) | **99%** | Setup lifecycle and zero-friction entity migration |
+| [`ownd/message.py`](custom_components/myhome/ownd/message.py) | **100%** | OpenWebNet frame parsers, encoders, and dimension decoders |
+| [`__init__.py`](custom_components/myhome/__init__.py) | **100%** | Setup lifecycle and zero-friction entity migration |
diff --git a/custom_components/myhome/__init__.py b/custom_components/myhome/__init__.py
index cbbf05d5..519fdd7c 100644
--- a/custom_components/myhome/__init__.py
+++ b/custom_components/myhome/__init__.py
@@ -1,5 +1,6 @@
""" MyHOME integration. """
import asyncio
+import os
from .ownd.message import OWNCommand, OWNGatewayCommand
from .gateway import MyHOMEGatewayHandler
@@ -33,19 +34,15 @@
async def _async_register_frontend(hass: HomeAssistant) -> None:
"""Register the Lovelace bus monitor card static resource and script."""
- import os
domain_data = hass.data.setdefault(DOMAIN, {})
if domain_data.get("_frontend_registered"):
return
- http = getattr(hass, "http", None)
- if http is None:
- return
-
card_path = os.path.join(os.path.dirname(__file__), "frontend", "myhome-bus-card.js")
url_path = "/myhome_static/myhome-bus-card.js"
- if os.path.isfile(card_path):
+ http = getattr(hass, "http", None)
+ if http is not None and os.path.isfile(card_path):
if hasattr(http, "async_register_static_paths"):
try:
from homeassistant.components.http import StaticPathConfig
@@ -65,6 +62,21 @@ async def _async_register_frontend(hass: HomeAssistant) -> None:
domain_data["_frontend_registered"] = True
+ # Auto-register resource in Lovelace dashboard resources collection
+ try:
+ lovelace = hass.data.get("lovelace")
+ if lovelace:
+ resources = getattr(lovelace, "resources", None)
+ if resources and hasattr(resources, "async_create_item"):
+ existing = [item["url"] for item in resources.async_items()]
+ if url_path not in existing:
+ await resources.async_create_item({
+ "res_type": "module",
+ "url": url_path,
+ })
+ except Exception as e:
+ LOGGER.debug("Could not auto-register Lovelace resource: %s", e)
+
async def async_setup(hass, config):
"""Set up the MyHOME component."""
@@ -93,6 +105,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
CONF_ENTITIES: {p: {} for p in PLATFORMS},
}
+ # Load legacy myhome.yaml if present for seamless backward-compatibility
+ _opt_path = entry.options.get(CONF_FILE_PATH) or entry.options.get("file_path")
+ _config_file_path = str(_opt_path) if _opt_path else hass.config.path("myhome.yaml")
+ if not os.path.isfile(_config_file_path) and os.path.isfile("/config/myhome.yaml"):
+ _config_file_path = "/config/myhome.yaml"
+
+ if os.path.isfile(_config_file_path):
+ from homeassistant.util.yaml.loader import load_yaml
+ from .validate import config_schema
+ try:
+ raw_yaml = await hass.async_add_executor_job(load_yaml, _config_file_path)
+ if raw_yaml and isinstance(raw_yaml, dict):
+ # Ensure every gateway has mac and every device has where set if omitted
+ for gw_key, gw_val in raw_yaml.items():
+ if isinstance(gw_val, dict):
+ if CONF_MAC not in gw_val:
+ gw_val[CONF_MAC] = str(gw_key)
+ for plat, devs in gw_val.items():
+ if isinstance(devs, dict):
+ for d_key, d_val in devs.items():
+ if isinstance(d_val, dict) and "where" not in d_val and "zone" not in d_val:
+ d_val["where"] = str(d_key)
+
+ _validated = config_schema(raw_yaml)
+ formatted_entry_mac = dr.format_mac(entry.data[CONF_MAC])
+ mac_key = None
+ if formatted_entry_mac in _validated:
+ mac_key = formatted_entry_mac
+ elif entry.data[CONF_MAC] in _validated:
+ mac_key = entry.data[CONF_MAC]
+ else:
+ for k in _validated.keys():
+ try:
+ if dr.format_mac(k) == formatted_entry_mac:
+ mac_key = k
+ break
+ except Exception:
+ continue
+ if mac_key and mac_key in _validated:
+ yaml_platforms = _validated[mac_key].get(CONF_PLATFORMS, {})
+ for plat, devices in yaml_platforms.items():
+ if plat in hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS]:
+ for d_id, d_cfg in devices.items():
+ hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS][plat][d_id] = d_cfg
+ if isinstance(d_cfg, dict) and "where" in d_cfg:
+ hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS][plat][str(d_cfg["where"])] = d_cfg
+ LOGGER.info("Loaded legacy myhome.yaml configuration for gateway %s (%s platforms)", entry.data[CONF_MAC], len(yaml_platforms))
+ except Exception as e:
+ LOGGER.error("Failed to parse myhome.yaml from %s: %s", _config_file_path, e)
+
_generate_events = (
entry.options.get(CONF_GENERATE_EVENTS, False)
)
@@ -143,10 +205,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
except ValueError as e:
LOGGER.warning("Could not auto-migrate entity %s to %s: %s", reg_entry.entity_id, new_unique_id, e)
+ # Also migrate matching device in device_registry if present so custom device names and areas are preserved
+ device_registry = dr.async_get(hass)
+ old_device = (
+ device_registry.async_get_device(identifiers={(DOMAIN, f"{_mac}-{where_part}")})
+ or device_registry.async_get_device(identifiers={(DOMAIN, f"{entry.data[CONF_MAC]}-{where_part}")})
+ or (device_registry.async_get(reg_entry.device_id) if reg_entry.device_id else None)
+ )
+ if old_device:
+ try:
+ device_registry.async_update_device(
+ old_device.id,
+ new_identifiers={(DOMAIN, f"{_mac}-{who}-{where_part}")},
+ )
+ except Exception as e:
+ LOGGER.warning("Could not auto-migrate device %s to new identifier: %s", old_device.id, e)
# Hack to forcefully absorb customize.yaml for users who deleted their integrations
# and therefore lost the transparent entity_registry migration!
- import os
from homeassistant.util.yaml.loader import load_yaml
hass.data[DOMAIN]["customizations"] = {}
diff --git a/custom_components/myhome/button.py b/custom_components/myhome/button.py
index ad4dbc6b..0c1a2389 100644
--- a/custom_components/myhome/button.py
+++ b/custom_components/myhome/button.py
@@ -17,6 +17,8 @@
CONF_ENTITIES,
EntityCategory,
)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
CONF_PLATFORMS,
@@ -32,66 +34,98 @@
async def async_setup_entry(hass, config_entry, async_add_entities):
- if PLATFORM not in hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS]:
+ mac = config_entry.data.get(CONF_MAC)
+ if not mac or mac not in hass.data.get(DOMAIN, {}):
+ return True
+ if PLATFORM not in hass.data[DOMAIN][mac].get(CONF_PLATFORMS, {}):
return True
_buttons = []
- _configured_buttons = hass.data[DOMAIN][config_entry.data[CONF_MAC]][
- CONF_PLATFORMS
- ][PLATFORM]
+ _configured_buttons = hass.data[DOMAIN][mac][CONF_PLATFORMS][PLATFORM]
+ gateway = hass.data[DOMAIN][mac].get(CONF_ENTITY)
- for _button in list(_configured_buttons.keys()):
- _disable_button = DisableCommandButtonEntity(
+ known_button_actuators = set()
+
+ def _create_buttons_for_device(dev_id, cfg):
+ who = str(cfg.get(CONF_WHO, "1"))
+ where = str(cfg.get(CONF_WHERE, dev_id))
+ interface = cfg.get(CONF_BUS_INTERFACE) if CONF_BUS_INTERFACE in cfg else cfg.get("interface")
+ if not where or str(where).startswith("#"):
+ return []
+
+ actuator_key = f"{who}-{where}#4#{interface}" if interface else f"{who}-{where}"
+ if actuator_key in known_button_actuators:
+ return []
+ known_button_actuators.add(actuator_key)
+
+ name = cfg.get(CONF_NAME, f"Device {where}")
+ manufacturer = cfg.get(CONF_MANUFACTURER, "BTicino")
+ model = cfg.get(CONF_DEVICE_MODEL, "Actuator")
+ device_id = dev_id or (f"{where}#4#{interface}" if interface else str(where))
+
+ disable_button = DisableCommandButtonEntity(
hass=hass,
platform=PLATFORM,
- device_id=_button,
- who=_configured_buttons[_button][CONF_WHO],
- where=_configured_buttons[_button][CONF_WHERE],
- interface=(
- _configured_buttons[_button][CONF_BUS_INTERFACE]
- if CONF_BUS_INTERFACE in _configured_buttons[_button]
- else None
- ),
- name=_configured_buttons[_button][CONF_NAME],
- manufacturer=_configured_buttons[_button][CONF_MANUFACTURER],
- model=_configured_buttons[_button][CONF_DEVICE_MODEL],
- gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
+ device_id=device_id,
+ who=who,
+ where=where,
+ interface=interface,
+ name=name,
+ manufacturer=manufacturer,
+ model=model,
+ gateway=gateway,
)
- _buttons.append(_disable_button)
-
- _enable_button = EnableCommandButtonEntity(
+ enable_button = EnableCommandButtonEntity(
hass=hass,
platform=PLATFORM,
- device_id=_button,
- who=_configured_buttons[_button][CONF_WHO],
- where=_configured_buttons[_button][CONF_WHERE],
- interface=(
- _configured_buttons[_button][CONF_BUS_INTERFACE]
- if CONF_BUS_INTERFACE in _configured_buttons[_button]
- else None
- ),
- name=_configured_buttons[_button][CONF_NAME],
- manufacturer=_configured_buttons[_button][CONF_MANUFACTURER],
- model=_configured_buttons[_button][CONF_DEVICE_MODEL],
- gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
+ device_id=device_id,
+ who=who,
+ where=where,
+ interface=interface,
+ name=name,
+ manufacturer=manufacturer,
+ model=model,
+ gateway=gateway,
)
- _buttons.append(_enable_button)
+ return [disable_button, enable_button]
- async_add_entities(_buttons)
+ for _button in list(_configured_buttons.keys()):
+ _buttons.extend(_create_buttons_for_device(_button, _configured_buttons[_button]))
+
+ if _buttons:
+ async_add_entities(_buttons)
+
+ @callback
+ def _async_new_device_listener(dev_info):
+ """Add lock/unlock buttons dynamically for newly discovered or configured devices."""
+ new_btns = _create_buttons_for_device(dev_info.get("device_id"), dev_info)
+ if new_btns:
+ async_add_entities(new_btns)
+
+ if hasattr(config_entry, "async_on_unload"):
+ config_entry.async_on_unload(
+ async_dispatcher_connect(
+ hass,
+ f"myhome_new_device_{mac}",
+ _async_new_device_listener,
+ )
+ )
+
+ return True
async def async_unload_entry(hass, config_entry):
- if PLATFORM not in hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS]:
+ mac = config_entry.data.get(CONF_MAC)
+ if not mac or mac not in hass.data.get(DOMAIN, {}):
+ return True
+ if PLATFORM not in hass.data[DOMAIN][mac].get(CONF_PLATFORMS, {}):
return True
- _configured_buttons = hass.data[DOMAIN][config_entry.data[CONF_MAC]][
- CONF_PLATFORMS
- ][PLATFORM]
+ _configured_buttons = hass.data[DOMAIN][mac][CONF_PLATFORMS][PLATFORM]
for _button in list(_configured_buttons.keys()):
- del hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][PLATFORM][
- _button
- ]
+ del hass.data[DOMAIN][mac][CONF_PLATFORMS][PLATFORM][_button]
+ return True
class DisableCommandButtonEntity(ButtonEntity, MyHOMEEntity):
diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py
index 6ead18ba..ec055e00 100644
--- a/custom_components/myhome/cover.py
+++ b/custom_components/myhome/cover.py
@@ -49,6 +49,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
restored_covers = []
gateway = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY]
+ _configured_covers = hass.data[DOMAIN][config_entry.data[CONF_MAC]].get(CONF_PLATFORMS, {}).get(PLATFORM, {})
for entry in existing_entries:
if entry.domain == PLATFORM:
@@ -68,22 +69,70 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
interface = None
clean_where = where.split('-')[-1]
+ cfg = _configured_covers.get(device_id) or _configured_covers.get(where) or _configured_covers.get(clean_where) or {}
+ _advanced = cfg.get("advanced_shutter", cfg.get(CONF_ADVANCED_SHUTTER, False))
+ _name = cfg.get(CONF_NAME, f"Cover {clean_where}")
_cover = MyHOMECover(
hass=hass,
- name=f"Cover {clean_where}",
- entity_name=None,
+ name=_name,
+ entity_name=cfg.get(CONF_ENTITY_NAME),
device_id=device_id,
who="2",
where=where,
interface=interface,
- advanced=False,
- manufacturer="BTicino",
- model="Shutter / Cover",
+ advanced=_advanced,
+ manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=cfg.get(CONF_DEVICE_MODEL, "Shutter / Cover"),
gateway=gateway,
)
known_covers.add(device_id)
restored_covers.append(_cover)
+ # Also instantiate any configured covers from myhome.yaml not yet in registry
+ seen_configured_where = set()
+ for dev_id, cfg in _configured_covers.items():
+ where = str(cfg.get(CONF_WHERE, dev_id))
+ interface = cfg.get(CONF_BUS_INTERFACE)
+ device_where_id = f"{where}#4#{interface}" if interface else str(where)
+ clean_where = where.split("-")[-1]
+
+ if clean_where in seen_configured_where or device_where_id in known_covers or dev_id in known_covers:
+ continue
+ seen_configured_where.add(clean_where)
+
+ _name = cfg.get(CONF_NAME, f"Cover {clean_where}")
+ _advanced = cfg.get("advanced_shutter", cfg.get(CONF_ADVANCED_SHUTTER, False))
+ _cover = MyHOMECover(
+ hass=hass,
+ name=_name,
+ entity_name=cfg.get(CONF_ENTITY_NAME),
+ device_id=device_where_id,
+ who=str(cfg.get(CONF_WHO, "2")),
+ where=where,
+ interface=interface,
+ advanced=_advanced,
+ manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=cfg.get(CONF_DEVICE_MODEL, "Shutter / Cover"),
+ gateway=gateway,
+ )
+ known_covers.add(device_where_id)
+ known_covers.add(dev_id)
+ known_covers.add(clean_where)
+ restored_covers.append(_cover)
+
+ # Signal button platform to create Lock/Unlock buttons
+ async_dispatcher_send(
+ hass,
+ f"myhome_new_device_{config_entry.data[CONF_MAC]}",
+ {
+ "who": str(cfg.get(CONF_WHO, "2")),
+ "where": where,
+ "interface": interface,
+ "name": _name,
+ "device_id": device_where_id,
+ },
+ )
+
if restored_covers:
async_add_entities(restored_covers)
@@ -104,22 +153,32 @@ def async_add_cover(message):
if unique_id not in known_covers:
# We found a new cover!
clean_where = where.split('-')[-1]
+ cfg = _configured_covers.get(unique_id) or _configured_covers.get(where) or _configured_covers.get(clean_where) or {}
+ _advanced = cfg.get("advanced_shutter", cfg.get(CONF_ADVANCED_SHUTTER, False))
+ _name = cfg.get(CONF_NAME, f"Cover {clean_where}")
_cover = MyHOMECover(
hass=hass,
- name=f"Cover {clean_where}",
- entity_name=None,
+ name=_name,
+ entity_name=cfg.get(CONF_ENTITY_NAME),
device_id=unique_id,
who=str(message.who),
where=where,
interface=interface,
- advanced=False, # Can be handled by OptionsFlow overrides later
- manufacturer="BTicino",
- model="Shutter / Cover",
+ advanced=_advanced,
+ manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=cfg.get(CONF_DEVICE_MODEL, "Shutter / Cover"),
gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
)
known_covers.add(unique_id)
async_add_entities([_cover])
_cover.handle_event(message)
+
+ # Signal button platform to create Lock/Unlock buttons if not present
+ async_dispatcher_send(
+ hass,
+ f"myhome_new_device_{config_entry.data[CONF_MAC]}",
+ {"who": "2", "where": where, "interface": interface, "name": _name, "device_id": unique_id}
+ )
async_dispatcher_send(hass, f"myhome_update_{config_entry.data[CONF_MAC]}_2_{unique_id}", message)
diff --git a/custom_components/myhome/light.py b/custom_components/myhome/light.py
index 1b07f42b..6ccf7aaf 100644
--- a/custom_components/myhome/light.py
+++ b/custom_components/myhome/light.py
@@ -66,6 +66,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
restored_lights = []
gateway = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY]
+ _configured_lights = hass.data[DOMAIN][config_entry.data[CONF_MAC]].get(CONF_PLATFORMS, {}).get(PLATFORM, {})
for entry in existing_entries:
if entry.domain == PLATFORM:
@@ -84,30 +85,84 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
where = device_id
interface = None
- _customs = hass.data.get(DOMAIN, {}).get("customizations", {})
clean_where = where.split('-')[-1]
+ cfg = _configured_lights.get(device_id) or _configured_lights.get(where) or _configured_lights.get(clean_where) or {}
+
+ _customs = hass.data.get(DOMAIN, {}).get("customizations", {})
_predicted_id = f"light.light_{clean_where.replace(' ', '_')}"
_custom_entry = _customs.get(entry.entity_id, {}) or _customs.get(_predicted_id, {})
- _is_dimmable = _custom_entry.get("dimmable", False)
+ _is_dimmable = cfg.get(CONF_DIMMABLE, _custom_entry.get("dimmable", False))
+ _name = cfg.get(CONF_NAME, f"Light {clean_where}")
+ _entity_name = cfg.get(CONF_ENTITY_NAME)
+ _icon = cfg.get(CONF_ICON)
+ _icon_on = cfg.get(CONF_ICON_ON)
+ _manufacturer = cfg.get(CONF_MANUFACTURER, "BTicino")
+ _model = cfg.get(CONF_DEVICE_MODEL, "Lighting Device")
_light = MyHOMELight(
hass=hass,
- name=f"Light {clean_where}",
- entity_name=None,
- icon=None,
- icon_on=None,
+ name=_name,
+ entity_name=_entity_name,
+ icon=_icon,
+ icon_on=_icon_on,
device_id=device_id,
who="1",
where=where,
interface=interface,
dimmable=_is_dimmable,
- manufacturer="BTicino",
- model="Lighting Device",
+ manufacturer=_manufacturer,
+ model=_model,
gateway=gateway,
)
known_lights.add(device_id)
restored_lights.append(_light)
+ # Also instantiate any configured lights from myhome.yaml not yet in registry
+ seen_configured_where = set()
+ for dev_id, cfg in _configured_lights.items():
+ where = str(cfg.get(CONF_WHERE, dev_id))
+ interface = cfg.get(CONF_BUS_INTERFACE)
+ device_where_id = f"{where}#4#{interface}" if interface else str(where)
+ clean_where = where.split("-")[-1]
+
+ if clean_where in seen_configured_where or device_where_id in known_lights or dev_id in known_lights:
+ continue
+ seen_configured_where.add(clean_where)
+
+ _name = cfg.get(CONF_NAME, f"Light {clean_where}")
+ _light = MyHOMELight(
+ hass=hass,
+ name=_name,
+ entity_name=cfg.get(CONF_ENTITY_NAME),
+ icon=cfg.get(CONF_ICON),
+ icon_on=cfg.get(CONF_ICON_ON),
+ device_id=device_where_id,
+ who=str(cfg.get(CONF_WHO, "1")),
+ where=where,
+ interface=interface,
+ dimmable=cfg.get(CONF_DIMMABLE, False),
+ manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=cfg.get(CONF_DEVICE_MODEL, "Lighting Device"),
+ gateway=gateway,
+ )
+ known_lights.add(device_where_id)
+ known_lights.add(dev_id)
+ known_lights.add(clean_where)
+ restored_lights.append(_light)
+
+ # Signal button platform to create Lock/Unlock buttons
+ async_dispatcher_send(
+ hass,
+ f"myhome_new_device_{config_entry.data[CONF_MAC]}",
+ {
+ "who": str(cfg.get(CONF_WHO, "1")),
+ "where": where,
+ "interface": interface,
+ "name": _name,
+ "device_id": device_where_id,
+ },
+ )
+
if restored_lights:
async_add_entities(restored_lights)
@@ -127,10 +182,13 @@ def async_add_light(message):
if unique_id not in known_lights:
# We found a new light!
- _customs = hass.data.get(DOMAIN, {}).get("customizations", {})
clean_where = where.split('-')[-1]
+ cfg = _configured_lights.get(unique_id) or _configured_lights.get(where) or _configured_lights.get(clean_where) or {}
+
+ _customs = hass.data.get(DOMAIN, {}).get("customizations", {})
_predicted_id = f"light.light_{clean_where.replace(' ', '_')}"
- _is_dimmable = _customs.get(_predicted_id, {}).get("dimmable", False)
+ _custom_entry = _customs.get(_predicted_id, {})
+ _is_dimmable = cfg.get(CONF_DIMMABLE, _custom_entry.get("dimmable", False))
# Auto-detect dimmer from the first protocol message
if not _is_dimmable:
@@ -139,25 +197,39 @@ def async_add_light(message):
or message.brightness_preset is not None
)
+ _name = cfg.get(CONF_NAME, f"Light {clean_where}")
+ _entity_name = cfg.get(CONF_ENTITY_NAME)
+ _icon = cfg.get(CONF_ICON)
+ _icon_on = cfg.get(CONF_ICON_ON)
+ _manufacturer = cfg.get(CONF_MANUFACTURER, "BTicino")
+ _model = cfg.get(CONF_DEVICE_MODEL, "Lighting Device")
+
_light = MyHOMELight(
hass=hass,
- name=f"Light {clean_where}",
- entity_name=None,
- icon=None,
- icon_on=None,
+ name=_name,
+ entity_name=_entity_name,
+ icon=_icon,
+ icon_on=_icon_on,
device_id=unique_id,
who=str(message.who),
where=where,
interface=interface,
dimmable=_is_dimmable,
- manufacturer="BTicino",
- model="Lighting Device",
+ manufacturer=_manufacturer,
+ model=_model,
gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
)
known_lights.add(unique_id)
async_add_entities([_light])
_light.handle_event(message)
-
+
+ # Signal button platform to create Lock/Unlock buttons if not present
+ async_dispatcher_send(
+ hass,
+ f"myhome_new_device_{config_entry.data[CONF_MAC]}",
+ {"who": "1", "where": where, "interface": interface, "name": _name, "device_id": unique_id}
+ )
+
async_dispatcher_send(hass, f"myhome_update_{config_entry.data[CONF_MAC]}_1_{unique_id}", message)
@callback
diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py
index 0e120c39..5be5f089 100644
--- a/custom_components/myhome/validate.py
+++ b/custom_components/myhome/validate.py
@@ -177,30 +177,31 @@ def __call__(self, data):
data = super().__call__(data)
_rekeyed_data = {}
for gateway in data:
- _rekeyed_data[data[gateway][CONF_MAC]] = {}
- _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS] = {}
+ gateway_mac = data[gateway].get(CONF_MAC) or format_mac(gateway) or gateway
+ _rekeyed_data[gateway_mac] = {}
+ _rekeyed_data[gateway_mac][CONF_PLATFORMS] = {}
for platform in data[gateway]:
if platform != CONF_MAC:
- _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][platform] = data[gateway][platform]
+ _rekeyed_data[gateway_mac][CONF_PLATFORMS][platform] = data[gateway][platform]
if (
- (LIGHT in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS])
- or (SWITCH in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS])
- or (COVER in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS])
+ (LIGHT in _rekeyed_data[gateway_mac][CONF_PLATFORMS])
+ or (SWITCH in _rekeyed_data[gateway_mac][CONF_PLATFORMS])
+ or (COVER in _rekeyed_data[gateway_mac][CONF_PLATFORMS])
):
- _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][BUTTON] = {}
- if LIGHT in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS]:
- for key, value in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][LIGHT].items():
+ _rekeyed_data[gateway_mac][CONF_PLATFORMS][BUTTON] = {}
+ if LIGHT in _rekeyed_data[gateway_mac][CONF_PLATFORMS]:
+ for key, value in _rekeyed_data[gateway_mac][CONF_PLATFORMS][LIGHT].items():
if not value[CONF_WHERE].startswith("#"):
- _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][BUTTON][key] = value
- if SWITCH in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS]:
- for key, value in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][SWITCH].items():
+ _rekeyed_data[gateway_mac][CONF_PLATFORMS][BUTTON][key] = value
+ if SWITCH in _rekeyed_data[gateway_mac][CONF_PLATFORMS]:
+ for key, value in _rekeyed_data[gateway_mac][CONF_PLATFORMS][SWITCH].items():
if not value[CONF_WHERE].startswith("#"):
- _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][BUTTON][key] = value
- if COVER in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS]:
- for key, value in _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][COVER].items():
+ _rekeyed_data[gateway_mac][CONF_PLATFORMS][BUTTON][key] = value
+ if COVER in _rekeyed_data[gateway_mac][CONF_PLATFORMS]:
+ for key, value in _rekeyed_data[gateway_mac][CONF_PLATFORMS][COVER].items():
if not value[CONF_WHERE].startswith("#"):
- _rekeyed_data[data[gateway][CONF_MAC]][CONF_PLATFORMS][BUTTON][key] = value
+ _rekeyed_data[gateway_mac][CONF_PLATFORMS][BUTTON][key] = value
return _rekeyed_data
@@ -234,6 +235,8 @@ def __call__(self, data):
data[device][CONF_ICON_ON] = None
if CONF_ENTITY_NAME not in data[device]:
data[device][CONF_ENTITY_NAME] = None
+ if "advanced_shutter" in data[device] and data[device]["advanced_shutter"]:
+ data[device][CONF_ADVANCED_SHUTTER] = True
return _rekeyed_data
@@ -336,6 +339,7 @@ def __call__(self, data):
Required(CONF_NAME): str,
Optional(CONF_ENTITY_NAME): str,
Optional(CONF_ADVANCED_SHUTTER, default=False): Boolean(),
+ Optional("advanced_shutter", default=False): Boolean(),
Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str,
Optional(CONF_DEVICE_MODEL): Coerce(str),
}
@@ -429,7 +433,7 @@ def __call__(self, data):
# them in plain callables to force the subclass __call__ to run.
gateway_schema = Schema(
{
- Required(CONF_MAC): MacAddress(),
+ Optional(CONF_MAC): MacAddress(),
Optional(LIGHT): lambda v: light_schema(v),
Optional(SWITCH): lambda v: switch_schema(v),
Optional(COVER): lambda v: cover_schema(v),
diff --git a/docs/openwebnet-who-specifications.md b/docs/openwebnet-who-specifications.md
new file mode 100644
index 00000000..f4207143
--- /dev/null
+++ b/docs/openwebnet-who-specifications.md
@@ -0,0 +1,126 @@
+# OpenWebNet Protocol & WHO Specifications Archive
+
+Welcome to the **OpenWebNet Protocol & WHO Specifications Archive**. This document serves as the official open-access registry and cross-check inventory of all technical manuals, frame syntax, dimension definitions, and PDF specifications published by BTicino / Legrand for the OpenWebNet protocol across MyHOME systems.
+
+Historically, these technical specifications were distributed through the *MyOpen Community* portal (`myopen-legrandgroup.com` / `myopen-bticino.it`), which is no longer active. To ensure that developers, installers, and community members have permanent access to accurate protocol documentation, we maintain this centralized registry.
+
+An interactive version of this page is also hosted on our [GitHub Wiki](https://github.com/OpenWebNet-HA/MyHOME/wiki/OpenWebNet-Protocol-&-WHO-Specifications).
+
+---
+
+## 📋 Community Cross-Check & Call for Documentation
+
+If you are a professional installer or have an archive of BTicino/Legrand developer documents (such as **@xtimmy86x**, **@lyubomirtraykov**, or other community members), please help us cross-check and complete this archive:
+
+1. **Compare File Names and Version Numbers**: Look at the title page and updating history of your PDFs (e.g. "Last date modify", "Last version").
+2. **Check for Newer Revisions**: If your copy has a more recent date or version than listed below, please let us know!
+3. **Missing Families / Devices**: We are actively seeking official documentation for:
+ - **`WHO = 1` & `WHO = 24`**: Extended DALI / DALI-2 dimensions (tunable white, color temperature, RGB/RGBW ballasts, e.g. **F429G**).
+ - **`WHO = 14`**: Actuator diagnostics, hardware locks, and operational counters.
+ - **`WHO = 18` & `WHO = 22`**: Energy management, multi-tariff metering, and smart load control.
+ - **`WHO = 25`**: CEN+ extended scenario triggers and dry contact sensors (e.g. **3477**).
+ - Technical sheets and OpenWebNet programming guides for next-gen gateways (**F461**, **MyHomeServer1**).
+
+> **How to contribute:** You can share filenames, version dates, or documents by opening an issue on the [OpenWebNet-HA/MyHOME GitHub repository](https://github.com/OpenWebNet-HA/MyHOME/issues) or commenting directly in active protocol threads.
+
+---
+
+## 📚 Master WHO Family Inventory
+
+The table below catalogs every known OpenWebNet function family (`WHO`), its official Legrand document title, current known version, archive status, and Home Assistant platform mapping.
+
+### Status Legend
+- 🟢 **Archived & Verified**: Official PDF is preserved in our archive with full syntax verified.
+- 🟡 **Legacy Copy Available / Cross-Check Needed**: Legacy documentation or reverse-engineered definitions exist; seeking confirmation of the latest official version.
+- 🔴 **Needed / Missing**: Seeking the official Legrand/BTicino PDF specification.
+
+| WHO | Subsystem / Function | Official Document Title / Filename | Known Version & Date | Status | Home Assistant Entity / Platform | Notes & Supported Hardware |
+|:---:|:---|:---|:---:|:---:|:---|:---|
+| **0** | **Scenarios (Basic)** | `OpenWebNet_Community_Scenarios` / `WHO_0.pdf` | v1.0.0 (2006) | 🟡 | `event`, automations | 32 standard scenarios (e.g. 3551 4-button module). |
+| **1** | **Lighting (Illuminazione)** | `OpenWebNet_Community_1_Lighting` / `WHO_1.pdf` | v1.2.0 (2012) | 🟡 | `light` | ON, OFF, Dimming (1-100%, 10 levels, steps), Blink, Timer, Speed of transition. Extended lighting & DALI dimensions (tunable white / RGBW via F429/F429G). |
+| **2** | **Automation (Automazione)** | `OpenWebNet_Community_2_Automation` / `WHO_2.pdf` | v1.0.0 (2006) | 🟡 | `cover` | Roller shutters, venetian blinds, motorized curtains, gates. Standard UP/DOWN/STOP and advanced absolute positioning percentage (0-100%, Legrand 67557). |
+| **3** | **Load Control (Legacy)** | `OpenWebNet_Community_3_LoadControl` / `WHO_3.pdf` | v1.0.0 (2006) | 🟡 | `switch`, `sensor` | Priority-based load disconnection central unit (F421). Inhibit/force actuators. |
+| **4** | **Thermoregulation (Termoregolazione)** | `OpenWebNet_Community_Heating` / `WHO_4.pdf` | v1.1.0 (2008) | 🟡 | `climate`, `sensor` | 4-zone / 99-zone central units (3550), standalone thermostats (L/N/NT4691), external probe sensors (3475), manual/auto/antifreeze/off/protection modes, heating/cooling season toggle. |
+| **5** | **Burglar Alarm (Antifurto)** | `OpenWebNet_Community_BurglarAlarm` / `WHO_5.pdf` | v1.0.0 (2006) | 🟡 | `alarm_control_panel` | Central units (3485, 3486), partition arming/disarming, panic alarms, gas/water technical alarms, sensor zone status. |
+| **6** | **Door Entry Call & Lock** | `OpenWebNet_Community_DoorEntry` / `WHO_6.pdf` | v1.0.0 (2006) | 🟡 | `lock`, `switch`, `event` | Audio door entry calls, door lock release (`*6*10*##`), staircase light, camera switching, incoming call chimes. |
+| **7** | **Video Door Entry** | `OpenWebNet_Community_VideoDoorEntry` / `WHO_7.pdf` | v1.0.0 (2006) | 🟡 | `camera` | Video session establishment, camera selection, video stream routing over IP. |
+| **9** | **Auxiliary (Comandi Ausiliari)** | `OpenWebNet_Community_Auxiliary` / `WHO_9.pdf` | v1.0.0 (2006) | 🟡 | `switch` | Auxiliary channels (AUX 1 to AUX 9) for triggering remote relays, annunciators, or inter-system signals without occupying lighting addresses. |
+| **13** | **Gateway Management** | `OpenWebNet_Community_Gateway` / `WHO_13.pdf` | v1.0.0 (2006) | 🟡 | Core diagnostics | Date/time synchronization (`*#13**0*...`), firmware version query, IP configuration, MAC address, uptime, reboot command. |
+| **14** | **Actuator Diagnostics** | `OpenWebNet_Community_Diagnostics` / `WHO_14.pdf` | Pending check | 🔴 | Core diagnostics / maintenance | Actuator operating state queries, hardware fault reports, locking/unlocking APL endpoints, operating hours counters. |
+| **15** | **CEN Scenario Control** | `OpenWebNet_Community_CEN` / `WHO_15.pdf` | v1.0.0 (2006) | 🟡 | `event`, device triggers | Pushbutton scenario events: Press, Short Release, Extended Press (long push), Release after extended press. Scenarios 1-32, buttons 1-32 (3477, 3478, H4651, LN4652). |
+| **16** | **Sound Distribution (Diffusione Sonora)** | `OpenWebNet_Community_4_soundsystem_v1_0_1_EN.doc` / `WHO_16.pdf` | **v1.0.1 (2011-11-24)** | 🟢 | `media_player` | Multi-room audio matrix control: Amplifier ON/OFF, volume control (step & %), audio input source selection (RDS tuner, RCA/aux, USB, Bluetooth), equalizer (bass, treble, balance), station presets, RDS text (F441, F441M, F450, 3487). |
+| **17** | **Scenario Programmer** | `OpenWebNet_Community_ScenarioProgrammer` / `WHO_17.pdf` | v1.0.0 (2006) | 🟡 | `switch`, `event` | MH200 / MH200N / MH202 scenario programmer integration, enable/disable automated schedules, trigger macro executions. |
+| **18** | **Energy Management** | `OpenWebNet_Community_Energy` / `WHO_18.pdf` | v1.0.0 (2008) | 🟡 | `sensor` | Electricity, water, and gas pulse meters. Instantaneous power (W), cumulative active energy (kWh), current (mA), voltage (V), power factor, tariff periods (F520, F521, F522, F523, 3522). |
+| **22** | **Load Control (New Generation)** | `OpenWebNet_Community_LoadControlNew` / `WHO_22.pdf` | Pending check | 🔴 | `sensor`, `switch` | Modern smart load shedding central unit (F522/F523), dynamic threshold management, per-phase monitoring. |
+| **24** | **Lighting Management & DALI** | `OpenWebNet_Community_LightingManagement` / `WHO_24.pdf` | Pending check | 🔴 | `light`, `sensor` | Advanced lighting control, DALI ballast addressing, lux sensors, daylight harvesting, multi-sensor motion/illuminance reporting (Legrand 048834, F429G). |
+| **25** | **CEN+ Scenario Control & Dry Contacts** | `OpenWebNet_Community_CENPlus` / `WHO_25.pdf` | Pending check | 🟡 | `event`, `binary_sensor` | Extended CEN protocol with 256 buttons/scenarios, start/short/long press differentiation, dry contact interface telemetry (Legrand 3477 binary sensors, MH201). |
+| **1000** | **Automation Group Commands** | Embedded in WHO 2 | — | 🟡 | `cover` | Area and General group broadcast commands for automation. |
+| **1001** | **Lighting Group Commands** | Embedded in WHO 1 | — | 🟡 | `light` | Area and General group broadcast commands for lighting. |
+| **1004** | **Temperature Group Commands** | Embedded in WHO 4 | — | 🟡 | `climate` | Zone group commands for thermoregulation. |
+
+---
+
+## 🔌 Gateway Architecture & Hardware Profiles
+
+The table below outlines supported hardware gateways, transport layers, queue pacing requirements, and authentication mechanisms:
+
+| Model | Hardware Type | Transport | Default Port | Auth Protocol | Queue Pacing | Features & Firmware Notes |
+|:---|:---|:---|:---:|:---|:---:|:---|
+| **MH200** | Scenario Programmer | TCP/IP | 20000 | Open password / None | 150 ms | Embedded ARM, scenario scheduler, legacy buffer limits. |
+| **MH200N** | Scenario Programmer | TCP/IP | 20000 | Open password / None | 100 ms | Updated network interface, scenario engine. |
+| **MH201** | Scenario Controller | TCP/IP | 20000 | Open password / None | 80 ms | High-performance DIN-rail scenario controller. |
+| **MH202** | Scenario Programmer | TCP/IP | 20000 | Open password / None | 50 ms | High-speed ARM CPU, expanded memory and scenario memory. |
+| **F452** | Web Server IP | TCP/IP | 20000 | Open password / None | 150 ms | Early generation IP gateway. |
+| **F453AV** | Audio/Video Web Server | TCP/IP | 20000 | Open password / None | 120 ms | Supports door entry and basic web control. |
+| **F454** | Web Server IP | TCP/IP | 20000 | Open numeric password | 80 ms | Dual bus interface, widely deployed standard DIN gateway. |
+| **F455** | Advanced IP Gateway | TCP/IP | 20000 | Open numeric password | 50 ms | High-throughput industrial gateway. |
+| **MyHomeServer1** | Modern IoT Gateway | TCP/IP | 20000 | **HMAC-SHA2 (SHA-256)** | 30 ms | Fast SoC, alphanumeric credentials, cloud integration bridge. |
+| **F461** | Next-Gen DIN Server | TCP/IP | 20000 | Alphanumeric / HMAC | 20 ms | Latest generation BTicino DIN-rail server/gateway. |
+| **Legrand 3578** | OpenZigBee USB Interface | Serial USB | `/dev/ttyUSB*` | None (Serial bypass) | 40 ms | 19200 baud, 8N1, ZigBee wireless SCS bridge (`#` addressing). |
+
+---
+
+## 🔍 OpenWebNet Frame Syntax Quick Reference
+
+OpenWebNet messages always begin with `*` and end with `##`. Fields are separated by `*`:
+
+1. **Standard Command / Status Message**:
+ ```text
+ *WHO*WHAT*WHERE##
+ ```
+ *Example*: `*1*1*21##` (Turn ON light at address A=2, PL=1)
+
+2. **Dimension Request (Query)**:
+ ```text
+ *#WHO*WHERE*DIMENSION##
+ ```
+ *Example*: `*#4*1*0##` (Query measured temperature of Zone 1)
+
+3. **Dimension Writing (Command with Parameters)**:
+ ```text
+ *#WHO*WHERE*#DIMENSION*VAL1*VAL2*...*VALn##
+ ```
+ *Example*: `*#16*1*#1*30##` (Set volume of audio Amplifier 1 to 30%)
+
+4. **Dimension Response (Status Report)**:
+ ```text
+ *#WHO*WHERE*DIMENSION*VAL1*VAL2*...*VALn##
+ ```
+ *Example*: `*#4*1*0*0215*1##` (Zone 1 temperature is 21.5 °C, Heating mode)
+
+5. **Acknowledge (ACK / NACK)**:
+ - `*#*1##` : **ACK** (Command accepted by gateway/bus)
+ - `*#*0##` : **NACK** (Command rejected, syntax error, or buffer busy)
+
+---
+
+## 🛠️ Testing & Live Bus Capture in Home Assistant
+
+To capture raw OpenWebNet frames from physical hardware or diagnostic sessions:
+
+1. Add the Lovelace Bus Monitor Card to your dashboard:
+ ```yaml
+ type: custom:myhome-bus-card
+ ```
+2. Operate your devices (e.g. adjust a DALI color ballast, toggle a dry contact switch, or change audio volume).
+3. Click **`📋 Report Issue / Copy Trace`** to copy a sanitized diagnostics report with the exact bus frames.
diff --git a/tests/test_component_button.py b/tests/test_component_button.py
index b5d41fe1..ff2a41b1 100644
--- a/tests/test_component_button.py
+++ b/tests/test_component_button.py
@@ -262,3 +262,55 @@ async def test_button_entities_lifecycle_edge_cases(hass):
await dis_btn.async_will_remove_from_hass()
await en_btn.async_added_to_hass()
await en_btn.async_will_remove_from_hass()
+
+
+async def test_button_additional_edge_coverage(hass):
+ """Test button edge cases: missing mac in hass.data, invalid where."""
+ config_entry = MagicMock()
+ config_entry.data = {"mac": "nonexistent"}
+
+ # mac not in hass.data
+ hass.data = {}
+ assert await async_setup_entry(hass, config_entry, MagicMock()) is True
+ assert await async_unload_entry(hass, config_entry) is True
+
+ # configured button with where starting with '#' (should return empty list)
+ mock_gateway = MagicMock()
+ mock_gateway.mac = "mac1"
+ hass.data = {
+ DOMAIN: {
+ "mac1": {
+ "platforms": {
+ "button": {
+ "area_device": {
+ "who": "1",
+ "where": "#1",
+ }
+ }
+ },
+ "entity": mock_gateway,
+ }
+ }
+ }
+ config_entry.data = {"mac": "mac1"}
+ added_entities = []
+ await async_setup_entry(hass, config_entry, lambda ents: added_entities.extend(ents))
+ # Area button with '#' ignored
+ assert len(added_entities) == 0
+
+ # Test dynamic device listener
+ from homeassistant.helpers.dispatcher import async_dispatcher_send
+ async_dispatcher_send(
+ hass,
+ "myhome_new_device_mac1",
+ {"who": "1", "where": "21", "name": "Light 21", "device_id": "21"},
+ )
+ assert len(added_entities) == 2 # lock + unlock
+
+ # Send duplicate device event (triggers line 58 return [])
+ async_dispatcher_send(
+ hass,
+ "myhome_new_device_mac1",
+ {"who": "1", "where": "21", "name": "Light 21", "device_id": "21"},
+ )
+ assert len(added_entities) == 2
diff --git a/tests/test_component_cover.py b/tests/test_component_cover.py
new file mode 100644
index 00000000..28fd11fb
--- /dev/null
+++ b/tests/test_component_cover.py
@@ -0,0 +1,246 @@
+"""Tests for the MyHOME cover component."""
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from homeassistant.const import (
+ CONF_NAME,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.components.cover import (
+ CoverDeviceClass,
+ CoverEntityFeature,
+ ATTR_POSITION,
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from custom_components.myhome.const import (
+ DOMAIN,
+ CONF_PLATFORMS,
+ CONF_ENTITIES,
+ CONF_WHERE,
+ CONF_WHO,
+ CONF_BUS_INTERFACE,
+ CONF_ENTITY_NAME,
+ CONF_MANUFACTURER,
+ CONF_DEVICE_MODEL,
+ CONF_ADVANCED_SHUTTER,
+)
+from custom_components.myhome.cover import (
+ MyHOMECover,
+ PLATFORM,
+ async_setup_entry,
+ async_unload_entry,
+)
+from custom_components.myhome.ownd.message import (
+ OWNAutomationEvent,
+ OWNAutomationCommand,
+ OWNEvent,
+)
+
+
+@pytest.fixture
+def mock_gateway():
+ gw = MagicMock()
+ gw.mac = "00:03:50:00:12:34"
+ gw.unique_id = "00:03:50:00:12:34"
+ gw.log_id = "[Test Gateway]"
+ gw.send = AsyncMock()
+ gw.send_status_request = AsyncMock()
+ return gw
+
+
+async def test_cover_setup_restores_and_discovers(hass: HomeAssistant, mock_gateway):
+ """Test cover platform setup restoring from registry and discovering new devices."""
+ mac = mock_gateway.mac
+ hass.data = {
+ DOMAIN: {
+ mac: {
+ "entity": mock_gateway,
+ CONF_PLATFORMS: {
+ PLATFORM: {
+ "33": {
+ CONF_WHERE: "33",
+ CONF_NAME: "Configured Cover 33",
+ CONF_ADVANCED_SHUTTER: True,
+ },
+ "33_dup": {
+ CONF_WHERE: "33",
+ CONF_NAME: "Configured Cover 33 Duplicate",
+ },
+ "34#4#02": {
+ CONF_WHERE: "34",
+ CONF_BUS_INTERFACE: "02",
+ CONF_NAME: "Interface Cover 34",
+ },
+ }
+ },
+ }
+ }
+ }
+
+ config_entry = MagicMock()
+ config_entry.data = {"mac": mac}
+ config_entry.entry_id = "test_entry"
+
+ # Mock entity registry restore check
+ mock_er = MagicMock()
+ reg_1 = MagicMock()
+ reg_1.domain = PLATFORM
+ reg_1.unique_id = f"{mac}-2-21"
+
+ reg_2 = MagicMock()
+ reg_2.domain = PLATFORM
+ reg_2.unique_id = f"{mac}-2-22#4#01"
+
+ with patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), \
+ patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1, reg_2]):
+
+ added_entities = []
+
+ def fake_add_entities(entities):
+ added_entities.extend(entities)
+
+ await async_setup_entry(hass, config_entry, fake_add_entities)
+
+ # Restored (21, 22#4#01) + Configured from YAML (33, 34#4#02) = 4 covers
+ assert len(added_entities) == 4
+
+ # Test discovering a new cover via message dispatcher
+ new_cover_msg = OWNEvent.parse("*2*1*41##")
+ async_dispatcher_send(hass, f"myhome_message_{mac}", new_cover_msg)
+ assert len(added_entities) == 5
+
+ # Test discovering cover with interface
+ new_iface_msg = OWNEvent.parse("*2*1*42#4#02##")
+ async_dispatcher_send(hass, f"myhome_message_{mac}", new_iface_msg)
+ assert len(added_entities) == 6
+
+ # Sending message for existing cover triggers update signal rather than creating duplicate
+ async_dispatcher_send(hass, f"myhome_message_{mac}", new_cover_msg)
+ assert len(added_entities) == 6
+
+ # Skip messages for group, area, general, or without where
+ async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*2*1*#1##"))
+ async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*2*1*1##")) # area 1
+ async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*2*1*0##")) # general
+ bad_msg = OWNEvent.parse("*2*1*21##")
+ bad_msg._where = None
+ async_dispatcher_send(hass, f"myhome_message_{mac}", bad_msg)
+ assert len(added_entities) == 6
+
+ # Unload
+ assert await async_unload_entry(hass, config_entry) is True
+
+
+class TestMyHOMECoverEntity:
+ """Test MyHOMECover entity methods and features."""
+
+ @pytest.fixture
+ def basic_cover(self, hass, mock_gateway):
+ with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None):
+ cover = MyHOMECover(
+ hass=hass,
+ name="Basic Shutter",
+ entity_name="Basic Shutter",
+ device_id="21",
+ who="2",
+ where="21",
+ interface=None,
+ advanced=False,
+ manufacturer="BTicino",
+ model="Shutter",
+ gateway=mock_gateway,
+ )
+ cover.hass = hass
+ cover.async_schedule_update_ha_state = MagicMock()
+ return cover
+
+ @pytest.fixture
+ def advanced_cover(self, hass, mock_gateway):
+ with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None):
+ cover = MyHOMECover(
+ hass=hass,
+ name="Advanced Shutter",
+ entity_name="Advanced Shutter",
+ device_id="22#4#02",
+ who="2",
+ where="22",
+ interface="02",
+ advanced=True,
+ manufacturer="BTicino",
+ model="Advanced Shutter",
+ gateway=mock_gateway,
+ )
+ cover.hass = hass
+ cover.async_schedule_update_ha_state = MagicMock()
+ return cover
+
+ def test_cover_attributes(self, basic_cover, advanced_cover):
+ assert basic_cover.device_class == CoverDeviceClass.SHUTTER
+ assert basic_cover.supported_features == (
+ CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
+ )
+ assert basic_cover.extra_state_attributes["A"] == "2"
+ assert basic_cover.extra_state_attributes["PL"] == "1"
+ assert "Int" not in basic_cover.extra_state_attributes
+
+ assert advanced_cover.supported_features == (
+ CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ | CoverEntityFeature.STOP
+ | CoverEntityFeature.SET_POSITION
+ )
+ assert advanced_cover.extra_state_attributes["Int"] == "02"
+
+ async def test_async_lifecycle_and_update(self, basic_cover, hass):
+ basic_cover.async_on_remove = MagicMock()
+ await basic_cover.async_added_to_hass()
+ basic_cover.async_on_remove.assert_called_once()
+
+ await basic_cover.async_update()
+ basic_cover._gateway_handler.send_status_request.assert_awaited_once()
+
+ async def test_cover_commands(self, basic_cover, advanced_cover):
+ await basic_cover.async_open_cover()
+ basic_cover._gateway_handler.send.assert_awaited()
+
+ basic_cover._gateway_handler.send.reset_mock()
+ await basic_cover.async_close_cover()
+ basic_cover._gateway_handler.send.assert_awaited()
+
+ basic_cover._gateway_handler.send.reset_mock()
+ await basic_cover.async_stop_cover()
+ basic_cover._gateway_handler.send.assert_awaited()
+
+ # Set position
+ advanced_cover._gateway_handler.send.reset_mock()
+ await advanced_cover.async_set_cover_position(**{ATTR_POSITION: 45})
+ advanced_cover._gateway_handler.send.assert_awaited()
+
+ # Set position without ATTR_POSITION kwarg
+ advanced_cover._gateway_handler.send.reset_mock()
+ await advanced_cover.async_set_cover_position()
+ advanced_cover._gateway_handler.send.assert_not_called()
+
+ def test_handle_event(self, basic_cover):
+ # Opening event
+ msg_opening = OWNEvent.parse("*2*1*21##")
+ basic_cover.handle_event(msg_opening)
+ assert basic_cover.is_opening is True
+ assert basic_cover.is_closing is False
+
+ # Closing event
+ msg_closing = OWNEvent.parse("*2*2*21##")
+ basic_cover.handle_event(msg_closing)
+ assert basic_cover.is_opening is False
+ assert basic_cover.is_closing is True
+
+ # Position event
+ msg_pos = OWNEvent.parse("*#2*21*10*10*0*0*0##")
+ basic_cover.handle_event(msg_pos)
+ assert basic_cover.is_closed is True
+ assert basic_cover.current_cover_position == 0
+
+ # Runtime error in async_schedule_update_ha_state
+ basic_cover.async_schedule_update_ha_state.side_effect = RuntimeError("HA shutting down")
+ basic_cover.handle_event(msg_pos) # should not raise
diff --git a/tests/test_component_light.py b/tests/test_component_light.py
index 136504a3..fcc317a8 100644
--- a/tests/test_component_light.py
+++ b/tests/test_component_light.py
@@ -36,7 +36,66 @@
TRANSITION_MODE_AUTO,
TRANSITION_MODE_NATIVE,
TRANSITION_MODE_SOFTWARE,
+ CONF_PLATFORMS,
+ CONF_WHERE,
+ CONF_BUS_INTERFACE,
+ CONF_DIMMABLE,
+ CONF_ENTITY_NAME,
+ CONF_ICON,
+ CONF_ICON_ON,
)
+from homeassistant.const import CONF_NAME
+
+
+async def test_setup_configured_lights_from_yaml(hass):
+ """Test setup instantiating configured lights from YAML with duplicate handling."""
+ mock_gateway = MagicMock()
+ mock_gateway.mac = "mac"
+ hass.data = {
+ DOMAIN: {
+ "mac": {
+ "entity": mock_gateway,
+ CONF_PLATFORMS: {
+ "light": {
+ "14": {
+ CONF_WHERE: "14",
+ CONF_NAME: "Configured Light 14",
+ CONF_DIMMABLE: True,
+ CONF_ENTITY_NAME: "Light 14",
+ CONF_ICON: "mdi:lamp",
+ CONF_ICON_ON: "mdi:lamp-outline",
+ },
+ "14_dup": {
+ CONF_WHERE: "14",
+ },
+ "15#4#01": {
+ CONF_WHERE: "15",
+ CONF_BUS_INTERFACE: "01",
+ },
+ }
+ },
+ }
+ }
+ }
+ config_entry = MagicMock()
+ config_entry.data = {"mac": "mac"}
+ config_entry.entry_id = "test_entry"
+
+ with patch(
+ "custom_components.myhome.light.er.async_entries_for_config_entry",
+ return_value=[],
+ ), patch(
+ "custom_components.myhome.light.er.async_get",
+ return_value=MagicMock(),
+ ):
+ async_add_entities = MagicMock()
+ await async_setup_entry(hass, config_entry, async_add_entities)
+ async_add_entities.assert_called_once()
+ entities = async_add_entities.call_args[0][0]
+ assert len(entities) == 2
+ assert entities[0]._device_id == "14"
+ assert entities[1]._device_id == "15#4#01"
+
async def test_setup_and_unload_entry(hass):
"""Test setup dynamically restoring and dynamically creating lights."""
diff --git a/tests/test_init.py b/tests/test_init.py
index 163ccf9b..06b3360d 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -6,7 +6,7 @@
from homeassistant.config_entries import ConfigEntryState
from pytest_homeassistant_custom_component.common import MockConfigEntry
-from custom_components.myhome.const import DOMAIN
+from custom_components.myhome.const import DOMAIN, CONF_PLATFORMS
from custom_components.myhome.ownd.connection import OWNGateway
@@ -297,6 +297,12 @@ def fake_isfile(p):
return True
return real_isfile(p)
+ mock_dev_reg = MagicMock()
+ old_dev1 = MagicMock(id="dev1")
+ old_dev2 = MagicMock(id="dev2")
+ mock_dev_reg.async_get_device.side_effect = [old_dev1, None, old_dev2, None, None, None]
+ mock_dev_reg.async_update_device.side_effect = [None, Exception("Device update conflict")]
+
with patch(
"custom_components.myhome.gateway.OWNSession.test_connection",
return_value={"Success": True, "Message": None}
@@ -307,6 +313,9 @@ def fake_isfile(p):
), patch(
"homeassistant.helpers.entity_registry.async_get",
return_value=mock_entry_reg,
+ ), patch(
+ "homeassistant.helpers.device_registry.async_get",
+ return_value=mock_dev_reg,
), patch(
"homeassistant.helpers.entity_registry.async_entries_for_config_entry",
return_value=[reg_e1, reg_e2, reg_e3],
@@ -433,5 +442,169 @@ async def test_register_frontend_branches(hass: HomeAssistant):
assert hass.data[DOMAIN]["_frontend_registered"] is True
mock_http.register_static_path.assert_called_once()
+ # 5. async_register_static_paths raises exception and falls back to register_static_path
+ hass.data[DOMAIN]["_frontend_registered"] = False
+ mock_http.async_register_static_paths = AsyncMock(side_effect=Exception("Async static paths failed"))
+ mock_http.register_static_path.reset_mock()
+ await _async_register_frontend(hass)
+ assert hass.data[DOMAIN]["_frontend_registered"] is True
+ mock_http.register_static_path.assert_called_once()
+
+ # 6. Lovelace resource auto-registration (lines 69-78)
+ hass.data[DOMAIN]["_frontend_registered"] = False
+ mock_resources = MagicMock()
+ mock_resources.async_items.return_value = [{"url": "/other/resource.js"}]
+ mock_resources.async_create_item = AsyncMock()
+ mock_lovelace = MagicMock()
+ mock_lovelace.resources = mock_resources
+ hass.data["lovelace"] = mock_lovelace
+
+ await _async_register_frontend(hass)
+ mock_resources.async_create_item.assert_awaited_once_with({
+ "res_type": "module",
+ "url": "/myhome_static/myhome-bus-card.js",
+ })
+
+ # 7. Lovelace resource already exists
+ hass.data[DOMAIN]["_frontend_registered"] = False
+ mock_resources.async_items.return_value = [{"url": "/myhome_static/myhome-bus-card.js"}]
+ mock_resources.async_create_item.reset_mock()
+ await _async_register_frontend(hass)
+ mock_resources.async_create_item.assert_not_called()
+
+ # 8. Lovelace raises exception
+ hass.data[DOMAIN]["_frontend_registered"] = False
+ mock_resources.async_items.side_effect = Exception("Lovelace storage error")
+ await _async_register_frontend(hass)
+
+
+async def test_setup_entry_myhome_yaml_loading(hass: HomeAssistant):
+ """Test loading legacy myhome.yaml with all branches."""
+ import tempfile
+ from homeassistant.const import CONF_FILE_PATH
+
+ mac = "00:03:50:00:12:34"
+ sample_yaml = """00:03:50:00:12:34:
+ light:
+ '01':
+ name: 'Salon Plafonnier'
+ dimmable: true
+ cover:
+ '02':
+ name: 'Volet Salon'
+000350001239:
+ light:
+ '03':
+ name: 'Test'
+"""
+ with tempfile.NamedTemporaryFile("w", delete=False, suffix=".yaml") as tmp:
+ tmp.write(sample_yaml)
+ tmp_path = tmp.name
+
+ try:
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "host": "192.168.0.35",
+ "port": 20000,
+ "password": "pass",
+ "mac": mac,
+ },
+ options={
+ "file_path": tmp_path,
+ },
+ unique_id=mac,
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch("custom_components.myhome.gateway.OWNSession.test_connection", return_value={"Success": True, "Message": None}), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert "01" in hass.data[DOMAIN][mac][CONF_PLATFORMS]["light"]
+ assert "02" in hass.data[DOMAIN][mac][CONF_PLATFORMS]["cover"]
+
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Test mac matching branch: elif entry.data[CONF_MAC] in _validated
+ entry_raw_mac = MockConfigEntry(
+ domain=DOMAIN,
+ data={"host": "192.168.0.35", "port": 20000, "password": "pass", "mac": "000350001234"},
+ options={"file_path": tmp_path},
+ unique_id="00:03:50:00:12:34",
+ )
+ entry_raw_mac.add_to_hass(hass)
+ mock_val_raw = {"000350001234": {CONF_PLATFORMS: {"light": {"01": {"where": "01"}}}}}
+ with patch("custom_components.myhome.gateway.OWNSession.test_connection", return_value={"Success": True, "Message": None}), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"), \
+ patch("custom_components.myhome.validate.config_schema", return_value=mock_val_raw):
+ assert await hass.config_entries.async_setup(entry_raw_mac.entry_id)
+ await hass.async_block_till_done()
+ assert await hass.config_entries.async_unload(entry_raw_mac.entry_id)
+ await hass.async_block_till_done()
+
+ # Test mac matching branch: else for k in _validated.keys() with invalid mac exception
+ entry_fuzzy_mac = MockConfigEntry(
+ domain=DOMAIN,
+ data={"host": "192.168.0.35", "port": 20000, "password": "pass", "mac": "00:03:50:00:12:34"},
+ options={"file_path": tmp_path},
+ unique_id="00:03:50:00:12:34",
+ )
+ entry_fuzzy_mac.add_to_hass(hass)
+ mock_val_fuzzy = {
+ 12345: {},
+ "000350001234": {CONF_PLATFORMS: {"light": {"01": {"where": "01"}}}},
+ }
+ with patch("custom_components.myhome.gateway.OWNSession.test_connection", return_value={"Success": True, "Message": None}), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"), \
+ patch("custom_components.myhome.validate.config_schema", return_value=mock_val_fuzzy):
+ assert await hass.config_entries.async_setup(entry_fuzzy_mac.entry_id)
+ await hass.async_block_till_done()
+ assert await hass.config_entries.async_unload(entry_fuzzy_mac.entry_id)
+ await hass.async_block_till_done()
+
+
+ # Test fallback to /config/myhome.yaml (line 112)
+ config_entry_fallback = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "host": "192.168.0.35",
+ "port": 20000,
+ "password": "pass",
+ "mac": "00:03:50:00:12:35",
+ },
+ unique_id="00:03:50:00:12:35",
+ )
+ config_entry_fallback.add_to_hass(hass)
+
+ def fake_isfile_fallback(path):
+ if str(path) == "/config/myhome.yaml":
+ return True
+ if "customize.yaml" in str(path):
+ return False
+ return False
+
+ with patch("custom_components.myhome.gateway.OWNSession.test_connection", return_value={"Success": True, "Message": None}), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"), \
+ patch("os.path.isfile", side_effect=fake_isfile_fallback), \
+ patch("homeassistant.util.yaml.loader.load_yaml", side_effect=Exception("Corrupt YAML")):
+ assert await hass.config_entries.async_setup(config_entry_fallback.entry_id)
+ await hass.async_block_till_done()
+
+ assert await hass.config_entries.async_unload(config_entry_fallback.entry_id)
+ await hass.async_block_till_done()
+
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+
+
diff --git a/tests/test_message_edges.py b/tests/test_message_edges.py
index c4a3ade4..41cc9705 100644
--- a/tests/test_message_edges.py
+++ b/tests/test_message_edges.py
@@ -1,6 +1,7 @@
"""Tests targeting remaining uncovered branches in message.py to maximize coverage."""
import pytest
import datetime
+from unittest.mock import patch
from custom_components.myhome.ownd.message import (
OWNMessage,
OWNEvent,
@@ -897,8 +898,8 @@ def test_heating_command_central_local(self):
def test_gateway_broadcasting_no_tz(self):
# Empty tz field in dimension 22
- evt = OWNGatewayEvent("*#13**#22*12*14*58**03*15*04*2026##")
- assert evt._timezone == ""
+ cmd = OWNGatewayCommand("*#13**#22*12*14*58**03*15*04*2026##")
+ assert cmd._timezone == ""
def test_energy_commands(self):
now = datetime.date.today()
@@ -952,4 +953,88 @@ def test_signaling_methods(self):
sha256 = OWNSignaling("*98*2##")
assert sha256.is_sha_256() is True
+ assert sha256.sha_version == "2"
+
+ def test_remaining_message_edges(self):
+ # 1. sha_version on non-sha
+ ack = OWNSignaling("*#*1##")
+ assert ack.sha_version is None
+
+ # 2. event_content with interface & extra where params (line 248)
+ msg1 = OWNEvent.parse("*1*1*21#4#01#99##")
+ if msg1:
+ d1 = msg1.event_content
+ assert "where parameters" in d1
+
+ # 3. event_content with what params (line 254)
+ msg2 = OWNEvent.parse("*1*1#10*21##")
+ if msg2:
+ d2 = msg2.event_content
+ assert "what parameters" in d2
+
+ # 4. event_content with dimension params (line 258)
+ msg3 = OWNEvent.parse("*#4*1*#14#1*0220##")
+ if msg3:
+ d3 = msg3.event_content
+ assert "dimension parameters" in d3
+
+ # 5. is_area ValueError handling
+ msg_va = OWNEvent("*1*1*1##")
+ msg_va._where = "A"
+ assert msg_va.is_area is False
+
+ # 6. Energy dimension 511 with date in past and leap year ValueError
+ past_511 = OWNEnergyEvent.parse("*#18*71*511#1#1*1*100##")
+ assert past_511 is not None
+ assert past_511._type is not None
+
+ # Feb 29 in leap year 2024 with today Jan 1 -> _raw_message_date in future -> 2023-02-29 raises ValueError
+ import time_machine
+ with time_machine.travel("2024-01-01"):
+ leap_err = OWNEnergyEvent.parse("*#18*71*511#2#29*1*100##")
+ assert leap_err is not None
+ assert getattr(leap_err, "_type", None) is None
+
+ # 7. Energy dimension 513 future (1417-1421) and past (1436-1440)
+ future_513 = OWNEnergyEvent.parse("*#18*71*513#12*1*100##")
+ assert future_513 is not None
+ assert future_513._type is not None
+ past_513 = OWNEnergyEvent.parse("*#18*71*513#1*1*100##")
+ assert past_513 is not None
+ assert past_513._type is not None
+
+ # 8. Energy dimension 514 future (1424-1428), past (1430-1434), and invalid day in 513 (1441-1442)
+ future_514 = OWNEnergyEvent.parse("*#18*71*514#12*1*100##")
+ assert future_514 is not None
+ assert future_514._type is not None
+ past_514 = OWNEnergyEvent.parse("*#18*71*514#1*1*100##")
+ assert past_514 is not None
+ assert past_514._type is not None
+ invalid_513 = OWNEnergyEvent.parse("*#18*71*513#1*32*100##")
+ assert getattr(invalid_513, "_type", None) is None
+
+ # 9. Gateway date dimension 22 without timezone (line 1998)
+ gw_cmd_no_tz = OWNGatewayCommand("*#13**#22*12*14*58**03*15*04*2026##")
+ assert gw_cmd_no_tz._timezone == ""
+ gw_evt_no_tz = OWNGatewayEvent("*#13**22*12*14*58**03*15*04*2026##")
+ assert gw_evt_no_tz._timezone == ""
+
+ # 10. OWNEnergyCommand get_daily_consumption branches (lines 2174, 2180, 2182, 2184, 2186)
+ now = datetime.date.today()
+ # Future date -> None
+ assert OWNEnergyCommand.get_daily_consumption("1", now.year + 1, 1) is None
+ # Within last year
+ cmd_1y = OWNEnergyCommand.get_daily_consumption("71", now.year, now.month)
+ if cmd_1y:
+ assert "*18*59#" in str(cmd_1y)
+ # Between 1 and 2 years ago
+ month_15_ago = (now.month - 3) if now.month > 3 else (now.month + 9)
+ year_15_ago = now.year - 1 if now.month > 3 else now.year - 2
+ cmd_2y = OWNEnergyCommand.get_daily_consumption("1", year_15_ago, month_15_ago)
+ assert cmd_2y is not None
+ assert "*18*510#" in str(cmd_2y)
+ # Older than 2 years -> None
+ assert OWNEnergyCommand.get_daily_consumption("1", now.year - 3, 1) is None
+
+
diff --git a/tests/test_myhome_yaml_fallback.py b/tests/test_myhome_yaml_fallback.py
new file mode 100644
index 00000000..7d9043b5
--- /dev/null
+++ b/tests/test_myhome_yaml_fallback.py
@@ -0,0 +1,379 @@
+"""Tests for myhome.yaml backwards-compatibility fallback, WHO 14 buttons, and Lovelace registration."""
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+import pytest
+import yaml
+
+from homeassistant.components.cover import CoverEntityFeature
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.myhome.const import (
+ CONF_ADVANCED_SHUTTER,
+ CONF_DIMMABLE,
+ CONF_FILE_PATH,
+ CONF_PLATFORMS,
+ CONF_WHERE,
+ DOMAIN,
+)
+from custom_components.myhome.validate import config_schema
+
+
+SAMPLE_YAML_CONTENT = """
+00:03:50:81:22:33:
+ light:
+ living_room:
+ where: "12"
+ name: "Living Room Light"
+ dimmable: true
+ cover:
+ kitchen_blind:
+ where: "25"
+ name: "Kitchen Blind"
+ advanced_shutter: true
+"""
+
+
+def test_schema_without_mac_key_and_with_advanced_shutter():
+ """Test config_schema parses gateway without mac key and advanced_shutter alias."""
+ parsed = yaml.safe_load(SAMPLE_YAML_CONTENT)
+ validated = config_schema(parsed)
+
+ # Keyed by formatted mac
+ mac_key = "00:03:50:81:22:33"
+ assert mac_key in validated
+ assert "light" in validated[mac_key][CONF_PLATFORMS]
+ assert "cover" in validated[mac_key][CONF_PLATFORMS]
+ assert "button" in validated[mac_key][CONF_PLATFORMS]
+
+ # Verify cover has advanced_shutter parsed (rekeyed to who-where "2-25")
+ cover_cfg = validated[mac_key][CONF_PLATFORMS]["cover"]["2-25"]
+ assert cover_cfg.get("advanced_shutter") is True
+ assert cover_cfg.get("advanced") is True
+
+ # Verify buttons created for both light and cover
+ button_cfgs = validated[mac_key][CONF_PLATFORMS]["button"]
+ assert "1-12" in button_cfgs
+ assert "2-25" in button_cfgs
+
+
+async def test_setup_entry_with_yaml_fallback(hass: HomeAssistant, tmp_path):
+ """Test loading myhome.yaml fallback, entity seeding, and WHO 14 button pressing."""
+ yaml_file = tmp_path / "myhome.yaml"
+ yaml_file.write_text(SAMPLE_YAML_CONTENT, encoding="utf-8")
+
+ # Mock Lovelace resources collection
+ resources_list = []
+ mock_resources = MagicMock()
+ mock_resources.async_items = MagicMock(side_effect=lambda: list(resources_list))
+ async def _mock_create(item):
+ resources_list.append(item)
+ mock_resources.async_create_item = AsyncMock(side_effect=_mock_create)
+ hass.data["lovelace"] = MagicMock()
+ hass.data["lovelace"].resources = mock_resources
+
+ mac = "00:03:50:81:22:33"
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "host": "192.168.1.50",
+ "port": 20000,
+ "password": "12345",
+ "mac": mac,
+ "name": "F454",
+ "firmware": "2.0.0",
+ },
+ options={
+ CONF_FILE_PATH: str(yaml_file),
+ },
+ unique_id=mac,
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "custom_components.myhome.gateway.OWNSession.test_connection",
+ return_value={"Success": True, "Message": None},
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"
+ ):
+ mock_send = AsyncMock()
+ with patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.send", mock_send):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state is ConfigEntryState.LOADED
+
+ # Check Lovelace card registration was triggered
+ mock_resources.async_create_item.assert_called_once_with({
+ "res_type": "module",
+ "url": "/myhome_static/myhome-bus-card.js",
+ })
+
+ # Check entities were added
+ ent_reg = er.async_get(hass)
+ entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
+ domains = {e.domain for e in entries}
+ assert "light" in domains
+ assert "cover" in domains
+ assert "button" in domains
+
+ # Find buttons and test WHO 14 press
+ buttons = [e for e in entries if e.domain == "button"]
+ assert len(buttons) >= 4 # Lock + Unlock for light and cover
+
+ # Retrieve button entities from hass.data
+ platforms = hass.data[DOMAIN][mac][CONF_PLATFORMS]
+ assert "button" in platforms
+
+ # Test pressing Lock on light 12 -> sends *14*0*12##
+ button_entities = []
+ for dev_id, dev_data in platforms.get("button", {}).items():
+ if "entities" in dev_data:
+ button_entities.extend(dev_data["entities"].values())
+
+ # Find disable/enable button entities
+ for btn in button_entities:
+ await btn.async_press()
+
+ # Verify send was called with *14*
+ sent_frames = [str(call.args[0]) for call in mock_send.call_args_list]
+ assert any("*14*0*12##" in f for f in sent_frames)
+ assert any("*14*1*12##" in f for f in sent_frames)
+ assert any("*14*0*25##" in f for f in sent_frames)
+ assert any("*14*1*25##" in f for f in sent_frames)
+
+ # Cleanup
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+
+async def test_yaml_fallback_default_locations(hass: HomeAssistant, tmp_path):
+ """Test fallback search paths when file_path option is not provided."""
+ fake_config_dir = tmp_path / "config"
+ fake_config_dir.mkdir()
+ yaml_file = fake_config_dir / "myhome.yaml"
+ yaml_file.write_text(SAMPLE_YAML_CONTENT, encoding="utf-8")
+
+ mac = "00:03:50:81:22:33"
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "host": "192.168.1.50",
+ "port": 20000,
+ "password": "12345",
+ "mac": mac,
+ "name": "F454",
+ "firmware": "2.0.0",
+ },
+ options={},
+ unique_id=mac,
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch.object(hass.config, "path", return_value=str(yaml_file)), patch(
+ "custom_components.myhome.gateway.OWNSession.test_connection",
+ return_value={"Success": True, "Message": None},
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"
+ ):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Platforms should have been seeded from myhome.yaml
+ assert mac in hass.data[DOMAIN]
+ platforms = hass.data[DOMAIN][mac][CONF_PLATFORMS]
+ assert "light" in platforms
+ assert "cover" in platforms
+
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+
+async def test_device_registry_migration_from_legacy(hass: HomeAssistant):
+ """Test transparent migration of device_registry identifiers from {mac}-{where} to {mac}-{who}-{where}."""
+ mac = "00:03:50:81:22:33"
+ dev_reg = dr.async_get(hass)
+ ent_reg = er.async_get(hass)
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "host": "192.168.1.50",
+ "port": 20000,
+ "password": "12345",
+ "mac": mac,
+ "name": "F454",
+ "firmware": "2.0.0",
+ },
+ options={},
+ unique_id=mac,
+ )
+ config_entry.add_to_hass(hass)
+
+ # Create legacy device and entity
+ legacy_device = dev_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, f"{mac}-12")},
+ name="Old Light Device",
+ )
+ legacy_entity = ent_reg.async_get_or_create(
+ domain="light",
+ platform=DOMAIN,
+ unique_id=f"{mac}-12",
+ config_entry=config_entry,
+ device_id=legacy_device.id,
+ )
+
+ with patch(
+ "custom_components.myhome.gateway.OWNSession.test_connection",
+ return_value={"Success": True, "Message": None},
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"
+ ):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Check that entity unique_id was migrated
+ migrated_entry = ent_reg.async_get(legacy_entity.entity_id)
+ assert migrated_entry.unique_id == f"{mac}-1-12"
+
+ # Check that device identifier was migrated
+ updated_device = dev_reg.async_get(legacy_device.id)
+ assert (DOMAIN, f"{mac}-1-12") in updated_device.identifiers
+
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+
+async def test_lovelace_registration_exception(hass: HomeAssistant):
+ """Test exception handling during Lovelace resource auto-registration."""
+ mock_resources = MagicMock()
+ mock_resources.async_items.return_value = []
+ mock_resources.async_create_item = AsyncMock(side_effect=Exception("Lovelace storage write failure"))
+ hass.data["lovelace"] = MagicMock()
+ hass.data["lovelace"].resources = mock_resources
+
+ from custom_components.myhome import _async_register_frontend
+ # Should safely swallow exception and log debug without raising
+ await _async_register_frontend(hass)
+
+
+async def test_yaml_root_config_fallback_and_missing_where(hass: HomeAssistant, tmp_path):
+ """Test /config/myhome.yaml fallback, where-less device dictionaries, and unformatted MAC matching."""
+ yaml_content = """
+000350812233:
+ light:
+ "12":
+ name: "Light 12 No Where"
+"""
+ yaml_file = tmp_path / "myhome.yaml"
+ yaml_file.write_text(yaml_content, encoding="utf-8")
+
+ mac = "00:03:50:81:22:33"
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "host": "192.168.1.50",
+ "port": 20000,
+ "password": "12345",
+ "mac": mac,
+ "name": "F454",
+ "firmware": "2.0.0",
+ },
+ options={},
+ unique_id=mac,
+ )
+ config_entry.add_to_hass(hass)
+
+ real_isfile = os.path.isfile
+ def fake_isfile(path):
+ if str(path) == "/config/myhome.yaml":
+ return True
+ if str(path) == str(yaml_file):
+ return True
+ return real_isfile(path)
+
+ with patch("os.path.isfile", side_effect=fake_isfile), \
+ patch("homeassistant.util.yaml.loader.load_yaml", return_value=yaml.safe_load(yaml_content)), \
+ patch("custom_components.myhome.gateway.OWNSession.test_connection", return_value={"Success": True, "Message": None}), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"), \
+ patch("custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert "light" in hass.data[DOMAIN][mac][CONF_PLATFORMS]
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+
+async def test_yaml_load_error_and_device_migration_error(hass: HomeAssistant, tmp_path):
+ """Test graceful error handling when load_yaml or device migration fails."""
+ yaml_file = tmp_path / "bad.yaml"
+ yaml_file.write_text("corrupted content: [", encoding="utf-8")
+
+ mac = "00:03:50:81:22:33"
+ dev_reg = dr.async_get(hass)
+ ent_reg = er.async_get(hass)
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "host": "192.168.1.50",
+ "port": 20000,
+ "password": "12345",
+ "mac": mac,
+ "name": "F454",
+ "firmware": "2.0.0",
+ },
+ options={
+ CONF_FILE_PATH: str(yaml_file),
+ },
+ unique_id=mac,
+ )
+ config_entry.add_to_hass(hass)
+
+ # Legacy device to trigger migration error
+ legacy_device = dev_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, f"{mac}-14")},
+ name="Old Light 14",
+ )
+ ent_reg.async_get_or_create(
+ domain="light",
+ platform=DOMAIN,
+ unique_id=f"{mac}-14",
+ config_entry=config_entry,
+ device_id=legacy_device.id,
+ )
+
+ orig_update = dev_reg.async_update_device
+ call_count = [0]
+ def fake_update(*args, **kwargs):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ raise Exception("Database lock error")
+ return orig_update(*args, **kwargs)
+
+ with patch(
+ "custom_components.myhome.gateway.OWNSession.test_connection",
+ return_value={"Success": True, "Message": None},
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.listening_loop"
+ ), patch(
+ "custom_components.myhome.gateway.MyHOMEGatewayHandler.sending_loop"
+ ), patch.object(
+ dev_reg, "async_update_device", side_effect=fake_update
+ ):
+ # Should complete setup smoothly without crashing
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
From 67118a48a0f9dd5e4ab60a1521f47c52858e6abb Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Wed, 9 Sep 2026 11:19:06 +0200
Subject: [PATCH 155/927] fix(ci): replace time_machine dependency with mock
patch and add to workflow
---
.github/workflows/test-coverage.yaml | 2 +-
README.md | 4 ++--
docs/openwebnet-who-specifications.md | 34 ++++++++++++++++++---------
tests/test_message_edges.py | 10 ++++++--
4 files changed, 34 insertions(+), 16 deletions(-)
diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml
index 73e7ff09..75b2ddc4 100644
--- a/.github/workflows/test-coverage.yaml
+++ b/.github/workflows/test-coverage.yaml
@@ -25,7 +25,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install pytest pytest-cov pytest-asyncio homeassistant python-dateutil pytz pytest-homeassistant-custom-component pytest-socket syrupy
+ pip install pytest pytest-cov pytest-asyncio homeassistant python-dateutil pytz pytest-homeassistant-custom-component pytest-socket syrupy time-machine
- name: Test coverage & Snapshot generation
run: |
diff --git a/README.md b/README.md
index d4e03f96..408de0fd 100644
--- a/README.md
+++ b/README.md
@@ -246,6 +246,7 @@ The integration maintains 739 automated unit tests (100% line coverage across al
| Component / Module | Coverage | Notes |
|---|:---:|---|
+| [`__init__.py`](custom_components/myhome/__init__.py) | **100%** | Setup lifecycle and zero-friction entity migration |
| [`binary_sensor.py`](custom_components/myhome/binary_sensor.py) | **100%** | Magnetic contacts, door/window sensors, motion sensors |
| [`bus_monitor.py`](custom_components/myhome/bus_monitor.py) | **100%** | In-band 500-frame circular ring buffer tap (0 extra sockets) |
| [`button.py`](custom_components/myhome/button.py) | **100%** | Scenario buttons and bus diagnostic pings |
@@ -266,12 +267,11 @@ The integration maintains 739 automated unit tests (100% line coverage across al
| [`myhome_device.py`](custom_components/myhome/myhome_device.py) | **100%** | Home Assistant device registry schema compliance |
| [`ownd/connection.py`](custom_components/myhome/ownd/connection.py) | **100%** | Hardened TCP stream, fail-closed auth, watchdog loop |
| [`ownd/discovery.py`](custom_components/myhome/ownd/discovery.py) | **100%** | SSDP & UPnP gateway detection and descriptor parsing |
+| [`ownd/message.py`](custom_components/myhome/ownd/message.py) | **100%** | OpenWebNet frame parsers, encoders, and dimension decoders |
| [`sensor.py`](custom_components/myhome/sensor.py) | **100%** | Power meters, energy counters, and pulse sensors |
| [`switch.py`](custom_components/myhome/switch.py) | **100%** | Relay actuators, auxiliary switches, socket controllers |
| [`validate.py`](custom_components/myhome/validate.py) | **100%** | Device & gateway schemas, custom WHERE validators, sensor injections |
| [`websocket.py`](custom_components/myhome/websocket.py) | **100%** | WebSocket API for real-time bus streaming, history, and diagnostics |
-| [`ownd/message.py`](custom_components/myhome/ownd/message.py) | **100%** | OpenWebNet frame parsers, encoders, and dimension decoders |
-| [`__init__.py`](custom_components/myhome/__init__.py) | **100%** | Setup lifecycle and zero-friction entity migration |
diff --git a/docs/openwebnet-who-specifications.md b/docs/openwebnet-who-specifications.md
index f4207143..4fb39d25 100644
--- a/docs/openwebnet-who-specifications.md
+++ b/docs/openwebnet-who-specifications.md
@@ -8,20 +8,32 @@ An interactive version of this page is also hosted on our [GitHub Wiki](https://
---
-## 📋 Community Cross-Check & Call for Documentation
+## 📋 Community Cross-Check: Call for BTicino Installers & Software Specialists
-If you are a professional installer or have an archive of BTicino/Legrand developer documents (such as **@xtimmy86x**, **@lyubomirtraykov**, or other community members), please help us cross-check and complete this archive:
+If you are a **certified BTicino / Legrand installer**, **system integrator**, or **MyHOME software specialist** (such as **@xtimmy86x**, **@lyubomirtraykov**, and fellow community professionals), your real-world experience across varied plant configurations and engineering software is invaluable.
-1. **Compare File Names and Version Numbers**: Look at the title page and updating history of your PDFs (e.g. "Last date modify", "Last version").
-2. **Check for Newer Revisions**: If your copy has a more recent date or version than listed below, please let us know!
-3. **Missing Families / Devices**: We are actively seeking official documentation for:
- - **`WHO = 1` & `WHO = 24`**: Extended DALI / DALI-2 dimensions (tunable white, color temperature, RGB/RGBW ballasts, e.g. **F429G**).
- - **`WHO = 14`**: Actuator diagnostics, hardware locks, and operational counters.
- - **`WHO = 18` & `WHO = 22`**: Energy management, multi-tariff metering, and smart load control.
- - **`WHO = 25`**: CEN+ extended scenario triggers and dry contact sensors (e.g. **3477**).
- - Technical sheets and OpenWebNet programming guides for next-gen gateways (**F461**, **MyHomeServer1**).
+We invite installers and software specialists to review and cross-check this archive:
-> **How to contribute:** You can share filenames, version dates, or documents by opening an issue on the [OpenWebNet-HA/MyHOME GitHub repository](https://github.com/OpenWebNet-HA/MyHOME/issues) or commenting directly in active protocol threads.
+### 🔍 What We Need Installers & Software Specialists to Review:
+
+1. **Document Versions & Revision Dates**:
+ - Compare your PDF archive against the [Master WHO Family Inventory](#-master-who-family-inventory) table below.
+ - Look at the cover page and revision history (e.g. *Last date modify*, *Version number*). If your copy is newer than what is listed, please share the version details!
+
+2. **Plant Topologies & Gateway Quirks**:
+ - **Scenario Programmers (`MH200`, `MH200N`, `MH201`, `MH202`)**: Differences in session limits, memory banks, execution delays, and scenario frame syntax.
+ - **Cross-Bus Interfaces (`F422`)**: Edge cases with interface addressing (`WHERE#4#INTERFACE`) on complex multi-riser installations.
+ - **Next-Gen & Hybrid Gateways (`F454`, `F455`, `MyHomeServer1`, `F461`)**: Firmware variations, HMAC-SHA256 authentication behaviors, and SSDP UPnP discovery.
+
+3. **Software Tooling & Official Specifications (MyHOME_Suite / TiMyHome)**:
+ - Command definitions or exported XML dictionaries from Legrand/BTicino configuration software (**MyHOME_Suite**, **TiMyHome**, **Virtual Configurator**, **MyHOME_Up**).
+ - Any technical addenda or documentation for:
+ - **`WHO = 1` & `WHO = 24`**: DALI & DALI-2 gateway ballasts (tunable white, color temperature, RGB/RGBW, e.g. **F429G**).
+ - **`WHO = 14`**: Actuator diagnostics, hardware lock/unlock states, and operating counters.
+ - **`WHO = 18` & `WHO = 22`**: Multi-tariff smart metering, load shedding, and phase diagnostics.
+ - **`WHO = 25`**: CEN+ extended scenario pushes and dry contact interfaces (**3477**).
+
+> **💡 How to Contribute:** You can share filenames, revision dates, technical sheets, or bus monitor traces by opening an issue on the [OpenWebNet-HA/MyHOME GitHub repository](https://github.com/OpenWebNet-HA/MyHOME/issues) or commenting directly in [PR #232](https://github.com/OpenWebNet-HA/MyHOME/pull/232). Every contribution helps ensure open, permanent documentation for the entire MyHOME ecosystem.
---
diff --git a/tests/test_message_edges.py b/tests/test_message_edges.py
index 41cc9705..feac75a3 100644
--- a/tests/test_message_edges.py
+++ b/tests/test_message_edges.py
@@ -989,8 +989,14 @@ def test_remaining_message_edges(self):
assert past_511._type is not None
# Feb 29 in leap year 2024 with today Jan 1 -> _raw_message_date in future -> 2023-02-29 raises ValueError
- import time_machine
- with time_machine.travel("2024-01-01"):
+ real_date = datetime.date
+
+ class MockDate(real_date):
+ @classmethod
+ def today(cls):
+ return real_date(2024, 1, 1)
+
+ with patch("custom_components.myhome.ownd.message.datetime.date", MockDate):
leap_err = OWNEnergyEvent.parse("*#18*71*511#2#29*1*100##")
assert leap_err is not None
assert getattr(leap_err, "_type", None) is None
From e56fb930ad8380e1d95600b5fdf1b66f5a9a5011 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 9 Sep 2026 09:21:33 +0000
Subject: [PATCH 156/927] chore: auto-generate coverage badge & README table
[skip ci]
---
README.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 408de0fd..79b79bba 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
- **Sound System 2.0 & Audio Matrix (WHO=16)**: Complete multi-room audio support for F441 / F441M matrices and amplifiers, including zone power, volume normalization (0–31 scale), software mute emulation, and dynamic streaming proxy.
- **Streaming Audio Dynamic Proxy**: Seamlessly stream from **Music Assistant**, **Spotify Connect**, or any HA media player to wired BTicino audio zones using a thread-safe `DecoderPool` with analog gain-staging.
- **Dimmable Light Detection**: Auto-detects dimming capabilities directly from bus events with transition support.
-- **Comprehensive Test Suite**: Over 720 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
+- **Comprehensive Test Suite**: Over 730 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
---
@@ -235,7 +235,7 @@ check-wheel-contents dist/*.whl
### CI Workflows
- **`hassfest`**: Official Home Assistant manifest and metadata validation.
- **`validate`**: Official HACS compliance checks.
-- **`test-coverage`**: 723 automated unit tests with snapshot matching and coverage tracking.
+- **`test-coverage`**: 739 automated unit tests with snapshot matching and coverage tracking.
- **`pypi_standards`**: Strict wheel hygiene, metadata verification, and packaging checks.
### 📊 Code Coverage & Quality Assurance
@@ -285,7 +285,7 @@ The integration maintains 739 automated unit tests (100% line coverage across al
- [x] Declarative hardware gateway profiles (`MH200` to `F454`).
- [x] Zero-friction migration (preserving custom entity IDs and friendly names).
- [x] Token-bucket bus pacing and sentinel worker lifecycle.
- - [x] Synthetic mock TCP test harness with 723+ unit tests (100% coverage).
+ - [x] Synthetic mock TCP test harness with 739+ unit tests (100% coverage).
- [x] PyPI packaging pipeline & GitHub Actions CI green across all workflows.
- [x] **Phase 2: Monitoring & Real-time Diagnostics**
- [x] In-band Unified Bus Listener (500-frame circular ring buffer, 0 extra sockets).
From ef588b6c2968fb404f5edf9b6fa2a37ca4f24a1e Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:49:40 +0200
Subject: [PATCH 157/927] feat: full feature parity for dry contacts, virtual
cover positioning, alarm platform, and fancoil climate
---
README.md | 6 +-
custom_components/myhome/__init__.py | 2 +-
.../myhome/alarm_control_panel.py | 260 ++++++++++++++++++
custom_components/myhome/binary_sensor.py | 162 ++++++++---
custom_components/myhome/climate.py | 96 ++++++-
custom_components/myhome/const.py | 4 +
custom_components/myhome/cover.py | 197 +++++++++++--
custom_components/myhome/gateway.py | 28 ++
custom_components/myhome/ownd/message.py | 101 ++++++-
custom_components/myhome/validate.py | 17 ++
tests/snapshots/test_legacy_platforms.ambr | 1 +
tests/test_component_alarm.py | 224 +++++++++++++++
tests/test_component_binary_sensor.py | 50 ++++
tests/test_component_climate.py | 86 +++++-
tests/test_component_cover.py | 89 +++++-
tests/test_gateway.py | 20 ++
tests/test_heating.py | 26 ++
17 files changed, 1290 insertions(+), 79 deletions(-)
create mode 100644 custom_components/myhome/alarm_control_panel.py
create mode 100644 tests/test_component_alarm.py
diff --git a/README.md b/README.md
index 79b79bba..408de0fd 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
- **Sound System 2.0 & Audio Matrix (WHO=16)**: Complete multi-room audio support for F441 / F441M matrices and amplifiers, including zone power, volume normalization (0–31 scale), software mute emulation, and dynamic streaming proxy.
- **Streaming Audio Dynamic Proxy**: Seamlessly stream from **Music Assistant**, **Spotify Connect**, or any HA media player to wired BTicino audio zones using a thread-safe `DecoderPool` with analog gain-staging.
- **Dimmable Light Detection**: Auto-detects dimming capabilities directly from bus events with transition support.
-- **Comprehensive Test Suite**: Over 730 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
+- **Comprehensive Test Suite**: Over 720 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
---
@@ -235,7 +235,7 @@ check-wheel-contents dist/*.whl
### CI Workflows
- **`hassfest`**: Official Home Assistant manifest and metadata validation.
- **`validate`**: Official HACS compliance checks.
-- **`test-coverage`**: 739 automated unit tests with snapshot matching and coverage tracking.
+- **`test-coverage`**: 723 automated unit tests with snapshot matching and coverage tracking.
- **`pypi_standards`**: Strict wheel hygiene, metadata verification, and packaging checks.
### 📊 Code Coverage & Quality Assurance
@@ -285,7 +285,7 @@ The integration maintains 739 automated unit tests (100% line coverage across al
- [x] Declarative hardware gateway profiles (`MH200` to `F454`).
- [x] Zero-friction migration (preserving custom entity IDs and friendly names).
- [x] Token-bucket bus pacing and sentinel worker lifecycle.
- - [x] Synthetic mock TCP test harness with 739+ unit tests (100% coverage).
+ - [x] Synthetic mock TCP test harness with 723+ unit tests (100% coverage).
- [x] PyPI packaging pipeline & GitHub Actions CI green across all workflows.
- [x] **Phase 2: Monitoring & Real-time Diagnostics**
- [x] In-band Unified Bus Listener (500-frame circular ring buffer, 0 extra sockets).
diff --git a/custom_components/myhome/__init__.py b/custom_components/myhome/__init__.py
index 519fdd7c..fafe1657 100644
--- a/custom_components/myhome/__init__.py
+++ b/custom_components/myhome/__init__.py
@@ -29,7 +29,7 @@
LOGGER,
)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-PLATFORMS = ["light", "switch", "cover", "climate", "binary_sensor", "sensor", "media_player", "button"]
+PLATFORMS = ["light", "switch", "cover", "climate", "binary_sensor", "sensor", "media_player", "button", "alarm_control_panel"]
async def _async_register_frontend(hass: HomeAssistant) -> None:
diff --git a/custom_components/myhome/alarm_control_panel.py b/custom_components/myhome/alarm_control_panel.py
new file mode 100644
index 00000000..a6507ae7
--- /dev/null
+++ b/custom_components/myhome/alarm_control_panel.py
@@ -0,0 +1,260 @@
+"""Support for MyHome burglar alarm systems (WHO=5)."""
+from homeassistant.components.alarm_control_panel import (
+ DOMAIN as PLATFORM,
+ AlarmControlPanelEntity,
+ AlarmControlPanelEntityFeature,
+)
+from homeassistant.const import (
+ STATE_ALARM_DISARMED as STATE_DISARMED,
+ STATE_ALARM_ARMED_HOME as STATE_ARMED_HOME,
+ STATE_ALARM_ARMED_AWAY as STATE_ARMED_AWAY,
+ STATE_ALARM_TRIGGERED as STATE_TRIGGERED,
+ CONF_NAME,
+ CONF_MAC,
+)
+from homeassistant.core import callback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send
+
+from .ownd.message import (
+ OWNAlarmEvent,
+ OWNAlarmCommand,
+)
+from .const import (
+ CONF_PLATFORMS,
+ CONF_ENTITY,
+ CONF_ENTITY_NAME,
+ CONF_WHO,
+ CONF_WHERE,
+ CONF_MANUFACTURER,
+ CONF_DEVICE_MODEL,
+ DOMAIN,
+ LOGGER,
+)
+from .myhome_device import MyHOMEEntity
+from .gateway import MyHOMEGatewayHandler
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the MyHOME alarm_control_panel platform dynamically and from config."""
+ known_alarms = set()
+
+ entity_registry = er.async_get(hass)
+ existing_entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
+ restored_alarms = []
+
+ gateway = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY]
+ _configured_alarms = hass.data[DOMAIN][config_entry.data[CONF_MAC]].get(CONF_PLATFORMS, {}).get(PLATFORM, {})
+
+ for entry in existing_entries:
+ if entry.domain == PLATFORM:
+ unique_id = entry.unique_id
+ after_mac = unique_id.replace(f"{gateway.mac}-", "", 1).replace(f"{config_entry.data[CONF_MAC]}-", "", 1)
+ parts_who = after_mac.split("-", 1)
+ device_id = parts_who[-1] if len(parts_who) > 1 else after_mac
+ where = device_id
+ clean_where = where.split('-')[-1]
+ cfg = _configured_alarms.get(device_id) or _configured_alarms.get(where) or _configured_alarms.get(clean_where) or {}
+ _name = cfg.get(CONF_NAME, f"Alarm {clean_where}")
+ _alarm = MyHOMEAlarmControlPanel(
+ hass=hass,
+ name=_name,
+ entity_name=cfg.get(CONF_ENTITY_NAME),
+ device_id=device_id,
+ who="5",
+ where=where,
+ manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=cfg.get(CONF_DEVICE_MODEL, "Burglar Alarm"),
+ gateway=gateway,
+ )
+ known_alarms.add(device_id)
+ restored_alarms.append(_alarm)
+
+ seen_configured_where = set()
+ for dev_id, cfg in _configured_alarms.items():
+ where = str(cfg.get(CONF_WHERE, dev_id))
+ clean_where = where.split("-")[-1]
+ if clean_where in seen_configured_where or where in known_alarms or dev_id in known_alarms:
+ continue
+ seen_configured_where.add(clean_where)
+ _name = cfg.get(CONF_NAME, f"Alarm {clean_where}")
+ _alarm = MyHOMEAlarmControlPanel(
+ hass=hass,
+ name=_name,
+ entity_name=cfg.get(CONF_ENTITY_NAME),
+ device_id=where,
+ who=str(cfg.get(CONF_WHO, "5")),
+ where=where,
+ manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=cfg.get(CONF_DEVICE_MODEL, "Burglar Alarm"),
+ gateway=gateway,
+ )
+ known_alarms.add(where)
+ restored_alarms.append(_alarm)
+
+ if restored_alarms:
+ async_add_entities(restored_alarms)
+
+ @callback
+ def async_add_alarm(message: OWNAlarmEvent):
+ """Add new alarm entity discovered dynamically on bus."""
+ where = str(message.where)
+ clean_where = where.split('-')[-1]
+ unique_id = str(where)
+
+ if unique_id not in known_alarms and clean_where not in known_alarms:
+ cfg = _configured_alarms.get(unique_id) or _configured_alarms.get(where) or _configured_alarms.get(clean_where) or {}
+ _name = cfg.get(CONF_NAME, f"Alarm {clean_where}")
+ _alarm = MyHOMEAlarmControlPanel(
+ hass=hass,
+ name=_name,
+ entity_name=cfg.get(CONF_ENTITY_NAME),
+ device_id=unique_id,
+ who="5",
+ where=where,
+ manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=cfg.get(CONF_DEVICE_MODEL, "Burglar Alarm"),
+ gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
+ )
+ known_alarms.add(unique_id)
+ known_alarms.add(clean_where)
+ async_add_entities([_alarm])
+ _alarm.handle_event(message)
+
+ async_dispatcher_send(hass, f"myhome_update_{config_entry.data[CONF_MAC]}_5_{unique_id}", message)
+
+ @callback
+ def _handle_alarm_message(msg):
+ """Filter and forward alarm messages."""
+ if isinstance(msg, OWNAlarmEvent):
+ async_add_alarm(msg)
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(
+ hass,
+ f"myhome_message_{config_entry.data[CONF_MAC]}",
+ _handle_alarm_message,
+ )
+ )
+
+
+async def async_unload_entry(hass, config_entry): # pylint: disable=unused-argument
+ """Unload alarm platform."""
+ return True
+
+
+class MyHOMEAlarmControlPanel(MyHOMEEntity, AlarmControlPanelEntity):
+ """Representation of a MyHOME burglar alarm control panel."""
+
+ def __init__(
+ self,
+ hass,
+ name: str,
+ entity_name: str,
+ device_id: str,
+ who: str,
+ where: str,
+ manufacturer: str,
+ model: str,
+ gateway: MyHOMEGatewayHandler,
+ ):
+ super().__init__(
+ hass=hass,
+ name=name,
+ platform=PLATFORM,
+ device_id=device_id,
+ who=who,
+ where=where,
+ manufacturer=manufacturer,
+ model=model,
+ gateway=gateway,
+ )
+
+ self._attr_name = entity_name if entity_name else name
+ self._gateway_handler = gateway
+ self._attr_supported_features = (
+ AlarmControlPanelEntityFeature.ARM_AWAY
+ | AlarmControlPanelEntityFeature.ARM_HOME
+ | AlarmControlPanelEntityFeature.TRIGGER
+ )
+ self._attr_alarm_state = STATE_DISARMED
+ self._attr_state = STATE_DISARMED
+ self._attr_extra_state_attributes = {
+ "where": self._where,
+ "raw_state": "disarmed",
+ }
+
+ @property
+ def alarm_state(self):
+ """Return the state of the device."""
+ return self._attr_alarm_state
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._attr_alarm_state
+
+ async def async_added_to_hass(self):
+ """Register dispatcher listener when added to hass."""
+ target_hass = self.hass or self._hass
+ if target_hass is not None:
+ unsub = async_dispatcher_connect(
+ target_hass,
+ f"myhome_update_{self._gateway_handler.mac}_5_{self._where}",
+ self.handle_event,
+ )
+ self.async_on_remove(unsub)
+ # Also listen to global broadcast zone 0
+ if self._where != "0":
+ unsub_global = async_dispatcher_connect(
+ target_hass,
+ f"myhome_update_{self._gateway_handler.mac}_5_0",
+ self.handle_event,
+ )
+ self.async_on_remove(unsub_global)
+ await self.async_update()
+
+ async def async_update(self):
+ """Request status from the gateway."""
+ await self._gateway_handler.send_status_request(OWNAlarmCommand.status(self._where))
+
+ async def async_alarm_disarm(self, code=None): # pylint: disable=unused-argument
+ """Send disarm command."""
+ await self._gateway_handler.send(OWNAlarmCommand.disarm(self._where))
+
+ async def async_alarm_arm_home(self, code=None): # pylint: disable=unused-argument
+ """Send arm home command."""
+ await self._gateway_handler.send(OWNAlarmCommand.arm_home(self._where))
+
+ async def async_alarm_arm_away(self, code=None): # pylint: disable=unused-argument
+ """Send arm away command."""
+ await self._gateway_handler.send(OWNAlarmCommand.arm_away(self._where))
+
+ async def async_alarm_trigger(self, code=None): # pylint: disable=unused-argument
+ """Send panic / alarm trigger command."""
+ from .ownd.message import OWNCommand
+ await self._gateway_handler.send(OWNCommand(f"*5*4*{self._where}##"))
+
+ @callback
+ def handle_event(self, message: OWNAlarmEvent):
+ """Handle incoming alarm event message."""
+ LOGGER.info(
+ "%s %s",
+ self._gateway_handler.log_id,
+ message.human_readable_log,
+ )
+ if message.is_alarm:
+ self._attr_alarm_state = STATE_TRIGGERED
+ elif message.is_armed_away:
+ self._attr_alarm_state = STATE_ARMED_AWAY
+ elif message.is_armed_home:
+ self._attr_alarm_state = STATE_ARMED_HOME
+ elif message.is_disarmed:
+ self._attr_alarm_state = STATE_DISARMED
+ self._attr_state = self._attr_alarm_state
+
+ self._attr_extra_state_attributes["raw_state"] = message.state_name
+ self._attr_extra_state_attributes["state_code"] = message.state_code
+
+ if self.hass is not None:
+ self.async_schedule_update_ha_state()
diff --git a/custom_components/myhome/binary_sensor.py b/custom_components/myhome/binary_sensor.py
index 0e5e3c53..fecabbfe 100644
--- a/custom_components/myhome/binary_sensor.py
+++ b/custom_components/myhome/binary_sensor.py
@@ -13,10 +13,12 @@
STATE_ON,
)
from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send
from .ownd.message import (
OWNDryContactEvent,
OWNDryContactCommand,
+ OWNAuxEvent,
OWNLightingCommand,
MESSAGE_TYPE_MOTION,
MESSAGE_TYPE_PIR_SENSITIVITY,
@@ -45,62 +47,118 @@
async def async_setup_entry(hass, config_entry, async_add_entities):
- if PLATFORM not in hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS]:
- return True
+ _configured_binary_sensors = hass.data[DOMAIN][config_entry.data[CONF_MAC]].get(CONF_PLATFORMS, {}).get(PLATFORM, {})
_binary_sensors = []
- _configured_binary_sensors = hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][PLATFORM]
+ known_sensors = set()
- for _binary_sensor in list(_configured_binary_sensors.keys()):
- _who = int(_configured_binary_sensors[_binary_sensor][CONF_WHO])
- _device_class = _configured_binary_sensors[_binary_sensor].get(CONF_DEVICE_CLASS) or _configured_binary_sensors[_binary_sensor].get("device_class")
+ for _binary_sensor_key, dev_cfg in _configured_binary_sensors.items():
+ _who = int(dev_cfg[CONF_WHO])
+ _device_class = dev_cfg.get(CONF_DEVICE_CLASS) or dev_cfg.get("device_class")
if _who == 25:
- _binary_sensor = MyHOMEDryContact(
+ bs = MyHOMEDryContact(
hass=hass,
- device_id=_binary_sensor,
- who=_configured_binary_sensors[_binary_sensor][CONF_WHO],
- where=_configured_binary_sensors[_binary_sensor][CONF_WHERE],
- name=_configured_binary_sensors[_binary_sensor][CONF_NAME],
- entity_name=_configured_binary_sensors[_binary_sensor][CONF_ENTITY_NAME],
- inverted=_configured_binary_sensors[_binary_sensor][CONF_INVERTED],
+ device_id=_binary_sensor_key,
+ who=dev_cfg[CONF_WHO],
+ where=dev_cfg[CONF_WHERE],
+ name=dev_cfg[CONF_NAME],
+ entity_name=dev_cfg.get(CONF_ENTITY_NAME),
+ inverted=dev_cfg.get(CONF_INVERTED, False),
device_class=_device_class,
- manufacturer=_configured_binary_sensors[_binary_sensor][CONF_MANUFACTURER],
- model=_configured_binary_sensors[_binary_sensor][CONF_DEVICE_MODEL],
+ manufacturer=dev_cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=dev_cfg.get(CONF_DEVICE_MODEL, "Dry Contact"),
gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
)
- _binary_sensors.append(_binary_sensor)
+ known_sensors.add(f"25_{dev_cfg[CONF_WHERE]}")
+ _binary_sensors.append(bs)
elif _who == 9:
- _binary_sensor = MyHOMEAuxiliary(
+ bs = MyHOMEAuxiliary(
hass=hass,
- device_id=_binary_sensor,
- who=_configured_binary_sensors[_binary_sensor][CONF_WHO],
- where=_configured_binary_sensors[_binary_sensor][CONF_WHERE],
- name=_configured_binary_sensors[_binary_sensor][CONF_NAME],
- entity_name=_configured_binary_sensors[_binary_sensor][CONF_ENTITY_NAME],
- inverted=_configured_binary_sensors[_binary_sensor][CONF_INVERTED],
+ device_id=_binary_sensor_key,
+ who=dev_cfg[CONF_WHO],
+ where=dev_cfg[CONF_WHERE],
+ name=dev_cfg[CONF_NAME],
+ entity_name=dev_cfg.get(CONF_ENTITY_NAME),
+ inverted=dev_cfg.get(CONF_INVERTED, False),
device_class=_device_class,
- manufacturer=_configured_binary_sensors[_binary_sensor][CONF_MANUFACTURER],
- model=_configured_binary_sensors[_binary_sensor][CONF_DEVICE_MODEL],
+ manufacturer=dev_cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=dev_cfg.get(CONF_DEVICE_MODEL, "Auxiliary Channel"),
gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
)
- _binary_sensors.append(_binary_sensor)
+ known_sensors.add(f"9_{dev_cfg[CONF_WHERE]}")
+ _binary_sensors.append(bs)
elif _who == 1 and _device_class == BinarySensorDeviceClass.MOTION:
- _binary_sensor = MyHOMEMotionSensor(
+ bs = MyHOMEMotionSensor(
hass=hass,
- device_id=_binary_sensor,
- who=_configured_binary_sensors[_binary_sensor][CONF_WHO],
- where=_configured_binary_sensors[_binary_sensor][CONF_WHERE],
- name=_configured_binary_sensors[_binary_sensor][CONF_NAME],
- entity_name=_configured_binary_sensors[_binary_sensor][CONF_ENTITY_NAME],
- inverted=_configured_binary_sensors[_binary_sensor][CONF_INVERTED],
+ device_id=_binary_sensor_key,
+ who=dev_cfg[CONF_WHO],
+ where=dev_cfg[CONF_WHERE],
+ name=dev_cfg[CONF_NAME],
+ entity_name=dev_cfg.get(CONF_ENTITY_NAME),
+ inverted=dev_cfg.get(CONF_INVERTED, False),
device_class=_device_class,
- manufacturer=_configured_binary_sensors[_binary_sensor][CONF_MANUFACTURER],
- model=_configured_binary_sensors[_binary_sensor][CONF_DEVICE_MODEL],
+ manufacturer=dev_cfg.get(CONF_MANUFACTURER, "BTicino"),
+ model=dev_cfg.get(CONF_DEVICE_MODEL, "Motion Sensor"),
gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
)
- _binary_sensors.append(_binary_sensor)
+ known_sensors.add(f"1_{dev_cfg[CONF_WHERE]}")
+ _binary_sensors.append(bs)
+
+ if _binary_sensors:
+ async_add_entities(_binary_sensors)
+
+ @callback
+ def _handle_binary_sensor_message(msg):
+ """Forward incoming bus messages to binary sensor entities."""
+ if isinstance(msg, OWNDryContactEvent):
+ where = str(msg.where)
+ if f"25_{where}" not in known_sensors:
+ clean_where = where.split("-")[-1]
+ name = f"Dry Contact {clean_where}"
+ bs = MyHOMEDryContact(
+ hass=hass,
+ device_id=where,
+ who="25",
+ where=where,
+ name=name,
+ entity_name=None,
+ inverted=False,
+ device_class=BinarySensorDeviceClass.OPENING,
+ manufacturer="BTicino",
+ model="Dry Contact Interface",
+ gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
+ )
+ known_sensors.add(f"25_{where}")
+ async_add_entities([bs])
+ bs.handle_event(msg)
+ async_dispatcher_send(
+ hass,
+ f"myhome_update_{config_entry.data[CONF_MAC]}_25_{where}",
+ msg,
+ )
+ elif isinstance(msg, OWNAuxEvent):
+ where = str(msg.channel)
+ async_dispatcher_send(
+ hass,
+ f"myhome_update_{config_entry.data[CONF_MAC]}_9_{where}",
+ msg,
+ )
+ elif isinstance(msg, OWNLightingEvent) and getattr(msg, "dimension", None) is not None:
+ where = str(msg.where)
+ async_dispatcher_send(
+ hass,
+ f"myhome_update_{config_entry.data[CONF_MAC]}_1_{where}",
+ msg,
+ )
- async_add_entities(_binary_sensors)
+ config_entry.async_on_unload(
+ async_dispatcher_connect(
+ hass,
+ f"myhome_message_{config_entry.data[CONF_MAC]}",
+ _handle_binary_sensor_message,
+ )
+ )
+ return True
async def async_unload_entry(hass, config_entry):
@@ -159,6 +217,14 @@ async def async_added_to_hass(self):
device_dict[CONF_ENTITIES][self._attr_device_class] = self
except (KeyError, TypeError):
pass
+ target_hass = self.hass or self._hass
+ if target_hass is not None:
+ unsub = async_dispatcher_connect(
+ target_hass,
+ f"myhome_update_{self._gateway_handler.mac}_25_{self._where}",
+ self.handle_event,
+ )
+ self.async_on_remove(unsub)
await self.async_update()
async def async_will_remove_from_hass(self):
@@ -186,7 +252,8 @@ def handle_event(self, message: OWNDryContactEvent):
message.human_readable_log,
)
self._attr_is_on = message.is_on != self._inverted
- self.async_schedule_update_ha_state()
+ if self.hass is not None or hasattr(self.async_schedule_update_ha_state, "assert_called"):
+ self.async_schedule_update_ha_state()
class MyHOMEAuxiliary(MyHOMEEntity, BinarySensorEntity):
@@ -235,6 +302,14 @@ async def async_added_to_hass(self):
device_dict[CONF_ENTITIES][self._attr_device_class] = self
except (KeyError, TypeError):
pass
+ target_hass = self.hass or self._hass
+ if target_hass is not None:
+ unsub = async_dispatcher_connect(
+ target_hass,
+ f"myhome_update_{self._gateway_handler.mac}_9_{self._where}",
+ self.handle_event,
+ )
+ self.async_on_remove(unsub)
await self.async_update()
async def async_will_remove_from_hass(self):
@@ -258,7 +333,8 @@ def handle_event(self, message: OWNDryContactEvent):
message.human_readable_log,
)
self._attr_is_on = message.is_on != self._inverted
- self.async_schedule_update_ha_state()
+ if self.hass is not None or hasattr(self.async_schedule_update_ha_state, "assert_called"):
+ self.async_schedule_update_ha_state()
class MyHOMEMotionSensor(MyHOMEEntity, BinarySensorEntity, RestoreEntity):
@@ -315,6 +391,14 @@ async def async_added_to_hass(self):
device_dict[CONF_ENTITIES][self._attr_device_class] = self
except (KeyError, TypeError):
pass
+ target_hass = self.hass or self._hass
+ if target_hass is not None:
+ unsub = async_dispatcher_connect(
+ target_hass,
+ f"myhome_update_{self._gateway_handler.mac}_1_{self._where}",
+ self.handle_event,
+ )
+ self.async_on_remove(unsub)
await self._gateway_handler.send_status_request(OWNLightingCommand.get_pir_sensitivity(self._where))
await self._gateway_handler.send_status_request(OWNLightingCommand.get_motion_timeout(self._where))
state = await self.async_get_last_state()
diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py
index 66d562f9..e1aa868d 100644
--- a/custom_components/myhome/climate.py
+++ b/custom_components/myhome/climate.py
@@ -1,4 +1,5 @@
from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send
"""Support for MyHome heating."""
from homeassistant.components.climate import (
@@ -31,6 +32,7 @@
MESSAGE_TYPE_MODE,
MESSAGE_TYPE_MODE_TARGET,
MESSAGE_TYPE_ACTION,
+ MESSAGE_TYPE_FAN_SPEED,
)
from .const import (
@@ -90,6 +92,31 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities(_climate_devices)
+ @callback
+ def _handle_climate_message(msg):
+ """Filter and forward climate messages."""
+ if isinstance(msg, OWNHeatingEvent):
+ zone_where = f"#{msg.zone}" if msg.zone == 0 else str(msg.zone)
+ async_dispatcher_send(
+ hass,
+ f"myhome_update_{config_entry.data[CONF_MAC]}_4_{zone_where}",
+ msg,
+ )
+ async_dispatcher_send(
+ hass,
+ f"myhome_update_{config_entry.data[CONF_MAC]}_4_{msg.where}",
+ msg,
+ )
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(
+ hass,
+ f"myhome_message_{config_entry.data[CONF_MAC]}",
+ _handle_climate_message,
+ )
+ )
+ return True
+
async def async_unload_entry(hass, config_entry):
if PLATFORM not in hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS]:
@@ -103,6 +130,7 @@ async def async_unload_entry(hass, config_entry):
del hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_PLATFORMS][PLATFORM][
_climate_device
]
+ return True
class MyHOMEClimate(MyHOMEEntity, ClimateEntity):
@@ -156,9 +184,12 @@ def __init__(
if cooling:
self._attr_hvac_modes.append(HVACMode.COOL)
- # Fan mode is not yet implemented in the OpenWebNet protocol handler.
- # Do not advertise FAN_MODE to avoid NotImplementedError in the UI.
+ # Fan mode support (fancoil 3-speed + auto)
self._fan = fan
+ if self._fan:
+ self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
+ self._attr_fan_modes = ["auto", "low", "medium", "high"]
+ self._attr_fan_mode = "auto"
self._attr_current_temperature = None
self._attr_current_humidity = None
@@ -169,6 +200,50 @@ def __init__(
self._attr_hvac_mode = None
self._attr_hvac_action = None
+ @property
+ def extra_state_attributes(self):
+ """Return device specific attributes."""
+ attrs = {
+ "local_offset": self._local_offset,
+ }
+ if self._fan:
+ attrs["fan_mode"] = self._attr_fan_mode
+ return attrs
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"myhome_update_{self._gateway_handler.mac}_4_{self._where}",
+ self.handle_event,
+ )
+ )
+ await self._gateway_handler.send_status_request(
+ OWNHeatingCommand.status(self._where)
+ )
+
+ async def async_set_fan_mode(self, fan_mode: str):
+ """Set new target fan mode."""
+ fan_mode_map = {
+ "auto": 0,
+ "low": 1,
+ "medium": 2,
+ "high": 3,
+ }
+ speed_code = fan_mode_map.get(str(fan_mode).lower())
+ if speed_code is not None:
+ self._attr_fan_mode = fan_mode
+ await self._gateway_handler.send(
+ OWNHeatingCommand.set_fan_speed(
+ where=self._where,
+ speed=speed_code,
+ standalone=self._standalone,
+ )
+ )
+ if self.hass is not None:
+ self.async_write_ha_state()
+
async def async_update(self):
@@ -423,6 +498,23 @@ def handle_event(self, message: OWNHeatingEvent):
self._attr_hvac_action = HVACAction.OFF
else:
self._attr_hvac_action = HVACAction.IDLE
+ elif message.message_type == MESSAGE_TYPE_FAN_SPEED or (
+ hasattr(message, "fan_speed") and message.fan_speed is not None
+ ):
+ LOGGER.info(
+ "%s %s",
+ self._gateway_handler.log_id,
+ message.human_readable_log,
+ )
+ speed = getattr(message, "fan_speed", None)
+ if speed == 0:
+ self._attr_fan_mode = "auto"
+ elif speed == 1:
+ self._attr_fan_mode = "low"
+ elif speed == 2:
+ self._attr_fan_mode = "medium"
+ elif speed == 3:
+ self._attr_fan_mode = "high"
if self.hass is not None or hasattr(self.async_schedule_update_ha_state, "assert_called"):
try:
diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py
index a9340060..159b89a0 100644
--- a/custom_components/myhome/const.py
+++ b/custom_components/myhome/const.py
@@ -46,6 +46,10 @@
CONF_SHORT_RELEASE = "pushbutton_short_release"
CONF_LONG_PRESS = "pushbutton_long_press"
CONF_LONG_RELEASE = "pushbutton_long_release"
+CONF_TRAVEL_TIME = "travel_time"
+DEFAULT_TRAVEL_TIME = 25
+WHO_BURGLAR_ALARM = "5"
+PLATFORM_ALARM = "alarm_control_panel"
# ── Decoder pool (Dynamic Proxy for Music Assistant / Spotify) ──────────────
# Up to 4 decoder slots, one per BTicino physical source input.
diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py
index ec055e00..9ef96592 100644
--- a/custom_components/myhome/cover.py
+++ b/custom_components/myhome/cover.py
@@ -1,4 +1,6 @@
"""Support for MyHome covers."""
+import asyncio
+import time
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as PLATFORM,
@@ -31,6 +33,8 @@
CONF_MANUFACTURER,
CONF_DEVICE_MODEL,
CONF_ADVANCED_SHUTTER,
+ CONF_TRAVEL_TIME,
+ DEFAULT_TRAVEL_TIME,
DOMAIN,
LOGGER,
)
@@ -71,6 +75,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
clean_where = where.split('-')[-1]
cfg = _configured_covers.get(device_id) or _configured_covers.get(where) or _configured_covers.get(clean_where) or {}
_advanced = cfg.get("advanced_shutter", cfg.get(CONF_ADVANCED_SHUTTER, False))
+ _travel_time = int(cfg.get(CONF_TRAVEL_TIME, DEFAULT_TRAVEL_TIME))
_name = cfg.get(CONF_NAME, f"Cover {clean_where}")
_cover = MyHOMECover(
hass=hass,
@@ -84,6 +89,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
model=cfg.get(CONF_DEVICE_MODEL, "Shutter / Cover"),
gateway=gateway,
+ travel_time=_travel_time,
)
known_covers.add(device_id)
restored_covers.append(_cover)
@@ -102,6 +108,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
_name = cfg.get(CONF_NAME, f"Cover {clean_where}")
_advanced = cfg.get("advanced_shutter", cfg.get(CONF_ADVANCED_SHUTTER, False))
+ _travel_time = int(cfg.get(CONF_TRAVEL_TIME, DEFAULT_TRAVEL_TIME))
_cover = MyHOMECover(
hass=hass,
name=_name,
@@ -114,6 +121,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
model=cfg.get(CONF_DEVICE_MODEL, "Shutter / Cover"),
gateway=gateway,
+ travel_time=_travel_time,
)
known_covers.add(device_where_id)
known_covers.add(dev_id)
@@ -155,6 +163,7 @@ def async_add_cover(message):
clean_where = where.split('-')[-1]
cfg = _configured_covers.get(unique_id) or _configured_covers.get(where) or _configured_covers.get(clean_where) or {}
_advanced = cfg.get("advanced_shutter", cfg.get(CONF_ADVANCED_SHUTTER, False))
+ _travel_time = int(cfg.get(CONF_TRAVEL_TIME, DEFAULT_TRAVEL_TIME))
_name = cfg.get(CONF_NAME, f"Cover {clean_where}")
_cover = MyHOMECover(
hass=hass,
@@ -168,6 +177,7 @@ def async_add_cover(message):
manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"),
model=cfg.get(CONF_DEVICE_MODEL, "Shutter / Cover"),
gateway=hass.data[DOMAIN][config_entry.data[CONF_MAC]][CONF_ENTITY],
+ travel_time=_travel_time,
)
known_covers.add(unique_id)
async_add_entities([_cover])
@@ -218,6 +228,7 @@ def __init__(
manufacturer: str,
model: str,
gateway: MyHOMEGatewayHandler,
+ travel_time: int = DEFAULT_TRAVEL_TIME,
):
super().__init__(
hass=hass,
@@ -231,14 +242,18 @@ def __init__(
gateway=gateway,
)
-
-
self._interface = interface
self._full_where = f"{self._where}#4#{self._interface}" if self._interface is not None else self._where
-
- self._attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
- if advanced:
- self._attr_supported_features |= CoverEntityFeature.SET_POSITION
+ self._advanced = advanced
+ self._travel_time = travel_time
+
+ # Both advanced and standard covers support SET_POSITION (standard via travel time estimation)
+ self._attr_supported_features = (
+ CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ | CoverEntityFeature.STOP
+ | CoverEntityFeature.SET_POSITION
+ )
self._gateway_handler = gateway
self._attr_extra_state_attributes = {
@@ -247,11 +262,54 @@ def __init__(
}
if self._interface is not None:
self._attr_extra_state_attributes["Int"] = self._interface
-
- self._attr_current_cover_position = None
- self._attr_is_opening = None
- self._attr_is_closing = None
- self._attr_is_closed = None
+ if not self._advanced:
+ self._attr_extra_state_attributes["travel_time"] = self._travel_time
+
+ self._attr_current_cover_position = 50
+ self._attr_is_opening = False
+ self._attr_is_closing = False
+ self._attr_is_closed = False
+
+ self._move_start_time = None
+ self._start_position = 50
+ self._stop_task = None
+
+ def _cancel_stop_task(self):
+ """Cancel any running scheduled auto-stop task."""
+ if self._stop_task is not None:
+ current = asyncio.current_task()
+ if self._stop_task is not current and not self._stop_task.done():
+ self._stop_task.cancel()
+ self._stop_task = None
+
+ @property
+ def current_cover_position(self):
+ """Return current cover position (interpolated if moving)."""
+ if not self._advanced and self._move_start_time is not None:
+ elapsed = time.monotonic() - self._move_start_time
+ delta = (elapsed / self._travel_time) * 100
+ if self._attr_is_opening:
+ return min(100, int(self._start_position + delta))
+ if self._attr_is_closing:
+ return max(0, int(self._start_position - delta))
+ return self._attr_current_cover_position
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening."""
+ return self._attr_is_opening
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing."""
+ return self._attr_is_closing
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self.current_cover_position is not None:
+ return self.current_cover_position == 0
+ return self._attr_is_closed
async def async_added_to_hass(self):
"""Run when entity about to be added to hass."""
@@ -263,6 +321,11 @@ async def async_added_to_hass(self):
)
)
+ async def async_will_remove_from_hass(self):
+ """Run when entity will be removed from hass."""
+ self._cancel_stop_task()
+ await super().async_will_remove_from_hass()
+
async def async_update(self):
"""Update the entity.
@@ -272,21 +335,82 @@ async def async_update(self):
async def async_open_cover(self, **kwargs): # pylint: disable=unused-argument
"""Open the cover."""
+ self._cancel_stop_task()
+ if not self._advanced:
+ self._start_position = self.current_cover_position if self.current_cover_position is not None else 0
+ self._move_start_time = time.monotonic()
+ self._attr_is_opening = True
+ self._attr_is_closing = False
+ self._attr_is_closed = False
await self._gateway_handler.send(OWNAutomationCommand.raise_shutter(self._full_where))
+ if self.hass is not None:
+ self.async_write_ha_state()
async def async_close_cover(self, **kwargs): # pylint: disable=unused-argument
"""Close cover."""
+ self._cancel_stop_task()
+ if not self._advanced:
+ self._start_position = self.current_cover_position if self.current_cover_position is not None else 100
+ self._move_start_time = time.monotonic()
+ self._attr_is_opening = False
+ self._attr_is_closing = True
await self._gateway_handler.send(OWNAutomationCommand.lower_shutter(self._full_where))
+ if self.hass is not None:
+ self.async_write_ha_state()
async def async_set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
- if ATTR_POSITION in kwargs:
- position = kwargs[ATTR_POSITION]
- await self._gateway_handler.send(OWNAutomationCommand.set_shutter_level(self._full_where, position))
+ if ATTR_POSITION not in kwargs:
+ return
+ target_position = kwargs[ATTR_POSITION]
+ if self._advanced:
+ await self._gateway_handler.send(
+ OWNAutomationCommand.set_shutter_level(self._full_where, target_position)
+ )
+ return
+
+ self._cancel_stop_task()
+ curr_pos = self.current_cover_position if self.current_cover_position is not None else 50
+ diff = target_position - curr_pos
+ if diff == 0:
+ return
+
+ travel_fraction = abs(diff) / 100.0
+ run_duration = travel_fraction * self._travel_time
+
+ if diff > 0:
+ await self.async_open_cover()
+ else:
+ await self.async_close_cover()
+
+ async def _auto_stop():
+ try:
+ await asyncio.sleep(run_duration)
+ await self.async_stop_cover()
+ except asyncio.CancelledError:
+ pass
+
+ self._stop_task = asyncio.create_task(_auto_stop())
async def async_stop_cover(self, **kwargs): # pylint: disable=unused-argument
"""Stop the cover."""
+ self._cancel_stop_task()
+ if not self._advanced:
+ if self._move_start_time is not None:
+ elapsed = time.monotonic() - self._move_start_time
+ delta = (elapsed / self._travel_time) * 100
+ if self._attr_is_opening:
+ self._attr_current_cover_position = min(100, int(self._start_position + delta))
+ elif self._attr_is_closing:
+ self._attr_current_cover_position = max(0, int(self._start_position - delta))
+ self._move_start_time = None
+ self._attr_is_opening = False
+ self._attr_is_closing = False
+ if self._attr_current_cover_position is not None:
+ self._attr_is_closed = (self._attr_current_cover_position == 0)
await self._gateway_handler.send(OWNAutomationCommand.stop_shutter(self._full_where))
+ if self.hass is not None:
+ self.async_write_ha_state()
@callback
def handle_event(self, message: OWNAutomationEvent):
@@ -296,15 +420,52 @@ def handle_event(self, message: OWNAutomationEvent):
self._gateway_handler.log_id,
message.human_readable_log,
)
- self._attr_is_opening = message.is_opening
- self._attr_is_closing = message.is_closing
- if message.is_closed is not None:
- self._attr_is_closed = message.is_closed
if message.current_position is not None:
+ self._cancel_stop_task()
self._attr_current_cover_position = message.current_position
+ self._move_start_time = None
+ self._attr_is_opening = False
+ self._attr_is_closing = False
+ if message.is_closed is not None:
+ self._attr_is_closed = message.is_closed
+ else:
+ self._attr_is_closed = (self._attr_current_cover_position == 0)
+ elif message.is_opening:
+ self._cancel_stop_task()
+ if not self._attr_is_opening:
+ self._start_position = self.current_cover_position if self.current_cover_position is not None else 0
+ self._move_start_time = time.monotonic()
+ self._attr_is_opening = True
+ self._attr_is_closing = False
+ self._attr_is_closed = False
+ elif message.is_closing:
+ self._cancel_stop_task()
+ if not self._attr_is_closing:
+ self._start_position = self.current_cover_position if self.current_cover_position is not None else 100
+ self._move_start_time = time.monotonic()
+ self._attr_is_opening = False
+ self._attr_is_closing = True
+ else:
+ # Stopped (state == 0 or other)
+ self._cancel_stop_task()
+ if self._move_start_time is not None:
+ elapsed = time.monotonic() - self._move_start_time
+ delta = (elapsed / self._travel_time) * 100
+ if self._attr_is_opening:
+ self._attr_current_cover_position = min(100, int(self._start_position + delta))
+ elif self._attr_is_closing:
+ self._attr_current_cover_position = max(0, int(self._start_position - delta))
+ self._move_start_time = None
+ self._attr_is_opening = False
+ self._attr_is_closing = False
+ if message.is_closed is not None:
+ self._attr_is_closed = message.is_closed
+ elif self._attr_current_cover_position is not None:
+ self._attr_is_closed = (self._attr_current_cover_position == 0)
if self.hass is not None or hasattr(self.async_schedule_update_ha_state, "assert_called"):
try:
self.async_schedule_update_ha_state()
except RuntimeError:
pass
+
diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py
index 71d68059..b1d382b2 100644
--- a/custom_components/myhome/gateway.py
+++ b/custom_components/myhome/gateway.py
@@ -42,6 +42,8 @@
OWNAuxEvent,
OWNHeatingEvent,
OWNHeatingCommand,
+ OWNAlarmEvent,
+ OWNAlarmCommand,
OWNCENPlusEvent,
OWNCENEvent,
OWNGatewayEvent,
@@ -343,6 +345,32 @@ async def listening_loop(self):
self.log_id,
message.human_readable_log,
)
+ elif isinstance(message, OWNAlarmEvent):
+ self.hass.bus.async_fire(
+ "myhome_alarm_event",
+ {
+ "where": str(message.where),
+ "state": message.state_name,
+ "state_code": message.state_code,
+ "is_alarm": message.is_alarm,
+ "message": str(message),
+ },
+ )
+ async_dispatcher_send(
+ self.hass,
+ f"myhome_update_{self.mac}_5_{message.where}",
+ message,
+ )
+ async_dispatcher_send(
+ self.hass,
+ f"myhome_update_{self.mac}_5_0",
+ message,
+ )
+ LOGGER.info(
+ "%s %s",
+ self.log_id,
+ message.human_readable_log,
+ )
elif isinstance(message, OWNGatewayEvent) or isinstance(message, OWNGatewayCommand):
LOGGER.info(
"%s %s",
diff --git a/custom_components/myhome/ownd/message.py b/custom_components/myhome/ownd/message.py
index 19d30469..d35519a3 100644
--- a/custom_components/myhome/ownd/message.py
+++ b/custom_components/myhome/ownd/message.py
@@ -25,6 +25,7 @@
MESSAGE_TYPE_PIR_SENSITIVITY = "pir_sensitivity"
MESSAGE_TYPE_ILLUMINANCE = "illuminance_value"
MESSAGE_TYPE_MOTION_TIMEOUT = "motion_timeout"
+MESSAGE_TYPE_FAN_SPEED = "fan_speed"
CLIMATE_MODE_OFF = "off"
CLIMATE_MODE_HEAT = "heat"
@@ -365,11 +366,12 @@ def parse(cls, data):
elif _who == 18:
return OWNEnergyEvent(data)
elif _who == 25:
- _where = re.match(r"^\*.+\*(?P\d+)##$", data).group("where")
- if _where.startswith("2"):
+ _parts = data.strip("#").split("*")
+ _what = _parts[2] if len(_parts) > 2 else ""
+ _where = _parts[3] if len(_parts) > 3 else ""
+ if _what.startswith("2") or _where.startswith("2"):
return OWNCENPlusEvent(data)
- elif _where.startswith("3"):
- return OWNDryContactEvent(data)
+ return OWNDryContactEvent(data)
elif _who > 1000:
return cls(data)
@@ -456,7 +458,7 @@ def __init__(self, data):
self._motion = True
self._human_readable_log = f"Light/motion sensor {self._where}{self._interface_log_text} detected motion"
- if self._dimension is not None:
+ if self._dimension is not None and self._dimension_value:
if self._dimension == 1 or self._dimension == 4: # Brightness value
self._brightness = int(self._dimension_value[0]) - 100
self._transition = int(self._dimension_value[1])
@@ -737,6 +739,7 @@ def __init__(self, data):
self._human_readable_log = f"Zone {self._zone}'s secondary sensor {self._sensor} is reporting a temperature of {self._secondary_temperature}°C." # pylint: disable=line-too-long
elif self._dimension == 11: # Fan speed
+ self._type = MESSAGE_TYPE_FAN_SPEED
_fan_mode = int(self._dimension_value[0])
if _fan_mode < 4:
self._fan_on = True
@@ -747,12 +750,14 @@ def __init__(self, data):
f"Zone {self._zone}'s fan is on at speed {self._fan_speed}."
)
else:
+ self._fan_speed = 0
self._human_readable_log = (
f"Zone {self._zone}'s fan is on at 'Auto' speed."
)
else:
self._fan_on = False
self._is_active = False
+ self._fan_speed = None
self._human_readable_log = f"Zone {self._zone}'s fan is off."
elif self._dimension == 12: # Local set temperature (set+offset)
@@ -952,6 +957,14 @@ def local_offset(self) -> int:
def local_set_temperature(self) -> float:
return self._local_set_temperature
+ @property
+ def fan_speed(self):
+ return self._fan_speed
+
+ @property
+ def fan_on(self):
+ return self._fan_on
+
class OWNAlarmEvent(OWNEvent):
def __init__(self, data):
@@ -1055,6 +1068,26 @@ def is_active(self):
def is_engaged(self):
return self._state_code == 8
+ @property
+ def is_disarmed(self):
+ return self._state_code in (0, 2, 9)
+
+ @property
+ def is_armed_away(self):
+ return self._state_code in (1, 8)
+
+ @property
+ def is_armed_home(self):
+ return self._state_code in (11,)
+
+ @property
+ def state_name(self):
+ return self._state
+
+ @property
+ def state_code(self):
+ return self._state_code
+
@property
def is_alarm(self):
return (
@@ -1507,8 +1540,8 @@ def __init__(self, data):
super().__init__(data)
self._state = 1 if self._what == 31 else 0
- self._detection = int(self._what_param[0])
- self._sensor = self._where[1:]
+ self._detection = int(self._what_param[0]) if self._what_param else 1
+ self._sensor = self._where[1:] if len(self._where) > 1 and self._where.startswith("3") else self._where
if self._detection == 1:
self._human_readable_log = (
@@ -1706,11 +1739,12 @@ def parse(cls, data):
elif _who == 24:
return cls(data)
elif _who == 25:
- _where = re.match(r"^\*.+\*(?P\d+)##$", data).group("where")
- if _where.startswith("2"):
+ _parts = data.strip("#").split("*")
+ _what = _parts[2] if len(_parts) > 2 else ""
+ _where = _parts[3] if len(_parts) > 3 else ""
+ if _what.startswith("2") or _where.startswith("2"):
return cls(data)
- elif _where.startswith("3"):
- return OWNDryContactCommand(data)
+ return OWNDryContactCommand(data)
elif _who > 1000:
return cls(data)
@@ -1913,6 +1947,51 @@ def set_temperature(cls, where, temperature: float, mode: str, standalone=False)
)
return message
+ @classmethod
+ def set_fan_speed(cls, where, speed: int, standalone=False):
+ central_local = re.compile(r"^#0#\d+$")
+ if central_local.match(str(where)):
+ zone = where
+ zone_name = f"zone {int(where.split('#')[-1])}"
+ else:
+ zone = int(where.split("#")[-1]) if where.startswith("#") else int(where)
+ zone_name = f"zone {zone}" if zone > 0 else "general"
+ if standalone:
+ zone = f"#{zone}" if zone == 0 else str(zone)
+ else:
+ zone = f"#{zone}"
+
+ speed_val = int(speed)
+ message = cls(f"*#4*{zone}*#11*{speed_val}##")
+ message._human_readable_log = f"Setting {zone_name} fan speed to {speed_val}."
+ return message
+
+
+class OWNAlarmCommand(OWNCommand):
+ @classmethod
+ def status(cls, where="0"):
+ message = cls(f"*#5*{where}##")
+ message._human_readable_log = f"Querying burglar alarm status for zone {where}."
+ return message
+
+ @classmethod
+ def disarm(cls, where="0"):
+ message = cls(f"*5*0*{where}##")
+ message._human_readable_log = f"Disarming burglar alarm for zone {where}."
+ return message
+
+ @classmethod
+ def arm_away(cls, where="0"):
+ message = cls(f"*5*1*{where}##")
+ message._human_readable_log = f"Arming burglar alarm (away) for zone {where}."
+ return message
+
+ @classmethod
+ def arm_home(cls, where="0"):
+ message = cls(f"*5*2*{where}##")
+ message._human_readable_log = f"Arming burglar alarm (home) for zone {where}."
+ return message
+
class OWNAVCommand(OWNCommand):
@classmethod
diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py
index 5be5f089..cf3bf6b4 100644
--- a/custom_components/myhome/validate.py
+++ b/custom_components/myhome/validate.py
@@ -29,6 +29,7 @@
DOMAIN as SENSOR,
)
from homeassistant.components.climate import DOMAIN as CLIMATE
+from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_CONTROL_PANEL
from homeassistant.const import CONF_NAME, CONF_MAC
from .const import (
@@ -47,6 +48,7 @@
CONF_DEVICE_CLASS,
CONF_DIMMABLE,
CONF_ADVANCED_SHUTTER,
+ CONF_TRAVEL_TIME,
CONF_INVERTED,
CONF_HEATING_SUPPORT,
CONF_COOLING_SUPPORT,
@@ -340,6 +342,7 @@ def __call__(self, data):
Optional(CONF_ENTITY_NAME): str,
Optional(CONF_ADVANCED_SHUTTER, default=False): Boolean(),
Optional("advanced_shutter", default=False): Boolean(),
+ Optional(CONF_TRAVEL_TIME, default=25): Coerce(int),
Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str,
Optional(CONF_DEVICE_MODEL): Coerce(str),
}
@@ -425,6 +428,19 @@ def __call__(self, data):
}
)
+alarm_control_panel_schema = MyHomeDeviceSchema(
+ {
+ Required(str): {
+ Optional(CONF_WHO, default="5"): "5",
+ Required(CONF_WHERE): All(Coerce(str), Any(General(), Area(), Group(), PointToPoint(), SpecialWhere())),
+ Required(CONF_NAME): str,
+ Optional(CONF_ENTITY_NAME): str,
+ Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str,
+ Optional(CONF_DEVICE_MODEL, default="F4201"): Coerce(str),
+ }
+ }
+)
+
# The device schemas are Schema subclasses whose overridden __call__ performs
# post-processing (rekeying to "who-where" and injecting default keys). Nested
# schema instances are not guaranteed to be invoked through __call__ by the
@@ -440,6 +456,7 @@ def __call__(self, data):
Optional(BINARY_SENSOR): lambda v: binary_sensor_schema(v),
Optional(SENSOR): lambda v: sensor_schema(v),
Optional(CLIMATE): lambda v: climate_schema(v),
+ Optional(ALARM_CONTROL_PANEL): lambda v: alarm_control_panel_schema(v),
}
)
diff --git a/tests/snapshots/test_legacy_platforms.ambr b/tests/snapshots/test_legacy_platforms.ambr
index 403407e7..7cab6eca 100644
--- a/tests/snapshots/test_legacy_platforms.ambr
+++ b/tests/snapshots/test_legacy_platforms.ambr
@@ -41,6 +41,7 @@
,
,
]),
+ 'local_offset': 0,
'max_temp': 40,
'min_temp': 5,
'supported_features': ,
diff --git a/tests/test_component_alarm.py b/tests/test_component_alarm.py
new file mode 100644
index 00000000..c5004ec1
--- /dev/null
+++ b/tests/test_component_alarm.py
@@ -0,0 +1,224 @@
+"""Tests for MyHOME alarm_control_panel platform (WHO=5)."""
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from homeassistant.components.alarm_control_panel import (
+ AlarmControlPanelEntityFeature,
+)
+from homeassistant.const import (
+ STATE_ALARM_DISARMED as STATE_DISARMED,
+ STATE_ALARM_ARMED_HOME as STATE_ARMED_HOME,
+ STATE_ALARM_ARMED_AWAY as STATE_ARMED_AWAY,
+ STATE_ALARM_TRIGGERED as STATE_TRIGGERED,
+ CONF_NAME,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from custom_components.myhome.const import (
+ DOMAIN,
+ CONF_PLATFORMS,
+ CONF_WHERE,
+ CONF_WHO,
+ CONF_ENTITY_NAME,
+ CONF_MANUFACTURER,
+ CONF_DEVICE_MODEL,
+)
+from custom_components.myhome.alarm_control_panel import (
+ MyHOMEAlarmControlPanel,
+ PLATFORM,
+ async_setup_entry,
+ async_unload_entry,
+)
+from custom_components.myhome.ownd.message import (
+ OWNAlarmEvent,
+ OWNAlarmCommand,
+ OWNEvent,
+)
+
+
+@pytest.fixture
+def mock_gateway():
+ gw = MagicMock()
+ gw.mac = "00:03:50:00:55:55"
+ gw.unique_id = "00:03:50:00:55:55"
+ gw.log_id = "[Test Alarm Gateway]"
+ gw.send = AsyncMock()
+ gw.send_status_request = AsyncMock()
+ return gw
+
+
+async def test_alarm_setup_restores_and_discovers(hass: HomeAssistant, mock_gateway):
+ """Test alarm platform setup restoring from registry and discovering new devices."""
+ mac = mock_gateway.mac
+ hass.data = {
+ DOMAIN: {
+ mac: {
+ "entity": mock_gateway,
+ CONF_PLATFORMS: {
+ PLATFORM: {
+ "0": {
+ CONF_WHERE: "0",
+ CONF_NAME: "Central Alarm",
+ },
+ "1": {
+ CONF_WHERE: "1",
+ CONF_NAME: "Zone 1 Alarm",
+ },
+ }
+ },
+ }
+ }
+ }
+
+ config_entry = MagicMock()
+ config_entry.data = {"mac": mac}
+ config_entry.entry_id = "alarm_test_entry"
+
+ # Mock entity registry restore check
+ mock_er = MagicMock()
+ reg_1 = MagicMock()
+ reg_1.domain = PLATFORM
+ reg_1.unique_id = f"{mac}-5-0"
+
+ with patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), \
+ patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1]):
+
+ added_entities = []
+
+ def fake_add_entities(entities):
+ added_entities.extend(entities)
+
+ await async_setup_entry(hass, config_entry, fake_add_entities)
+
+ # Restored (0) + Configured from YAML (1) = 2 alarms
+ assert len(added_entities) == 2
+
+ # Test discovering a new alarm zone 2 via message dispatcher
+ new_alarm_msg = OWNEvent.parse("*5*1*2##")
+ assert isinstance(new_alarm_msg, OWNAlarmEvent)
+ async_dispatcher_send(hass, f"myhome_message_{mac}", new_alarm_msg)
+ assert len(added_entities) == 3
+
+ # Sending message for existing alarm zone triggers update signal rather than creating duplicate
+ async_dispatcher_send(hass, f"myhome_message_{mac}", new_alarm_msg)
+ assert len(added_entities) == 3
+
+ # Unload
+ assert await async_unload_entry(hass, config_entry) is True
+
+
+class TestMyHOMEAlarmEntity:
+ """Test MyHOMEAlarmControlPanel entity methods and features."""
+
+ @pytest.fixture
+ def alarm_central(self, hass, mock_gateway):
+ with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None):
+ alarm = MyHOMEAlarmControlPanel(
+ hass=hass,
+ name="Central Burglar Alarm",
+ entity_name="Central Burglar Alarm",
+ device_id="0",
+ who="5",
+ where="0",
+ manufacturer="BTicino",
+ model="Burglar Alarm 3486",
+ gateway=mock_gateway,
+ )
+ alarm.hass = hass
+ alarm.async_schedule_update_ha_state = MagicMock()
+ return alarm
+
+ @pytest.fixture
+ def alarm_zone1(self, hass, mock_gateway):
+ with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None):
+ alarm = MyHOMEAlarmControlPanel(
+ hass=hass,
+ name="Zone 1 Burglar Alarm",
+ entity_name=None,
+ device_id="1",
+ who="5",
+ where="1",
+ manufacturer="BTicino",
+ model="Burglar Alarm Zone",
+ gateway=mock_gateway,
+ )
+ alarm.hass = hass
+ alarm.async_schedule_update_ha_state = MagicMock()
+ return alarm
+
+ def test_alarm_attributes(self, alarm_central, alarm_zone1):
+ assert alarm_central.supported_features == (
+ AlarmControlPanelEntityFeature.ARM_AWAY
+ | AlarmControlPanelEntityFeature.ARM_HOME
+ | AlarmControlPanelEntityFeature.TRIGGER
+ )
+ assert alarm_central.alarm_state == STATE_DISARMED
+ assert alarm_central.state == STATE_DISARMED
+ assert alarm_central.extra_state_attributes["where"] == "0"
+ assert alarm_central.extra_state_attributes["raw_state"] == "disarmed"
+ assert alarm_zone1.name == "Zone 1 Burglar Alarm"
+
+ async def test_async_lifecycle_and_update(self, alarm_central, alarm_zone1):
+ alarm_central.async_on_remove = MagicMock()
+ await alarm_central.async_added_to_hass()
+ alarm_central.async_on_remove.assert_called_once()
+ alarm_central._gateway_handler.send_status_request.assert_awaited()
+
+ # Zone 1 also subscribes to global zone 0 broadcast
+ alarm_zone1.async_on_remove = MagicMock()
+ await alarm_zone1.async_added_to_hass()
+ assert alarm_zone1.async_on_remove.call_count == 2
+
+ async def test_alarm_commands(self, alarm_central):
+ # Disarm
+ await alarm_central.async_alarm_disarm()
+ alarm_central._gateway_handler.send.assert_awaited()
+ assert str(alarm_central._gateway_handler.send.call_args[0][0]) == "*5*0*0##"
+
+ # Arm Away
+ alarm_central._gateway_handler.send.reset_mock()
+ await alarm_central.async_alarm_arm_away()
+ alarm_central._gateway_handler.send.assert_awaited()
+ assert str(alarm_central._gateway_handler.send.call_args[0][0]) == "*5*1*0##"
+
+ # Arm Home
+ alarm_central._gateway_handler.send.reset_mock()
+ await alarm_central.async_alarm_arm_home()
+ alarm_central._gateway_handler.send.assert_awaited()
+ assert str(alarm_central._gateway_handler.send.call_args[0][0]) == "*5*2*0##"
+
+ # Trigger / Panic
+ alarm_central._gateway_handler.send.reset_mock()
+ await alarm_central.async_alarm_trigger()
+ alarm_central._gateway_handler.send.assert_awaited()
+ assert str(alarm_central._gateway_handler.send.call_args[0][0]) == "*5*4*0##"
+
+ def test_handle_event(self, alarm_central):
+ # Disarmed event (*5*2*0## - deactivation)
+ msg_disarmed = OWNEvent.parse("*5*2*0##")
+ alarm_central.handle_event(msg_disarmed)
+ assert alarm_central.alarm_state == STATE_DISARMED
+ assert alarm_central.extra_state_attributes["raw_state"] == "deactivation"
+ assert alarm_central.extra_state_attributes["state_code"] == 2
+
+ # Armed away event (*5*1*0## - activation)
+ msg_away = OWNEvent.parse("*5*1*0##")
+ alarm_central.handle_event(msg_away)
+ assert alarm_central.alarm_state == STATE_ARMED_AWAY
+ assert alarm_central.extra_state_attributes["raw_state"] == "activation"
+ assert alarm_central.extra_state_attributes["state_code"] == 1
+
+ # Armed home event (*5*11*0## - active zone)
+ msg_home = OWNEvent.parse("*5*11*0##")
+ alarm_central.handle_event(msg_home)
+ assert alarm_central.alarm_state == STATE_ARMED_HOME
+ assert alarm_central.extra_state_attributes["raw_state"] == "active zone"
+ assert alarm_central.extra_state_attributes["state_code"] == 11
+
+ # Triggered event (*5*15*0## - intrusion alarm)
+ msg_alarm = OWNEvent.parse("*5*15*0##")
+ alarm_central.handle_event(msg_alarm)
+ assert alarm_central.alarm_state == STATE_TRIGGERED
+ assert alarm_central.extra_state_attributes["raw_state"] == "intrusion alarm"
+ assert alarm_central.extra_state_attributes["state_code"] == 15
diff --git a/tests/test_component_binary_sensor.py b/tests/test_component_binary_sensor.py
index 28da928d..63a45682 100644
--- a/tests/test_component_binary_sensor.py
+++ b/tests/test_component_binary_sensor.py
@@ -13,6 +13,7 @@
)
from custom_components.myhome.ownd.message import (
OWNDryContactEvent,
+ OWNAuxEvent,
OWNLightingEvent,
MESSAGE_TYPE_MOTION,
MESSAGE_TYPE_PIR_SENSITIVITY,
@@ -343,3 +344,52 @@ async def test_motion_sensor_restore_state_and_timeout_expiration(hass):
# lines 341-343: _attr_is_on becomes False, _last_updated updated, async_schedule_update_ha_state called
assert motion._attr_is_on is False
motion.async_schedule_update_ha_state.assert_called()
+
+
+async def test_binary_sensor_dispatcher_and_discovery(hass):
+ """Test dynamic discovery and forwarding of binary sensor bus events."""
+ from homeassistant.helpers.dispatcher import async_dispatcher_send
+ from custom_components.myhome.ownd.message import OWNEvent
+
+ mock_gateway = MagicMock()
+ mock_gateway.mac = "00:03:50:00:25:25"
+ mock_gateway.send_status_request = AsyncMock()
+
+ hass.data = {
+ DOMAIN: {
+ mock_gateway.mac: {
+ "platforms": {},
+ "entity": mock_gateway,
+ }
+ }
+ }
+ config_entry = MagicMock()
+ config_entry.data = {"mac": mock_gateway.mac}
+
+ added = []
+
+ def fake_add(entities):
+ added.extend(entities)
+
+ assert await async_setup_entry(hass, config_entry, fake_add) is True
+
+ # Discover new dry contact
+ dry_msg = OWNEvent.parse("*25*31#1*99##")
+ async_dispatcher_send(hass, f"myhome_message_{mock_gateway.mac}", dry_msg)
+ assert len(added) == 1
+ assert added[0]._where == "99"
+
+ # Forward Aux event
+ aux_msg = OWNEvent.parse("*9*1*1##")
+ async_dispatcher_send(hass, f"myhome_message_{mock_gateway.mac}", aux_msg)
+
+ # Forward motion lighting event with dimension
+ light_msg = OWNEvent.parse("*#1*12*1##")
+ async_dispatcher_send(hass, f"myhome_message_{mock_gateway.mac}", light_msg)
+
+ # Test async_added_to_hass
+ sensor = added[0]
+ sensor.async_on_remove = MagicMock()
+ await sensor.async_added_to_hass()
+ mock_gateway.send_status_request.assert_awaited()
+
diff --git a/tests/test_component_climate.py b/tests/test_component_climate.py
index 08dfe5f7..6a2d0dcb 100644
--- a/tests/test_component_climate.py
+++ b/tests/test_component_climate.py
@@ -1,7 +1,7 @@
"""Test the MyHOME climate component."""
from unittest.mock import AsyncMock, MagicMock, patch
-from homeassistant.components.climate.const import HVACMode, HVACAction
+from homeassistant.components.climate.const import HVACMode, HVACAction, ClimateEntityFeature
from homeassistant.const import UnitOfTemperature
from custom_components.myhome.climate import (
@@ -507,3 +507,87 @@ async def test_climate_handle_events_action_variations_and_runtime_error(hass):
climate_cool.async_schedule_update_ha_state.side_effect = RuntimeError("State update error")
climate_cool.handle_event(event) # Should not raise
+
+async def test_climate_fan_mode_and_attributes(hass):
+ """Test fancoil fan modes, attributes, and status query on added to hass."""
+ gateway = MagicMock()
+ gateway.mac = "00:03:50:00:11:22"
+ gateway.log_id = "[Test Gateway]"
+ gateway.send = AsyncMock()
+ gateway.send_status_request = AsyncMock()
+
+ climate_fancoil = MyHOMEClimate(
+ hass=hass,
+ name="Fancoil",
+ device_id="device_fan",
+ who="4",
+ where="5",
+ heating=True,
+ cooling=True,
+ fan=True,
+ standalone=False,
+ central=False,
+ manufacturer="BTicino",
+ model="Fancoil Unit",
+ gateway=gateway,
+ )
+ climate_fancoil.hass = hass
+ climate_fancoil.async_schedule_update_ha_state = MagicMock()
+
+ assert climate_fancoil.supported_features & ClimateEntityFeature.FAN_MODE
+ assert climate_fancoil.fan_modes == ["auto", "low", "medium", "high"]
+ assert climate_fancoil.fan_mode == "auto"
+ assert climate_fancoil.extra_state_attributes["local_offset"] == 0
+ assert climate_fancoil.extra_state_attributes["fan_mode"] == "auto"
+
+ # Test setting fan modes: low (1), medium (2), high (3), auto (0)
+ await climate_fancoil.async_set_fan_mode("low")
+ assert climate_fancoil.fan_mode == "low"
+ assert str(gateway.send.call_args[0][0]) == "*#4*#5*#11*1##"
+
+ await climate_fancoil.async_set_fan_mode("medium")
+ assert climate_fancoil.fan_mode == "medium"
+ assert str(gateway.send.call_args[0][0]) == "*#4*#5*#11*2##"
+
+ await climate_fancoil.async_set_fan_mode("high")
+ assert climate_fancoil.fan_mode == "high"
+ assert str(gateway.send.call_args[0][0]) == "*#4*#5*#11*3##"
+
+ await climate_fancoil.async_set_fan_mode("auto")
+ assert climate_fancoil.fan_mode == "auto"
+ assert str(gateway.send.call_args[0][0]) == "*#4*#5*#11*0##"
+
+ # Unknown fan mode
+ gateway.send.reset_mock()
+ await climate_fancoil.async_set_fan_mode("turbo")
+ gateway.send.assert_not_called()
+
+ # Event handling for fan speeds
+ from custom_components.myhome.ownd.message import MESSAGE_TYPE_FAN_SPEED
+ event = MagicMock()
+ event.message_type = MESSAGE_TYPE_FAN_SPEED
+ event.human_readable_log = "Fan speed event"
+
+ event.fan_speed = 1
+ climate_fancoil.handle_event(event)
+ assert climate_fancoil.fan_mode == "low"
+
+ event.fan_speed = 2
+ climate_fancoil.handle_event(event)
+ assert climate_fancoil.fan_mode == "medium"
+
+ event.fan_speed = 3
+ climate_fancoil.handle_event(event)
+ assert climate_fancoil.fan_mode == "high"
+
+ event.fan_speed = 0
+ climate_fancoil.handle_event(event)
+ assert climate_fancoil.fan_mode == "auto"
+
+ # Test async_added_to_hass immediately sends status request
+ climate_fancoil.async_on_remove = MagicMock()
+ await climate_fancoil.async_added_to_hass()
+ gateway.send_status_request.assert_awaited_once()
+ assert str(gateway.send_status_request.call_args[0][0]) == "*#4*5##"
+
+
diff --git a/tests/test_component_cover.py b/tests/test_component_cover.py
index 28fd11fb..221d069b 100644
--- a/tests/test_component_cover.py
+++ b/tests/test_component_cover.py
@@ -1,4 +1,5 @@
"""Tests for the MyHOME cover component."""
+import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@@ -178,10 +179,14 @@ def advanced_cover(self, hass, mock_gateway):
def test_cover_attributes(self, basic_cover, advanced_cover):
assert basic_cover.device_class == CoverDeviceClass.SHUTTER
assert basic_cover.supported_features == (
- CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
+ CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ | CoverEntityFeature.STOP
+ | CoverEntityFeature.SET_POSITION
)
assert basic_cover.extra_state_attributes["A"] == "2"
assert basic_cover.extra_state_attributes["PL"] == "1"
+ assert basic_cover.extra_state_attributes["travel_time"] == 25
assert "Int" not in basic_cover.extra_state_attributes
assert advanced_cover.supported_features == (
@@ -192,6 +197,11 @@ def test_cover_attributes(self, basic_cover, advanced_cover):
)
assert advanced_cover.extra_state_attributes["Int"] == "02"
+ # When current_cover_position is None, is_closed falls back to _attr_is_closed
+ basic_cover._attr_current_cover_position = None
+ basic_cover._attr_is_closed = True
+ assert basic_cover.is_closed is True
+
async def test_async_lifecycle_and_update(self, basic_cover, hass):
basic_cover.async_on_remove = MagicMock()
await basic_cover.async_added_to_hass()
@@ -222,6 +232,43 @@ async def test_cover_commands(self, basic_cover, advanced_cover):
await advanced_cover.async_set_cover_position()
advanced_cover._gateway_handler.send.assert_not_called()
+ # Virtual travel time positioning for basic cover
+ basic_cover._gateway_handler.send.reset_mock()
+ basic_cover._attr_current_cover_position = 50
+ # Set to 50 (same) -> no-op
+ await basic_cover.async_set_cover_position(**{ATTR_POSITION: 50})
+ basic_cover._gateway_handler.send.assert_not_called()
+
+ # Set to 80 (open)
+ with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
+ await basic_cover.async_set_cover_position(**{ATTR_POSITION: 80})
+ assert basic_cover.is_opening is True
+ assert basic_cover._stop_task is not None
+ # Await the stop task
+ await basic_cover._stop_task
+ assert basic_cover.is_opening is False
+
+ # Set to 20 (close)
+ with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
+ await basic_cover.async_set_cover_position(**{ATTR_POSITION: 20})
+ assert basic_cover.is_closing is True
+ assert basic_cover._stop_task is not None
+ await basic_cover._stop_task
+ assert basic_cover.is_closing is False
+
+ # Test active stop task cancellation
+ await basic_cover.async_set_cover_position(**{ATTR_POSITION: 80})
+ task = basic_cover._stop_task
+ assert task is not None
+ await asyncio.sleep(0)
+ basic_cover._cancel_stop_task()
+ assert basic_cover._stop_task is None
+ await asyncio.sleep(0)
+
+ # Unload / remove from hass cleans up tasks
+ await basic_cover.async_will_remove_from_hass()
+ assert basic_cover._stop_task is None
+
def test_handle_event(self, basic_cover):
# Opening event
msg_opening = OWNEvent.parse("*2*1*21##")
@@ -229,18 +276,52 @@ def test_handle_event(self, basic_cover):
assert basic_cover.is_opening is True
assert basic_cover.is_closing is False
+ # Moving position interpolation
+ with patch("time.monotonic", return_value=basic_cover._move_start_time + 12.5):
+ # After 12.5s out of 25s full travel time from 50%, position should advance ~50%
+ pos = basic_cover.current_cover_position
+ assert pos >= 90
+
+ # Stop event
+ msg_stop = OWNEvent.parse("*2*0*21##")
+ basic_cover.handle_event(msg_stop)
+ assert basic_cover.is_opening is False
+ assert basic_cover.is_closing is False
+
# Closing event
msg_closing = OWNEvent.parse("*2*2*21##")
basic_cover.handle_event(msg_closing)
assert basic_cover.is_opening is False
assert basic_cover.is_closing is True
+ # Moving closing interpolation
+ with patch("time.monotonic", return_value=basic_cover._move_start_time + 12.5):
+ pos = basic_cover.current_cover_position
+ assert pos <= 60
+
+ basic_cover.handle_event(msg_stop)
+ assert basic_cover.is_closing is False
+
# Position event
msg_pos = OWNEvent.parse("*#2*21*10*10*0*0*0##")
basic_cover.handle_event(msg_pos)
assert basic_cover.is_closed is True
assert basic_cover.current_cover_position == 0
- # Runtime error in async_schedule_update_ha_state
- basic_cover.async_schedule_update_ha_state.side_effect = RuntimeError("HA shutting down")
- basic_cover.handle_event(msg_pos) # should not raise
+ # Position event without is_closed
+ msg_pos_no_closed = MagicMock(spec=OWNAutomationEvent)
+ msg_pos_no_closed.current_position = 0
+ msg_pos_no_closed.is_closed = None
+ msg_pos_no_closed.human_readable_log = "Pos no closed"
+ basic_cover.handle_event(msg_pos_no_closed)
+ assert basic_cover.is_closed is True
+
+ # Stop event with is_closed reported
+ msg_stopped_with_closed = MagicMock(spec=OWNAutomationEvent)
+ msg_stopped_with_closed.current_position = None
+ msg_stopped_with_closed.is_opening = False
+ msg_stopped_with_closed.is_closing = False
+ msg_stopped_with_closed.is_closed = True
+ msg_stopped_with_closed.human_readable_log = "Stopped with closed"
+ basic_cover.handle_event(msg_stopped_with_closed)
+ assert basic_cover.is_closed is True
diff --git a/tests/test_gateway.py b/tests/test_gateway.py
index be44e326..28bad9ad 100644
--- a/tests/test_gateway.py
+++ b/tests/test_gateway.py
@@ -35,6 +35,7 @@
OWNCENEvent,
OWNGatewayEvent,
OWNGatewayCommand,
+ OWNAlarmEvent,
OWNMessage,
OWNCommand,
)
@@ -354,11 +355,19 @@ async def test_listening_loop_other_events(gateway_handler):
msg_cen.push_button = "4"
msg_cen.human_readable_log = "C"
+ msg_alarm = MagicMock(spec=OWNAlarmEvent)
+ msg_alarm.where = "0"
+ msg_alarm.state_name = "activation"
+ msg_alarm.state_code = 1
+ msg_alarm.is_alarm = False
+ msg_alarm.human_readable_log = "Alarm Activation"
+
mock_session.get_next.side_effect = [
msg_heat,
msg_heat2,
msg_cenplus,
msg_cen,
+ msg_alarm,
asyncio.CancelledError(),
]
mock_session_class.return_value = mock_session
@@ -372,9 +381,20 @@ async def test_listening_loop_other_events(gateway_handler):
gateway_handler.hass.bus.async_fire.assert_any_call("myhome_cenplus_event", {"object": 1, "pushbutton": 2, "event": CONF_SHORT_PRESS})
gateway_handler.hass.bus.async_fire.assert_any_call("myhome_cen_event", {"object": 3, "pushbutton": 4, "event": CONF_LONG_PRESS})
+ gateway_handler.hass.bus.async_fire.assert_any_call(
+ "myhome_alarm_event",
+ {
+ "where": "0",
+ "state": "activation",
+ "state_code": 1,
+ "is_alarm": False,
+ "message": str(msg_alarm),
+ },
+ )
assert gateway_handler.send_status_request.call_count >= 2
+
@pytest.mark.asyncio
async def test_listening_loop_cen_and_cenplus_variants(gateway_handler):
with patch("custom_components.myhome.gateway.OWNEventSession") as mock_session_class:
diff --git a/tests/test_heating.py b/tests/test_heating.py
index 8e03f5cf..094ea23a 100644
--- a/tests/test_heating.py
+++ b/tests/test_heating.py
@@ -184,3 +184,29 @@ def test_set_temperature_clamp_high(self):
def test_command_parse_who4(self):
cmd = OWNCommand.parse("*#4*1##")
assert isinstance(cmd, OWNHeatingCommand)
+
+ def test_fan_speed_and_on_properties(self):
+ msg_speed2 = OWNEvent.parse("*#4*1*11*2##")
+ assert isinstance(msg_speed2, OWNHeatingEvent)
+ assert msg_speed2.fan_speed == 2
+ assert msg_speed2.fan_on is True
+
+ msg_speed0 = OWNEvent.parse("*#4*1*11*0##")
+ assert isinstance(msg_speed0, OWNHeatingEvent)
+ assert msg_speed0.fan_speed == 0
+ assert msg_speed0.fan_on is True
+
+ msg_off = OWNEvent.parse("*#4*1*11*4##")
+ assert isinstance(msg_off, OWNHeatingEvent)
+ assert msg_off.fan_speed is None
+ assert msg_off.fan_on is False
+
+ def test_set_fan_speed_variations(self):
+ cmd_central = OWNHeatingCommand.set_fan_speed("#0#1", 2)
+ assert "*#4*#0#1*#11*2##" == str(cmd_central)
+
+ cmd_standalone_zero = OWNHeatingCommand.set_fan_speed("0", 1, standalone=True)
+ assert "*#4*#0*#11*1##" == str(cmd_standalone_zero)
+
+ cmd_standalone_zone = OWNHeatingCommand.set_fan_speed("1", 3, standalone=True)
+ assert "*#4*1*#11*3##" == str(cmd_standalone_zone)
From 67627e125964a463fa2da8c318492149acb92312 Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:55:02 +0200
Subject: [PATCH 158/927] fix(alarm): remove deprecated _attr_state assignment
in favor of _attr_alarm_state
---
custom_components/myhome/alarm_control_panel.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/custom_components/myhome/alarm_control_panel.py b/custom_components/myhome/alarm_control_panel.py
index a6507ae7..4bfcfef3 100644
--- a/custom_components/myhome/alarm_control_panel.py
+++ b/custom_components/myhome/alarm_control_panel.py
@@ -178,7 +178,6 @@ def __init__(
| AlarmControlPanelEntityFeature.TRIGGER
)
self._attr_alarm_state = STATE_DISARMED
- self._attr_state = STATE_DISARMED
self._attr_extra_state_attributes = {
"where": self._where,
"raw_state": "disarmed",
@@ -251,7 +250,6 @@ def handle_event(self, message: OWNAlarmEvent):
self._attr_alarm_state = STATE_ARMED_HOME
elif message.is_disarmed:
self._attr_alarm_state = STATE_DISARMED
- self._attr_state = self._attr_alarm_state
self._attr_extra_state_attributes["raw_state"] = message.state_name
self._attr_extra_state_attributes["state_code"] = message.state_code
From 34a6e223178c41d6b48a97e2c65c3e1743815cbc Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 9 Sep 2026 10:57:30 +0000
Subject: [PATCH 159/927] chore: auto-generate coverage badge & README table
[skip ci]
---
README.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 408de0fd..7f7e1fed 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
- **Sound System 2.0 & Audio Matrix (WHO=16)**: Complete multi-room audio support for F441 / F441M matrices and amplifiers, including zone power, volume normalization (0–31 scale), software mute emulation, and dynamic streaming proxy.
- **Streaming Audio Dynamic Proxy**: Seamlessly stream from **Music Assistant**, **Spotify Connect**, or any HA media player to wired BTicino audio zones using a thread-safe `DecoderPool` with analog gain-staging.
- **Dimmable Light Detection**: Auto-detects dimming capabilities directly from bus events with transition support.
-- **Comprehensive Test Suite**: Over 720 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
+- **Comprehensive Test Suite**: Over 740 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
---
@@ -235,7 +235,7 @@ check-wheel-contents dist/*.whl
### CI Workflows
- **`hassfest`**: Official Home Assistant manifest and metadata validation.
- **`validate`**: Official HACS compliance checks.
-- **`test-coverage`**: 723 automated unit tests with snapshot matching and coverage tracking.
+- **`test-coverage`**: 748 automated unit tests with snapshot matching and coverage tracking.
- **`pypi_standards`**: Strict wheel hygiene, metadata verification, and packaging checks.
### 📊 Code Coverage & Quality Assurance
@@ -247,6 +247,7 @@ The integration maintains 739 automated unit tests (100% line coverage across al
| Component / Module | Coverage | Notes |
|---|:---:|---|
| [`__init__.py`](custom_components/myhome/__init__.py) | **100%** | Setup lifecycle and zero-friction entity migration |
+| [`alarm_control_panel.py`](custom_components/myhome/alarm_control_panel.py) | **100%** | Core integration component |
| [`binary_sensor.py`](custom_components/myhome/binary_sensor.py) | **100%** | Magnetic contacts, door/window sensors, motion sensors |
| [`bus_monitor.py`](custom_components/myhome/bus_monitor.py) | **100%** | In-band 500-frame circular ring buffer tap (0 extra sockets) |
| [`button.py`](custom_components/myhome/button.py) | **100%** | Scenario buttons and bus diagnostic pings |
@@ -285,7 +286,7 @@ The integration maintains 739 automated unit tests (100% line coverage across al
- [x] Declarative hardware gateway profiles (`MH200` to `F454`).
- [x] Zero-friction migration (preserving custom entity IDs and friendly names).
- [x] Token-bucket bus pacing and sentinel worker lifecycle.
- - [x] Synthetic mock TCP test harness with 723+ unit tests (100% coverage).
+ - [x] Synthetic mock TCP test harness with 748+ unit tests (100% coverage).
- [x] PyPI packaging pipeline & GitHub Actions CI green across all workflows.
- [x] **Phase 2: Monitoring & Real-time Diagnostics**
- [x] In-band Unified Bus Listener (500-frame circular ring buffer, 0 extra sockets).
From 39247ca93f006a611eba8f7861a4b60dabc5ef52 Mon Sep 17 00:00:00 2001
From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com>
Date: Wed, 9 Sep 2026 13:31:54 +0200
Subject: [PATCH 160/927] fix(bus-card): resolve Lovelace resource
auto-registration and websocket command registration (#237)
- Fix WebSocket command registration using @websocket_api.websocket_command decorators so command strings map properly in HA websocket handlers
- Allow nullable mac in schemas and omit null/undefined mac from card payloads
- Add resilient exponential backoff retry and connection status badges in myhome-bus-card
- Ensure idempotent customElement and window.customCards registration
- Add lovelace to after_dependencies and auto-register card resource with deferred startup listener
- Maintain 100% automated test coverage across all components
---
README.md | 10 +-
custom_components/myhome/__init__.py | 81 ++++---
custom_components/myhome/const.py | 1 +
.../myhome/frontend/myhome-bus-card.js | 209 +++++++++++++-----
custom_components/myhome/manifest.json | 3 +-
custom_components/myhome/websocket.py | 118 ++++------
tests/test_init.py | 38 ++++
tests/test_websocket.py | 58 +++++
8 files changed, 361 insertions(+), 157 deletions(-)
diff --git a/README.md b/README.md
index 7f7e1fed..c96175dd 100644
--- a/README.md
+++ b/README.md
@@ -7,14 +7,18 @@
[](https://app.codecov.io/gh/OpenWebNet-HA/MyHOME/tree/v2-phase1-architecture)
[](https://github.com/OpenWebNet-HA/MyHOME/actions/workflows/pypi_standards.yml)
[](https://hacs.xyz)
+[](https://github.com/OpenWebNet-HA/MyHOME/releases)
[](https://www.python.org/)
[](https://github.com/OpenWebNet-HA/MyHOME/wiki/OpenWebNet-Protocol-&-WHO-Specifications)
+[](https://github.com/OpenWebNet-HA/MyHOME/discussions)
[](https://www.gnu.org/licenses/gpl-3.0)
Modern, async-native Home Assistant integration for **BTicino / Legrand MyHOME** SCS bus systems connected via OpenWebNet IP gateways.
Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** community organisation.
+[📦 Installation](#-installation) • [🏛️ Supported Hardware](#️-supported-hardware) • [📚 Wiki Docs](https://github.com/OpenWebNet-HA/MyHOME/wiki) • [💬 Discussions](https://github.com/OpenWebNet-HA/MyHOME/discussions) • [🤝 Contributing](CONTRIBUTING.md) • [🔒 Security](SECURITY.md)
+
> [!TIP]
> **🧪 Community Testing Active**: The modernized architecture is currently undergoing community validation in **[PR #232](https://github.com/OpenWebNet-HA/MyHOME/pull/232)**. Because HACS does not index unmerged Git branches from its default store, please see the [Testing the V2 Architecture Branch](#testing-the-v2-architecture-branch-v2-phase1-architecture) guide below to install it in 2 minutes!
@@ -30,7 +34,7 @@ Maintained by the **[OpenWebNet-HA](https://github.com/OpenWebNet-HA)** communit
- **Sound System 2.0 & Audio Matrix (WHO=16)**: Complete multi-room audio support for F441 / F441M matrices and amplifiers, including zone power, volume normalization (0–31 scale), software mute emulation, and dynamic streaming proxy.
- **Streaming Audio Dynamic Proxy**: Seamlessly stream from **Music Assistant**, **Spotify Connect**, or any HA media player to wired BTicino audio zones using a thread-safe `DecoderPool` with analog gain-staging.
- **Dimmable Light Detection**: Auto-detects dimming capabilities directly from bus events with transition support.
-- **Comprehensive Test Suite**: Over 740 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
+- **Comprehensive Test Suite**: Over 750 automated unit tests (100% line coverage) executed across modern Python 3.12+ and Home Assistant core standards.
---
@@ -235,7 +239,7 @@ check-wheel-contents dist/*.whl
### CI Workflows
- **`hassfest`**: Official Home Assistant manifest and metadata validation.
- **`validate`**: Official HACS compliance checks.
-- **`test-coverage`**: 748 automated unit tests with snapshot matching and coverage tracking.
+- **`test-coverage`**: 750 automated unit tests with snapshot matching and coverage tracking.
- **`pypi_standards`**: Strict wheel hygiene, metadata verification, and packaging checks.
### 📊 Code Coverage & Quality Assurance
@@ -286,7 +290,7 @@ The integration maintains 739 automated unit tests (100% line coverage across al
- [x] Declarative hardware gateway profiles (`MH200` to `F454`).
- [x] Zero-friction migration (preserving custom entity IDs and friendly names).
- [x] Token-bucket bus pacing and sentinel worker lifecycle.
- - [x] Synthetic mock TCP test harness with 748+ unit tests (100% coverage).
+ - [x] Synthetic mock TCP test harness with 750+ unit tests (100% coverage).
- [x] PyPI packaging pipeline & GitHub Actions CI green across all workflows.
- [x] **Phase 2: Monitoring & Real-time Diagnostics**
- [x] In-band Unified Bus Listener (500-frame circular ring buffer, 0 extra sockets).
diff --git a/custom_components/myhome/__init__.py b/custom_components/myhome/__init__.py
index fafe1657..aef12b97 100644
--- a/custom_components/myhome/__init__.py
+++ b/custom_components/myhome/__init__.py
@@ -32,50 +32,69 @@
PLATFORMS = ["light", "switch", "cover", "climate", "binary_sensor", "sensor", "media_player", "button", "alarm_control_panel"]
+async def _async_register_lovelace_resource(hass: HomeAssistant, url_path: str) -> bool:
+ """Auto-register resource in Lovelace dashboard resources collection."""
+ try:
+ lovelace = hass.data.get("lovelace")
+ if not lovelace:
+ return False
+ resources = getattr(lovelace, "resources", None)
+ if not resources:
+ return False
+ if hasattr(resources, "loaded") and not resources.loaded:
+ await resources.async_load()
+ resources.loaded = True
+ if hasattr(resources, "async_create_item"):
+ existing = [item["url"] for item in (resources.async_items() or []) if isinstance(item, dict) and "url" in item]
+ if url_path not in existing:
+ await resources.async_create_item({
+ "res_type": "module",
+ "url": url_path,
+ })
+ return True
+ except Exception as e:
+ LOGGER.debug("Could not auto-register Lovelace resource: %s", e)
+ return False
+
+
async def _async_register_frontend(hass: HomeAssistant) -> None:
"""Register the Lovelace bus monitor card static resource and script."""
domain_data = hass.data.setdefault(DOMAIN, {})
- if domain_data.get("_frontend_registered"):
- return
card_path = os.path.join(os.path.dirname(__file__), "frontend", "myhome-bus-card.js")
url_path = "/myhome_static/myhome-bus-card.js"
http = getattr(hass, "http", None)
- if http is not None and os.path.isfile(card_path):
- if hasattr(http, "async_register_static_paths"):
- try:
- from homeassistant.components.http import StaticPathConfig
- await http.async_register_static_paths([
- StaticPathConfig(url_path, card_path, False)
- ])
- except Exception:
+ if not domain_data.get("_frontend_registered"):
+ if http is not None and os.path.isfile(card_path):
+ if hasattr(http, "async_register_static_paths"):
+ try:
+ from homeassistant.components.http import StaticPathConfig
+ await http.async_register_static_paths([
+ StaticPathConfig(url_path, card_path, False)
+ ])
+ except Exception:
+ http.register_static_path(url_path, card_path, False)
+ elif hasattr(http, "register_static_path"):
http.register_static_path(url_path, card_path, False)
- elif hasattr(http, "register_static_path"):
- http.register_static_path(url_path, card_path, False)
- try:
- from homeassistant.components import frontend
- frontend.add_extra_js_url(hass, url_path)
- except Exception as e:
- LOGGER.debug("Could not add extra js url for Lovelace card: %s", e)
+ try:
+ from homeassistant.components import frontend
+ frontend.add_extra_js_url(hass, url_path)
+ except Exception as e:
+ LOGGER.debug("Could not add extra js url for Lovelace card: %s", e)
- domain_data["_frontend_registered"] = True
+ domain_data["_frontend_registered"] = True
# Auto-register resource in Lovelace dashboard resources collection
- try:
- lovelace = hass.data.get("lovelace")
- if lovelace:
- resources = getattr(lovelace, "resources", None)
- if resources and hasattr(resources, "async_create_item"):
- existing = [item["url"] for item in resources.async_items()]
- if url_path not in existing:
- await resources.async_create_item({
- "res_type": "module",
- "url": url_path,
- })
- except Exception as e:
- LOGGER.debug("Could not auto-register Lovelace resource: %s", e)
+ if not await _async_register_lovelace_resource(hass, url_path):
+ if not domain_data.get("_lovelace_listener_registered"):
+ async def _on_ha_started(event):
+ await _async_register_lovelace_resource(hass, url_path)
+
+ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_ha_started)
+ domain_data["_lovelace_listener_registered"] = True
async def async_setup(hass, config):
diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py
index 159b89a0..2d8777fd 100644
--- a/custom_components/myhome/const.py
+++ b/custom_components/myhome/const.py
@@ -6,6 +6,7 @@
ATTR_GATEWAY = "gateway"
ATTR_MESSAGE = "message"
+INTEGRATION_VERSION = "1.0.0-beta"
CONF = "config"
CONF_ENTITY = "entity"
diff --git a/custom_components/myhome/frontend/myhome-bus-card.js b/custom_components/myhome/frontend/myhome-bus-card.js
index 8fec25d0..0ea23707 100644
--- a/custom_components/myhome/frontend/myhome-bus-card.js
+++ b/custom_components/myhome/frontend/myhome-bus-card.js
@@ -18,6 +18,11 @@ class MyHomeBusCard extends HTMLElement {
this._unsub = null;
this._stats = { captured: 0, total_rx: 0, total_tx: 0 };
this._gatewayInfo = {};
+ this._connectionStatus = "connecting"; // "connecting" | "connected" | "disconnected" | "paused"
+ this._retryTimeout = null;
+ this._retryDelay = 1000;
+ this._maxRetryDelay = 30000;
+ this._isSubscribing = false;
}
setConfig(config) {
@@ -44,32 +49,47 @@ class MyHomeBusCard extends HTMLElement {
// Connect stream once hass is available
if (!oldHass && hass) {
this._subscribeStream();
- this._loadHistory();
+ } else if (oldHass && hass && oldHass.connection !== hass.connection) {
+ if (this._unsub) {
+ try { this._unsub(); } catch (e) {}
+ this._unsub = null;
+ }
+ this._subscribeStream();
}
}
connectedCallback() {
- if (this._hass && !this._unsub) {
+ if (this._hass && !this._unsub && !this._isSubscribing) {
this._subscribeStream();
- this._loadHistory();
}
}
disconnectedCallback() {
+ if (this._retryTimeout) {
+ clearTimeout(this._retryTimeout);
+ this._retryTimeout = null;
+ }
if (this._unsub) {
- this._unsub();
+ try { this._unsub(); } catch (e) {}
this._unsub = null;
}
+ this._isSubscribing = false;
+ }
+
+ _wsPayload(type, extra = {}) {
+ const payload = Object.assign({ type }, extra);
+ if (this._config && this._config.mac != null && String(this._config.mac).trim() !== "") {
+ payload.mac = String(this._config.mac).trim();
+ }
+ return payload;
}
async _loadHistory() {
if (!this._hass) return;
try {
- const res = await this._hass.callWS({
- type: "myhome/bus_monitor/history",
- mac: this._config.mac,
- limit: 50,
- });
+ const res = await this._hass.callWS(
+ this._wsPayload("myhome/bus_monitor/history", { limit: 50 })
+ );
if (res && res.frames) {
this._frames = res.frames;
if (res.stats) this._stats = res.stats;
@@ -83,21 +103,90 @@ class MyHomeBusCard extends HTMLElement {
}
async _subscribeStream() {
- if (!this._hass || this._unsub) return;
+ if (!this._hass || this._unsub || this._isSubscribing) return;
+ this._isSubscribing = true;
+ this._updateConnectionStatus("connecting");
+
try {
this._unsub = await this._hass.connection.subscribeMessage(
(frame) => this._onNewFrame(frame),
- {
- type: "myhome/bus_monitor/stream",
- mac: this._config.mac,
- }
+ this._wsPayload("myhome/bus_monitor/stream")
);
+ this._isSubscribing = false;
+ this._retryDelay = 1000;
+ this._updateConnectionStatus("connected");
+ this._loadHistory();
} catch (err) {
- console.error("MyHOME Bus Monitor: Failed to subscribe to stream", err);
+ this._isSubscribing = false;
+ console.warn(`MyHOME Bus Monitor: Failed to subscribe to stream, retrying in ${this._retryDelay / 1000}s`, err);
+ this._updateConnectionStatus("disconnected");
+ this._scheduleRetry();
+ }
+ }
+
+ _scheduleRetry() {
+ if (this._retryTimeout) {
+ clearTimeout(this._retryTimeout);
+ this._retryTimeout = null;
+ }
+ const delay = this._retryDelay;
+ this._retryTimeout = setTimeout(() => {
+ this._retryTimeout = null;
+ if (this._hass && !this._unsub && !this._isSubscribing) {
+ this._subscribeStream();
+ }
+ }, delay);
+
+ this._retryDelay = Math.min(this._retryDelay * 2, this._maxRetryDelay);
+ }
+
+ _updateConnectionStatus(status) {
+ if (this._isPaused) {
+ this._connectionStatus = "paused";
+ } else {
+ this._connectionStatus = status;
+ }
+ this._updateBadge();
+ this._updatePlaceholder();
+ }
+
+ _updateBadge() {
+ const badge = this.shadowRoot && this.shadowRoot.getElementById("badge");
+ if (!badge) return;
+
+ if (this._isPaused) {
+ badge.textContent = "PAUSED";
+ badge.className = "badge badge-paused";
+ } else if (this._connectionStatus === "connected") {
+ badge.textContent = "LIVE";
+ badge.className = "badge badge-live";
+ } else if (this._connectionStatus === "connecting") {
+ badge.textContent = "CONNECTING...";
+ badge.className = "badge badge-connecting";
+ } else if (this._connectionStatus === "disconnected") {
+ badge.textContent = "DISCONNECTED";
+ badge.className = "badge badge-disconnected";
+ }
+ }
+
+ _updatePlaceholder() {
+ const container = this.shadowRoot && this.shadowRoot.getElementById("stream");
+ if (!container) return;
+ if (this._frames.length === 0) {
+ let msg = "Waiting for OpenWebNet bus frames...";
+ if (this._connectionStatus === "connecting") {
+ msg = "Connecting to MyHOME gateway stream...";
+ } else if (this._connectionStatus === "disconnected") {
+ msg = `Disconnected from MyHOME gateway. Reconnecting in ${Math.round(this._retryDelay / 1000)}s...`;
+ }
+ container.innerHTML = `${msg}
`;
}
}
_onNewFrame(frame) {
+ if (this._connectionStatus !== "connected" && !this._isPaused) {
+ this._updateConnectionStatus("connected");
+ }
if (this._isPaused) return;
if (frame.direction === "rx") this._stats.total_rx++;
@@ -190,10 +279,24 @@ class MyHomeBusCard extends HTMLElement {
background: rgba(76, 175, 80, 0.15);
color: #2e7d32;
}
+ .badge-connecting {
+ background: rgba(255, 152, 0, 0.15);
+ color: #f57c00;
+ }
+ .badge-disconnected {
+ background: rgba(244, 67, 54, 0.15);
+ color: #d32f2f;
+ }
.badge-paused {
background: rgba(244, 67, 54, 0.15);
color: #d32f2f;
}
+ .placeholder-msg {
+ color: #888;
+ font-style: italic;
+ padding: 24px 16px;
+ text-align: center;
+ }
.stats-bar {
display: flex;
gap: 16px;
@@ -338,7 +441,7 @@ class MyHomeBusCard extends HTMLElement {