feat: add live K1 Foxglove console
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user