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()))