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.
This commit is contained in:
2026-10-02 00:29:20 -06:00
parent 96ab07889d
commit 21bae69275
+37 -11
View File
@@ -24,6 +24,7 @@ import threading
import time import time
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import cache
from typing import Any from typing import Any
from mcbebop.arsdk.connection import Connection from mcbebop.arsdk.connection import Connection
@@ -101,14 +102,31 @@ def _default_decoder(payload: bytes) -> Any:
return decode_event(payload) 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: def _as_event(decoded: Any, payload: bytes) -> Event | None:
"""Normalise whatever the codec returned into an `Event`. """Normalise whatever the codec returned into an `Event`.
The codec is written by another stream against the same `Event` type, so `decode_event` returns `(ids, values)`, which is the second branch. The
the first branch is the expected one. The rest exist because a decoder others are here because an `Event`, a 3-tuple and a mapping are all
that returns a plain tuple or mapping is a reasonable thing to write, and reasonable things for a decoder or a test fake to hand back, and a
a transport that crashes on the shape of a decode result would be a silly transport that died on the shape of a decode result would be a silly way
way to lose a link. to lose a link.
""" """
if decoded is None: if decoded is None:
return None return None
@@ -119,16 +137,18 @@ def _as_event(decoded: Any, payload: bytes) -> Event | None:
values = getattr(decoded, "values", None) values = getattr(decoded, "values", None)
if ids is None and isinstance(decoded, dict): if ids is None and isinstance(decoded, dict):
ids, name, values = decoded.get("ids"), decoded.get("name"), decoded.get("values") 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: if ids is None and isinstance(decoded, tuple | list):
if len(decoded) == 2:
ids, values = decoded
elif len(decoded) == 3:
ids, name, values = decoded ids, name, values = decoded
if ids is None: if ids is None:
log.debug("decoder returned an unusable shape %r for %s", type(decoded), payload[:8].hex()) log.debug("decoder returned an unusable shape %r for %s", type(decoded), payload[:8].hex())
return None return None
if name is None: ids = tuple(ids)
name = f"cmd_{tuple(ids)}"
return Event( return Event(
ids=tuple(ids), # type: ignore[arg-type] ids=ids, # type: ignore[arg-type]
name=str(name), name=str(name) if name else _name_for(ids), # type: ignore[arg-type]
values=dict(values or {}), values=dict(values or {}),
at=getattr(decoded, "at", None) or time.monotonic(), at=getattr(decoded, "at", None) or time.monotonic(),
) )
@@ -409,7 +429,13 @@ class DroneSession:
def predicate(event: Event) -> bool: def predicate(event: Event) -> bool:
for name, expected in checks.get(event.ids, {}).items(): 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 `<Command>_<arg>`, 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: if got is not None and got != expected:
return False return False
return True return True