feat: add live K1 Foxglove console

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 21:53:43 +03:00
parent 6b22e5a1d2
commit 6be96f0b85
34 changed files with 6037 additions and 15 deletions
+2
View File
@@ -4,6 +4,7 @@ from k1link.mqtt.capture import (
DEFAULT_MAX_MESSAGE_BYTES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
REPORT_TOPICS,
CapturedMqttMessage,
CaptureError,
CaptureFormatError,
CaptureFrame,
@@ -21,6 +22,7 @@ __all__ = [
"CaptureFormatError",
"CaptureFrame",
"CaptureSummary",
"CapturedMqttMessage",
"capture_mqtt",
"iter_capture_frames",
"validate_private_ipv4",
+46 -11
View File
@@ -44,6 +44,7 @@ _PRIVATE_NETWORKS = tuple(
StopReason = Literal[
"duration_elapsed",
"external_stop",
"keyboard_interrupt",
"message_too_large",
"connection_failed",
@@ -127,6 +128,21 @@ class CaptureFrame:
raw_frame_bytes: int
@dataclass(frozen=True, slots=True)
class CapturedMqttMessage:
"""A message made durable by the raw writer and ready for live preview."""
sequence: int
topic: str
payload: bytes
qos: int
retain: bool
dup: bool
received_at_utc: str
received_at_epoch_ns: int
received_monotonic_ns: int
@dataclass
class _CaptureState:
connected: bool = False
@@ -168,19 +184,19 @@ class _CaptureWriter:
self.close()
raise
def record(self, message: mqtt.MQTTMessage) -> None:
def record(self, message: mqtt.MQTTMessage) -> CapturedMqttMessage:
raw = self._require_raw()
metadata = self._require_metadata()
topic = message.topic
topic_bytes = topic.encode("utf-8")
payload = message.payload
received_at_utc = utc_now_iso()
received_at_epoch_ns = time.time_ns()
received_monotonic_ns = time.monotonic_ns()
if not 1 <= len(topic_bytes) <= MAX_TOPIC_BYTES:
raise ValueError(
f"incoming MQTT topic is {len(topic_bytes)} bytes; "
f"expected 1..{MAX_TOPIC_BYTES}"
f"incoming MQTT topic is {len(topic_bytes)} bytes; expected 1..{MAX_TOPIC_BYTES}"
)
if len(payload) > self.max_message_bytes:
@@ -218,6 +234,7 @@ class _CaptureWriter:
"record_type": "message",
"sequence": self.message_count,
"received_at_utc": received_at_utc,
"received_at_epoch_ns": received_at_epoch_ns,
"received_monotonic_ns": received_monotonic_ns,
"topic": topic,
"qos": message.qos,
@@ -230,6 +247,17 @@ class _CaptureWriter:
"raw_frame_bytes": frame_bytes,
}
self._write_metadata(metadata, record)
return CapturedMqttMessage(
sequence=self.message_count,
topic=topic,
payload=payload,
qos=message.qos,
retain=message.retain,
dup=message.dup,
received_at_utc=received_at_utc,
received_at_epoch_ns=received_at_epoch_ns,
received_monotonic_ns=received_monotonic_ns,
)
def close(self) -> None:
first_error: OSError | None = None
@@ -296,8 +324,7 @@ def iter_capture_frames(
"""Yield validated frames from a K1 MQTT raw capture without decoding payloads."""
if not 1 <= max_payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
raise ValueError(
"max_payload_bytes must be between 1 and "
f"{MAX_CONFIGURABLE_MESSAGE_BYTES}"
f"max_payload_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
if not 1 <= max_topic_bytes <= MAX_TOPIC_BYTES:
raise ValueError(f"max_topic_bytes must be between 1 and {MAX_TOPIC_BYTES}")
@@ -367,6 +394,8 @@ def capture_mqtt(
duration_seconds: float = 60.0,
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
on_ready: Callable[[], None] | None = None,
on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None,
should_stop: Callable[[], bool] | None = None,
_client_factory: Callable[[], mqtt.Client] | None = None,
) -> CaptureSummary:
"""Capture the fixed K1 report subscriptions once, without publishing or reconnecting."""
@@ -377,8 +406,7 @@ def capture_mqtt(
raise ValueError("duration_seconds must be finite and greater than zero")
if not 1 <= max_message_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
raise ValueError(
"max_message_bytes must be between 1 and "
f"{MAX_CONFIGURABLE_MESSAGE_BYTES}"
f"max_message_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
client = (
@@ -453,11 +481,17 @@ def capture_mqtt(
if state.error is not None:
return
try:
writer.record(message)
recorded = writer.record(message)
except MessageTooLargeError as exc:
fail("message_too_large", str(exc))
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}")
return
if on_message_recorded is not None:
try:
on_message_recorded(recorded)
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"preview callback failed: {type(exc).__name__}: {exc}")
def on_disconnect(
_callback_client: mqtt.Client,
@@ -487,6 +521,9 @@ def capture_mqtt(
while state.error is None:
now = time.monotonic()
if should_stop is not None and should_stop():
state.stop_reason = "external_stop"
break
if state.subscribed and capture_started is None:
capture_started = now
if on_ready is not None:
@@ -523,9 +560,7 @@ def capture_mqtt(
fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}")
operation_completed = time.monotonic()
capture_elapsed = (
0.0 if capture_started is None else operation_completed - capture_started
)
capture_elapsed = 0.0 if capture_started is None else operation_completed - capture_started
summary = _build_summary(
writer=writer,