Upload files to "/"
This commit is contained in:
Binary file not shown.
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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()))
|
||||||
Reference in New Issue
Block a user