Upload files to "/"

This commit is contained in:
2026-08-12 15:32:36 +10:00
parent de09756731
commit 368ab2956f
5 changed files with 1545 additions and 0 deletions
BIN
View File
Binary file not shown.
+515
View File
@@ -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
+589
View File
@@ -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
+172
View File
@@ -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)
+269
View File
@@ -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=('<?xml version="1.0"?><methodResponse><params><param>'
f"{body}</param></params></methodResponse>").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"<value><array><data><value><string>{tk}</string></value>"
"<value><int>0</int></value>"
"<value><string>8.9.1.0</string></value></data></array></value>")
if method == "GetState":
return resp(f"<value><array><data><value><string>{tk}</string></value>"
"<value><int>0</int></value>"
"<value><string>Working</string></value></data></array></value>")
if method == "IsConnectedToArtist":
return resp("<value><struct>"
"<member><name>IsConnected</name><value><boolean>1</boolean></value></member>"
f"<member><name>TransKey</name><value><string>{tk}</string></value></member>"
"</struct></value>")
if method == "GetAllActiveXpsCount":
return resp(f"<value><array><data><value><string>{tk}</string></value>"
"<value><int>0</int></value>"
"<value><i4>42</i4></value></data></array></value>")
if method == "GetAllLogicSources_v2":
return resp("<value><struct>"
"<member><name>ErrorCode</name><value><int>0</int></value></member>"
f"<member><name>TransKey</name><value><string>{tk}</string></value></member>"
"<member><name>LogicSourceCount</name><value><int>2</int></value></member>"
"<member><name>LogicSource#1</name><value><array><data>"
"<value><string>Ben Busy</string></value>"
"<value><string>BenBusy</string></value>"
"<value><i4>1454317500</i4></value>"
"<value><boolean>1</boolean></value>"
"</data></array></value></member>"
"<member><name>LogicSource#2</name><value><array><data>"
"<value><string>Studio Red Light</string></value>"
"<value><string>RedLight</string></value>"
"<value><i4>1454317501</i4></value>"
"<value><boolean>0</boolean></value>"
"</data></array></value></member>"
"</struct></value>")
if method in ("SetLogicSourceState", "SetGpOutput", "SetXp", "SetKeyLabel"):
return resp(f"<value><array><data><value><string>{tk}</string></value>"
"<value><int>0</int></value></data></array></value>")
if method == "SetInputGain":
return resp(f"<value><array><data><value><string>{tk}</string></value>"
"<value><int>6</int></value></data></array></value>")
if method in ("GetGpInputState", "GetGpOutputState"):
return resp(f"<value><array><data><value><string>{tk}</string></value>"
"<value><int>0</int></value>"
"<value><boolean>1</boolean></value></data></array></value>")
if method == "GetLevelMeterValues":
# Deliberately puts a payload int where ErrorCode usually sits.
return resp(f"<value><array><data><value><string>{tk}</string></value>"
"<value><int>17</int></value><value><int>4</int></value>"
"<value><boolean>0</boolean></value>"
"<value><int>21</int></value></data></array></value>")
if method == "GetAllGpIns":
return resp(f"<value><array><data><value><string>{tk}</string></value>"
"<value><array><data>"
"<value><struct>"
"<member><name>Index</name><value><i4>5</i4></value></member>"
"<member><name>IsInput</name><value><boolean>1</boolean></value></member>"
"<member><name>Node</name><value><i4>2</i4></value></member>"
"<member><name>Bay</name><value><i4>6</i4></value></member>"
"</struct></value>"
"<value><struct>"
"<member><name>PortAddress</name><value><struct>"
"<member><name>IsInput</name><value><boolean>1</boolean></value></member>"
"<member><name>Node</name><value><i4>4</i4></value></member>"
"<member><name>Port</name><value><i4>9</i4></value></member>"
"</struct></value></member>"
"<member><name>Index</name><value><i4>0</i4></value></member>"
"<member><name>IsInput</name><value><boolean>1</boolean></value></member>"
"</struct></value>"
"</data></array></value></data></array></value>")
if method == "GetAllGpOuts":
raise web.HTTPInternalServerError()
if method == "RegisterForAllEvents":
return resp(f"<value><string>{tk}</string></value>")
if method == "IsRegisteredForAllEvents":
return resp("<value><struct>"
"<member><name>IsRegistered</name><value><boolean>1</boolean></value></member>"
f"<member><name>TransKey</name><value><string>{tk}</string></value></member>"
"</struct></value>")
if method == "BadLabel":
# UTF-8 declared, Latin-1 emitted: the tolerant parser must recover.
body = ('<?xml version="1.0" encoding="UTF-8"?><methodResponse><params><param>'
f'<value><array><data><value><string>{tk}</string></value>'
'<value><int>0</int></value>'
'<value><string>Caf\xe9 Mic</string></value>'
'</data></array></value></param></params></methodResponse>')
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"<value><string>{tk}</string></value>")
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()))