Skip to content
Merged
3 changes: 3 additions & 0 deletions custom_components/myhome/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions custom_components/myhome/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 54 additions & 2 deletions custom_components/myhome/gateway_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
85 changes: 84 additions & 1 deletion custom_components/myhome/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
32 changes: 32 additions & 0 deletions custom_components/myhome/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

33 changes: 33 additions & 0 deletions custom_components/myhome/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
33 changes: 33 additions & 0 deletions custom_components/myhome/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration/cen_cenplus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

---

Expand Down Expand Up @@ -180,3 +181,8 @@ Enabling **Generate Events** in the **Options Flow** additionally fires every bu
| **CEN+ Start Long** | 25 | `*25*22#<BUTTON>*<WHERE>##` | `*25*22#1*21##` |
| **CEN+ Still Held** (repeats ~0.5 s) | 25 | `*25*23#<BUTTON>*<WHERE>##` | `*25*23#1*21##` |
| **CEN+ Release** | 25 | `*25*24#<BUTTON>*<WHERE>##` | `*25*24#1*21##` |
| **Scenario Plus On** | 25 | `*25*11#0*<WHERE>##` | `*25*11#0*11##` |
| **Scenario Plus Off** | 25 | `*25*12*<WHERE>##` | `*25*12*11##` |
| **Scenario Plus Increase** | 25 | `*25*13#0#5*<WHERE>##` | `*25*13#0#5*11##` |
| **Scenario Plus Decrease** | 25 | `*25*14#0#5*<WHERE>##` | `*25*14#0#5*11##` |
| **Scenario Plus Stop** | 25 | `*25*15*<WHERE>##` | `*25*15*11##` |
Loading
Loading