返回教程正文

配套源码

main.py

app/python/main.py
ROS 2 与 App 通讯 03:发布 BNO086 IMU 数据app/python/main.py
Python750 行
"""BNO086 snapshot adapter: App Lab RPC -> WebSocket -> ros_ws_bridge.

The MCU owns sampling and the BNO086 driver.  This file only reads the
read-only ``imu_get_sample`` RPC, validates one complete snapshot, and sends
standard ROS message envelopes to the WebSocket Brick.
"""

import json
import math
import threading
import time

try:  # App Lab supplies these modules; the fallback keeps host tests importable.
    from arduino.app_utils import App, Bridge
except ImportError:  # pragma: no cover - exercised only outside App Lab
    App = None
    Bridge = None

try:
    from websocket_server import WebSocketServer
except ImportError:  # pragma: no cover - exercised only outside App Lab
    WebSocketServer = None

try:
    from bno086_imu import Bno086Imu
except ImportError:  # pragma: no cover - exercised only outside App Lab
    Bno086Imu = None


IMU_TOPIC = "/imu/data"
STATUS_TOPIC = "/imu/status"
IMU_TYPE = "sensor_msgs/msg/Imu"
STATUS_TYPE = "std_msgs/msg/String"
FRAME_ID = "imu_link"
SAMPLE_PERIOD_S = 0.019  # 52.6 Hz target leaves margin for a 50 Hz ROS stream.
RPC_TIMEOUT_S = 0.05
STATUS_PERIOD_S = 1.0
SNAPSHOT_RATE_HZ = 1.0 / SAMPLE_PERIOD_S
# The MCU marks a report stale after 200 ms.  This slightly larger host-side
# limit catches a stalled marker stream even if a stale flag is delayed, while
# allowing normal scheduling jitter in the 50 Hz RPC loop.
MARKER_NO_PROGRESS_TIMEOUT_S = 0.25
MARKER_NO_PROGRESS_TIMEOUT_NS = int(MARKER_NO_PROGRESS_TIMEOUT_S * 1_000_000_000)
# A 100 Hz marker should normally advance by about 10 ms (or 20 ms between
# 50 Hz RPC snapshots).  A 250 ms jump is an explicit sensor-time interruption
# threshold, not a raw SH-2 sequence-gap gate.
MARKER_SENSOR_TIME_MAX_GAP_US = 250_000
# The vendored ``sh2_SensorValue_t.timestamp`` is uint64_t and the MCU JSON
# writer preserves it in a uint64_t field.  Keep this width explicit so a
# future protocol proven to expose uint32 timestamps can opt into the
# wrap-safe extension without treating a relative counter as Unix time.
SENSOR_TIME_BITS = 64
STREAMS = (
    "accelerometer",
    "linear_acceleration",
    "gyroscope",
    "orientation",
)
UPDATE_MARKER_STREAMS = ("linear_acceleration", "orientation")
REPORT_FIELDS = (
    "valid",
    "stale",
    "accuracy",
    "quality",
    "finite",
    "seq",
    "sample_seq",
    "sensor_seq",
    "sensor_time_us",
    "count",
    "sequence_gap_count",
    "unit",
)


class SampleRejected(ValueError):
    """A snapshot must not be published when this validation fails."""


def _integer(value):
    return isinstance(value, int) and not isinstance(value, bool)


def _nonnegative_integer(value):
    return _integer(value) and value >= 0


def _finite_number(value, label):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise SampleRejected(f"{label} is not numeric")
    number = float(value)
    if not math.isfinite(number):
        raise SampleRejected(f"{label} is not finite")
    return number


def _ready_report(name, report):
    if not isinstance(report, dict):
        raise SampleRejected(f"{name} report is missing")
    if report.get("valid") is not True:
        raise SampleRejected(f"{name} valid=false")
    if report.get("stale") is not False:
        raise SampleRejected(f"{name} stale=true")
    quality = report.get("accuracy", report.get("quality"))
    if not _integer(quality) or not 1 <= quality <= 3:
        raise SampleRejected(f"{name} quality is outside 1..3")


def _vector(report, name, field):
    values = report.get(field)
    if not isinstance(values, dict):
        raise SampleRejected(f"{name}.{field} is missing")
    return {
        axis: _finite_number(values.get(axis), f"{name}.{field}.{axis}")
        for axis in ("x", "y", "z")
    }


def _quaternion(report):
    values = report.get("quaternion")
    if not isinstance(values, dict):
        raise SampleRejected("orientation.quaternion is missing")
    result = {
        axis: _finite_number(values.get(axis), f"orientation.quaternion.{axis}")
        for axis in ("x", "y", "z", "w")
    }
    norm = math.sqrt(sum(value * value for value in result.values()))
    if not math.isfinite(norm) or abs(norm - 1.0) > 0.05:
        raise SampleRejected("orientation quaternion norm is invalid")
    return result


def _sample_data(sample, receive_time_ns):
    """Validate a complete sample and build ``sensor_msgs/msg/Imu.data``.

    The BNO086 sketch already supplies SI units and a normalized quaternion.
    We deliberately read ``raw`` (not the display-only ``filtered`` values).
    ``receive_time_ns`` is the mapped ROS stamp.  The caller derives it from
    the relative sensor clock and uses Linux wall time only for the first
    epoch anchor; it is not based on RPC arrival intervals.
    """
    if not isinstance(sample, dict):
        raise SampleRejected("RPC result is not an object")
    if not _nonnegative_integer(sample.get("reset_count")):
        raise SampleRejected("reset_count is missing or invalid")

    accelerometer = sample.get("accelerometer")
    linear_acceleration = sample.get("linear_acceleration")
    gyroscope = sample.get("gyroscope")
    orientation = sample.get("orientation")
    for name, report in (
        ("accelerometer", accelerometer),
        ("linear_acceleration", linear_acceleration),
        ("gyroscope", gyroscope),
        ("orientation", orientation),
    ):
        _ready_report(name, report)

    # Keep validating the legacy accelerometer for compatibility, but never
    # use it as motion input because it contains gravity.
    if accelerometer.get("unit") != "m/s^2":
        raise SampleRejected("accelerometer unit is not m/s^2")
    if linear_acceleration.get("unit") != "m/s^2":
        raise SampleRejected("linear_acceleration unit is not m/s^2")
    if gyroscope.get("unit") != "rad/s":
        raise SampleRejected("gyroscope unit is not rad/s")
    if orientation.get("mode") != "game_rotation_vector":
        raise SampleRejected("orientation mode is not game_rotation_vector")
    _vector(accelerometer, "accelerometer", "raw")
    linear_accel = _vector(
        linear_acceleration,
        "linear_acceleration",
        "raw",
    )
    gyro = _vector(gyroscope, "gyroscope", "raw")
    quaternion = _quaternion(orientation)
    seconds, nanoseconds = divmod(int(receive_time_ns), 1_000_000_000)

    return {
        "header": {
            "stamp": {"sec": seconds, "nanosec": nanoseconds},
            "frame_id": FRAME_ID,
        },
        "orientation": quaternion,
        "orientation_covariance": [0.0] * 9,  # unknown, not measured
        "angular_velocity": gyro,
        "angular_velocity_covariance": [0.0] * 9,  # unknown, not measured
        "linear_acceleration": linear_accel,
        "linear_acceleration_covariance": [0.0] * 9,  # unknown, not measured
    }


class SensorClockMapper:
    """Map the linear-acceleration sensor clock into ROS Unix nanoseconds.

    BNO086/SH-2 timestamps are relative microseconds, not Unix time.  The
    first accepted linear-acceleration sample establishes an anchor using the
    Linux wall clock captured after the RPC returns.  Later stamps use only
    the extended sensor delta, so WebSocket batching cannot create dt=0 or a
    large arrival-time dt in downstream odometry.

    The current vendored ``sh2_SensorValue_t.timestamp`` is uint64_t.  The
    ``sensor_time_bits`` argument remains explicit because some older sensor
    protocols expose a uint32 counter; when set to 32, one decreasing raw
    value is accepted as a wrap only when the backwards distance exceeds the
    half-range.  A normal 64-bit decrease is rejected as a rollback.
    """

    def __init__(self, max_gap_us=MARKER_SENSOR_TIME_MAX_GAP_US,
                 sensor_time_bits=SENSOR_TIME_BITS):
        if sensor_time_bits not in (32, 64):
            raise ValueError("sensor_time_bits must be 32 or 64")
        self.max_gap_us = int(max_gap_us)
        self.sensor_time_bits = int(sensor_time_bits)
        self.modulus = 1 << self.sensor_time_bits
        self.half_range = self.modulus // 2
        self.reset_count = None
        self.epoch = 0
        self.raw_previous = None
        self.extended_previous = None
        self.anchor_sensor_us = None
        self.anchor_ros_ns = None
        self.last_mapped_ns = None
        self.mapping_state = "unanchored"

    @staticmethod
    def _integer(value, label):
        if not _nonnegative_integer(value):
            raise SampleRejected(f"{label} is missing or invalid")
        return int(value)

    def _start_epoch(self, raw_sensor_us, reset_count, receive_time_ns,
                     reason):
        self.epoch += 1
        self.reset_count = reset_count
        self.raw_previous = raw_sensor_us
        self.extended_previous = raw_sensor_us
        self.anchor_sensor_us = raw_sensor_us
        self.anchor_ros_ns = receive_time_ns
        self.last_mapped_ns = receive_time_ns
        self.mapping_state = reason
        return receive_time_ns

    def observe(self, raw_sensor_us, reset_count, receive_time_ns):
        """Return a mapped ROS stamp for one new linear sample."""
        raw_sensor_us = self._integer(raw_sensor_us, "sensor_time_us")
        reset_count = self._integer(reset_count, "reset_count")
        receive_time_ns = self._integer(receive_time_ns, "receive_time_ns")
        if raw_sensor_us >= self.modulus:
            raise SampleRejected("sensor_time_us exceeds configured counter width")

        if self.reset_count is None:
            return self._start_epoch(
                raw_sensor_us, reset_count, receive_time_ns, "anchored")

        # A reset starts a fresh relative clock epoch.  Do not subtract the
        # old sensor counter from the new one, even if the raw values repeat.
        # The ROS odometry node observes the reset in /imu/status and enters
        # FAULT until the learner recalibrates.
        if reset_count != self.reset_count:
            return self._start_epoch(
                raw_sensor_us, reset_count, receive_time_ns,
                "reanchored_reset")

        if raw_sensor_us < self.raw_previous:
            backwards = self.raw_previous - raw_sensor_us
            if self.sensor_time_bits != 32 or backwards <= self.half_range:
                raise SampleRejected("sensor_time_us moved backwards")
            # Only the explicitly configured uint32 protocol uses wrap.  The
            # modulo extension keeps the mapped time monotonically increasing.
            extended = raw_sensor_us + (self.epoch + 1) * self.modulus
            self.epoch += 1
            self.mapping_state = "wrapped_uint32"
        else:
            delta = raw_sensor_us - self.raw_previous
            if delta <= 0:
                raise SampleRejected("sensor_time_us did not advance")
            if delta > self.max_gap_us:
                raise SampleRejected(
                    f"sensor_time_us gap {delta}us exceeds {self.max_gap_us}us")
            extended = self.epoch * self.modulus + raw_sensor_us

        if (
            self.extended_previous is not None
            and extended - self.extended_previous > self.max_gap_us
        ):
            raise SampleRejected(
                "sensor_time_us extended gap exceeds "
                f"{self.max_gap_us}us"
            )
        mapped_ns = self.anchor_ros_ns + (
            extended - self.anchor_sensor_us
        ) * 1_000
        if self.last_mapped_ns is not None and mapped_ns <= self.last_mapped_ns:
            raise SampleRejected("mapped ROS timestamp did not advance")
        self.raw_previous = raw_sensor_us
        self.extended_previous = extended
        self.last_mapped_ns = mapped_ns
        if self.mapping_state != "wrapped_uint32":
            self.mapping_state = "anchored"
        return mapped_ns

    def status(self):
        """Return JSON-safe diagnostics for /imu/status."""
        return {
            "timestamp_source": "linear_acceleration_sensor_time_mapped",
            "mapping_state": self.mapping_state,
            "sensor_time_bits": self.sensor_time_bits,
            "epoch": self.epoch,
            "reset_count": self.reset_count,
            "anchor_sensor_time_us": self.anchor_sensor_us,
            "anchor_ros_time_ns": self.anchor_ros_ns,
            "last_sensor_time_us": self.raw_previous,
            "last_mapped_ros_time_ns": self.last_mapped_ns,
            "max_sensor_time_gap_us": self.max_gap_us,
        }


def _identity(report, name):
    """Return per-stream sample progress markers for a stream.

    ``sensor_seq`` is retained in the report for diagnostics, but is not a
    per-stream counter.  The MCU's ``sample_seq``/``count`` pair is the only
    progress marker used to reject duplicate snapshots.
    """
    if not isinstance(report, dict):
        raise SampleRejected(f"{name} report is missing")
    result = {}
    for field in ("sample_seq", "count", "sensor_time_us", "sequence_gap_count"):
        value = report.get(field)
        if value is not None:
            if not _nonnegative_integer(value):
                raise SampleRejected(f"{name}.{field} is invalid")
            result[field] = value
    # ``sequence_gap_count`` is an independent diagnostic/gate counter, not
    # a fourth progress marker.  The current MCU includes it in every report,
    # so require the canonical markers without rejecting that extra field.
    if not {"sample_seq", "count", "sensor_time_us"}.issubset(result):
        raise SampleRejected(f"{name} is missing an MCU progress marker")
    return result


class SampleTracker:
    """Track snapshot progress without treating raw sequence gaps as faults."""

    def __init__(self):
        self.reset_count = None
        self.identities = {}
        self.marker_sensor_times = {}
        self.last_marker_progress_ns = None

    def accept(self, sample, progress_time_ns=None):
        """Validate markers and return whether the linear stream advanced.

        ``sample_seq`` and ``count`` are MCU-owned canonical progress markers.
        The raw SH-2 ``sensor_seq`` and its gap counters remain diagnostics and
        are intentionally never used as a publish gate.  A duplicate or
        orientation-only snapshot is valid to read but returns ``False`` so
        callers do not republish without a new linear sample.
        """
        reset_count = sample.get("reset_count") if isinstance(sample, dict) else None
        if not _nonnegative_integer(reset_count):
            raise SampleRejected("reset_count is missing or invalid")
        # The top-level field is an aggregate diagnostic (the MCU sums the
        # independent per-stream counters).  Validate its shape when present,
        # but never use its value as a cross-report continuity gate.
        source_gap_count = sample.get("source_sequence_gap_count")
        if not _nonnegative_integer(source_gap_count):
            raise SampleRejected("source_sequence_gap_count is missing or invalid")
        identities = {name: _identity(sample.get(name), name) for name in STREAMS}
        if progress_time_ns is None:
            progress_time_ns = time.monotonic_ns()
        if not _integer(progress_time_ns) or progress_time_ns < 0:
            raise SampleRejected("host progress time is invalid")
        if (
            self.last_marker_progress_ns is not None
            and progress_time_ns < self.last_marker_progress_ns
        ):
            raise SampleRejected("host progress time moved backwards")
        if self.reset_count is not None:
            if reset_count != self.reset_count:
                # A reset starts a new sensor epoch.  Clear marker baselines
                # so the first complete post-reset snapshot can be published;
                # the ROS odometry node observes the status change and asks
                # the learner to recalibrate before integrating again.
                self.reset_count = reset_count
                self.identities = identities
                self.marker_sensor_times = {
                    name: identities[name]["sensor_time_us"]
                    for name in UPDATE_MARKER_STREAMS
                }
                self.last_marker_progress_ns = progress_time_ns
                return True

            linear_updated = False
            for name in STREAMS:
                old = self.identities[name]
                new = identities[name]
                if new["sample_seq"] < old["sample_seq"]:
                    raise SampleRejected(f"{name}.sample_seq moved backwards")
                for field in ("count", "sensor_time_us"):
                    if new[field] < old[field]:
                        raise SampleRejected(f"{name}.{field} moved backwards")
                if name not in UPDATE_MARKER_STREAMS:
                    continue
                marker_changed = (
                    new["sample_seq"] != old["sample_seq"]
                    or new["count"] != old["count"]
                )
                if not marker_changed:
                    continue
                if name == "linear_acceleration":
                    linear_updated = True
                previous_sensor_time = self.marker_sensor_times[name]
                sensor_time_gap = new["sensor_time_us"] - previous_sensor_time
                if sensor_time_gap > MARKER_SENSOR_TIME_MAX_GAP_US:
                    raise SampleRejected(
                        f"{name} sensor time gap {sensor_time_gap}us exceeds "
                        f"{MARKER_SENSOR_TIME_MAX_GAP_US}us"
                    )

            if not linear_updated:
                if (
                    self.last_marker_progress_ns is not None
                    and progress_time_ns - self.last_marker_progress_ns
                    > MARKER_NO_PROGRESS_TIMEOUT_NS
                ):
                    elapsed_s = (
                        progress_time_ns - self.last_marker_progress_ns
                    ) / 1_000_000_000
                    raise SampleRejected(
                        "linear_acceleration/orientation made no progress for "
                        f"{elapsed_s:.3f}s"
                    )
                # A duplicate or a snapshot where only compatibility streams
                # changed is readable for diagnostics, but must not be sent as
                # a new /imu/data message.
                self.identities = identities
                return False

        self.reset_count = reset_count
        # Record the latest sensor clock only when that marker really advanced;
        # otherwise a repeated RPC snapshot must not hide a later time jump.
        for name in UPDATE_MARKER_STREAMS:
            if (
                name not in self.marker_sensor_times
                or identities[name]["sample_seq"]
                != self.identities.get(name, {}).get("sample_seq")
                or identities[name]["count"]
                != self.identities.get(name, {}).get("count")
            ):
                self.marker_sensor_times[name] = identities[name]["sensor_time_us"]
        self.identities = identities
        self.last_marker_progress_ns = progress_time_ns
        return True


def _report_metadata(sample):
    """Keep report time/status/count/reset fields without copying NaN values."""
    reset_count = sample.get("reset_count")
    metadata = {
        "reset_count": reset_count if _nonnegative_integer(reset_count) else None,
        "source_sequence_gap_count": (
            sample.get("source_sequence_gap_count")
            if _nonnegative_integer(sample.get("source_sequence_gap_count"))
            else None
        ),
    }
    for name in STREAMS:
        report = sample.get(name)
        if not isinstance(report, dict):
            metadata[name] = {"status": "missing"}
            continue
        item = {}
        for field in REPORT_FIELDS:
            if field in report and field != "finite":
                value = report[field]
                if isinstance(value, (str, bool, int)) or (
                    isinstance(value, float) and math.isfinite(value)
                ):
                    item[field] = value
        raw = report.get("raw")
        if isinstance(raw, dict):
            item["finite"] = all(
                isinstance(raw.get(axis), (int, float))
                and not isinstance(raw.get(axis), bool)
                and math.isfinite(float(raw.get(axis)))
                for axis in ("x", "y", "z")
            )
        if name == "orientation":
            quaternion = report.get("quaternion")
            if isinstance(quaternion, dict):
                item["finite"] = all(
                    isinstance(quaternion.get(axis), (int, float))
                    and not isinstance(quaternion.get(axis), bool)
                    and math.isfinite(float(quaternion.get(axis)))
                    for axis in ("x", "y", "z", "w")
                )
        metadata[name] = item
    return metadata


def make_envelope(topic, ros_type, sequence, data, timestamp_ms):
    """Build the six-field envelope consumed by ``ros_ws_bridge``."""
    return {
        "topic": topic,
        "ros_type": ros_type,
        "direction": "ws_to_ros",
        "seq": int(sequence),
        "timestamp": int(timestamp_ms),
        "data": data,
    }


class ImuAdapter:
    """Small App Lab loop with no write/control RPCs."""

    def __init__(self, bridge, server, clock_ns=time.time_ns, monotonic=time.monotonic):
        self.bridge = bridge
        self.server = server
        if Bno086Imu is None:
            raise RuntimeError("bno086_imu Brick is unavailable")
        self.imu = Bno086Imu(bridge=bridge, timeout_s=RPC_TIMEOUT_S)
        self.clock_ns = clock_ns
        self.monotonic = monotonic
        self.clients = set()
        self.clients_lock = threading.Lock()
        self.tracker = SampleTracker()
        self.clock_mapper = SensorClockMapper()
        self.sequence = 0
        self.next_sample = 0.0
        self.next_status = 0.0
        self.last_receive_ns = None
        self.last_valid_snapshot_ns = None
        self.last_rpc_ok = False
        self.last_rpc_error = "not_started"
        self.last_fault = "not_started"
        self.fault_count = 0
        self.last_report = {"reset_count": None}
        self.network_error_count = 0
        self.last_network_error = None
        self.next_network_log = 0.0
        self._install_callbacks()

    @staticmethod
    def _client_id(info):
        if isinstance(info, dict):
            return info.get("client_id", info.get("id"))
        return getattr(info, "client_id", getattr(info, "id", None))

    def _install_callbacks(self):
        self.server.on_connect(self._on_connect)
        self.server.on_disconnect(self._on_disconnect)
        # This lesson is uplink-only.  No on_message callback means no input
        # can become a control/RPC path accidentally.

    def _on_connect(self, info):
        client_id = self._client_id(info)
        if client_id is not None:
            with self.clients_lock:
                self.clients.add(client_id)

    def _on_disconnect(self, info, code, reason):
        del code, reason
        client_id = self._client_id(info)
        if client_id is not None:
            with self.clients_lock:
                self.clients.discard(client_id)

    def _log_fault(self, reason):
        reason = str(reason)
        self.last_fault = reason
        self.fault_count += 1
        if reason != getattr(self, "last_logged_fault", None):
            print(f"[BNO086] {reason}", flush=True)
            self.last_logged_fault = reason

    def _log_network_error(self, error):
        self.network_error_count += 1
        self.last_network_error = f"{type(error).__name__}: {error}"
        now = self.monotonic()
        if now >= self.next_network_log:
            print(f"[BNO086] WebSocket send error: {self.last_network_error}", flush=True)
            self.next_network_log = now + 1.0

    def _send_envelope(self, envelope):
        payload = json.dumps(envelope, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
        with self.clients_lock:
            client_ids = tuple(self.clients)
        for client_id in client_ids:
            try:
                result = self.server.send(client_id, payload)
                if result is False:
                    raise RuntimeError(f"server.send returned False for {client_id}")
            except Exception as error:  # the disconnect callback removes stale clients
                self._log_network_error(error)

    def _publish(self, topic, ros_type, data, timestamp_ms):
        self.sequence += 1
        envelope = make_envelope(topic, ros_type, self.sequence, data, timestamp_ms)
        self._send_envelope(envelope)

    @staticmethod
    def _decode_rpc(value):
        if isinstance(value, str):
            value = json.loads(value)
        if not isinstance(value, dict):
            raise SampleRejected("RPC result is not an object")
        return value

    def poll_sample(self):
        """Read one snapshot and publish only after all gates pass."""
        self.last_rpc_ok = False
        self.last_rpc_error = None
        try:
            value = self.imu.get_sample()
            self.last_rpc_ok = True
            # Take the explicit host receive timestamp immediately after the
            # RPC returns, before parsing/building the ROS message.
            receive_ns = int(self.clock_ns())
            if self.last_receive_ns is not None and receive_ns < self.last_receive_ns:
                self._log_fault(
                    "Linux receive time moved backwards; sample not published"
                )
                return
            self.last_receive_ns = receive_ns
            sample = self._decode_rpc(value)
            self.last_report = _report_metadata(sample)
            marker_updated = self.tracker.accept(
                sample,
                progress_time_ns=int(self.monotonic() * 1_000_000_000),
            )
            if not marker_updated:
                return
            linear_report = sample["linear_acceleration"]
            mapped_stamp_ns = self.clock_mapper.observe(
                linear_report["sensor_time_us"],
                sample["reset_count"],
                receive_ns,
            )
            data = _sample_data(sample, mapped_stamp_ns)
            self._publish(
                IMU_TOPIC,
                IMU_TYPE,
                data,
                mapped_stamp_ns // 1_000_000,
            )
            self.last_valid_snapshot_ns = receive_ns
            self.last_fault = "none"
        except Exception as error:
            if not self.last_rpc_ok:
                self.last_rpc_error = f"{type(error).__name__}: {error}"
            self._log_fault(f"sample rejected: {type(error).__name__}: {error}")

    def _status_data(self):
        try:
            websocket = self.server.get_status()
        except Exception as error:
            websocket = {"status_error": f"{type(error).__name__}: {error}"}
        return {
            "frame_id": FRAME_ID,
            "imu_topic": IMU_TOPIC,
            "status_topic": STATUS_TOPIC,
            "last_rpc_ok": self.last_rpc_ok,
            "last_rpc_error": self.last_rpc_error,
            "last_fault": self.last_fault,
            "fault_count": self.fault_count,
            "last_receive_time_ns": self.last_receive_ns,
            "last_valid_snapshot_time_ns": self.last_valid_snapshot_ns,
            "timestamp_source": "linear_acceleration_sensor_time_mapped",
            "timestamp_mapping": self.clock_mapper.status(),
            "source_sequence_gap_count": self.last_report.get(
                "source_sequence_gap_count"
            ),
            "snapshot_rate_hz": SNAPSHOT_RATE_HZ,
            "sample_period_s": SAMPLE_PERIOD_S,
            "rpc_timeout_s": RPC_TIMEOUT_S,
            "marker_no_progress_timeout_s": MARKER_NO_PROGRESS_TIMEOUT_S,
            "marker_sensor_time_max_gap_us": MARKER_SENSOR_TIME_MAX_GAP_US,
            "update_marker_streams": UPDATE_MARKER_STREAMS,
            "next_seq": self.sequence + 1,
            "websocket": websocket,
            "network_error_count": self.network_error_count,
            "last_network_error": self.last_network_error,
            "report": self.last_report,
            "orientation_mode": "game_rotation_vector (non-magnetic north)",
            "tf_note": "No TF is fabricated by this adapter.",
        }

    def publish_status(self):
        """Publish a 1 Hz String heartbeat even when sample RPCs fail."""
        now_ns = int(self.clock_ns())
        status = self._status_data()
        websocket = status.get("websocket", {})
        clients = websocket.get("client_count", len(self.clients))
        print(
            f"[BNO086 ROS] clients={clients} rpc_ok={self.last_rpc_ok} "
            f"fault={self.last_fault}",
            flush=True,
        )
        status_string = json.dumps(
            status, ensure_ascii=False, separators=(",", ":"), allow_nan=False
        )
        self._publish(
            STATUS_TOPIC,
            STATUS_TYPE,
            {"data": status_string},
            now_ns // 1_000_000,
        )

    def _advance_deadline(self, deadline, period, now):
        """Advance one absolute deadline without replaying missed periods."""
        next_deadline = deadline + period
        if next_deadline <= now:
            return now + period
        return next_deadline

    def loop(self):
        now = self.monotonic()
        if now >= self.next_sample:
            self.poll_sample()
            self.next_sample = self._advance_deadline(
                self.next_sample,
                SAMPLE_PERIOD_S,
                self.monotonic(),
            )

        now = self.monotonic()
        if now >= self.next_status:
            try:
                self.publish_status()
            except Exception as error:
                self._log_network_error(error)
            self.next_status = self._advance_deadline(
                self.next_status,
                STATUS_PERIOD_S,
                self.monotonic(),
            )
        time.sleep(0.001)


def run_app():
    if App is None or Bridge is None or WebSocketServer is None or Bno086Imu is None:
        raise RuntimeError("run_app must be executed inside Arduino App Lab")
    adapter = ImuAdapter(Bridge, WebSocketServer())
    App.run(user_loop=adapter.loop)


if __name__ == "__main__":
    run_app()