From 300ae3545759d6e08fa3273868f7deb4f72676b2 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:57:56 +0200 Subject: [PATCH 1/7] feat(cenplus): support WHO 25 Scenario Plus commands and events (OWNd#91) --- custom_components/myhome/const.py | 3 + custom_components/myhome/gateway_events.py | 44 ++++++++++++++ custom_components/myhome/services.py | 43 ++++++++++++++ custom_components/myhome/services.yaml | 32 ++++++++++ custom_components/myhome/strings.json | 18 ++++++ custom_components/myhome/translations/en.json | 18 ++++++ docs/configuration/cen_cenplus.md | 6 ++ docs/configuration/services.md | 23 ++++++++ tests/test_gateway.py | 59 +++++++++++++++++++ tests/test_services.py | 56 ++++++++++++++++++ 10 files changed, 302 insertions(+) diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py index 03ce4f445..b0f7c4dee 100644 --- a/custom_components/myhome/const.py +++ b/custom_components/myhome/const.py @@ -11,6 +11,8 @@ ATTR_GATEWAY = "gateway" ATTR_MESSAGE = "message" +ATTR_WHERE = "where" +ATTR_ACTION = "action" INTEGRATION_VERSION = "2.0.0b15" # hass.data[DOMAIN] key holding the OWNd version resolved off the event loop DATA_OWND_VERSION = "_ownd_version" @@ -107,6 +109,7 @@ def get_ownd_version() -> str: SERVICE_STOP_COVER_CALIBRATION = "stop_cover_calibration" SERVICE_SET_COVER_TRAVEL_TIME = "set_cover_travel_time" SERVICE_RESET_COVER_TRAVEL_TIME = "reset_cover_travel_time" +SERVICE_SEND_SCENARIO_PLUS_COMMAND = "send_scenario_plus_command" EVENT_COVER_CALIBRATION = "myhome_cover_calibration" CALIBRATION_RUN_TIMEOUT = 180.0 # s to wait for the actuator's stop status per run CALIBRATION_MIN_RUN = 1.0 # s: anything shorter is not a full travel diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index f0de727e7..6cfd7bfe6 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -27,6 +27,12 @@ OWNMessage, ) +try: # OWNd > 2.0.0b10 (OpenWebNet-HA/OWNd#91) + from OWNd.message import OWNScenarioPlusEvent +except ImportError: # pragma: no cover - pinned OWNd 2.0.0b10 fallback + class OWNScenarioPlusEvent: # type: ignore[no-redef] + pass + from .const import ( CONF_LONG_PRESS, CONF_LONG_PRESS_REPEAT, @@ -420,6 +426,44 @@ async def process_message(self, message: Any) -> None: self.handler.log_id, message.human_readable_log, ) + elif isinstance(message, OWNScenarioPlusEvent): + raw_obj = str(message.object) + clean_obj = raw_obj.split("#")[0] + try: + obj_val: int | str = int(clean_obj) + except (ValueError, TypeError): # pragma: no cover - defensive + obj_val = raw_obj + + target_mac: str | None = self.handler.mac + config_entry = getattr(self.handler, "config_entry", None) + target_entry_id = getattr(config_entry, "entry_id", None) if config_entry else None + + if getattr(self.handler, "is_standby", False): + primary_gw = self.handler._get_primary_gateway() + if primary_gw is not None and not primary_gw.is_connected and self.handler._profile_supports_who(25): + target_mac = primary_gw.mac + pri_entry = getattr(primary_gw, "config_entry", None) + target_entry_id = getattr(pri_entry, "entry_id", None) if pri_entry else None + else: + target_mac = None + + if target_mac is not None and self._is_active_for_who(25): + self.handler._ensure_cen_device(25, raw_obj) + scenarioplus_payload: dict[str, Any] = { + "object": obj_val, + "action": getattr(message, "action", None), + "where": raw_obj, + "gateway_mac": target_mac, + } + if target_entry_id and isinstance(target_entry_id, str): + scenarioplus_payload["entry_id"] = target_entry_id + self.hass.bus.async_fire("myhome_scenario_plus_event", scenarioplus_payload) + dispatcher_send(self.hass, f"myhome_scenario_plus_event_{target_mac}", scenarioplus_payload) + self._logger.debug( + "%s %s", + self.handler.log_id, + message.human_readable_log, + ) elif isinstance(message, OWNCENEvent): event = None if message.is_pressed: diff --git a/custom_components/myhome/services.py b/custom_components/myhome/services.py index 4d24be3dd..a6969e5d8 100644 --- a/custom_components/myhome/services.py +++ b/custom_components/myhome/services.py @@ -10,9 +10,12 @@ from homeassistant.helpers import device_registry as dr from .const import ( + ATTR_ACTION, ATTR_GATEWAY, ATTR_MESSAGE, + ATTR_WHERE, DOMAIN, + SERVICE_SEND_SCENARIO_PLUS_COMMAND, SERVICE_STOP_COVER_CALIBRATION, ) from .data import get_runtime_data @@ -207,7 +210,47 @@ async def handle_stop_cover_calibration(call: ServiceCall) -> None: gateway = call.data.get(ATTR_GATEWAY, None) await async_stop_cover_calibration(hass, gateway_mac=gateway) + async def handle_send_scenario_plus_command(call: ServiceCall) -> None: + """Handle sending a scenario plus command.""" + where = call.data.get(ATTR_WHERE) + action = str(call.data.get(ATTR_ACTION, "")).lower() + gateway = call.data.get(ATTR_GATEWAY, None) + if gateway is not None: + gateway = dr.format_mac(gateway) + handler = _get_gateway_handler(hass, gateway) + if handler is None: + _LOGGER.error("Gateway `%s` not found for scenario plus command.", gateway) + return + + if where is None: + _LOGGER.error("No where/object specified for scenario plus command.") + return + + try: + from OWNd.message import OWNScenarioPlusCommand + except ImportError: # pragma: no cover + _LOGGER.error("OWNd does not support scenario plus commands.") + return + + cmd: OWNScenarioPlusCommand | None = None + if action == "on": + cmd = OWNScenarioPlusCommand.turn_on(where) + elif action == "off": + cmd = OWNScenarioPlusCommand.turn_off(where) + elif action == "increase": + cmd = OWNScenarioPlusCommand.increase(where) + elif action == "decrease": + cmd = OWNScenarioPlusCommand.decrease(where) + elif action == "stop": + cmd = OWNScenarioPlusCommand.stop(where) + else: + _LOGGER.error("Unknown scenario plus action `%s`.", action) + return + + await handler.send(cmd) + hass.services.async_register(DOMAIN, SERVICE_SYNC_TIME, handle_sync_time) hass.services.async_register(DOMAIN, SERVICE_SEND_MESSAGE, handle_send_message) hass.services.async_register(DOMAIN, SERVICE_SWEEP_BUS, handle_sweep_bus) hass.services.async_register(DOMAIN, SERVICE_STOP_COVER_CALIBRATION, handle_stop_cover_calibration) + hass.services.async_register(DOMAIN, SERVICE_SEND_SCENARIO_PLUS_COMMAND, handle_send_scenario_plus_command) diff --git a/custom_components/myhome/services.yaml b/custom_components/myhome/services.yaml index a09700078..eed5e233b 100644 --- a/custom_components/myhome/services.yaml +++ b/custom_components/myhome/services.yaml @@ -213,3 +213,35 @@ tuner_seek_down: entity: domain: media_player integration: myhome + +send_scenario_plus_command: + name: Send scenario plus command + description: Send a WHO 25 scenario plus command (on, off, increase, decrease, stop) to an addressed scenario plus object. + fields: + gateway: + name: Gateway + description: The gateway's MAC address (optional; defaults to primary gateway). + example: "00:03:50:00:00:01" + selector: + text: + where: + name: Where + description: The scenario plus object address on the bus. + required: true + example: "11" + selector: + text: + action: + name: Action + description: The scenario plus action to execute (on, off, increase, decrease, or stop). + required: true + example: "on" + selector: + select: + options: + - "on" + - "off" + - "increase" + - "decrease" + - "stop" + diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index 2dc8abc0e..42850564a 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -325,6 +325,24 @@ "tuner_seek_down": { "name": "Tuner seek down", "description": "Seek backward to the previous receivable FM radio frequency on an F500 tuner source." + }, + "send_scenario_plus_command": { + "name": "Send scenario plus command", + "description": "Send a WHO 25 scenario plus command (on, off, increase, decrease, stop) to an addressed scenario plus object.", + "fields": { + "gateway": { + "name": "Gateway", + "description": "The gateway's MAC address (optional; defaults to primary gateway)." + }, + "where": { + "name": "Where", + "description": "The scenario plus object address on the bus." + }, + "action": { + "name": "Action", + "description": "The scenario plus action to execute (on, off, increase, decrease, or stop)." + } + } } }, "issues": { diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index 2dc8abc0e..42850564a 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -325,6 +325,24 @@ "tuner_seek_down": { "name": "Tuner seek down", "description": "Seek backward to the previous receivable FM radio frequency on an F500 tuner source." + }, + "send_scenario_plus_command": { + "name": "Send scenario plus command", + "description": "Send a WHO 25 scenario plus command (on, off, increase, decrease, stop) to an addressed scenario plus object.", + "fields": { + "gateway": { + "name": "Gateway", + "description": "The gateway's MAC address (optional; defaults to primary gateway)." + }, + "where": { + "name": "Where", + "description": "The scenario plus object address on the bus." + }, + "action": { + "name": "Action", + "description": "The scenario plus action to execute (on, off, increase, decrease, or stop)." + } + } } }, "issues": { diff --git a/docs/configuration/cen_cenplus.md b/docs/configuration/cen_cenplus.md index 77783eab1..9ace5159a 100644 --- a/docs/configuration/cen_cenplus.md +++ b/docs/configuration/cen_cenplus.md @@ -18,6 +18,7 @@ BTicino / Legrand pushbuttons operate in either **CEN** (`WHO = 15`) or **CEN+** | **Release Long Press** | `WHAT = 2` | `WHAT = 24` | | **Rotary Dials** | Supported via vendor extensions | Supported (CW/CCW slow & fast) | | **Dry Contact Status** | N/A | `WHAT = 31` (Closed), `WHAT = 32` (Opened) | +| **Scenario Plus Actions** | N/A | `WHAT = 11..15` (`on`, `off`, `increase`, `decrease`, `stop`) | --- @@ -180,3 +181,8 @@ Enabling **Generate Events** in the **Options Flow** additionally fires every bu | **CEN+ Start Long** | 25 | `*25*22#