diff --git a/riedel_rrcs.zip b/riedel_rrcs.zip new file mode 100644 index 0000000..e8a9589 Binary files /dev/null and b/riedel_rrcs.zip differ diff --git a/rrcs.py b/rrcs.py new file mode 100644 index 0000000..543dbd3 --- /dev/null +++ b/rrcs.py @@ -0,0 +1,515 @@ +"""Minimal async XML-RPC client for the Riedel Router Control Software (RRCS). + +RRCS speaks plain XML-RPC over HTTP, by default on TCP 8193. Every request +takes a transaction key ("TransKey") as its first parameter and echoes it back +in the response. See the RRCS Interface Specification, chapter 5. + +Two shapes of reply are common: + + [TransKey, ErrorCode, ...] most Set*/Get* calls + {"TransKey": ..., "ErrorCode": ..., ...} the "dump" style calls + +A handful of calls (RegisterForAllEvents, XpVolumeChangeRegistry*) return a +bare string TransKey with no error code at all, so error checking is opt-in per +call rather than automatic. +""" + +from __future__ import annotations + +import asyncio +import itertools +import json +import logging +import re +import xmlrpc.client +from dataclasses import dataclass +from typing import Any +from xml.parsers.expat import ExpatError + +import aiohttp + +from .const import ERROR_CODES + +_LOGGER = logging.getLogger(__name__) + +_XML_DECL_RE = re.compile(rb"<\?xml[^>]*\?>") + + +class RRCSError(Exception): + """Base error for RRCS communication.""" + + def __init__(self, message: str, code: int | None = None) -> None: + """Initialise with an optional RRCS error code.""" + super().__init__(message) + self.code = code + + +class RRCSConnectionError(RRCSError): + """Raised when the gateway could not be reached.""" + + +class RRCSProtocolError(RRCSError): + """Raised when the gateway replied with something unparseable.""" + + +class RRCSFaultError(RRCSError): + """Raised when the gateway returned an XML-RPC fault.""" + + +def describe_error(code: int) -> str: + """Return the human readable text for an RRCS error code.""" + return ERROR_CODES.get(code, f"Unknown error code {code}") + + +def parse_xmlrpc(raw: bytes) -> tuple[tuple[Any, ...], str | None]: + """Parse an XML-RPC document, tolerating a mismatched encoding declaration. + + Some RRCS builds declare UTF-8 but emit single-byte port labels, which makes + expat refuse the document outright. Dropping the declaration and decoding + leniently recovers those responses instead of losing the whole poll. + """ + try: + return xmlrpc.client.loads(raw) + except ExpatError: + stripped = _XML_DECL_RE.sub(b"", raw, count=1) + text = stripped.decode("utf-8", errors="replace") + try: + return xmlrpc.client.loads(text) + except ExpatError as err: + raise RRCSProtocolError(f"Could not parse RRCS response: {err}") from err + + +def extract_error_code(result: Any) -> int | None: + """Pull the ErrorCode out of a reply, if it has one in a known position.""" + if isinstance(result, dict): + code = result.get("ErrorCode") + return code if isinstance(code, int) else None + if isinstance(result, (list, tuple)) and len(result) >= 2: + code = result[1] + return code if isinstance(code, int) and not isinstance(code, bool) else None + return None + + +@dataclass(frozen=True) +class LogicSource: + """A logic source as returned by GetAllLogicSources_v2.""" + + object_id: int + long_name: str + label: str + state: bool + + +@dataclass(frozen=True) +class GpioAddress: + """Addressing tuple used by Set/GetGpOutput and GetGpInputState. + + Chapter 6.9: for a GPIO card the port is 128 and the slot selects the bay; + for a panel GPIO the port is the panel's port address and the slot is + ignored. + """ + + net: int + node: int + port: int + slot: int + index: int + is_input: bool + + @property + def key(self) -> str: + """Stable identifier used for unique IDs.""" + direction = "in" if self.is_input else "out" + return f"{self.net}_{self.node}_{self.port}_{self.slot}_{self.index}_{direction}" + + @property + def pretty(self) -> str: + """Human readable address.""" + return ( + f"Net {self.net} / Node {self.node} / Port {self.port} / " + f"Slot {self.slot} / GPIO {self.index}" + ) + + +class RRCSClient: + """Talks XML-RPC to an RRCS gateway. + + Requests are serialised behind a lock. RRCS is a single Windows service + fronting a real intercom ring and it does not enjoy being hammered with + concurrent RPCs; the specification's own timing notes (chapter 12) assume an + otherwise idle gateway. + """ + + def __init__( + self, + session: aiohttp.ClientSession, + host: str, + port: int = 8193, + path: str = "/", + timeout: float = 15.0, + transkey_prefix: str = "C", + ) -> None: + """Initialise the client.""" + self._session = session + self.host = host + self.port = port + self.path = path if path.startswith("/") else f"/{path}" + self.timeout = timeout + self._prefix = (transkey_prefix or "C")[0] + self._counter = itertools.count(1) + self._lock = asyncio.Lock() + + @property + def url(self) -> str: + """Return the endpoint URL.""" + return f"http://{self.host}:{self.port}{self.path}" + + def next_transkey(self) -> str: + """Return a fresh transaction key. + + The specification wants a single leading character followed by exactly + ten digits. The counter wraps well before it runs out of room. + """ + return f"{self._prefix}{next(self._counter) % 10_000_000_000:010d}" + + async def call(self, method: str, *args: Any, check: bool = False) -> Any: + """Call an RRCS method, prepending a fresh transaction key. + + Set ``check=True`` for methods whose reply is known to be + ``[TransKey, ErrorCode, ...]`` or a struct with an ``ErrorCode`` member. + It is off by default because some replies put a payload integer in the + same slot (GetLevelMeterValues returns the level there, for instance). + """ + return await self.call_raw(method, (self.next_transkey(), *args), check=check) + + async def call_raw( + self, method: str, params: tuple[Any, ...], *, check: bool = False + ) -> Any: + """Call an RRCS method with an explicit parameter tuple.""" + body = xmlrpc.client.dumps(tuple(params), method, encoding="utf-8") + _LOGGER.debug("RRCS -> %s %s", method, params) + + async with self._lock: + try: + async with self._session.post( + self.url, + data=body.encode("utf-8"), + headers={"Content-Type": "text/xml; charset=utf-8"}, + timeout=aiohttp.ClientTimeout(total=self.timeout), + ) as response: + if response.status != 200: + raise RRCSConnectionError( + f"{method}: gateway returned HTTP {response.status}" + ) + raw = await response.read() + except asyncio.TimeoutError as err: + raise RRCSConnectionError(f"{method}: timed out after {self.timeout}s") from err + except aiohttp.ClientError as err: + raise RRCSConnectionError(f"{method}: {err}") from err + + try: + parsed, _ = parse_xmlrpc(raw) + except xmlrpc.client.Fault as err: + raise RRCSFaultError( + f"{method}: fault {err.faultCode}: {err.faultString}", err.faultCode + ) from err + + result = parsed[0] if parsed else None + _LOGGER.debug("RRCS <- %s %s", method, result) + + if check: + code = extract_error_code(result) + if code: + raise RRCSError(f"{method}: {describe_error(code)}", code) + + return result + + # --- Convenience wrappers ------------------------------------------------------ + + async def get_version(self) -> str | None: + """Return the RRCS version string.""" + result = await self.call("GetVersion") + if isinstance(result, (list, tuple)) and len(result) >= 3: + return str(result[2]) + return None + + async def get_state(self) -> str | None: + """Return the gateway state, 'Working' or 'Standby'.""" + result = await self.call("GetState") + if isinstance(result, (list, tuple)) and len(result) >= 3: + return str(result[2]) + return None + + async def is_connected_to_artist(self) -> bool: + """Return whether RRCS currently has a working link to the Artist net.""" + result = await self.call("IsConnectedToArtist") + if isinstance(result, dict): + return bool(result.get("IsConnected")) + return False + + async def get_active_xp_count(self) -> int | None: + """Return the number of crosspoints RRCS considers active.""" + result = await self.call("GetAllActiveXpsCount") + if isinstance(result, (list, tuple)) and len(result) >= 3: + value = result[2] + if isinstance(value, int): + return value + if isinstance(result, dict): + for key in ("XP Count", "XPCount", "Count"): + if isinstance(result.get(key), int): + return result[key] + return None + + async def get_logic_sources(self) -> dict[int, LogicSource]: + """Return every logic source in the system, keyed by object ID. + + GetAllLogicSources_v2 returns a struct whose members are named + ``LogicSource#1``, ``LogicSource#2`` and so on, each an array of + ``[LongName, Label, ObjectID, State]``. + """ + result = await self.call("GetAllLogicSources_v2") + if not isinstance(result, dict): + raise RRCSProtocolError("GetAllLogicSources_v2 did not return a struct") + + code = result.get("ErrorCode") + if isinstance(code, int) and code: + raise RRCSError(f"GetAllLogicSources_v2: {describe_error(code)}", code) + + sources: dict[int, LogicSource] = {} + for name, value in result.items(): + if not name.startswith("LogicSource#") or not isinstance(value, (list, tuple)): + continue + if len(value) < 3: + continue + long_name, label, object_id = value[0], value[1], value[2] + if not isinstance(object_id, int): + continue + state = bool(value[3]) if len(value) > 3 else False + sources[object_id] = LogicSource( + object_id=object_id, + long_name=str(long_name or "").strip(), + label=str(label or "").strip(), + state=state, + ) + return sources + + async def set_logic_source(self, object_id: int, state: bool) -> None: + """Activate or deactivate a logic source.""" + await self.call("SetLogicSourceState", int(object_id), bool(state), check=True) + + async def get_gp_input_state(self, address: GpioAddress) -> bool: + """Read a general purpose input.""" + result = await self.call( + "GetGpInputState", + address.net, + address.node, + address.port, + address.slot, + address.index, + ) + return _trailing_bool(result) + + async def get_gp_output_state(self, address: GpioAddress) -> bool: + """Read a general purpose output.""" + result = await self.call( + "GetGpOutputState", + address.net, + address.node, + address.port, + address.slot, + address.index, + ) + return _trailing_bool(result) + + async def set_gp_output(self, address: GpioAddress, state: bool) -> None: + """Drive a general purpose output.""" + await self.call( + "SetGpOutput", + address.net, + address.node, + address.port, + address.slot, + address.index, + int(bool(state)), + check=True, + ) + + async def discover_gpios(self) -> list[GpioAddress]: + """Best-effort discovery of configured GPIOs (RRCS 8.8 and later). + + GetAllGpIns / GetAllGpOuts are documented only by name, not by return + shape, so this walks whatever comes back looking for structs that carry + an addressable GPIO. Anything it cannot make sense of is skipped and + logged at debug level. + """ + found: dict[str, GpioAddress] = {} + for method, is_input in (("GetAllGpIns", True), ("GetAllGpOuts", False)): + try: + result = await self.call(method) + except RRCSError as err: + _LOGGER.debug("%s unavailable (%s); skipping GPIO discovery", method, err) + continue + _LOGGER.debug("%s raw payload: %r", method, result) + for address in _walk_gpio_structs(result, is_input): + found.setdefault(address.key, address) + return list(found.values()) + + # --- Notification registration ------------------------------------------------- + + async def register_for_all_events( + self, + tcp_port: int, + url_path: str, + pipelining: bool = False, + multicall: bool = False, + ) -> None: + """Ask RRCS to push every event to us. + + RRCS derives the destination host from the source address of this very + request, so the gateway must be able to reach Home Assistant back on the + same IP at ``tcp_port``. + """ + await self.call( + "RegisterForAllEvents", + int(tcp_port), + str(url_path), + bool(pipelining), + bool(multicall), + ) + + async def unregister_for_all_events(self, tcp_port: int, url_path: str) -> None: + """Stop receiving notifications.""" + await self.call("UnregisterForAllEvents", int(tcp_port), str(url_path)) + + async def is_registered_for_all_events(self, tcp_port: int, url_path: str) -> bool: + """Return whether RRCS still has our notification registration.""" + result = await self.call( + "IsRegisteredForAllEvents", int(tcp_port), str(url_path) + ) + if isinstance(result, dict): + return bool(result.get("IsRegistered")) + return False + + +def parse_gpio_config(raw: Any) -> list[tuple[GpioAddress, str | None]]: + """Parse the manual GPIO list from the options flow. + + Accepts a JSON array of objects, each with at minimum ``index`` and + ``node``. ``direction`` is ``in`` or ``out`` and defaults to ``in``; + ``net`` defaults to 1, ``port`` to 128 (the GPIO client card) and ``slot`` + to 0. + """ + if not raw: + return [] + if isinstance(raw, str): + raw = raw.strip() + if not raw: + return [] + try: + raw = json.loads(raw) + except json.JSONDecodeError as err: + raise ValueError(f"GPIO list is not valid JSON: {err}") from err + if isinstance(raw, dict): + raw = [raw] + if not isinstance(raw, list): + raise ValueError("GPIO list must be a JSON array of objects") + + result: list[tuple[GpioAddress, str | None]] = [] + for position, item in enumerate(raw, start=1): + if not isinstance(item, dict): + raise ValueError(f"GPIO entry {position} is not an object") + try: + node = int(item["node"]) + index = int(item["index"]) + except (KeyError, TypeError, ValueError) as err: + raise ValueError( + f"GPIO entry {position} needs numeric 'node' and 'index'" + ) from err + direction = str(item.get("direction", "in")).lower() + if direction not in ("in", "out", "input", "output"): + raise ValueError( + f"GPIO entry {position}: direction must be 'in' or 'out'" + ) + address = GpioAddress( + net=int(item.get("net", 1)), + node=node, + port=int(item.get("port", 128)), + slot=int(item.get("slot", 0)), + index=index, + is_input=direction.startswith("in"), + ) + name = item.get("name") + result.append((address, str(name) if name else None)) + return result + + +def _trailing_bool(result: Any) -> bool: + """Pull the boolean state out of a ``[TransKey, ErrorCode, state]`` reply.""" + if isinstance(result, (list, tuple)): + code = extract_error_code(result) + if code: + raise RRCSError(describe_error(code), code) + for value in reversed(result): + if isinstance(value, bool): + return value + if len(result) >= 3 and isinstance(result[2], int): + return bool(result[2]) + return False + + +def _walk_gpio_structs(node: Any, is_input: bool) -> list[GpioAddress]: + """Recursively collect GPIO addresses from an arbitrary decoded payload.""" + found: list[GpioAddress] = [] + if isinstance(node, dict): + address = _gpio_from_struct(node, is_input) + if address is not None: + found.append(address) + for value in node.values(): + found.extend(_walk_gpio_structs(value, is_input)) + elif isinstance(node, (list, tuple)): + for value in node: + found.extend(_walk_gpio_structs(value, is_input)) + return found + + +def _gpio_from_struct(struct: dict[str, Any], is_input: bool) -> GpioAddress | None: + """Map one decoded struct onto the Net/Node/Port/Slot/Index addressing. + + Follows the three TGPIOAddress variants in chapter 6.7: a GPIO on a port + carries a nested PortAddress, one on a client card carries a Bay, and one on + a SIC carries only Node and Index. + """ + index = struct.get("Index", struct.get("GpioNumber", struct.get("Gpio"))) + if not isinstance(index, int) or isinstance(index, bool): + return None + + direction = struct.get("IsInput") + if isinstance(direction, bool): + is_input = direction + + net = struct.get("Net", 1) + net = net if isinstance(net, int) and not isinstance(net, bool) else 1 + + port_address = struct.get("PortAddress") + if isinstance(port_address, dict): + node = port_address.get("Node") + port = port_address.get("Port") + if isinstance(node, int) and isinstance(port, int): + return GpioAddress(net, node, port, 0, index, is_input) + return None + + node = struct.get("Node") + if not isinstance(node, int) or isinstance(node, bool): + return None + + bay = struct.get("Bay", struct.get("Slot")) + if isinstance(bay, int) and not isinstance(bay, bool): + # Bays are 1-based in TGPIOAddress, slots are 0-based in Set/GetGpOutput. + slot = bay - 1 if "Bay" in struct else bay + return GpioAddress(net, node, 128, max(slot, 0), index, is_input) + + port = struct.get("Port") + if isinstance(port, int) and not isinstance(port, bool): + return GpioAddress(net, node, port, 0, index, is_input) + + return None diff --git a/services.yaml b/services.yaml new file mode 100644 index 0000000..6adc91f --- /dev/null +++ b/services.yaml @@ -0,0 +1,589 @@ +set_xp: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + source_net: + required: true + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + source_node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + source_port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + dest_net: + required: true + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + dest_node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + dest_port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + priority: + required: false + selector: + select: + options: + - label: 0 - below standard + value: '0' + - label: 1 - standard + value: '1' + - label: 2 - high + value: '2' + - label: 3 - paging + value: '3' + - label: 4 - emergency + value: '4' + destructive: + required: false + default: false + selector: + boolean: null +kill_xp: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + source_net: + required: true + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + source_node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + source_port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + dest_net: + required: true + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + dest_node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + dest_port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box +set_xp_volume: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + source_net: + required: true + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + source_node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + source_port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + dest_net: + required: true + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + dest_node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + dest_port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + single: + required: false + default: true + selector: + boolean: null + conference: + required: false + default: false + selector: + boolean: null + volume: + required: true + selector: + number: + min: 0 + max: 256 + mode: box +set_gp_output: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + net: + required: false + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + port: + required: false + default: 128 + selector: + number: + min: 0 + max: 1151 + mode: box + slot: + required: false + default: 0 + selector: + number: + min: 0 + max: 20 + mode: box + index: + required: true + selector: + number: + min: 0 + max: 63 + mode: box + state: + required: true + selector: + boolean: null +set_logic_source: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + object_id: + required: true + selector: + number: + min: 0 + max: 4294967295 + mode: box + state: + required: true + selector: + boolean: null +press_key: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + node: + required: true + selector: + number: + min: 2 + max: 255 + mode: box + port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + is_input: + required: false + default: false + selector: + boolean: null + page: + required: false + default: 1 + selector: + number: + min: 1 + max: 32 + mode: box + expansion_panel: + required: false + default: 0 + selector: + number: + min: 0 + max: 16 + mode: box + key_number: + required: true + selector: + number: + min: 1 + max: 128 + mode: box + is_virt_key: + required: false + default: false + selector: + boolean: null + press: + required: false + default: true + selector: + boolean: null + trigger: + required: false + selector: + select: + options: + - label: 1 - primary (lever down / left key) + value: '1' + - label: 2 - secondary (lever up / right key) + value: '2' + pool_port: + required: false + default: 0 + selector: + number: + min: 0 + max: 1151 + mode: box +set_key_label: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + node: + required: true + selector: + number: + min: 2 + max: 255 + mode: box + port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + is_input: + required: false + default: false + selector: + boolean: null + page: + required: false + default: 1 + selector: + number: + min: 1 + max: 32 + mode: box + expansion_panel: + required: false + default: 0 + selector: + number: + min: 0 + max: 16 + mode: box + key_number: + required: true + selector: + number: + min: 1 + max: 128 + mode: box + is_virt_key: + required: false + default: false + selector: + boolean: null + label: + required: true + selector: + text: null + marker: + required: false + selector: + number: + min: 0 + max: 255 + mode: box +clear_key_label: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + node: + required: true + selector: + number: + min: 2 + max: 255 + mode: box + port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + is_input: + required: false + default: false + selector: + boolean: null + page: + required: false + default: 1 + selector: + number: + min: 1 + max: 32 + mode: box + expansion_panel: + required: false + default: 0 + selector: + number: + min: 0 + max: 16 + mode: box + key_number: + required: true + selector: + number: + min: 1 + max: 128 + mode: box + is_virt_key: + required: false + default: false + selector: + boolean: null + clear_marker: + required: false + default: false + selector: + boolean: null +set_port_alias: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + net: + required: false + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + alias: + required: true + selector: + text: null + is_input: + required: false + default: false + selector: + boolean: null +set_input_gain: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + net: + required: false + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + gain: + required: true + selector: + number: + min: -128 + max: 36 + mode: box +set_output_gain: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + net: + required: false + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + node: + required: true + selector: + number: + min: 0 + max: 255 + mode: box + port: + required: true + selector: + number: + min: 0 + max: 1151 + mode: box + gain: + required: true + selector: + number: + min: -128 + max: 36 + mode: box +call_method: + fields: + entry_id: + required: false + selector: + config_entry: + integration: riedel_rrcs + method: + required: true + example: GetAllPorts + selector: + text: null + params: + required: false + example: '[2, 0, false]' + selector: + object: null + include_transkey: + required: false + default: true + selector: + boolean: null diff --git a/switch.py b/switch.py new file mode 100644 index 0000000..7a1e473 --- /dev/null +++ b/switch.py @@ -0,0 +1,172 @@ +"""Switch platform for the Riedel RRCS integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import RRCSCoordinator +from .entity import RRCSEntity +from .models import RRCSConfigEntry +from .rrcs import GpioAddress, RRCSError + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RRCSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the switch entities.""" + runtime = entry.runtime_data + coordinator = runtime.coordinator + + entities: list[SwitchEntity] = [ + RRCSLogicSourceSwitch(coordinator, entry.entry_id, object_id) + for object_id in (coordinator.data.logic_sources if coordinator.data else {}) + ] + entities.extend( + RRCSGpOutputSwitch( + coordinator, + entry.entry_id, + address, + runtime.gpio_names.get(address.key), + ) + for address in runtime.gpio_outputs + ) + + async_add_entities(entities) + + known = { + object_id for object_id in (coordinator.data.logic_sources if coordinator.data else {}) + } + + @callback + def _add_new_logic_sources() -> None: + """Pick up logic sources added to the Artist configuration later.""" + if coordinator.data is None: + return + new = set(coordinator.data.logic_sources) - known + if not new: + return + known.update(new) + async_add_entities( + RRCSLogicSourceSwitch(coordinator, entry.entry_id, object_id) + for object_id in new + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_new_logic_sources)) + + +class RRCSLogicSourceSwitch(RRCSEntity, SwitchEntity): + """A logic source, driven through SetLogicSourceState.""" + + _attr_icon = "mdi:toggle-switch-outline" + + def __init__( + self, coordinator: RRCSCoordinator, entry_id: str, object_id: int + ) -> None: + """Initialise the switch.""" + super().__init__(coordinator, entry_id) + self._object_id = object_id + self._attr_unique_id = f"{entry_id}_logic_source_{object_id}" + source = coordinator.data.logic_sources.get(object_id) if coordinator.data else None + self._attr_name = (source.long_name or source.label) if source else f"Logic source {object_id}" + + @property + def available(self) -> bool: + """Return whether the logic source is still in the configuration.""" + return ( + super().available + and self.coordinator.data is not None + and self._object_id in self.coordinator.data.logic_sources + ) + + @property + def is_on(self) -> bool | None: + """Return the logic source state.""" + if self.coordinator.data is None: + return None + source = self.coordinator.data.logic_sources.get(self._object_id) + return source.state if source else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Expose the Artist identifiers for use in automations.""" + source = ( + self.coordinator.data.logic_sources.get(self._object_id) + if self.coordinator.data + else None + ) + return { + "object_id": self._object_id, + "label": source.label if source else None, + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Activate the logic source.""" + await self._async_set(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Deactivate the logic source. + + RRCS only clears logic sources it set itself; one driven from a panel + key will not respond here. + """ + await self._async_set(False) + + async def _async_set(self, state: bool) -> None: + try: + await self.coordinator.client.set_logic_source(self._object_id, state) + except RRCSError as err: + raise HomeAssistantError(f"Could not set logic source: {err}") from err + self.coordinator.apply_logic_source_change(self._object_id, state) + + +class RRCSGpOutputSwitch(RRCSEntity, SwitchEntity): + """A general purpose output.""" + + _attr_icon = "mdi:electric-switch" + + def __init__( + self, + coordinator: RRCSCoordinator, + entry_id: str, + address: GpioAddress, + name: str | None, + ) -> None: + """Initialise the switch.""" + super().__init__(coordinator, entry_id) + self._address = address + self._attr_unique_id = f"{entry_id}_gpo_{address.key}" + self._attr_name = name or f"GP out {address.node}.{address.slot}.{address.index}" + + @property + def is_on(self) -> bool | None: + """Return the output state.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.gpio_outputs.get(self._address.key) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Expose the GPIO address.""" + return {"address": self._address.pretty} + + async def async_turn_on(self, **kwargs: Any) -> None: + """Close the output.""" + await self._async_set(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Open the output.""" + await self._async_set(False) + + async def _async_set(self, state: bool) -> None: + try: + await self.coordinator.client.set_gp_output(self._address, state) + except RRCSError as err: + raise HomeAssistantError(f"Could not switch GP output: {err}") from err + self.coordinator.apply_gpio_change(self._address, state) diff --git a/test_client.py b/test_client.py new file mode 100644 index 0000000..b03db72 --- /dev/null +++ b/test_client.py @@ -0,0 +1,269 @@ +"""Exercise the RRCS client against a mock gateway that speaks spec-shaped XML.""" + +import asyncio +import sys +import xmlrpc.client + +import aiohttp +from aiohttp import web + +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[1] / "custom_components" +DIR = str(ROOT / "riedel_rrcs") + + +def _load_module(): + """Load rrcs.py without dragging in the Home Assistant package __init__.""" + import importlib.util + import types + + pkg = types.ModuleType("rrcspkg") + pkg.__path__ = [DIR] + sys.modules["rrcspkg"] = pkg + for name in ("const", "rrcs"): + spec = importlib.util.spec_from_file_location(f"rrcspkg.{name}", f"{DIR}/{name}.py") + module = importlib.util.module_from_spec(spec) + sys.modules[f"rrcspkg.{name}"] = module + spec.loader.exec_module(module) + return sys.modules["rrcspkg.rrcs"] + + +R = _load_module() + +RECEIVED = [] + + +def resp(body: str) -> web.Response: + return web.Response( + body=('' + f"{body}").encode(), + content_type="text/xml", + ) + + +async def handler(request: web.Request) -> web.Response: + raw = await request.read() + params, method = xmlrpc.client.loads(raw) + RECEIVED.append((method, params)) + tk = params[0] + + if method == "GetVersion": + return resp(f"{tk}" + "0" + "8.9.1.0") + if method == "GetState": + return resp(f"{tk}" + "0" + "Working") + if method == "IsConnectedToArtist": + return resp("" + "IsConnected1" + f"TransKey{tk}" + "") + if method == "GetAllActiveXpsCount": + return resp(f"{tk}" + "0" + "42") + if method == "GetAllLogicSources_v2": + return resp("" + "ErrorCode0" + f"TransKey{tk}" + "LogicSourceCount2" + "LogicSource#1" + "Ben Busy" + "BenBusy" + "1454317500" + "1" + "" + "LogicSource#2" + "Studio Red Light" + "RedLight" + "1454317501" + "0" + "" + "") + if method in ("SetLogicSourceState", "SetGpOutput", "SetXp", "SetKeyLabel"): + return resp(f"{tk}" + "0") + if method == "SetInputGain": + return resp(f"{tk}" + "6") + if method in ("GetGpInputState", "GetGpOutputState"): + return resp(f"{tk}" + "0" + "1") + if method == "GetLevelMeterValues": + # Deliberately puts a payload int where ErrorCode usually sits. + return resp(f"{tk}" + "174" + "0" + "21") + if method == "GetAllGpIns": + return resp(f"{tk}" + "" + "" + "Index5" + "IsInput1" + "Node2" + "Bay6" + "" + "" + "PortAddress" + "IsInput1" + "Node4" + "Port9" + "" + "Index0" + "IsInput1" + "" + "") + if method == "GetAllGpOuts": + raise web.HTTPInternalServerError() + if method == "RegisterForAllEvents": + return resp(f"{tk}") + if method == "IsRegisteredForAllEvents": + return resp("" + "IsRegistered1" + f"TransKey{tk}" + "") + if method == "BadLabel": + # UTF-8 declared, Latin-1 emitted: the tolerant parser must recover. + body = ('' + f'{tk}' + '0' + 'Caf\xe9 Mic' + '') + return web.Response(body=body.encode("latin-1"), content_type="text/xml") + if method == "Faulty": + return web.Response( + body=xmlrpc.client.dumps( + xmlrpc.client.Fault(14, "XML-RPC parameters wrong") + ).encode(), + content_type="text/xml", + ) + return resp(f"{tk}") + + +async def main() -> int: + app = web.Application() + app.router.add_post("/", handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 18193) + await site.start() + + failures = [] + + def check(label, got, want): + ok = got == want + print(f"{'PASS' if ok else 'FAIL'} {label}: {got!r}") + if not ok: + failures.append((label, got, want)) + + async with aiohttp.ClientSession() as session: + c = R.RRCSClient(session, "127.0.0.1", 18193) + + check("url", c.url, "http://127.0.0.1:18193/") + check("transkey format", len(c.next_transkey()), 11) + + check("get_version", await c.get_version(), "8.9.1.0") + check("get_state", await c.get_state(), "Working") + check("is_connected_to_artist", await c.is_connected_to_artist(), True) + check("get_active_xp_count", await c.get_active_xp_count(), 42) + + sources = await c.get_logic_sources() + check("logic source count", len(sources), 2) + check("logic source name", sources[1454317500].long_name, "Ben Busy") + check("logic source label", sources[1454317500].label, "BenBusy") + check("logic source state on", sources[1454317500].state, True) + check("logic source state off", sources[1454317501].state, False) + + await c.set_logic_source(1454317500, True) + method, params = RECEIVED[-1] + check("SetLogicSourceState params", params[1:], (1454317500, True)) + + addr = R.GpioAddress(1, 2, 128, 0, 5, True) + check("gp input read", await c.get_gp_input_state(addr), True) + await c.set_gp_output(R.GpioAddress(1, 2, 128, 0, 5, False), True) + check("SetGpOutput params", RECEIVED[-1][1][1:], (1, 2, 128, 0, 5, 1)) + + # A payload integer in slot 1 must not be mistaken for an error code. + level = await c.call("GetLevelMeterValues", 2, 0, 0) + check("level meter untouched", level[1:], [17, 4, False, 21]) + + # ...but a real error code on a checked call must raise. + try: + await c.call("SetInputGain", 1, 2, 3, 99, check=True) + check("error raised", False, True) + except R.RRCSError as err: + check("error code", err.code, 6) + check("error text", str(err), "SetInputGain: Input gain invalid") + + gpios = await c.discover_gpios() + keys = sorted(g.key for g in gpios) + check("discovered client-card gpio", "1_2_128_5_5_in" in keys, True) + check("discovered port gpio", "1_4_9_0_0_in" in keys, True) + check("failed GetAllGpOuts tolerated", len(gpios), 2) + + check("mojibake tolerated", (await c.call("BadLabel"))[2], "Caf\ufffd Mic") + + try: + await c.call("Faulty") + check("fault raised", False, True) + except R.RRCSFaultError as err: + check("fault code", err.code, 14) + + await c.register_for_all_events(8123, "/api/riedel_rrcs/abc123") + check("register params", RECEIVED[-1][1][1:], + (8123, "/api/riedel_rrcs/abc123", False, False)) + check("still registered", + await c.is_registered_for_all_events(8123, "/api/riedel_rrcs/abc123"), True) + + # Unreachable host must surface as a connection error, not a crash. + dead = R.RRCSClient(session, "127.0.0.1", 18194, timeout=2) + try: + await dead.get_version() + check("dead host raises", False, True) + except R.RRCSConnectionError: + check("dead host raises", True, True) + + # --- Manual GPIO config parsing --- + parsed = R.parse_gpio_config( + '[{"name":"Tally A","direction":"in","node":2,"index":3},' + ' {"name":"Red Light","direction":"out","net":1,"node":2,"port":128,' + ' "slot":1,"index":5}]' + ) + check("parsed count", len(parsed), 2) + check("parsed defaults", parsed[0][0], R.GpioAddress(1, 2, 128, 0, 3, True)) + check("parsed name", parsed[1][1], "Red Light") + check("parsed direction", parsed[1][0].is_input, False) + check("empty config", R.parse_gpio_config(""), []) + for bad, label in (("not json", "bad json"), ('[{"node":1}]', "missing index"), + ('[{"node":1,"index":2,"direction":"sideways"}]', "bad direction")): + try: + R.parse_gpio_config(bad) + check(label, False, True) + except ValueError: + check(label, True, True) + + # --- Inbound notification decoding --- + call = xmlrpc.client.dumps(("R0000000001", 1454317500, True), "LogicSourceChange") + params, method = R.parse_xmlrpc(call.encode()) + check("notification method", method, "LogicSourceChange") + check("notification params", params, ("R0000000001", 1454317500, True)) + + ack = xmlrpc.client.dumps((["R0000000001", 0],), methodresponse=True) + check("ack parses", xmlrpc.client.loads(ack.encode())[0][0], ["R0000000001", 0]) + + await runner.cleanup() + + print() + if failures: + print(f"{len(failures)} FAILURE(S)") + return 1 + print("all checks passed") + return 0 + + +sys.exit(asyncio.run(main()))