From 21bae69275832fd50b5aa50d78e3a7ed55bd5948 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 2 Oct 2026 00:29:20 -0600 Subject: [PATCH] arsdk: read the codec's (ids, values) decode shape protocol/codec.py landed with decode_event returning ids and a values dict, so the session looks the command name up in the index rather than expecting it in the result. Verified end to end against that stream's codec in a stitched tree: the sim encodes events through it, the session decodes through it, and the test that skips until it exists now passes. The argument name cannot be recovered by splitting a telemetry key, because an argument can contain an underscore, so the expectation predicate matches on the suffix instead. --- src/mcbebop/arsdk/session.py | 50 +++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/mcbebop/arsdk/session.py b/src/mcbebop/arsdk/session.py index 23df17c..d0e8440 100644 --- a/src/mcbebop/arsdk/session.py +++ b/src/mcbebop/arsdk/session.py @@ -24,6 +24,7 @@ import threading import time from collections.abc import Callable, Iterable from dataclasses import dataclass, field +from functools import cache from typing import Any from mcbebop.arsdk.connection import Connection @@ -101,14 +102,31 @@ def _default_decoder(payload: bytes) -> Any: return decode_event(payload) +@cache +def _name_for(ids: tuple[int, int, int]) -> str: + """The command's own name, for an event that arrived without one. + + `protocol.codec.decode_event` returns ids and values; the name lives in + the index. It cannot be recovered from the keys, because an argument may + itself contain an underscore (`MassStorageInfoStateListChanged_mass_storage_id`). + """ + try: + from mcbebop.protocol import xml_index # deferred, and optional + + spec = xml_index.index().by_ids(ids) + except Exception: # pragma: no cover - only without the protocol layer + spec = None + return spec.name if spec is not None else f"cmd_{ids[0]}_{ids[1]}_{ids[2]}" + + def _as_event(decoded: Any, payload: bytes) -> Event | None: """Normalise whatever the codec returned into an `Event`. - The codec is written by another stream against the same `Event` type, so - the first branch is the expected one. The rest exist because a decoder - that returns a plain tuple or mapping is a reasonable thing to write, and - a transport that crashes on the shape of a decode result would be a silly - way to lose a link. + `decode_event` returns `(ids, values)`, which is the second branch. The + others are here because an `Event`, a 3-tuple and a mapping are all + reasonable things for a decoder or a test fake to hand back, and a + transport that died on the shape of a decode result would be a silly way + to lose a link. """ if decoded is None: return None @@ -119,16 +137,18 @@ def _as_event(decoded: Any, payload: bytes) -> Event | None: values = getattr(decoded, "values", None) if ids is None and isinstance(decoded, dict): ids, name, values = decoded.get("ids"), decoded.get("name"), decoded.get("values") - if ids is None and isinstance(decoded, tuple | list) and len(decoded) == 3: - ids, name, values = decoded + if ids is None and isinstance(decoded, tuple | list): + if len(decoded) == 2: + ids, values = decoded + elif len(decoded) == 3: + ids, name, values = decoded if ids is None: log.debug("decoder returned an unusable shape %r for %s", type(decoded), payload[:8].hex()) return None - if name is None: - name = f"cmd_{tuple(ids)}" + ids = tuple(ids) return Event( - ids=tuple(ids), # type: ignore[arg-type] - name=str(name), + ids=ids, # type: ignore[arg-type] + name=str(name) if name else _name_for(ids), # type: ignore[arg-type] values=dict(values or {}), at=getattr(decoded, "at", None) or time.monotonic(), ) @@ -409,7 +429,13 @@ class DroneSession: def predicate(event: Event) -> bool: for name, expected in checks.get(event.ids, {}).items(): - got = event.values.get(f"{event.name}_{name}", event.values.get(name)) + got = event.values.get(f"{event.name}_{name}") + if got is None: + # Telemetry keys are `_`, but an argument + # can contain an underscore, so match on the suffix + # rather than trusting a split. + matches = [v for k, v in event.values.items() if k.endswith(f"_{name}") or k == name] + got = matches[0] if len(matches) == 1 else None if got is not None and got != expected: return False return True