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
+38 -12
View File
@@ -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 `<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:
return False
return True