From 368ab2956f10f75abbaeca6c384d09f2e79c3203 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Wed, 12 Aug 2026 15:32:36 +1000 Subject: [PATCH] Upload files to "/" --- riedel_rrcs.zip | Bin 0 -> 35310 bytes rrcs.py | 515 ++++++++++++++++++++++++++++++++++++++++++ services.yaml | 589 ++++++++++++++++++++++++++++++++++++++++++++++++ switch.py | 172 ++++++++++++++ test_client.py | 269 ++++++++++++++++++++++ 5 files changed, 1545 insertions(+) create mode 100644 riedel_rrcs.zip create mode 100644 rrcs.py create mode 100644 services.yaml create mode 100644 switch.py create mode 100644 test_client.py diff --git a/riedel_rrcs.zip b/riedel_rrcs.zip new file mode 100644 index 0000000000000000000000000000000000000000..e8a95898a08892d7a419fbeb3fad2348a15b1c04 GIT binary patch literal 35310 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 uzbT)8^&#Weq38c?==c7g`2ViF{-1^c>^)u{2]*\?>") + + +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()))