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.py b/custom_components/myhome/gateway.py index f2e454d29..977109960 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -314,9 +314,9 @@ def _event_session_ready(self) -> asyncio.Event: """Event set when the event session is established.""" return self._event_runner._event_session_ready - def _ensure_cen_device(self, who: int, object_id: int | str) -> None: + def _ensure_cen_device(self, who: int, object_id: int | str, model: str | None = None) -> None: """Ensure CEN/CEN+ scenario unit is registered in device registry.""" - self._event_dispatcher.ensure_cen_device(who, object_id) + self._event_dispatcher.ensure_cen_device(who, object_id, model=model) @property def identification_source(self) -> str: diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index f0de727e7..14ce79309 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(OWNMessage): # type: ignore[misc,no-redef] + pass + from .const import ( CONF_LONG_PRESS, CONF_LONG_PRESS_REPEAT, @@ -75,7 +81,12 @@ def cen_devices(self) -> set[tuple[int, Any]]: """Return the registered CEN device set.""" return self._cen_devices - def ensure_cen_device(self, who: int, object_id: int | str) -> None: + def ensure_cen_device( + self, + who: int, + object_id: int | str, + model: str | None = None, + ) -> None: """Ensure CEN/CEN+ scenario unit is registered in device registry.""" if getattr(self.handler, "is_standby", False) or not self._is_active_for_who(who): # Standby gateways never register devices on their own config entry, @@ -141,7 +152,7 @@ def ensure_cen_device(self, who: int, object_id: int | str) -> None: identifiers=identifiers, name=dev_name, manufacturer="BTicino", - model=f"{type_name} Scenario Control", + model=model or f"{type_name} Scenario Control", **via_kwargs, ) self._cen_devices.add(device_key) @@ -420,6 +431,47 @@ 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) if getattr(message, "object", None) is not None else "" + if not raw_obj and getattr(message, "where", None) is not None: # pragma: no cover - defensive + raw_obj = str(message.where) + clean_obj = raw_obj.split("#")[0] + try: + obj_val = int(clean_obj) + except (ValueError, TypeError): # pragma: no cover - defensive + obj_val = raw_obj + + target_mac = 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): + if raw_obj: + self.handler._ensure_cen_device(25, raw_obj, model="CEN+ Scenario Plus Control") + 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..208a783b3 100644 --- a/custom_components/myhome/services.py +++ b/custom_components/myhome/services.py @@ -3,16 +3,20 @@ import asyncio import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError 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 +211,86 @@ 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.""" + 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: + if gateway is not None: + raise ServiceValidationError( + f"Gateway '{gateway}' not found for scenario plus command", + translation_domain=DOMAIN, + translation_key="scenario_plus_gateway_not_found", + translation_placeholders={"gateway": str(gateway)}, + ) + raise ServiceValidationError( + "No active MyHOME gateways found", + translation_domain=DOMAIN, + translation_key="scenario_plus_no_gateway", + ) + + where_raw = call.data.get(ATTR_WHERE) + if where_raw is None or not str(where_raw).strip(): + raise ServiceValidationError( + "No where/object specified for scenario plus command", + translation_domain=DOMAIN, + translation_key="scenario_plus_missing_where", + ) + where = str(where_raw).strip() + + action = str(call.data.get(ATTR_ACTION) or "").lower().strip() + allowed_actions = ("on", "off", "increase", "decrease", "stop") + if action not in allowed_actions: + raise ServiceValidationError( + f"Unknown scenario plus action '{action}'", + translation_domain=DOMAIN, + translation_key="scenario_plus_unknown_action", + translation_placeholders={"action": action}, + ) + + try: + from OWNd.message import OWNScenarioPlusCommand + except ImportError: # pragma: no cover - fallback when OWNd unreleased + OWNScenarioPlusCommand = None + + cmd: Any = None + if OWNScenarioPlusCommand is not 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: + action_frames = { + "on": f"*25*11#0*{where}##", + "off": f"*25*12*{where}##", + "increase": f"*25*13#0#5*{where}##", + "decrease": f"*25*14#0#5*{where}##", + "stop": f"*25*15*{where}##", + } + from OWNd.message import OWNCommand + + cmd = OWNCommand.parse(action_frames[action]) + + if cmd is None: + raise ServiceValidationError( + f"Could not build scenario plus command for where '{where}' and action '{action}'", + translation_domain=DOMAIN, + translation_key="scenario_plus_command_invalid", + translation_placeholders={"where": where, "action": action}, + ) + + 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..ac84851b4 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; step/speed for increase/decrease is fixed per protocol). + 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..6d4054314 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; step/speed for increase/decrease is fixed per protocol)." + } + } } }, "issues": { @@ -504,6 +522,21 @@ }, "seek_not_supported": { "message": "{entity_id}: seek is only supported on tuner source entities" + }, + "scenario_plus_gateway_not_found": { + "message": "Gateway \"{gateway}\" not found for scenario plus command" + }, + "scenario_plus_no_gateway": { + "message": "No active MyHOME gateways found" + }, + "scenario_plus_missing_where": { + "message": "No where/object specified for scenario plus command" + }, + "scenario_plus_unknown_action": { + "message": "Unknown scenario plus action \"{action}\"" + }, + "scenario_plus_command_invalid": { + "message": "Could not build scenario plus command for where \"{where}\" and action \"{action}\"" } }, "entity": { diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index 2dc8abc0e..6d4054314 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; step/speed for increase/decrease is fixed per protocol)." + } + } } }, "issues": { @@ -504,6 +522,21 @@ }, "seek_not_supported": { "message": "{entity_id}: seek is only supported on tuner source entities" + }, + "scenario_plus_gateway_not_found": { + "message": "Gateway \"{gateway}\" not found for scenario plus command" + }, + "scenario_plus_no_gateway": { + "message": "No active MyHOME gateways found" + }, + "scenario_plus_missing_where": { + "message": "No where/object specified for scenario plus command" + }, + "scenario_plus_unknown_action": { + "message": "Unknown scenario plus action \"{action}\"" + }, + "scenario_plus_command_invalid": { + "message": "Could not build scenario plus command for where \"{where}\" and action \"{action}\"" } }, "entity": { 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#