Files
rrcs-home-assistant/rrcs.py
T
2026-08-12 15:32:36 +10:00

516 lines
18 KiB
Python

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