Track which part of the aircraft reported a value, and resolve the battery

The drone sends BatteryStateChanged from two places with different values: the
true figure on buffer 126, corroborated by its own log and tracking a smooth
discharge, and a constant 0 on buffer 127 that appears in no log line. Keeping
the newest frame meant a healthy aircraft read as flat and preflight refused.

Events now carry their buffer and the store keeps the last value per source, so
a disagreement is visible instead of silently resolved. get_state reports the
sources whenever they differ, rather than handing over a winner.

preflight breaks the tie on physical grounds, not preference: an aircraft that
is powered and holding a link is not at 0%, so a zero from a live drone is not
credible while a non-zero one is. It says the number was contested either way,
and a genuine zero from every source still reports zero and still blocks.
This commit is contained in:
Ryan Malloy
2026-10-02 07:00:10 -06:00
parent 8611d6112d
commit 907f876773
5 changed files with 147 additions and 15 deletions
+31 -7
View File
@@ -23,7 +23,7 @@ import logging
import threading
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from functools import cache
from typing import Any
@@ -70,15 +70,27 @@ class Timeouts:
@dataclass
class Reading:
"""One telemetry key: the value, and when the drone last said so."""
"""One telemetry key: the value, and when the drone last said so.
`sources` holds the last value seen from each buffer. It exists because
this aircraft sends `BatteryStateChanged` from two places with different
values, so "the newest frame" and "the truth" are not the same thing, and
a consumer needs to be able to see that rather than be handed a winner.
"""
value: Any
at: float # time.monotonic()
count: int = 1
sources: dict[int, Any] = field(default_factory=dict)
def age(self, now: float | None = None) -> float:
return (now if now is not None else time.monotonic()) - self.at
@property
def conflicting(self) -> bool:
"""True when two buffers are currently reporting different values."""
return len(set(map(repr, self.sources.values()))) > 1
@dataclass
class _Waiter:
@@ -119,7 +131,7 @@ def _name_for(ids: tuple[int, int, int]) -> str:
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, buffer_id: int = 0) -> Event | None:
"""Normalise whatever the codec returned into an `Event`.
`decode_event` returns `(ids, values)`, which is the second branch. The
@@ -131,7 +143,7 @@ def _as_event(decoded: Any, payload: bytes) -> Event | None:
if decoded is None:
return None
if isinstance(decoded, Event):
return decoded
return decoded if decoded.buffer_id else replace(decoded, buffer_id=buffer_id)
ids = getattr(decoded, "ids", None)
name = getattr(decoded, "name", None)
values = getattr(decoded, "values", None)
@@ -151,6 +163,7 @@ def _as_event(decoded: Any, payload: bytes) -> Event | None:
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(),
buffer_id=buffer_id,
)
@@ -265,7 +278,7 @@ class DroneSession:
if len(frame.payload) < COMMAND_HEADER.size:
return
try:
event = _as_event(self._decode(frame.payload), frame.payload)
event = _as_event(self._decode(frame.payload), frame.payload, frame.buffer_id)
except Exception:
# An event we cannot decode is a gap in coverage, not a reason to
# drop the link: the next frame may be the one that matters.
@@ -282,7 +295,9 @@ class DroneSession:
with self._lock:
for key, value in values.items():
prior = self._store.get(key)
self._store[key] = Reading(value, event.at, (prior.count + 1) if prior else 1)
sources = dict(prior.sources) if prior else {}
sources[event.buffer_id] = value
self._store[key] = Reading(value, event.at, (prior.count + 1) if prior else 1, sources)
hit = [w for w in self._waiters if event.ids in w.ids and w.predicate(event)]
for waiter in hit:
waiter.hit = event
@@ -309,7 +324,16 @@ class DroneSession:
if keys is not None:
wanted = list(keys)
items = {k: v for k, v in items.items() if k in wanted or any(k.startswith(w) for w in wanted)}
return {k: {"value": v.value, "age": round(v.age(now), 3)} for k, v in sorted(items.items())}
out: dict[str, Any] = {}
for key, reading in sorted(items.items()):
entry: dict[str, Any] = {"value": reading.value, "age": round(reading.age(now), 3)}
if reading.conflicting:
# Say so rather than hand over a winner. The newest frame is not
# necessarily the true one when two parts of the aircraft report
# the same thing differently.
entry["sources"] = dict(reading.sources)
out[key] = entry
return out
def values(self, keys: Iterable[str] | None = None) -> dict[str, Any]:
"""The same thing without the staleness wrapper, for internal checks."""
+8
View File
@@ -90,6 +90,14 @@ class Event:
name: str # the command name, e.g. "BatteryStateChanged"
values: dict[str, Any] # keyed `<Command>_<arg>`, as our captures are
at: float # time.monotonic()
buffer_id: int = 0
"""Which buffer carried it.
Kept because this aircraft sends the same command from more than one place
with different values: `BatteryStateChanged` arrives as the true figure on
one buffer and as a constant 0 on another. Without knowing the source, a
store that keeps the newest value silently reports whichever spoke last.
"""
class HandshakeError(RuntimeError):
+8
View File
@@ -64,6 +64,14 @@ class SendResult(BaseModel):
class StateValue(BaseModel):
value: object
age: float = Field(description="Seconds since the drone last reported this. Old values may be stale.")
sources: dict[int, object] | None = Field(
default=None,
description=(
"Present only when parts of the aircraft disagree about this value, mapping the link "
"buffer each report arrived on to what it said. `value` is then merely the most recent "
"of them, not a decision about which is right."
),
)
class Check(BaseModel):
+40 -8
View File
@@ -28,6 +28,40 @@ def _sensor_results(values: dict) -> dict[str, bool]:
return out
def _battery(session) -> tuple[int | None, str]:
"""The battery percentage, and how much to trust it.
This aircraft sends `BatteryStateChanged` from two places with different
values: the real figure, which its own log corroborates and which tracks a
smooth discharge, and a constant 0 that appears in no log line. Taking the
newest frame therefore reports a healthy drone as flat.
The tie is broken on physical grounds rather than preference: an aircraft
that is powered, holding a link and streaming telemetry is not at 0%, so a
zero from a live drone is not a credible reading while a non-zero one is.
The disagreement is reported either way, because a caller should know the
number was contested.
"""
raw = session.state(["BatteryStateChanged_percent"]).get("BatteryStateChanged_percent")
if raw is None:
return None, "not reported"
sources = raw.get("sources") or {}
distinct = {v for v in sources.values()}
if len(distinct) < 2:
return raw["value"], f"{raw['value']}%"
credible = sorted(v for v in distinct if isinstance(v, int) and v > 0)
if not credible:
return None, f"sources disagree but none is credible: {sources}"
best = credible[-1]
others = sorted(distinct - {best})
return best, (
f"{best}%, contested: the aircraft also reports {others} on another channel. "
"A drone that is powered and holding a link is not at 0%, so that reading is disregarded."
)
def register(mcp: FastMCP, settings: Settings) -> None:
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def get_state(
@@ -49,7 +83,10 @@ def register(mcp: FastMCP, settings: Settings) -> None:
"""
session = require_session()
raw = session.state(keys or None)
return {k: StateValue(value=v["value"], age=round(v["age"], 2)) for k, v in raw.items()}
return {
k: StateValue(value=v["value"], age=round(v["age"], 2), sources=v.get("sources"))
for k, v in raw.items()
}
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def watch_state(
@@ -96,13 +133,8 @@ def register(mcp: FastMCP, settings: Settings) -> None:
# one that says nothing at all.
(blocking if blocks else advisory).append(f"{name}: not reported")
battery = v.get("BatteryStateChanged_percent")
add(
"battery",
None if battery is None else battery >= 30,
"not reported" if battery is None else f"{battery}%",
blocks=True,
)
battery, battery_detail = _battery(session)
add("battery", None if battery is None else battery >= 30, battery_detail, blocks=True)
rssi = v.get("WifiSignalChanged_rssi")
add("link", None if rssi is None else rssi > -75, "not reported" if rssi is None else f"{rssi} dBm")
+60
View File
@@ -0,0 +1,60 @@
"""The aircraft reports its battery twice, with different values.
Observed on the live drone 2026-10-02: `BatteryStateChanged` (0-5-1) arrives as
`00 05 01 00 40` on buffer 126 and `00 05 01 00 00` on buffer 127, repeatedly.
The 64 is corroborated by the drone's own log and tracks a smooth discharge; the
zeros appear in no log line. A store that keeps the newest frame therefore
reported a healthy aircraft as flat, and preflight refused on it.
"""
import pytest
from mcbebop.arsdk.session import Reading
from mcbebop.arsdk.types import Event
from mcbebop.tools.state import _battery
KEY = "BatteryStateChanged_percent"
class FakeSession:
def __init__(self, entry):
self.entry = entry
def state(self, keys):
return {KEY: self.entry} if self.entry is not None else {}
def test_reading_detects_disagreement():
assert Reading(0, 1.0, 4, {126: 64, 127: 0}).conflicting
assert not Reading(64, 1.0, 1, {126: 64}).conflicting
assert not Reading(64, 1.0, 2, {126: 64, 127: 64}).conflicting
def test_event_carries_its_buffer():
"""Without the source, the store cannot tell two reporters apart."""
assert Event(ids=(0, 5, 1), name="x", values={}, at=0.0).buffer_id == 0
assert Event(ids=(0, 5, 1), name="x", values={}, at=0.0, buffer_id=127).buffer_id == 127
def test_the_real_conflict_resolves_to_the_credible_value():
pct, detail = _battery(FakeSession({"value": 0, "age": 0.1, "sources": {126: 64, 127: 0}}))
assert pct == 64, "the newest frame said 0; the credible one is 64"
assert "contested" in detail and "0" in detail, "the caller must learn the number was disputed"
def test_a_lone_reading_is_not_described_as_contested():
pct, detail = _battery(FakeSession({"value": 64, "age": 0.1, "sources": {126: 64}}))
assert pct == 64 and detail == "64%"
@pytest.mark.parametrize(
("entry", "expected"),
[
({"value": 58, "age": 0.1}, 58),
({"value": 0, "age": 0.1, "sources": {126: 0, 127: 0}}, 0),
(None, None),
],
)
def test_other_shapes(entry, expected):
"""A genuine zero from every source still reports zero, and still blocks."""
assert _battery(FakeSession(entry))[0] == expected