"""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