Upload files to "/"
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user