custom_components/omni_pca/services.yaml — declares 7 services with config_entry selectors so HA's UI gives users a panel picker: bypass_zone, restore_zone, execute_program, show_message, clear_message, acknowledge_alerts, send_command (raw escape hatch) custom_components/omni_pca/services.py — async handlers wired via async_setup_services on entry setup; idempotent across multiple entries. Each handler validates entry_id, looks up the right coordinator, calls the matching OmniClient method. CommandFailedError wrapped to HomeAssistantError; unknown Command codes raise ServiceValidationError. async_unload_services removes them when the last entry unloads. custom_components/omni_pca/diagnostics.py — async_get_config_entry_ diagnostics dumps a redacted snapshot for bug reports: panel model + firmware, discovered/live counts per object type, sha256-hashed zone/ unit/area names (so uniqueness is visible without leaking PII), last event class, controller key REDACTED via async_redact_data. custom_components/omni_pca/__init__.py — wires async_setup_services on entry setup and async_unload_services on the last entry unload. custom_components/omni_pca/README.md — full entity table, service list, example automation, troubleshooting section, link to JOURNEY.md. Top-level README — entity rundown updated to reflect the full v1.0 surface (was: 'binary_sensor for zones'). 331 tests still pass; ruff clean across src/ tests/ custom_components/. hacs.json already in place from initial scaffold.
186 lines
6.3 KiB
Python
186 lines
6.3 KiB
Python
"""Service handlers for the omni_pca integration.
|
|
|
|
Services give the user a write-surface for things the entity layer
|
|
doesn't naturally expose: program execution (no Properties opcode for
|
|
Programs in v1.0), arbitrary panel messages, raw commands for power
|
|
users, and panel-wide alert acknowledgement.
|
|
|
|
All services route through the per-entry coordinator's ``OmniClient``;
|
|
each accepts an ``entry_id`` field so HA can pick the right panel when
|
|
multiple are configured.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import voluptuous as vol
|
|
from homeassistant.const import CONF_ENTRY_ID
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
from homeassistant.helpers import config_validation as cv
|
|
|
|
from omni_pca.commands import Command, CommandFailedError
|
|
|
|
from .const import DOMAIN, LOGGER
|
|
from .coordinator import OmniDataUpdateCoordinator
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.core import HomeAssistant, ServiceCall
|
|
|
|
SERVICE_BYPASS_ZONE = "bypass_zone"
|
|
SERVICE_RESTORE_ZONE = "restore_zone"
|
|
SERVICE_EXECUTE_PROGRAM = "execute_program"
|
|
SERVICE_SHOW_MESSAGE = "show_message"
|
|
SERVICE_CLEAR_MESSAGE = "clear_message"
|
|
SERVICE_ACKNOWLEDGE_ALERTS = "acknowledge_alerts"
|
|
SERVICE_SEND_COMMAND = "send_command"
|
|
|
|
ATTR_ZONE_INDEX = "zone_index"
|
|
ATTR_PROGRAM_INDEX = "program_index"
|
|
ATTR_MESSAGE_INDEX = "message_index"
|
|
ATTR_COMMAND = "command"
|
|
ATTR_PARAM_1 = "parameter1"
|
|
ATTR_PARAM_2 = "parameter2"
|
|
|
|
|
|
_BASE_SCHEMA = vol.Schema({vol.Required(CONF_ENTRY_ID): cv.string})
|
|
|
|
|
|
def _zone_schema() -> vol.Schema:
|
|
return _BASE_SCHEMA.extend(
|
|
{vol.Required(ATTR_ZONE_INDEX): vol.All(int, vol.Range(min=1, max=0xFFFF))}
|
|
)
|
|
|
|
|
|
def _program_schema() -> vol.Schema:
|
|
return _BASE_SCHEMA.extend(
|
|
{vol.Required(ATTR_PROGRAM_INDEX): vol.All(int, vol.Range(min=1, max=0xFFFF))}
|
|
)
|
|
|
|
|
|
def _message_schema() -> vol.Schema:
|
|
return _BASE_SCHEMA.extend(
|
|
{vol.Required(ATTR_MESSAGE_INDEX): vol.All(int, vol.Range(min=1, max=0xFFFF))}
|
|
)
|
|
|
|
|
|
def _command_schema() -> vol.Schema:
|
|
return _BASE_SCHEMA.extend(
|
|
{
|
|
vol.Required(ATTR_COMMAND): vol.All(int, vol.Range(min=0, max=255)),
|
|
vol.Optional(ATTR_PARAM_1, default=0): vol.All(
|
|
int, vol.Range(min=0, max=255)
|
|
),
|
|
vol.Optional(ATTR_PARAM_2, default=0): vol.All(
|
|
int, vol.Range(min=0, max=0xFFFF)
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def _coordinator_for(
|
|
hass: HomeAssistant, call: ServiceCall
|
|
) -> OmniDataUpdateCoordinator:
|
|
entry_id = call.data[CONF_ENTRY_ID]
|
|
coordinators = hass.data.get(DOMAIN, {})
|
|
if entry_id not in coordinators:
|
|
raise ServiceValidationError(
|
|
f"No Omni panel configured with entry_id {entry_id!r}"
|
|
)
|
|
return coordinators[entry_id]
|
|
|
|
|
|
async def _wrap(coro_factory) -> None: # type: ignore[no-untyped-def]
|
|
try:
|
|
await coro_factory()
|
|
except CommandFailedError as err:
|
|
raise HomeAssistantError(f"Panel rejected command: {err}") from err
|
|
|
|
|
|
async def async_setup_services(hass: HomeAssistant) -> None:
|
|
"""Register all services for the integration. Idempotent."""
|
|
|
|
if hass.services.has_service(DOMAIN, SERVICE_BYPASS_ZONE):
|
|
return # already registered (multiple entries reuse the same services)
|
|
|
|
async def _bypass_zone(call: ServiceCall) -> None:
|
|
coord = _coordinator_for(hass, call)
|
|
idx = int(call.data[ATTR_ZONE_INDEX])
|
|
await _wrap(lambda: coord.client.bypass_zone(idx))
|
|
|
|
async def _restore_zone(call: ServiceCall) -> None:
|
|
coord = _coordinator_for(hass, call)
|
|
idx = int(call.data[ATTR_ZONE_INDEX])
|
|
await _wrap(lambda: coord.client.restore_zone(idx))
|
|
|
|
async def _execute_program(call: ServiceCall) -> None:
|
|
coord = _coordinator_for(hass, call)
|
|
idx = int(call.data[ATTR_PROGRAM_INDEX])
|
|
await _wrap(lambda: coord.client.execute_program(idx))
|
|
|
|
async def _show_message(call: ServiceCall) -> None:
|
|
coord = _coordinator_for(hass, call)
|
|
idx = int(call.data[ATTR_MESSAGE_INDEX])
|
|
await _wrap(lambda: coord.client.show_message(idx))
|
|
|
|
async def _clear_message(call: ServiceCall) -> None:
|
|
coord = _coordinator_for(hass, call)
|
|
idx = int(call.data[ATTR_MESSAGE_INDEX])
|
|
await _wrap(lambda: coord.client.clear_message(idx))
|
|
|
|
async def _acknowledge_alerts(call: ServiceCall) -> None:
|
|
coord = _coordinator_for(hass, call)
|
|
await _wrap(lambda: coord.client.acknowledge_alerts())
|
|
|
|
async def _send_command(call: ServiceCall) -> None:
|
|
coord = _coordinator_for(hass, call)
|
|
cmd_byte = int(call.data[ATTR_COMMAND])
|
|
try:
|
|
cmd = Command(cmd_byte)
|
|
except ValueError as err:
|
|
raise ServiceValidationError(
|
|
f"Unknown Command code {cmd_byte}; see omni_pca.commands.Command"
|
|
) from err
|
|
p1 = int(call.data[ATTR_PARAM_1])
|
|
p2 = int(call.data[ATTR_PARAM_2])
|
|
LOGGER.debug("send_command %s p1=%d p2=%d", cmd.name, p1, p2)
|
|
await _wrap(lambda: coord.client.execute_command(cmd, p1, p2))
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_BYPASS_ZONE, _bypass_zone, schema=_zone_schema()
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_RESTORE_ZONE, _restore_zone, schema=_zone_schema()
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_EXECUTE_PROGRAM, _execute_program, schema=_program_schema()
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_SHOW_MESSAGE, _show_message, schema=_message_schema()
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_CLEAR_MESSAGE, _clear_message, schema=_message_schema()
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_ACKNOWLEDGE_ALERTS, _acknowledge_alerts, schema=_BASE_SCHEMA
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_SEND_COMMAND, _send_command, schema=_command_schema()
|
|
)
|
|
|
|
|
|
async def async_unload_services(hass: HomeAssistant) -> None:
|
|
"""Tear down services if no entries remain."""
|
|
if hass.data.get(DOMAIN):
|
|
return # other entries still active
|
|
for svc in (
|
|
SERVICE_BYPASS_ZONE,
|
|
SERVICE_RESTORE_ZONE,
|
|
SERVICE_EXECUTE_PROGRAM,
|
|
SERVICE_SHOW_MESSAGE,
|
|
SERVICE_CLEAR_MESSAGE,
|
|
SERVICE_ACKNOWLEDGE_ALERTS,
|
|
SERVICE_SEND_COMMAND,
|
|
):
|
|
hass.services.async_remove(DOMAIN, svc)
|