"""Exercise the RRCS client against a mock gateway that speaks spec-shaped XML."""
import asyncio
import sys
import xmlrpc.client
import aiohttp
from aiohttp import web
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1] / "custom_components"
DIR = str(ROOT / "riedel_rrcs")
def _load_module():
"""Load rrcs.py without dragging in the Home Assistant package __init__."""
import importlib.util
import types
pkg = types.ModuleType("rrcspkg")
pkg.__path__ = [DIR]
sys.modules["rrcspkg"] = pkg
for name in ("const", "rrcs"):
spec = importlib.util.spec_from_file_location(f"rrcspkg.{name}", f"{DIR}/{name}.py")
module = importlib.util.module_from_spec(spec)
sys.modules[f"rrcspkg.{name}"] = module
spec.loader.exec_module(module)
return sys.modules["rrcspkg.rrcs"]
R = _load_module()
RECEIVED = []
def resp(body: str) -> web.Response:
return web.Response(
body=(''
f"{body}").encode(),
content_type="text/xml",
)
async def handler(request: web.Request) -> web.Response:
raw = await request.read()
params, method = xmlrpc.client.loads(raw)
RECEIVED.append((method, params))
tk = params[0]
if method == "GetVersion":
return resp(f"{tk}"
"0"
"8.9.1.0")
if method == "GetState":
return resp(f"{tk}"
"0"
"Working")
if method == "IsConnectedToArtist":
return resp(""
"IsConnected1"
f"TransKey{tk}"
"")
if method == "GetAllActiveXpsCount":
return resp(f"{tk}"
"0"
"42")
if method == "GetAllLogicSources_v2":
return resp(""
"ErrorCode0"
f"TransKey{tk}"
"LogicSourceCount2"
"LogicSource#1"
"Ben Busy"
"BenBusy"
"1454317500"
"1"
""
"LogicSource#2"
"Studio Red Light"
"RedLight"
"1454317501"
"0"
""
"")
if method in ("SetLogicSourceState", "SetGpOutput", "SetXp", "SetKeyLabel"):
return resp(f"{tk}"
"0")
if method == "SetInputGain":
return resp(f"{tk}"
"6")
if method in ("GetGpInputState", "GetGpOutputState"):
return resp(f"{tk}"
"0"
"1")
if method == "GetLevelMeterValues":
# Deliberately puts a payload int where ErrorCode usually sits.
return resp(f"{tk}"
"174"
"0"
"21")
if method == "GetAllGpIns":
return resp(f"{tk}"
""
""
"Index5"
"IsInput1"
"Node2"
"Bay6"
""
""
"PortAddress"
"IsInput1"
"Node4"
"Port9"
""
"Index0"
"IsInput1"
""
"")
if method == "GetAllGpOuts":
raise web.HTTPInternalServerError()
if method == "RegisterForAllEvents":
return resp(f"{tk}")
if method == "IsRegisteredForAllEvents":
return resp(""
"IsRegistered1"
f"TransKey{tk}"
"")
if method == "BadLabel":
# UTF-8 declared, Latin-1 emitted: the tolerant parser must recover.
body = (''
f'{tk}'
'0'
'Caf\xe9 Mic'
'')
return web.Response(body=body.encode("latin-1"), content_type="text/xml")
if method == "Faulty":
return web.Response(
body=xmlrpc.client.dumps(
xmlrpc.client.Fault(14, "XML-RPC parameters wrong")
).encode(),
content_type="text/xml",
)
return resp(f"{tk}")
async def main() -> int:
app = web.Application()
app.router.add_post("/", handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 18193)
await site.start()
failures = []
def check(label, got, want):
ok = got == want
print(f"{'PASS' if ok else 'FAIL'} {label}: {got!r}")
if not ok:
failures.append((label, got, want))
async with aiohttp.ClientSession() as session:
c = R.RRCSClient(session, "127.0.0.1", 18193)
check("url", c.url, "http://127.0.0.1:18193/")
check("transkey format", len(c.next_transkey()), 11)
check("get_version", await c.get_version(), "8.9.1.0")
check("get_state", await c.get_state(), "Working")
check("is_connected_to_artist", await c.is_connected_to_artist(), True)
check("get_active_xp_count", await c.get_active_xp_count(), 42)
sources = await c.get_logic_sources()
check("logic source count", len(sources), 2)
check("logic source name", sources[1454317500].long_name, "Ben Busy")
check("logic source label", sources[1454317500].label, "BenBusy")
check("logic source state on", sources[1454317500].state, True)
check("logic source state off", sources[1454317501].state, False)
await c.set_logic_source(1454317500, True)
method, params = RECEIVED[-1]
check("SetLogicSourceState params", params[1:], (1454317500, True))
addr = R.GpioAddress(1, 2, 128, 0, 5, True)
check("gp input read", await c.get_gp_input_state(addr), True)
await c.set_gp_output(R.GpioAddress(1, 2, 128, 0, 5, False), True)
check("SetGpOutput params", RECEIVED[-1][1][1:], (1, 2, 128, 0, 5, 1))
# A payload integer in slot 1 must not be mistaken for an error code.
level = await c.call("GetLevelMeterValues", 2, 0, 0)
check("level meter untouched", level[1:], [17, 4, False, 21])
# ...but a real error code on a checked call must raise.
try:
await c.call("SetInputGain", 1, 2, 3, 99, check=True)
check("error raised", False, True)
except R.RRCSError as err:
check("error code", err.code, 6)
check("error text", str(err), "SetInputGain: Input gain invalid")
gpios = await c.discover_gpios()
keys = sorted(g.key for g in gpios)
check("discovered client-card gpio", "1_2_128_5_5_in" in keys, True)
check("discovered port gpio", "1_4_9_0_0_in" in keys, True)
check("failed GetAllGpOuts tolerated", len(gpios), 2)
check("mojibake tolerated", (await c.call("BadLabel"))[2], "Caf\ufffd Mic")
try:
await c.call("Faulty")
check("fault raised", False, True)
except R.RRCSFaultError as err:
check("fault code", err.code, 14)
await c.register_for_all_events(8123, "/api/riedel_rrcs/abc123")
check("register params", RECEIVED[-1][1][1:],
(8123, "/api/riedel_rrcs/abc123", False, False))
check("still registered",
await c.is_registered_for_all_events(8123, "/api/riedel_rrcs/abc123"), True)
# Unreachable host must surface as a connection error, not a crash.
dead = R.RRCSClient(session, "127.0.0.1", 18194, timeout=2)
try:
await dead.get_version()
check("dead host raises", False, True)
except R.RRCSConnectionError:
check("dead host raises", True, True)
# --- Manual GPIO config parsing ---
parsed = R.parse_gpio_config(
'[{"name":"Tally A","direction":"in","node":2,"index":3},'
' {"name":"Red Light","direction":"out","net":1,"node":2,"port":128,'
' "slot":1,"index":5}]'
)
check("parsed count", len(parsed), 2)
check("parsed defaults", parsed[0][0], R.GpioAddress(1, 2, 128, 0, 3, True))
check("parsed name", parsed[1][1], "Red Light")
check("parsed direction", parsed[1][0].is_input, False)
check("empty config", R.parse_gpio_config(""), [])
for bad, label in (("not json", "bad json"), ('[{"node":1}]', "missing index"),
('[{"node":1,"index":2,"direction":"sideways"}]', "bad direction")):
try:
R.parse_gpio_config(bad)
check(label, False, True)
except ValueError:
check(label, True, True)
# --- Inbound notification decoding ---
call = xmlrpc.client.dumps(("R0000000001", 1454317500, True), "LogicSourceChange")
params, method = R.parse_xmlrpc(call.encode())
check("notification method", method, "LogicSourceChange")
check("notification params", params, ("R0000000001", 1454317500, True))
ack = xmlrpc.client.dumps((["R0000000001", 0],), methodresponse=True)
check("ack parses", xmlrpc.client.loads(ack.encode())[0][0], ["R0000000001", 0])
await runner.cleanup()
print()
if failures:
print(f"{len(failures)} FAILURE(S)")
return 1
print("all checks passed")
return 0
sys.exit(asyncio.run(main()))