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