feat: add live K1 Foxglove console
This commit is contained in:
parent
6b22e5a1d2
commit
6be96f0b85
43
README.md
43
README.md
|
|
@ -7,6 +7,8 @@ or speculative writes.
|
|||
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
|
||||
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
|
||||
the scan-correlated point-cloud and pose streams, and decoded both successfully.
|
||||
The repository also contains a local React control console and a Foxglove live
|
||||
bridge for the verified point-cloud and trajectory topics.
|
||||
|
||||
The repository now contains one narrowly gated state-changing command:
|
||||
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
|
||||
|
|
@ -53,6 +55,46 @@ uv run k1link doctor
|
|||
uv run pytest
|
||||
```
|
||||
|
||||
## Live console and Foxglove
|
||||
|
||||
Build the browser control surface once, then launch the whole local stand from
|
||||
the repository root:
|
||||
|
||||
```bash
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
npm run build
|
||||
cd ../..
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000`. The API, credential form and Foxglove WebSocket
|
||||
bind to loopback only. The console supports:
|
||||
|
||||
- real CoreBluetooth K1 discovery;
|
||||
- one operator-triggered reviewed BLE Wi-Fi provisioning write;
|
||||
- live read-only MQTT capture with raw-first evidence storage;
|
||||
- replay of native `.k1mqtt` evidence and the reviewed four-column TSV capture;
|
||||
- `/k1/points`, `/k1/pose`, `/k1/trajectory` and `/k1/metrics` for Foxglove;
|
||||
- measured Mac-side MQTT-receive-to-Foxglove-publish latency and bounded
|
||||
latest-wins preview dropping.
|
||||
|
||||
For live mode, start the session in the console and use the verified physical
|
||||
double-click on K1 to start or stop scanning. The connector deliberately does
|
||||
not publish modeling commands yet. For the recorded proof, replay this ignored
|
||||
local file at `1x` with loop enabled:
|
||||
|
||||
```text
|
||||
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
|
||||
```
|
||||
|
||||
Foxglove remains the full 3D workspace rather than being reimplemented in the
|
||||
React console. In its 3D panel, select `/k1/points`, color by `intensity` (or
|
||||
`z`/`<distance>`) and choose Turbo, Rainbow or a custom gradient. Add
|
||||
`/k1/trajectory` for the cyan path and `/k1/pose` for the current pose. See the
|
||||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) for timing semantics and current
|
||||
limitations.
|
||||
|
||||
`doctor` is intentionally non-invasive. It checks the local Python environment
|
||||
and reports external tools; it does not request Bluetooth permission, scan the
|
||||
LAN, touch the K1, alter Homebrew, or change capture permissions.
|
||||
|
|
@ -93,6 +135,7 @@ present.
|
|||
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
||||
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
||||
- [Live console and Foxglove runbook](docs/06_K1_LIVE_VIEWER.md)
|
||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
- [Reference input provenance](docs/reference/README.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
VITE_API_TARGET=http://127.0.0.1:8000
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# K1 Live Console
|
||||
|
||||
React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles
|
||||
connection setup, source selection, transport status, latency metrics and handoff to
|
||||
the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder
|
||||
telemetry.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The development server listens on `http://127.0.0.1:5173` and proxies `/api` to
|
||||
`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a
|
||||
different backend address. Production artifacts are built with `npm run build`.
|
||||
|
||||
The NODE.DC UI packages are consumed from the sibling `NODEDC_DESIGN_GUIDELINE`
|
||||
checkout through `file:` dependencies. Their source is not copied into this app.
|
||||
|
||||
## Backend contract
|
||||
|
||||
| Method | Route | Body / purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Local service health |
|
||||
| `GET` | `/api/state` | Authoritative console state |
|
||||
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` |
|
||||
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` |
|
||||
| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` |
|
||||
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` |
|
||||
| `POST` | `/api/session/stop` | Stop the active source |
|
||||
| `WS` | `/api/events` | State snapshots for live UI updates |
|
||||
|
||||
`/api/state` and state-changing responses may return the snapshot directly or as
|
||||
`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`,
|
||||
`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`,
|
||||
`source_mode` and `metrics`.
|
||||
|
||||
The Wi-Fi password remains only in React memory, is sent in the JSON POST body, and is
|
||||
cleared after the backend acknowledges a successful connection. It is never written to
|
||||
local storage or included in a URL.
|
||||
|
||||
## Explicit unavailable states
|
||||
|
||||
- Missing metrics render as an em dash; no synthetic values are used.
|
||||
- BLE scanning remains a real API action and reports HTTP/network errors visibly.
|
||||
- The Foxglove button is disabled until the backend supplies
|
||||
`foxglove_viewer_url`.
|
||||
- Replay cannot start without a backend-local capture path.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Local control and live telemetry console for an owner-controlled XGRIDS Lixel K1 scanner."
|
||||
/>
|
||||
<meta name="theme-color" content="#08090b" />
|
||||
<title>K1 Live Console · NODE.DC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@nodedc/k1-viewer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,699 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
AppHeader,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
HeaderAvatar,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderProfileButton,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { BleDevice, ConsoleState, K1Metrics } from "./api";
|
||||
import { useK1Console, type BackendStatus } from "./useK1Console";
|
||||
|
||||
type ConsoleSection = "monitor" | "connect" | "session";
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sectionItems = [
|
||||
{ value: "monitor", label: "Monitor" },
|
||||
{ value: "connect", label: "Connect" },
|
||||
{ value: "session", label: "Session" },
|
||||
] as const;
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Live scanner" },
|
||||
{ value: "replay", label: "Replay capture" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
const phaseLabels: Record<string, string> = {
|
||||
idle: "Idle",
|
||||
scanning: "BLE scan",
|
||||
device_selected: "Device selected",
|
||||
provisioning: "Provisioning Wi-Fi",
|
||||
connecting: "Connecting",
|
||||
connected: "K1 connected",
|
||||
starting_live: "Starting live",
|
||||
live: "Live stream",
|
||||
replay: "Replay",
|
||||
stopping: "Stopping",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
function phaseLabel(phase: string | null | undefined): string {
|
||||
if (!phase) return "No state";
|
||||
return phaseLabels[phase] ?? phase.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function phaseTone(phase: string | null | undefined): StatusTone {
|
||||
if (!phase) return "neutral";
|
||||
if (phase === "error") return "danger";
|
||||
if (["connected", "live", "replay"].includes(phase)) return "success";
|
||||
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) {
|
||||
return "accent";
|
||||
}
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function backendLabel(status: BackendStatus): string {
|
||||
return {
|
||||
checking: "Checking API",
|
||||
online: "API online",
|
||||
degraded: "API degraded",
|
||||
offline: "API offline",
|
||||
}[status];
|
||||
}
|
||||
|
||||
function backendTone(status: BackendStatus): StatusTone {
|
||||
if (status === "online") return "success";
|
||||
if (status === "degraded" || status === "checking") return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function finiteMetric(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function pipelineLatency(metrics: K1Metrics | undefined): number | null {
|
||||
if (!metrics) return null;
|
||||
const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
const segments = [
|
||||
finiteMetric(metrics.mqtt_to_decode_ms),
|
||||
finiteMetric(metrics.decode_ms),
|
||||
finiteMetric(metrics.publish_ms),
|
||||
].filter((value): value is number => value !== null);
|
||||
|
||||
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("en-US", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
eyebrow,
|
||||
value,
|
||||
unit,
|
||||
detail,
|
||||
featured = false,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
detail: string;
|
||||
featured?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<GlassSurface className="metric-card" padding="md" data-featured={featured ? "true" : undefined}>
|
||||
<span className="metric-card__eyebrow">{eyebrow}</span>
|
||||
<div className="metric-card__reading">
|
||||
<strong>{value}</strong>
|
||||
{unit ? <span>{unit}</span> : null}
|
||||
</div>
|
||||
<p>{detail}</p>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardStep({
|
||||
number,
|
||||
title,
|
||||
status,
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
tone?: StatusTone;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="wizard-step">
|
||||
<div className="wizard-step__rail" aria-hidden="true">
|
||||
<span>{number}</span>
|
||||
</div>
|
||||
<div className="wizard-step__content">
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
<StatusBadge tone={tone}>{status}</StatusBadge>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({
|
||||
device,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
device: BleDevice;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="device-row" data-selected={selected ? "true" : undefined}>
|
||||
<div className="device-row__identity">
|
||||
<span className="device-row__signal" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{device.name?.trim() || "K1 candidate"}</strong>
|
||||
<code>{device.device_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="device-row__action">
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} dBm`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "accent" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{selected ? "Selected" : "Select"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyTrace({ values }: { values: number[] }) {
|
||||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<div className="latency-trace" aria-label="Recent measured pipeline latency samples">
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<span
|
||||
key={`${index}-${value}`}
|
||||
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
|
||||
title={`${value.toFixed(1)} ms`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p>No latency samples yet. The chart remains empty until the backend reports metrics.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FoxglovePanel({ state }: { state: ConsoleState | null }) {
|
||||
const viewerUrl = state?.foxglove_viewer_url?.trim();
|
||||
|
||||
return (
|
||||
<GlassSurface className="foxglove-panel" padding="lg">
|
||||
<div className="foxglove-panel__grid" aria-hidden="true" />
|
||||
<div className="foxglove-panel__content">
|
||||
<span className="section-eyebrow">3D WORKSPACE</span>
|
||||
<h2>Point cloud + trajectory</h2>
|
||||
<p>
|
||||
Foxglove renders the live topics. This console only reports transport state and never
|
||||
substitutes a simulated cloud when the scanner is silent.
|
||||
</p>
|
||||
<div className="topic-list" aria-label="Foxglove topics">
|
||||
<code>/k1/points</code>
|
||||
<code>/k1/pose</code>
|
||||
<code>/k1/trajectory</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="foxglove-panel__action">
|
||||
<StatusBadge tone={viewerUrl ? "accent" : "neutral"}>
|
||||
{viewerUrl ? "Viewer ready" : "Viewer URL unavailable"}
|
||||
</StatusBadge>
|
||||
<Button
|
||||
variant="accent"
|
||||
icon={<Icon name="external" />}
|
||||
disabled={!viewerUrl}
|
||||
onClick={() => viewerUrl && window.open(viewerUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
Open Foxglove 3D
|
||||
</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh,
|
||||
clearError,
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
} = useK1Console();
|
||||
|
||||
const [activeSection, setActiveSection] = useState<ConsoleSection>("monitor");
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
const [liveHost, setLiveHost] = useState("");
|
||||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) setSelectedDeviceId(state.selected_device_id);
|
||||
}, [state?.selected_device_id]);
|
||||
|
||||
const metrics = state?.metrics;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
|
||||
const sourceLabel = state?.source_mode
|
||||
? state.source_mode === "live"
|
||||
? "Live"
|
||||
: state.source_mode === "replay"
|
||||
? "Replay"
|
||||
: "Idle"
|
||||
: "Unknown";
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
|
||||
const goToSection = (section: ConsoleSection) => {
|
||||
setActiveSection(section);
|
||||
document.getElementById(section)?.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
});
|
||||
if (succeeded) setPassword("");
|
||||
};
|
||||
|
||||
const submitLive = () => {
|
||||
const host = liveHost.trim();
|
||||
void startLive(host ? { host } : {});
|
||||
};
|
||||
|
||||
const submitReplay = () => {
|
||||
const speed = Number(replaySpeed);
|
||||
void startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
});
|
||||
};
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={
|
||||
<span className="brand-lockup">
|
||||
NODE<span>.DC</span>
|
||||
</span>
|
||||
}
|
||||
brandLabel="NODE.DC"
|
||||
left={<span className="product-label">K1 LIVE CONSOLE</span>}
|
||||
center={
|
||||
<HeaderNavigation
|
||||
label="Console sections"
|
||||
value={activeSection}
|
||||
items={sectionItems}
|
||||
onChange={goToSection}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void refresh()} title="Refresh API state">
|
||||
<span className="api-dot" data-status={backendStatus} aria-hidden="true" />
|
||||
{backendLabel(backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar label="K1" />
|
||||
</HeaderProfile>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ApplicationShell
|
||||
data-nodedc-ui
|
||||
className="k1-console"
|
||||
header={header}
|
||||
stage={
|
||||
<div className="console-stage">
|
||||
<div className="console-canvas">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<div>
|
||||
<strong>Local API operation failed</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||
Retry state
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section className="console-hero" id="monitor">
|
||||
<div className="console-hero__copy">
|
||||
<span className="section-eyebrow">LOCAL TELEMETRY BRIDGE</span>
|
||||
<h1>See the scan arrive, frame by frame.</h1>
|
||||
<p>
|
||||
Owner-controlled K1 transport, latency and Foxglove handoff on the local network.
|
||||
Blank values mean no measurement has arrived yet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="console-hero__status">
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
<span>{state?.message || "Waiting for an authoritative backend state snapshot."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="Live stream metrics">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="PIPELINE LATENCY"
|
||||
value={formatNumber(latency)}
|
||||
unit="ms"
|
||||
detail="MQTT receive → Foxglove publish"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="FRAME RATE"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="fps"
|
||||
detail="Latest backend measurement"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="POINTS / FRAME"
|
||||
value={points === null ? "—" : points.toLocaleString("en-US", { maximumFractionDigits: 0 })}
|
||||
detail="Published point count"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="DROPPED PREVIEW"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("en-US", { maximumFractionDigits: 0 })}
|
||||
detail="Frames omitted before display"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="console-layout">
|
||||
<GlassSurface className="connection-panel" padding="lg" id="connect">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">CONNECTION WIZARD</span>
|
||||
<h2>Bring K1 online</h2>
|
||||
</div>
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="wizard-list">
|
||||
<WizardStep
|
||||
number="01"
|
||||
title="Scanner power"
|
||||
status={powerConfirmed ? "Confirmed" : "Manual check"}
|
||||
tone={powerConfirmed ? "success" : "warning"}
|
||||
>
|
||||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="K1 is powered and nearby"
|
||||
description="Local checklist only — this does not switch or write to the scanner."
|
||||
onChange={setPowerConfirmed}
|
||||
/>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="02"
|
||||
title="Discover over BLE"
|
||||
status={pendingAction === "scan" ? "Scanning" : `${devices.length} found`}
|
||||
tone={pendingAction === "scan" ? "accent" : devices.length ? "success" : "neutral"}
|
||||
>
|
||||
<p className="step-copy">
|
||||
Scanning is active discovery. It does not send provisioning writes.
|
||||
</p>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => void scan()}
|
||||
>
|
||||
{pendingAction === "scan" ? "Scanning for 6 seconds…" : "Scan for K1 devices"}
|
||||
</Button>
|
||||
<div className="device-list">
|
||||
{devices.length ? (
|
||||
devices.map((device) => (
|
||||
<DeviceRow
|
||||
key={device.device_id}
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={() => setSelectedDeviceId(device.device_id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-device-list">
|
||||
No devices reported. Confirm scanner power, then run the real BLE scan.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Local Wi-Fi"
|
||||
status={credentialsReady ? "Ready" : "Required"}
|
||||
tone={credentialsReady ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<TextField
|
||||
label="Network name"
|
||||
hint="SSID"
|
||||
value={ssid}
|
||||
onChange={(event) => setSsid(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Workshop Wi-Fi"
|
||||
/>
|
||||
<TextField
|
||||
label="Network password"
|
||||
hint="Kept in memory only"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="04"
|
||||
title="Provision & connect"
|
||||
status={state?.k1_ip ? "Connected" : "Not connected"}
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="connection-summary">
|
||||
<span>Device</span>
|
||||
<strong>{deviceSummary?.name || selectedDeviceId || "Select a BLE device"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
variant="accent"
|
||||
icon={<Icon name="network" />}
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
>
|
||||
{pendingAction === "connect" ? "Provisioning…" : "Provision Wi-Fi & connect"}
|
||||
</Button>
|
||||
<p className="safety-note">
|
||||
The password is sent only in the POST body to the local API and is cleared from
|
||||
this form after a successful request.
|
||||
</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="monitor-column">
|
||||
<FoxglovePanel state={state} />
|
||||
|
||||
<div className="monitor-grid">
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">LIVE STATUS</span>
|
||||
<h2>Transport</h2>
|
||||
</div>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>
|
||||
{backendLabel(backendStatus)}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Events socket">
|
||||
<span className="inline-state" data-state={eventStatus}>
|
||||
{eventStatus}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Source">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="K1 address">
|
||||
<code>{state?.k1_ip || "Not reported"}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label="Foxglove bridge">
|
||||
<code>{state?.foxglove_ws_url || "Not reported"}</code>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="latency-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">RECENT SAMPLES</span>
|
||||
<h2>Pipeline latency</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>ms</span>
|
||||
</strong>
|
||||
</header>
|
||||
<LatencyTrace values={latencyHistory} />
|
||||
<div className="latency-legend">
|
||||
<span>Oldest</span>
|
||||
<span>Latest</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
|
||||
<GlassSurface className="session-panel" padding="lg" id="session">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">SOURCE CONTROL</span>
|
||||
<h2>Live or replay</h2>
|
||||
</div>
|
||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
||||
{sourceLabel}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<SegmentedControl
|
||||
label="Data source"
|
||||
value={sessionIntent}
|
||||
items={sessionItems}
|
||||
onChange={setSessionIntent}
|
||||
/>
|
||||
|
||||
{sessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="K1 host override"
|
||||
hint="Optional"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || "Use connected K1 address"}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy}
|
||||
onClick={submitLive}
|
||||
>
|
||||
{pendingAction === "live" ? "Starting live…" : "Start live stream"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField
|
||||
label="Capture path"
|
||||
hint="Backend-local path"
|
||||
value={replayPath}
|
||||
onChange={(event) => setReplayPath(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="sessions/.../mqtt.raw.k1mqtt or capture.tsv"
|
||||
/>
|
||||
<TextField
|
||||
label="Playback speed"
|
||||
hint="Multiplier"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={replaySpeed}
|
||||
onChange={(event) => setReplaySpeed(event.target.value)}
|
||||
/>
|
||||
<Checker
|
||||
checked={replayLoop}
|
||||
label="Loop replay"
|
||||
description="Restart the same capture after its last frame."
|
||||
onChange={setReplayLoop}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
onClick={submitReplay}
|
||||
>
|
||||
{pendingAction === "replay" ? "Starting replay…" : "Start replay"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="session-footer">
|
||||
<p>
|
||||
Source changes call the local API. The console never toggles its status before
|
||||
the backend acknowledges the request.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop" ? "Stopping…" : "Stop session"}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
export interface BleDevice {
|
||||
device_id: string;
|
||||
name?: string | null;
|
||||
rssi?: number | null;
|
||||
address?: string | null;
|
||||
connectable?: boolean | null;
|
||||
}
|
||||
|
||||
export type SourceMode = "idle" | "live" | "replay";
|
||||
|
||||
export interface K1Metrics {
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
pipeline_ms?: number | null;
|
||||
end_to_end_ms?: number | null;
|
||||
frame_rate?: number | null;
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface ConsoleState {
|
||||
phase?: string | null;
|
||||
message?: string | null;
|
||||
devices?: BleDevice[];
|
||||
selected_device_id?: string | null;
|
||||
k1_ip?: string | null;
|
||||
foxglove_ws_url?: string | null;
|
||||
foxglove_viewer_url?: string | null;
|
||||
source_mode?: SourceMode | null;
|
||||
metrics?: K1Metrics;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
service?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ScanRequest {
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
ssid: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LiveRequest {
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface ReplayRequest {
|
||||
path: string;
|
||||
speed?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function unwrapState(payload: unknown): ConsoleState {
|
||||
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
|
||||
|
||||
if (!isRecord(value)) {
|
||||
throw new ApiError("Backend returned an invalid state snapshot.");
|
||||
}
|
||||
|
||||
return value as ConsoleState;
|
||||
}
|
||||
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiError(
|
||||
error instanceof Error
|
||||
? `Cannot reach the local K1 API: ${error.message}`
|
||||
: "Cannot reach the local K1 API.",
|
||||
);
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
let body: unknown;
|
||||
|
||||
if (bodyText) {
|
||||
try {
|
||||
body = JSON.parse(bodyText) as unknown;
|
||||
} catch {
|
||||
body = bodyText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
isRecord(body) && typeof body.detail === "string"
|
||||
? body.detail
|
||||
: typeof body === "string" && body.trim()
|
||||
? body.trim()
|
||||
: response.statusText;
|
||||
throw new ApiError(
|
||||
detail || `K1 API request failed with HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postState(path: string, body?: object): Promise<ConsoleState> {
|
||||
const payload = await requestJson(path, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (payload === undefined) {
|
||||
return api.getState();
|
||||
}
|
||||
|
||||
return unwrapState(payload);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async getHealth(): Promise<HealthResponse> {
|
||||
const payload = await requestJson("/api/health");
|
||||
if (!isRecord(payload)) {
|
||||
throw new ApiError("Backend returned an invalid health response.");
|
||||
}
|
||||
return payload as HealthResponse;
|
||||
},
|
||||
|
||||
async getState(): Promise<ConsoleState> {
|
||||
return unwrapState(await requestJson("/api/state"));
|
||||
},
|
||||
|
||||
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/ble/scan", body);
|
||||
},
|
||||
|
||||
connect(body: ConnectRequest): Promise<ConsoleState> {
|
||||
return postState("/api/connect", body);
|
||||
},
|
||||
|
||||
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/session/live", body);
|
||||
},
|
||||
|
||||
startReplay(body: ReplayRequest): Promise<ConsoleState> {
|
||||
return postState("/api/session/replay", body);
|
||||
},
|
||||
|
||||
stopSession(): Promise<ConsoleState> {
|
||||
return postState("/api/session/stop");
|
||||
},
|
||||
};
|
||||
|
||||
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
function eventSocketUrl(): string {
|
||||
const url = new URL("/api/events", window.location.href);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function openEventSocket(
|
||||
onState: (state: ConsoleState) => void,
|
||||
onStatus: (status: EventSocketStatus) => void,
|
||||
): () => void {
|
||||
onStatus("connecting");
|
||||
const socket = new WebSocket(eventSocketUrl());
|
||||
|
||||
socket.addEventListener("open", () => onStatus("open"));
|
||||
socket.addEventListener("message", (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data)) as unknown;
|
||||
onState(unwrapState(payload));
|
||||
} catch {
|
||||
// The REST poll remains authoritative if an unrelated event is received.
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => onStatus("error"));
|
||||
socket.addEventListener("close", () => onStatus("closed"));
|
||||
|
||||
return () => socket.close(1000, "K1 console unmounted");
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { applyNodedcTheme } from "@nodedc/ui-core";
|
||||
import "@nodedc/tokens/tokens.css";
|
||||
import "@nodedc/tokens/themes.css";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("K1 console root element is missing.");
|
||||
}
|
||||
|
||||
rootElement.classList.add("nodedc-ui-root");
|
||||
rootElement.dataset.nodedcUi = "";
|
||||
applyNodedcTheme(rootElement, { theme: "dark" });
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
@ -0,0 +1,884 @@
|
|||
:root {
|
||||
background: #050506;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
background: #050506;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #050506;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.k1-console {
|
||||
--k1-panel: #151517;
|
||||
--k1-panel-soft: #0d0d0f;
|
||||
--k1-hairline: rgba(255, 255, 255, 0.08);
|
||||
--k1-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11);
|
||||
--k1-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.32rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: -0.075em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.brand-lockup span {
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.product-label {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.13em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.api-dot {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
flex: 0 0 0.48rem;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.api-dot[data-status="online"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
|
||||
}
|
||||
|
||||
.api-dot[data-status="checking"],
|
||||
.api-dot[data-status="degraded"] {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.api-dot[data-status="offline"] {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.console-stage {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.console-canvas {
|
||||
position: relative;
|
||||
width: min(100%, 112rem);
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 2.75rem;
|
||||
}
|
||||
|
||||
.console-canvas::before {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: -8rem;
|
||||
right: -8rem;
|
||||
width: 34rem;
|
||||
height: 34rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 66%);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.console-canvas > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-radius: 1.1rem;
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.1);
|
||||
color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 82%, white);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.error-banner > svg {
|
||||
align-self: start;
|
||||
margin-top: 0.12rem;
|
||||
}
|
||||
|
||||
.error-banner strong,
|
||||
.error-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-banner strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.error-banner p {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.console-hero {
|
||||
display: flex;
|
||||
min-height: 15.5rem;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 3rem;
|
||||
overflow: hidden;
|
||||
scroll-margin-top: 1rem;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background:
|
||||
linear-gradient(112deg, rgba(255, 255, 255, 0.048), transparent 44%),
|
||||
radial-gradient(circle at 79% 16%, rgb(var(--nodedc-accent-rgb) / 0.12), transparent 34%),
|
||||
#0b0b0d;
|
||||
padding: clamp(1.6rem, 3vw, 3rem);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.console-hero__copy {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.section-eyebrow,
|
||||
.metric-card__eyebrow {
|
||||
display: block;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.console-hero h1 {
|
||||
max-width: 42rem;
|
||||
margin: 0.65rem 0 0.9rem;
|
||||
font-size: clamp(2rem, 4vw, 4.2rem);
|
||||
font-weight: 710;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.console-hero__copy p {
|
||||
max-width: 39rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.console-hero__status {
|
||||
display: grid;
|
||||
max-width: 21rem;
|
||||
justify-items: end;
|
||||
gap: 0.75rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 9.3rem;
|
||||
}
|
||||
|
||||
.metric-card[data-featured="true"] {
|
||||
background: linear-gradient(145deg, var(--k1-accent-soft), transparent 72%), var(--k1-panel);
|
||||
}
|
||||
|
||||
.metric-card__reading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1.15rem;
|
||||
}
|
||||
|
||||
.metric-card__reading strong {
|
||||
font-size: clamp(1.75rem, 2.6vw, 2.7rem);
|
||||
font-weight: 690;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-card__reading span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-card p {
|
||||
margin: 0.9rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.console-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(21rem, 28.5rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.connection-panel,
|
||||
.session-panel {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
background: var(--k1-panel);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-heading h2 {
|
||||
margin: 0.42rem 0 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 730;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.panel-heading--compact {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizard-list {
|
||||
display: grid;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.wizard-step__rail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wizard-step__rail::after {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: var(--k1-hairline);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.wizard-step:last-child .wizard-step__rail::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wizard-step__rail span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #27272a;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.wizard-step__content {
|
||||
min-width: 0;
|
||||
padding: 0.1rem 0 1.6rem;
|
||||
}
|
||||
|
||||
.wizard-step:last-child .wizard-step__content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wizard-step__content > header {
|
||||
display: flex;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.wizard-step__content h3 {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.step-copy,
|
||||
.safety-note {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.69rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.safety-note {
|
||||
margin: 0.7rem 0 0;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.device-row[data-selected="true"] {
|
||||
background: var(--k1-accent-soft);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
|
||||
}
|
||||
|
||||
.device-row__identity,
|
||||
.device-row__action {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.device-row__identity {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.device-row__identity > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.device-row__identity strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.74rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-row code,
|
||||
.detail-row code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.62rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-row__signal {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
flex: 0 0 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
box-shadow: 0 0 0 0.25rem var(--k1-success-soft);
|
||||
}
|
||||
|
||||
.device-row__action > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.empty-device-list {
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.95rem;
|
||||
font-size: 0.69rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.field-stack {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.connection-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
|
||||
.connection-summary span {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.connection-summary strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.monitor-column {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.foxglove-panel {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 20rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
overflow: hidden;
|
||||
background: #0b0b0e;
|
||||
}
|
||||
|
||||
.foxglove-panel__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.36;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
|
||||
background-position: center;
|
||||
background-size: 3rem 3rem;
|
||||
mask-image: radial-gradient(circle at 58% 48%, #000 0%, transparent 66%);
|
||||
transform: perspective(32rem) rotateX(56deg) scale(1.42) translateY(18%);
|
||||
}
|
||||
|
||||
.foxglove-panel::after {
|
||||
position: absolute;
|
||||
top: 16%;
|
||||
right: 14%;
|
||||
width: 13rem;
|
||||
height: 13rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 68%);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.foxglove-panel__content,
|
||||
.foxglove-panel__action {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.foxglove-panel__content {
|
||||
max-width: 37rem;
|
||||
}
|
||||
|
||||
.foxglove-panel h2 {
|
||||
margin: 0.5rem 0 0.7rem;
|
||||
font-size: clamp(1.6rem, 3vw, 3rem);
|
||||
font-weight: 690;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.foxglove-panel p {
|
||||
max-width: 33rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.topic-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.topic-list code {
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.42rem 0.7rem;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.foxglove-panel__action {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.status-panel,
|
||||
.latency-panel,
|
||||
.session-panel {
|
||||
background: var(--k1-panel);
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
|
||||
gap: 1rem;
|
||||
border-top: 1px solid var(--k1-hairline);
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.detail-row dt,
|
||||
.detail-row dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.detail-row dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.detail-row dd {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inline-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.inline-state::before {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.inline-state[data-state="open"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.inline-state[data-state="error"]::before,
|
||||
.inline-state[data-state="closed"]::before {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.latency-now {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 680;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.latency-now span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.latency-trace {
|
||||
display: flex;
|
||||
height: 8.4rem;
|
||||
align-items: end;
|
||||
gap: 0.22rem;
|
||||
margin-top: 1.25rem;
|
||||
border-radius: 0.9rem;
|
||||
background:
|
||||
linear-gradient(to top, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
rgba(255, 255, 255, 0.018);
|
||||
background-size: 100% 25%;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.latency-trace span {
|
||||
min-width: 0.18rem;
|
||||
flex: 1 1 0;
|
||||
border-radius: 999px 999px 0.14rem 0.14rem;
|
||||
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb)));
|
||||
}
|
||||
|
||||
.latency-trace p {
|
||||
align-self: center;
|
||||
margin: auto;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.latency-legend {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.session-panel > .nodedc-segmented {
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.session-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto;
|
||||
}
|
||||
|
||||
.session-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid var(--k1-hairline);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.session-footer p {
|
||||
max-width: 42rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.console-layout {
|
||||
grid-template-columns: minmax(20rem, 24rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.monitor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.3fr);
|
||||
}
|
||||
|
||||
.session-form--replay > :nth-child(3),
|
||||
.session-form--replay > :nth-child(4) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.console-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.monitor-column {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.foxglove-panel {
|
||||
min-height: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.product-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.console-canvas {
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.console-hero {
|
||||
min-height: 20rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.console-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.console-hero__status {
|
||||
max-width: none;
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 8.2rem;
|
||||
}
|
||||
|
||||
.console-layout,
|
||||
.monitor-column,
|
||||
.monitor-grid {
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.foxglove-panel {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.foxglove-panel__action {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.session-form,
|
||||
.session-form--replay {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-form--replay > :nth-child(3),
|
||||
.session-form--replay > :nth-child(4) {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.session-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
grid-template-columns: 1.8rem minmax(0, 1fr);
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.wizard-step__rail span {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
}
|
||||
|
||||
.wizard-step__rail::after {
|
||||
top: 1.65rem;
|
||||
}
|
||||
|
||||
.wizard-step__content > header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-panel > .nodedc-segmented {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
openEventSocket,
|
||||
type ConnectRequest,
|
||||
type ConsoleState,
|
||||
type EventSocketStatus,
|
||||
type LiveRequest,
|
||||
type ReplayRequest,
|
||||
} from "./api";
|
||||
|
||||
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.status
|
||||
? `${error.message} (HTTP ${error.status})`
|
||||
: error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : "The local K1 API request failed.";
|
||||
}
|
||||
|
||||
function measuredLatency(state: ConsoleState | null): number | null {
|
||||
const metrics = state?.metrics;
|
||||
if (!metrics) return null;
|
||||
|
||||
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
|
||||
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
|
||||
|
||||
const segments = [
|
||||
metrics.mqtt_to_decode_ms,
|
||||
metrics.decode_ms,
|
||||
metrics.publish_ms,
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
|
||||
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
export function useK1Console() {
|
||||
const [state, setState] = useState<ConsoleState | null>(null);
|
||||
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
|
||||
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const acceptState = useCallback((nextState: ConsoleState) => {
|
||||
setState(nextState);
|
||||
setBackendStatus("online");
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (reportErrors = true) => {
|
||||
const [healthResult, stateResult] = await Promise.allSettled([
|
||||
api.getHealth(),
|
||||
api.getState(),
|
||||
]);
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
if (stateResult.status === "fulfilled") {
|
||||
acceptState(stateResult.value);
|
||||
if (reportErrors) setError(null);
|
||||
}
|
||||
|
||||
if (healthResult.status === "fulfilled") {
|
||||
const health = healthResult.value;
|
||||
const healthy = health.ok !== false && health.status !== "error";
|
||||
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
|
||||
} else if (stateResult.status === "rejected") {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
|
||||
if (stateResult.status === "rejected" && reportErrors) {
|
||||
setError(messageFor(stateResult.reason));
|
||||
}
|
||||
}, [acceptState]);
|
||||
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextState = await operation();
|
||||
if (mounted.current) acceptState(nextState);
|
||||
return true;
|
||||
} catch (operationError) {
|
||||
if (mounted.current) {
|
||||
setError(messageFor(operationError));
|
||||
if (operationError instanceof ApiError && operationError.status === 0) {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted.current) setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[acceptState],
|
||||
);
|
||||
|
||||
const scan = useCallback(
|
||||
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
|
||||
[run],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
(request: ConnectRequest) => run("connect", () => api.connect(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startLive = useCallback(
|
||||
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startReplay = useCallback(
|
||||
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
() => run("stop", () => api.stopSession()),
|
||||
[run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh(true);
|
||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
let retry: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
dispose = openEventSocket(acceptState, (status) => {
|
||||
if (cancelled) return;
|
||||
setEventStatus(status);
|
||||
if ((status === "closed" || status === "error") && retry === undefined) {
|
||||
retry = window.setTimeout(() => {
|
||||
retry = undefined;
|
||||
connectEvents();
|
||||
}, 3_000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connectEvents();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retry !== undefined) window.clearTimeout(retry);
|
||||
dispose?.();
|
||||
};
|
||||
}, [acceptState]);
|
||||
|
||||
useEffect(() => {
|
||||
const latency = measuredLatency(state);
|
||||
if (latency === null) return;
|
||||
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh: () => refresh(true),
|
||||
clearError: () => setError(null),
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: "127.0.0.1",
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -16,11 +16,17 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
|||
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
|
||||
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
|
||||
| Stage 5 camera | PAUSE — no independent frame/video stream observed |
|
||||
| Stage 6 live viewer | GO — React console, Foxglove cloud/path and Mac latency metrics |
|
||||
|
||||
USB project copying remains optional ground truth rather than a blocker for the
|
||||
now-verified network path. MQTT control publishing remains deliberately deferred
|
||||
because the physical button is a known-safe start/stop mechanism.
|
||||
|
||||
The Stage 6 alpha uses a bounded raw-first bridge: loss in the visualization
|
||||
queue cannot discard MQTT evidence. Acceptance is replay of the full captured
|
||||
scan followed by a live ideal-LAN run with measured host pipeline latency.
|
||||
Sensor-to-display latency remains a separate clock-correlation test.
|
||||
|
||||
## Stage 0 — repository and host baseline
|
||||
|
||||
Deliverables:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
# K1 live console and Foxglove bridge
|
||||
|
||||
Status: alpha implementation for the verified firmware-3 MQTT streams. It uses
|
||||
real K1 data and does not generate a placeholder cloud, pose or latency value.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
K1 lio_pcl / lio_pose
|
||||
|
|
||||
v
|
||||
read-only MQTT subscription on TCP 1883
|
||||
|
|
||||
+--> raw .k1mqtt + JSONL + SHA-256 summary (first)
|
||||
|
|
||||
v
|
||||
bounded latest-wins preview queue (32 messages)
|
||||
|
|
||||
v
|
||||
raw-LZ4/protobuf decoder --> foxglove.PointCloud / Pose / SceneUpdate
|
||||
|
|
||||
v
|
||||
ws://127.0.0.1:8765 --> Foxglove 3D
|
||||
|
||||
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
|
||||
```
|
||||
|
||||
The Paho MQTT callback never decodes the point cloud. It writes and flushes the
|
||||
raw frame and metadata, then enqueues a preview reference. If visualization cannot
|
||||
keep up, the oldest queued preview is discarded while the raw capture continues.
|
||||
|
||||
## Start the local application
|
||||
|
||||
Prerequisites are the repository-local Python environment and Node.js 20 or
|
||||
newer. No global Python package or system component is installed.
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
npm run build
|
||||
cd ../..
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000`. `k1link serve` intentionally exposes no LAN bind
|
||||
option because its provisioning endpoint temporarily receives a Wi-Fi password.
|
||||
The password is accepted only in the POST body, is never logged or persisted by
|
||||
the connector, and is cleared from the React form after success.
|
||||
|
||||
## Replay the captured proof
|
||||
|
||||
1. In **Session**, select **Replay capture**.
|
||||
2. Enter the repository-relative path:
|
||||
|
||||
```text
|
||||
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
|
||||
```
|
||||
|
||||
3. Use speed `1` and enable loop for initial viewer setup.
|
||||
4. Start replay, then select **Open Foxglove 3D**.
|
||||
5. In a Foxglove 3D panel, enable `/k1/points`, `/k1/pose` and
|
||||
`/k1/trajectory`.
|
||||
6. For the cloud, choose `intensity` as the color field and a Turbo/Rainbow
|
||||
colormap or custom two-color gradient. Foxglove can also color by `x`, `y`,
|
||||
`z` and its derived `<distance>`.
|
||||
|
||||
The reviewed TSV reader accepts exactly four columns: receive timestamp, topic,
|
||||
declared payload length and hex payload. It verifies timestamp, UTF-8 topic,
|
||||
declared length, hex encoding and allocation bounds. Native
|
||||
`mqtt.raw.k1mqtt` captures are supported directly and use their sibling metadata
|
||||
timestamps when present.
|
||||
|
||||
## Connect and stream live
|
||||
|
||||
1. Power K1 to its normal steady-green standby state.
|
||||
2. Confirm the manual power checklist in **Connect**.
|
||||
3. Run the real six-second BLE scan and select the K1 candidate.
|
||||
4. Enter the existing router SSID/password and press **Provision Wi-Fi &
|
||||
connect**. That button is the explicit authorization for one reviewed 99-byte
|
||||
provisioning write; the backend never retries automatically.
|
||||
5. When K1 reports a non-AP private address, press **Start live stream**.
|
||||
6. Open Foxglove before scanning if convenient.
|
||||
7. Double-click the physical K1 button to start scanning. Double-click again to
|
||||
stop, wait for the LED to return to steady green, then stop the local session.
|
||||
|
||||
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
|
||||
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
|
||||
hash summary. No application request topic is published.
|
||||
|
||||
## Published topics
|
||||
|
||||
| Topic | Foxglove schema | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `/k1/points` | `foxglove.PointCloud` | metric XYZ float32 + uint8 intensity, stride 16 |
|
||||
| `/k1/pose` | `foxglove.PoseInFrame` | current decoded K1 pose in the `map` frame |
|
||||
| `/k1/trajectory` | `foxglove.SceneUpdate` | bounded cyan line strip, throttled while growing |
|
||||
| `/k1/metrics` | JSON schema | latency, decode/publish time, FPS, points and drops |
|
||||
|
||||
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as verified.
|
||||
No axis swap, quaternion normalization or vehicle extrinsic is silently applied.
|
||||
Only the low byte of `rgbi` is labeled intensity; interpreting the upper bytes
|
||||
as RGB remains unverified.
|
||||
|
||||
## Latency semantics
|
||||
|
||||
`pipeline_ms` / `mqtt_to_publish_ms` is measured with the host monotonic clock
|
||||
from MQTT callback receipt through raw disk write, bounded queue wait, decode,
|
||||
packing and Foxglove channel publish. Rolling p50/p95 values use the last 512
|
||||
published decoded messages.
|
||||
|
||||
This is the exact Mac pipeline latency, not yet sensor-photon-to-screen latency.
|
||||
The K1 header timestamp epoch has not been proven, Foxglove rendering time is
|
||||
outside the Python publisher, and display latency is not observable without a
|
||||
clock-correlated device timestamp or high-speed-camera experiment. Foxglove
|
||||
message time therefore uses host receive Unix time while durations use
|
||||
`monotonic_ns`.
|
||||
|
||||
## Current boundaries
|
||||
|
||||
- Raw panoramic camera frames were not present on the observed MQTT report
|
||||
topics; this milestone intentionally ships point cloud plus trajectory only.
|
||||
- Physical double-click remains the start/stop control. A future MQTT modeling
|
||||
publisher needs a separately reviewed state-changing profile.
|
||||
- The React application opens the official Foxglove viewer over a local
|
||||
WebSocket. It does not embed or fork the proprietary modern viewer; Foxglove
|
||||
account/seat terms apply to that viewer independently of this connector.
|
||||
- Exact path axes and a scanner-to-vehicle transform must be calibrated before
|
||||
mounting on the unmanned platform.
|
||||
|
||||
## Verification checkpoint — 2026-07-15
|
||||
|
||||
The full captured 180-second scan was passed through the production Foxglove
|
||||
bridge without a viewer-side substitute:
|
||||
|
||||
| Check | Result |
|
||||
| --- | ---: |
|
||||
| MQTT messages read | 2,836 |
|
||||
| `lio_pcl` frames published | 1,140 |
|
||||
| `lio_pose` frames published | 1,215 |
|
||||
| points packed and published | 4,165,862 |
|
||||
| decoder errors | 0 |
|
||||
| final trajectory poses | 1,215 |
|
||||
|
||||
The same replay was then started through the FastAPI/React contract at `10x`.
|
||||
The API exposed the loopback Foxglove URL, live point/pose metrics and non-zero
|
||||
preview-drop accounting under deliberate acceleration, and released TCP 8765
|
||||
after the stop request. This validates replay, overload behavior and lifecycle;
|
||||
the next checkpoint is the powered K1 ideal-LAN live run.
|
||||
|
|
@ -12,10 +12,13 @@ license = { text = "Proprietary" }
|
|||
authors = [{ name = "NODE.DC" }]
|
||||
dependencies = [
|
||||
"bleak==3.0.2",
|
||||
"fastapi>=0.116,<1",
|
||||
"foxglove-sdk==0.25.3",
|
||||
"lz4>=4.4,<5",
|
||||
"paho-mqtt>=2.1,<3",
|
||||
"rich>=13.9,<15",
|
||||
"typer>=0.15,<1",
|
||||
"uvicorn>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
|||
from typing import Annotated, TypedDict
|
||||
|
||||
import typer
|
||||
import uvicorn
|
||||
from bleak.exc import BleakError
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
|
@ -200,6 +201,32 @@ def doctor(
|
|||
console.print(f"- {note}")
|
||||
|
||||
|
||||
@app.command("serve")
|
||||
def serve_console(
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option(min=1024, max=65535, help="Loopback HTTP port for the local console."),
|
||||
] = 8000,
|
||||
) -> None:
|
||||
"""Serve the built K1 console and local-only control API on loopback."""
|
||||
frontend = Path(__file__).resolve().parents[2] / "apps" / "k1-viewer" / "dist"
|
||||
if not frontend.is_dir():
|
||||
console.print(
|
||||
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
|
||||
"inside apps/k1-viewer."
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
console.print(f"K1 Live Console: http://127.0.0.1:{port}")
|
||||
console.print("The credential endpoint is bound to this Mac only.")
|
||||
uvicorn.run(
|
||||
"k1link.web.app:app",
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
@ble_app.command("scan")
|
||||
def ble_scan(
|
||||
out: Annotated[
|
||||
|
|
@ -300,8 +327,7 @@ def ble_wifi_configure(
|
|||
raise typer.Exit(code=2)
|
||||
if not confirm_write:
|
||||
console.print(
|
||||
"[red]Write not confirmed.[/red] "
|
||||
"Add --confirm-write after reviewing the profile."
|
||||
"[red]Write not confirmed.[/red] Add --confirm-write after reviewing the profile."
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"""Live/replay visualization bridge for verified K1 MQTT streams."""
|
||||
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
from k1link.viewer.replay import (
|
||||
ReplayFormatError,
|
||||
detect_replay_format,
|
||||
iter_replay_messages,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ReplayFormatError",
|
||||
"StreamMessage",
|
||||
"detect_replay_format",
|
||||
"iter_replay_messages",
|
||||
]
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import statistics
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import TypedDict
|
||||
|
||||
import foxglove
|
||||
from foxglove import Channel
|
||||
from foxglove.channels import (
|
||||
PointCloudChannel,
|
||||
PoseInFrameChannel,
|
||||
SceneUpdateChannel,
|
||||
)
|
||||
from foxglove.messages import (
|
||||
Color,
|
||||
LinePrimitive,
|
||||
LinePrimitiveLineType,
|
||||
PackedElementField,
|
||||
PackedElementFieldNumericType,
|
||||
Point3,
|
||||
PointCloud,
|
||||
Pose,
|
||||
PoseInFrame,
|
||||
Quaternion,
|
||||
SceneEntity,
|
||||
SceneUpdate,
|
||||
Timestamp,
|
||||
Vector3,
|
||||
)
|
||||
|
||||
from k1link.protocol.streams import (
|
||||
LegacyPointCloudFrame,
|
||||
LegacyPoseFrame,
|
||||
LioPointCloudFrame,
|
||||
LioPoseFrame,
|
||||
StreamDecodeError,
|
||||
decode_legacy_pointcloud,
|
||||
decode_legacy_pose,
|
||||
decode_lio_pcl,
|
||||
decode_lio_pose,
|
||||
)
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
|
||||
POINT_STRUCT = struct.Struct("<fffB3x")
|
||||
POINT_STRIDE = POINT_STRUCT.size
|
||||
FRAME_ID = "map"
|
||||
MAX_TRAJECTORY_POSES = 20_000
|
||||
|
||||
|
||||
class MetricsSnapshot(TypedDict):
|
||||
messages_received: int
|
||||
payload_bytes: int
|
||||
pcl_frames: int
|
||||
pose_frames: int
|
||||
points_published: int
|
||||
last_point_count: int
|
||||
decode_errors: int
|
||||
preview_dropped: int
|
||||
pcl_fps: float
|
||||
pose_fps: float
|
||||
mqtt_to_publish_ms: float | None
|
||||
mqtt_to_publish_p50_ms: float | None
|
||||
mqtt_to_publish_p95_ms: float | None
|
||||
decode_publish_ms: float | None
|
||||
trajectory_poses: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PackedPointCloud:
|
||||
data: bytes
|
||||
point_count: int
|
||||
|
||||
|
||||
class BridgeMetrics:
|
||||
"""Thread-safe counters shared with the local control API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._messages_received = 0
|
||||
self._payload_bytes = 0
|
||||
self._pcl_frames = 0
|
||||
self._pose_frames = 0
|
||||
self._points_published = 0
|
||||
self._last_point_count = 0
|
||||
self._decode_errors = 0
|
||||
self._preview_dropped = 0
|
||||
self._trajectory_poses = 0
|
||||
self._pcl_times: deque[int] = deque()
|
||||
self._pose_times: deque[int] = deque()
|
||||
self._latencies_ms: deque[float] = deque(maxlen=512)
|
||||
self._decode_publish_ms: float | None = None
|
||||
|
||||
def received(self, payload_bytes: int) -> None:
|
||||
with self._lock:
|
||||
self._messages_received += 1
|
||||
self._payload_bytes += payload_bytes
|
||||
|
||||
def published_pcl(self, point_count: int, now_ns: int, decode_publish_ms: float) -> None:
|
||||
with self._lock:
|
||||
self._pcl_frames += 1
|
||||
self._points_published += point_count
|
||||
self._last_point_count = point_count
|
||||
self._decode_publish_ms = decode_publish_ms
|
||||
self._pcl_times.append(now_ns)
|
||||
_trim_rate_window(self._pcl_times, now_ns)
|
||||
|
||||
def published_pose(self, now_ns: int, decode_publish_ms: float, path_size: int) -> None:
|
||||
with self._lock:
|
||||
self._pose_frames += 1
|
||||
self._decode_publish_ms = decode_publish_ms
|
||||
self._trajectory_poses = path_size
|
||||
self._pose_times.append(now_ns)
|
||||
_trim_rate_window(self._pose_times, now_ns)
|
||||
|
||||
def record_latency(self, milliseconds: float) -> None:
|
||||
if not math.isfinite(milliseconds) or milliseconds < 0:
|
||||
return
|
||||
with self._lock:
|
||||
self._latencies_ms.append(milliseconds)
|
||||
|
||||
def decode_error(self) -> None:
|
||||
with self._lock:
|
||||
self._decode_errors += 1
|
||||
|
||||
def preview_dropped(self) -> None:
|
||||
with self._lock:
|
||||
self._preview_dropped += 1
|
||||
|
||||
def snapshot(self) -> MetricsSnapshot:
|
||||
now_ns = time.monotonic_ns()
|
||||
with self._lock:
|
||||
_trim_rate_window(self._pcl_times, now_ns)
|
||||
_trim_rate_window(self._pose_times, now_ns)
|
||||
latencies = list(self._latencies_ms)
|
||||
last_latency = latencies[-1] if latencies else None
|
||||
p50 = statistics.median(latencies) if latencies else None
|
||||
p95 = _percentile(latencies, 0.95) if latencies else None
|
||||
return {
|
||||
"messages_received": self._messages_received,
|
||||
"payload_bytes": self._payload_bytes,
|
||||
"pcl_frames": self._pcl_frames,
|
||||
"pose_frames": self._pose_frames,
|
||||
"points_published": self._points_published,
|
||||
"last_point_count": self._last_point_count,
|
||||
"decode_errors": self._decode_errors,
|
||||
"preview_dropped": self._preview_dropped,
|
||||
"pcl_fps": _window_rate(self._pcl_times),
|
||||
"pose_fps": _window_rate(self._pose_times),
|
||||
"mqtt_to_publish_ms": _rounded(last_latency),
|
||||
"mqtt_to_publish_p50_ms": _rounded(p50),
|
||||
"mqtt_to_publish_p95_ms": _rounded(p95),
|
||||
"decode_publish_ms": _rounded(self._decode_publish_ms),
|
||||
"trajectory_poses": self._trajectory_poses,
|
||||
}
|
||||
|
||||
|
||||
class FoxgloveBridge:
|
||||
"""Decode verified K1 topics and publish Foxglove-native visualization messages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8765,
|
||||
metrics: BridgeMetrics | None = None,
|
||||
) -> None:
|
||||
self.metrics = metrics or BridgeMetrics()
|
||||
self._server = foxglove.start_server(
|
||||
name="NODE.DC K1 live bridge",
|
||||
host=host,
|
||||
port=port,
|
||||
message_backlog_size=32,
|
||||
)
|
||||
self._points = PointCloudChannel("/k1/points")
|
||||
self._pose = PoseInFrameChannel("/k1/pose")
|
||||
self._trajectory = SceneUpdateChannel("/k1/trajectory")
|
||||
self._metrics = Channel(
|
||||
"/k1/metrics",
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mqtt_to_publish_ms": {"type": ["number", "null"]},
|
||||
"mqtt_to_publish_p50_ms": {"type": ["number", "null"]},
|
||||
"mqtt_to_publish_p95_ms": {"type": ["number", "null"]},
|
||||
"decode_publish_ms": {"type": ["number", "null"]},
|
||||
"point_count": {"type": "integer"},
|
||||
"pcl_fps": {"type": "number"},
|
||||
"pose_fps": {"type": "number"},
|
||||
"preview_dropped": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES)
|
||||
self._last_trajectory_publish_ns = 0
|
||||
self._last_point_count = 0
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return int(self._server.port)
|
||||
|
||||
@property
|
||||
def websocket_url(self) -> str:
|
||||
return f"ws://127.0.0.1:{self.port}"
|
||||
|
||||
@property
|
||||
def viewer_url(self) -> str:
|
||||
return self._server.app_url() or "https://app.foxglove.dev/"
|
||||
|
||||
def process(self, message: StreamMessage) -> None:
|
||||
started_ns = time.monotonic_ns()
|
||||
self.metrics.received(len(message.payload))
|
||||
try:
|
||||
if message.topic.endswith("/lio_pcl"):
|
||||
self._publish_lio_pcl(decode_lio_pcl(message.payload), message)
|
||||
elif message.topic == "RealtimePointcloud":
|
||||
self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload), message)
|
||||
elif message.topic.endswith("/lio_pose"):
|
||||
self._publish_lio_pose(decode_lio_pose(message.payload), message)
|
||||
elif message.topic == "RealtimePath":
|
||||
self._publish_legacy_pose(decode_legacy_pose(message.payload), message)
|
||||
else:
|
||||
return
|
||||
except StreamDecodeError:
|
||||
self.metrics.decode_error()
|
||||
return
|
||||
|
||||
published_ns = time.monotonic_ns()
|
||||
decode_publish_ms = (published_ns - started_ns) / 1_000_000
|
||||
if message.topic.endswith("/lio_pcl") or message.topic == "RealtimePointcloud":
|
||||
self.metrics.published_pcl(self._last_point_count, published_ns, decode_publish_ms)
|
||||
else:
|
||||
self.metrics.published_pose(published_ns, decode_publish_ms, len(self._path))
|
||||
if message.received_monotonic_ns is not None:
|
||||
self.metrics.record_latency((published_ns - message.received_monotonic_ns) / 1_000_000)
|
||||
snapshot = self.metrics.snapshot()
|
||||
self._metrics.log(
|
||||
{
|
||||
"mqtt_to_publish_ms": snapshot["mqtt_to_publish_ms"],
|
||||
"mqtt_to_publish_p50_ms": snapshot["mqtt_to_publish_p50_ms"],
|
||||
"mqtt_to_publish_p95_ms": snapshot["mqtt_to_publish_p95_ms"],
|
||||
"decode_publish_ms": snapshot["decode_publish_ms"],
|
||||
"point_count": snapshot["last_point_count"],
|
||||
"pcl_fps": snapshot["pcl_fps"],
|
||||
"pose_fps": snapshot["pose_fps"],
|
||||
"preview_dropped": snapshot["preview_dropped"],
|
||||
},
|
||||
log_time=message.received_at_epoch_ns,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
for channel in (self._points, self._pose, self._trajectory, self._metrics):
|
||||
channel.close()
|
||||
self._server.stop()
|
||||
|
||||
def _publish_lio_pcl(self, frame: LioPointCloudFrame, message: StreamMessage) -> None:
|
||||
packed = pack_lio_point_cloud(frame)
|
||||
self._publish_point_cloud(packed, message)
|
||||
|
||||
def _publish_legacy_pcl(
|
||||
self,
|
||||
frame: LegacyPointCloudFrame,
|
||||
message: StreamMessage,
|
||||
) -> None:
|
||||
packed = pack_legacy_point_cloud(frame)
|
||||
self._publish_point_cloud(packed, message)
|
||||
|
||||
def _publish_point_cloud(self, packed: PackedPointCloud, message: StreamMessage) -> None:
|
||||
timestamp = _timestamp(message.received_at_epoch_ns)
|
||||
self._points.log(
|
||||
PointCloud(
|
||||
timestamp=timestamp,
|
||||
frame_id=FRAME_ID,
|
||||
pose=_identity_pose(),
|
||||
point_stride=POINT_STRIDE,
|
||||
fields=_point_fields(),
|
||||
data=packed.data,
|
||||
),
|
||||
log_time=message.received_at_epoch_ns,
|
||||
)
|
||||
self._last_point_count = packed.point_count
|
||||
|
||||
def _publish_lio_pose(self, frame: LioPoseFrame, message: StreamMessage) -> None:
|
||||
self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message)
|
||||
|
||||
def _publish_legacy_pose(self, frame: LegacyPoseFrame, message: StreamMessage) -> None:
|
||||
self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message)
|
||||
|
||||
def _publish_pose(
|
||||
self,
|
||||
position_xyz: tuple[float, float, float],
|
||||
orientation_xyzw: tuple[float, float, float, float],
|
||||
message: StreamMessage,
|
||||
) -> None:
|
||||
pose = Pose(
|
||||
position=Vector3(x=position_xyz[0], y=position_xyz[1], z=position_xyz[2]),
|
||||
orientation=Quaternion(
|
||||
x=orientation_xyzw[0],
|
||||
y=orientation_xyzw[1],
|
||||
z=orientation_xyzw[2],
|
||||
w=orientation_xyzw[3],
|
||||
),
|
||||
)
|
||||
timestamp = _timestamp(message.received_at_epoch_ns)
|
||||
self._pose.log(
|
||||
PoseInFrame(timestamp=timestamp, frame_id=FRAME_ID, pose=pose),
|
||||
log_time=message.received_at_epoch_ns,
|
||||
)
|
||||
self._path.append(position_xyz)
|
||||
now_ns = time.monotonic_ns()
|
||||
if (
|
||||
len(self._path) > 2
|
||||
and len(self._path) % 20
|
||||
and now_ns - self._last_trajectory_publish_ns < 200_000_000
|
||||
):
|
||||
return
|
||||
self._last_trajectory_publish_ns = now_ns
|
||||
line_points = [Point3(x=item[0], y=item[1], z=item[2]) for item in self._path]
|
||||
self._trajectory.log(
|
||||
SceneUpdate(
|
||||
entities=[
|
||||
SceneEntity(
|
||||
timestamp=timestamp,
|
||||
frame_id=FRAME_ID,
|
||||
id="k1-trajectory",
|
||||
frame_locked=True,
|
||||
lines=[
|
||||
LinePrimitive(
|
||||
type=LinePrimitiveLineType.LineStrip,
|
||||
thickness=3.0,
|
||||
scale_invariant=True,
|
||||
points=line_points,
|
||||
color=Color(r=0.08, g=0.82, b=1.0, a=1.0),
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
),
|
||||
log_time=message.received_at_epoch_ns,
|
||||
)
|
||||
|
||||
|
||||
def pack_lio_point_cloud(frame: LioPointCloudFrame) -> PackedPointCloud:
|
||||
data = bytearray(len(frame.points) * POINT_STRIDE)
|
||||
scaler = frame.header.scaler
|
||||
for index, point in enumerate(frame.points):
|
||||
x, y, z = point.scaled_xyz(scaler)
|
||||
POINT_STRUCT.pack_into(data, index * POINT_STRIDE, x, y, z, point.intensity)
|
||||
return PackedPointCloud(data=bytes(data), point_count=len(frame.points))
|
||||
|
||||
|
||||
def pack_legacy_point_cloud(frame: LegacyPointCloudFrame) -> PackedPointCloud:
|
||||
data = bytearray(len(frame.points) * POINT_STRIDE)
|
||||
for index, point in enumerate(frame.points):
|
||||
POINT_STRUCT.pack_into(
|
||||
data,
|
||||
index * POINT_STRIDE,
|
||||
point.x,
|
||||
point.y,
|
||||
point.z,
|
||||
point.intensity,
|
||||
)
|
||||
return PackedPointCloud(data=bytes(data), point_count=len(frame.points))
|
||||
|
||||
|
||||
def _point_fields() -> list[PackedElementField]:
|
||||
return [
|
||||
PackedElementField(name="x", offset=0, type=PackedElementFieldNumericType.Float32),
|
||||
PackedElementField(name="y", offset=4, type=PackedElementFieldNumericType.Float32),
|
||||
PackedElementField(name="z", offset=8, type=PackedElementFieldNumericType.Float32),
|
||||
PackedElementField(
|
||||
name="intensity",
|
||||
offset=12,
|
||||
type=PackedElementFieldNumericType.Uint8,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _identity_pose() -> Pose:
|
||||
return Pose(position=Vector3(), orientation=Quaternion(w=1.0))
|
||||
|
||||
|
||||
def _timestamp(epoch_ns: int) -> Timestamp:
|
||||
return Timestamp(epoch_ns // 1_000_000_000, epoch_ns % 1_000_000_000)
|
||||
|
||||
|
||||
def _trim_rate_window(values: deque[int], now_ns: int) -> None:
|
||||
cutoff = now_ns - 1_000_000_000
|
||||
while values and values[0] < cutoff:
|
||||
values.popleft()
|
||||
|
||||
|
||||
def _window_rate(values: deque[int]) -> float:
|
||||
if len(values) < 2:
|
||||
return float(len(values))
|
||||
elapsed = (values[-1] - values[0]) / 1_000_000_000
|
||||
return len(values) / max(elapsed, 1.0)
|
||||
|
||||
|
||||
def _percentile(values: list[float], fraction: float) -> float:
|
||||
if not values:
|
||||
raise ValueError("cannot calculate a percentile of an empty sample")
|
||||
ordered = sorted(values)
|
||||
index = min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1)
|
||||
return ordered[index]
|
||||
|
||||
|
||||
def _rounded(value: float | None) -> float | None:
|
||||
return None if value is None else round(value, 3)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StreamMessage:
|
||||
"""One MQTT message entering the derived visualization pipeline."""
|
||||
|
||||
sequence: int
|
||||
topic: str
|
||||
payload: bytes
|
||||
received_at_epoch_ns: int
|
||||
received_monotonic_ns: int | None = None
|
||||
source: str = "replay"
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Generator, Iterator
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import IO, Literal
|
||||
|
||||
from k1link.mqtt import CaptureFormatError, iter_capture_frames
|
||||
from k1link.mqtt.capture import RAW_MAGIC
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
|
||||
MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024
|
||||
MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024
|
||||
MAX_METADATA_LINE_CHARS = 1024 * 1024
|
||||
ReplayFormat = Literal["k1mqtt", "legacy_tsv"]
|
||||
|
||||
|
||||
class ReplayFormatError(ValueError):
|
||||
"""A replay input is corrupt or outside the reviewed bounds."""
|
||||
|
||||
|
||||
def detect_replay_format(path: Path) -> ReplayFormat:
|
||||
resolved = path.expanduser()
|
||||
with resolved.open("rb") as stream:
|
||||
prefix = stream.read(len(RAW_MAGIC))
|
||||
if prefix == RAW_MAGIC:
|
||||
return "k1mqtt"
|
||||
if resolved.suffix.casefold() == ".tsv":
|
||||
return "legacy_tsv"
|
||||
raise ReplayFormatError("input is neither a K1MQTT capture nor the reviewed legacy TSV")
|
||||
|
||||
|
||||
def iter_replay_messages(path: Path) -> Generator[StreamMessage, None, None]:
|
||||
"""Yield bounded messages from native evidence or the one reviewed TSV export."""
|
||||
resolved = path.expanduser().resolve()
|
||||
replay_format = detect_replay_format(resolved)
|
||||
if replay_format == "legacy_tsv":
|
||||
yield from _iter_legacy_tsv(resolved)
|
||||
return
|
||||
yield from _iter_native_capture(resolved)
|
||||
|
||||
|
||||
def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
|
||||
metadata_path = path.with_name("mqtt.metadata.jsonl")
|
||||
metadata_stream: IO[str] | None = None
|
||||
if metadata_path.is_file():
|
||||
metadata_stream = metadata_path.open("r", encoding="utf-8")
|
||||
fallback_epoch_ns = time.time_ns()
|
||||
try:
|
||||
for frame in iter_capture_frames(path, max_payload_bytes=MAX_REPLAY_PAYLOAD_BYTES):
|
||||
epoch_ns = fallback_epoch_ns + frame.sequence - 1
|
||||
monotonic_ns: int | None = None
|
||||
if metadata_stream is not None:
|
||||
epoch_ns, monotonic_ns = _read_native_timing(
|
||||
metadata_stream,
|
||||
expected_sequence=frame.sequence,
|
||||
fallback_epoch_ns=epoch_ns,
|
||||
)
|
||||
yield StreamMessage(
|
||||
sequence=frame.sequence,
|
||||
topic=frame.topic,
|
||||
payload=frame.payload,
|
||||
received_at_epoch_ns=epoch_ns,
|
||||
received_monotonic_ns=monotonic_ns,
|
||||
source="k1mqtt",
|
||||
)
|
||||
except CaptureFormatError as exc:
|
||||
raise ReplayFormatError(str(exc)) from exc
|
||||
finally:
|
||||
if metadata_stream is not None:
|
||||
metadata_stream.close()
|
||||
|
||||
|
||||
def _read_native_timing(
|
||||
stream: IO[str],
|
||||
*,
|
||||
expected_sequence: int,
|
||||
fallback_epoch_ns: int,
|
||||
) -> tuple[int, int | None]:
|
||||
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
|
||||
if not line:
|
||||
return fallback_epoch_ns, None
|
||||
if len(line) > MAX_METADATA_LINE_CHARS:
|
||||
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReplayFormatError(
|
||||
f"native metadata line {expected_sequence} is not valid JSON"
|
||||
) from exc
|
||||
if record.get("record_type") != "message" or record.get("sequence") != expected_sequence:
|
||||
raise ReplayFormatError(f"native metadata is not aligned at message {expected_sequence}")
|
||||
epoch_ns = record.get("received_at_epoch_ns", fallback_epoch_ns)
|
||||
monotonic_ns = record.get("received_monotonic_ns")
|
||||
if not isinstance(epoch_ns, int) or epoch_ns < 0:
|
||||
raise ReplayFormatError("native metadata received_at_epoch_ns is invalid")
|
||||
if monotonic_ns is not None and (not isinstance(monotonic_ns, int) or monotonic_ns < 0):
|
||||
raise ReplayFormatError("native metadata received_monotonic_ns is invalid")
|
||||
return epoch_ns, monotonic_ns
|
||||
|
||||
|
||||
def _iter_legacy_tsv(path: Path) -> Iterator[StreamMessage]:
|
||||
with path.open("rb") as stream:
|
||||
line_number = 0
|
||||
while True:
|
||||
raw_line = stream.readline(MAX_LEGACY_LINE_BYTES + 1)
|
||||
if not raw_line:
|
||||
return
|
||||
line_number += 1
|
||||
if len(raw_line) > MAX_LEGACY_LINE_BYTES:
|
||||
raise ReplayFormatError(
|
||||
f"legacy TSV line {line_number} exceeds {MAX_LEGACY_LINE_BYTES} bytes"
|
||||
)
|
||||
stripped = raw_line.rstrip(b"\r\n")
|
||||
if not stripped:
|
||||
continue
|
||||
columns = stripped.split(b"\t")
|
||||
if len(columns) != 4:
|
||||
raise ReplayFormatError(
|
||||
f"legacy TSV line {line_number} must contain exactly four columns"
|
||||
)
|
||||
timestamp_raw, topic_raw, length_raw, payload_hex = columns
|
||||
try:
|
||||
timestamp = Decimal(timestamp_raw.decode("ascii"))
|
||||
declared_length = int(length_raw.decode("ascii"), 10)
|
||||
topic = topic_raw.decode("utf-8")
|
||||
except (InvalidOperation, UnicodeDecodeError, ValueError) as exc:
|
||||
raise ReplayFormatError(
|
||||
f"legacy TSV line {line_number} has invalid timestamp/topic/length"
|
||||
) from exc
|
||||
if not timestamp.is_finite() or timestamp < 0:
|
||||
raise ReplayFormatError(
|
||||
f"legacy TSV line {line_number} timestamp is outside bounds"
|
||||
)
|
||||
if not topic:
|
||||
raise ReplayFormatError(f"legacy TSV line {line_number} has an empty topic")
|
||||
if declared_length < 0 or declared_length > MAX_REPLAY_PAYLOAD_BYTES:
|
||||
raise ReplayFormatError(
|
||||
f"legacy TSV line {line_number} payload length is outside bounds"
|
||||
)
|
||||
if len(payload_hex) != declared_length * 2:
|
||||
raise ReplayFormatError(
|
||||
f"legacy TSV line {line_number} declared payload length does not match hex"
|
||||
)
|
||||
try:
|
||||
payload = bytes.fromhex(payload_hex.decode("ascii"))
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise ReplayFormatError(
|
||||
f"legacy TSV line {line_number} payload is not valid hex"
|
||||
) from exc
|
||||
epoch_ns = int(timestamp * Decimal(1_000_000_000))
|
||||
yield StreamMessage(
|
||||
sequence=line_number,
|
||||
topic=topic,
|
||||
payload=payload,
|
||||
received_at_epoch_ns=epoch_ns,
|
||||
source="legacy_tsv",
|
||||
)
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||
from k1link.viewer.foxglove_bridge import BridgeMetrics, FoxgloveBridge, MetricsSnapshot
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
from k1link.viewer.replay import iter_replay_messages
|
||||
|
||||
RuntimePhase = Literal[
|
||||
"idle",
|
||||
"starting_live",
|
||||
"live",
|
||||
"replay",
|
||||
"stopping",
|
||||
"error",
|
||||
]
|
||||
SourceMode = Literal["idle", "live", "replay"]
|
||||
StateCallback = Callable[[], None]
|
||||
|
||||
|
||||
class RuntimeSnapshot(TypedDict):
|
||||
phase: RuntimePhase
|
||||
message: str
|
||||
source_mode: SourceMode
|
||||
foxglove_ws_url: str | None
|
||||
foxglove_viewer_url: str | None
|
||||
metrics: MetricsSnapshot
|
||||
|
||||
|
||||
class VisualizationRuntime:
|
||||
"""Own one bounded live/replay source and one deterministic publisher thread."""
|
||||
|
||||
def __init__(self, *, on_state_change: StateCallback | None = None) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._on_state_change = on_state_change
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._phase: RuntimePhase = "idle"
|
||||
self._message = "Ready for a live scanner or replay capture."
|
||||
self._source_mode: SourceMode = "idle"
|
||||
self._foxglove_ws_url: str | None = None
|
||||
self._foxglove_viewer_url: str | None = None
|
||||
self._metrics = BridgeMetrics()
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
with self._lock:
|
||||
return {
|
||||
"phase": self._phase,
|
||||
"message": self._message,
|
||||
"source_mode": self._source_mode,
|
||||
"foxglove_ws_url": self._foxglove_ws_url,
|
||||
"foxglove_viewer_url": self._foxglove_viewer_url,
|
||||
"metrics": self._metrics.snapshot(),
|
||||
}
|
||||
|
||||
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
|
||||
resolved = path.expanduser().resolve()
|
||||
if not resolved.is_file():
|
||||
raise ValueError("replay path must be an existing backend-local file")
|
||||
if not math.isfinite(speed) or speed < 0:
|
||||
raise ValueError("replay speed must be finite and non-negative")
|
||||
# Validate the reviewed shape before changing runtime state.
|
||||
iterator = iter_replay_messages(resolved)
|
||||
try:
|
||||
next(iterator)
|
||||
except StopIteration as exc:
|
||||
raise ValueError("replay capture contains no messages") from exc
|
||||
finally:
|
||||
iterator.close()
|
||||
|
||||
self._start(
|
||||
source_mode="replay",
|
||||
phase="replay",
|
||||
message=f"Starting replay: {resolved.name}",
|
||||
target=lambda: self._run_replay(resolved, speed=speed, loop=loop),
|
||||
)
|
||||
|
||||
def start_live(
|
||||
self,
|
||||
host: str,
|
||||
out_dir: Path,
|
||||
*,
|
||||
duration_seconds: float = 3600.0,
|
||||
) -> None:
|
||||
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
|
||||
raise ValueError("live duration must be finite and greater than zero")
|
||||
self._start(
|
||||
source_mode="live",
|
||||
phase="starting_live",
|
||||
message="Starting read-only MQTT capture and Foxglove bridge.",
|
||||
target=lambda: self._run_live(
|
||||
host,
|
||||
out_dir.expanduser().resolve(),
|
||||
duration_seconds=duration_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
def stop(self, *, wait_seconds: float = 5.0) -> None:
|
||||
notify_only = False
|
||||
with self._lock:
|
||||
thread = self._thread
|
||||
if thread is None or not thread.is_alive():
|
||||
self._phase = "idle"
|
||||
self._source_mode = "idle"
|
||||
self._message = "No active visualization session."
|
||||
notify_only = True
|
||||
else:
|
||||
self._phase = "stopping"
|
||||
self._message = "Stopping source after preserving queued evidence."
|
||||
self._stop_event.set()
|
||||
self._notify()
|
||||
if notify_only:
|
||||
return
|
||||
assert thread is not None
|
||||
thread.join(timeout=wait_seconds)
|
||||
|
||||
def _start(
|
||||
self,
|
||||
*,
|
||||
source_mode: SourceMode,
|
||||
phase: RuntimePhase,
|
||||
message: str,
|
||||
target: Callable[[], None],
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
raise RuntimeError("a visualization session is already active")
|
||||
self._stop_event = threading.Event()
|
||||
self._metrics = BridgeMetrics()
|
||||
self._phase = phase
|
||||
self._source_mode = source_mode
|
||||
self._message = message
|
||||
self._foxglove_ws_url = None
|
||||
self._foxglove_viewer_url = None
|
||||
self._thread = threading.Thread(
|
||||
target=target,
|
||||
name=f"k1-{source_mode}-session",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._notify()
|
||||
|
||||
def _run_replay(self, path: Path, *, speed: float, loop: bool) -> None:
|
||||
def produce(put: Callable[[StreamMessage], None]) -> str:
|
||||
while not self._stop_event.is_set():
|
||||
first_source_ns: int | None = None
|
||||
replay_started_ns = time.monotonic_ns()
|
||||
count = 0
|
||||
for message in iter_replay_messages(path):
|
||||
if self._stop_event.is_set():
|
||||
return "Replay stopped by operator."
|
||||
source_ns = (
|
||||
message.received_monotonic_ns
|
||||
if message.received_monotonic_ns is not None
|
||||
else message.received_at_epoch_ns
|
||||
)
|
||||
if first_source_ns is None:
|
||||
first_source_ns = source_ns
|
||||
if speed > 0:
|
||||
target_ns = replay_started_ns + int((source_ns - first_source_ns) / speed)
|
||||
remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000
|
||||
if remaining > 0 and self._stop_event.wait(remaining):
|
||||
return "Replay stopped by operator."
|
||||
put(message)
|
||||
count += 1
|
||||
if not loop:
|
||||
return f"Replay completed: {count} MQTT messages."
|
||||
return "Replay stopped by operator."
|
||||
|
||||
self._run_pipeline(produce, running_phase="replay")
|
||||
|
||||
def _run_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None:
|
||||
_write_live_session_preamble(out_dir, host, duration_seconds)
|
||||
|
||||
def produce(put: Callable[[StreamMessage], None]) -> str:
|
||||
def on_message(message: CapturedMqttMessage) -> None:
|
||||
put(
|
||||
StreamMessage(
|
||||
sequence=message.sequence,
|
||||
topic=message.topic,
|
||||
payload=message.payload,
|
||||
received_at_epoch_ns=message.received_at_epoch_ns,
|
||||
received_monotonic_ns=message.received_monotonic_ns,
|
||||
source="live_mqtt",
|
||||
)
|
||||
)
|
||||
|
||||
summary = capture_mqtt(
|
||||
host,
|
||||
out_dir / "captures" / "mqtt_live",
|
||||
duration_seconds=duration_seconds,
|
||||
on_ready=lambda: self._set_running(
|
||||
"live", "MQTT subscribed; waiting for K1 scan frames."
|
||||
),
|
||||
on_message_recorded=on_message,
|
||||
should_stop=self._stop_event.is_set,
|
||||
)
|
||||
return (
|
||||
f"Live capture stopped: {summary['stop_reason']}; "
|
||||
f"{summary['message_count']} messages preserved."
|
||||
)
|
||||
|
||||
try:
|
||||
self._run_pipeline(produce, running_phase="live")
|
||||
finally:
|
||||
_finalize_live_session(out_dir, self.snapshot())
|
||||
|
||||
def _run_pipeline(
|
||||
self,
|
||||
producer: Callable[[Callable[[StreamMessage], None]], str],
|
||||
*,
|
||||
running_phase: RuntimePhase,
|
||||
) -> None:
|
||||
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
|
||||
source_done = threading.Event()
|
||||
publisher_ready = threading.Event()
|
||||
publisher_error: list[BaseException] = []
|
||||
|
||||
def enqueue(message: StreamMessage) -> None:
|
||||
try:
|
||||
messages.put_nowait(message)
|
||||
return
|
||||
except queue.Full:
|
||||
pass
|
||||
try:
|
||||
messages.get_nowait()
|
||||
messages.task_done()
|
||||
except queue.Empty:
|
||||
pass
|
||||
self._metrics.preview_dropped()
|
||||
try:
|
||||
messages.put_nowait(message)
|
||||
except queue.Full:
|
||||
self._metrics.preview_dropped()
|
||||
|
||||
def publish() -> None:
|
||||
bridge: FoxgloveBridge | None = None
|
||||
try:
|
||||
bridge = FoxgloveBridge(metrics=self._metrics)
|
||||
with self._lock:
|
||||
self._foxglove_ws_url = bridge.websocket_url
|
||||
self._foxglove_viewer_url = bridge.viewer_url
|
||||
if self._phase != "stopping":
|
||||
self._phase = running_phase
|
||||
self._message = "Foxglove bridge ready; source is active."
|
||||
publisher_ready.set()
|
||||
self._notify()
|
||||
while not source_done.is_set() or not messages.empty():
|
||||
if self._stop_event.is_set() and source_done.is_set() and messages.empty():
|
||||
break
|
||||
try:
|
||||
message = messages.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
bridge.process(message)
|
||||
finally:
|
||||
messages.task_done()
|
||||
if self._metrics.snapshot()["messages_received"] % 10 == 0:
|
||||
self._notify()
|
||||
except BaseException as exc:
|
||||
publisher_error.append(exc)
|
||||
publisher_ready.set()
|
||||
self._stop_event.set()
|
||||
finally:
|
||||
if bridge is not None:
|
||||
bridge.close()
|
||||
|
||||
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
|
||||
publisher.start()
|
||||
if not publisher_ready.wait(timeout=15.0):
|
||||
self._finish_error("Foxglove bridge did not start within 15 seconds.")
|
||||
source_done.set()
|
||||
self._stop_event.set()
|
||||
return
|
||||
if publisher_error:
|
||||
self._finish_error(
|
||||
f"Foxglove bridge failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
||||
)
|
||||
source_done.set()
|
||||
return
|
||||
|
||||
final_message = "Session stopped."
|
||||
try:
|
||||
final_message = producer(enqueue)
|
||||
except CaptureError as exc:
|
||||
self._finish_error(f"Live MQTT capture failed: {exc}")
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self._finish_error(f"Source failed: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
source_done.set()
|
||||
publisher.join(timeout=15.0)
|
||||
if publisher.is_alive():
|
||||
self._stop_event.set()
|
||||
self._finish_error("Publisher did not drain within 15 seconds.")
|
||||
elif publisher_error:
|
||||
self._finish_error(
|
||||
f"Publisher failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
||||
)
|
||||
elif self.snapshot()["phase"] != "error":
|
||||
self._finish_idle(final_message)
|
||||
|
||||
def _set_running(self, phase: RuntimePhase, message: str) -> None:
|
||||
with self._lock:
|
||||
if self._phase != "stopping":
|
||||
self._phase = phase
|
||||
self._message = message
|
||||
self._notify()
|
||||
|
||||
def _finish_idle(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self._phase = "idle"
|
||||
self._source_mode = "idle"
|
||||
self._message = message
|
||||
self._foxglove_ws_url = None
|
||||
self._foxglove_viewer_url = None
|
||||
self._notify()
|
||||
|
||||
def _finish_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self._phase = "error"
|
||||
self._message = message
|
||||
self._foxglove_ws_url = None
|
||||
self._foxglove_viewer_url = None
|
||||
self._notify()
|
||||
|
||||
def _notify(self) -> None:
|
||||
callback = self._on_state_change
|
||||
if callback is not None:
|
||||
callback()
|
||||
|
||||
|
||||
def new_live_session_dir(repository_root: Path) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = repository_root / "sessions" / f"{stamp}_viewer_live"
|
||||
candidate = base
|
||||
suffix = 1
|
||||
while candidate.exists():
|
||||
suffix += 1
|
||||
candidate = base.with_name(f"{base.name}_{suffix:02d}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: float) -> None:
|
||||
out_dir.mkdir(parents=True, exist_ok=False)
|
||||
started_at_utc = utc_now_iso()
|
||||
write_json_atomic(
|
||||
out_dir / "manifest.redacted.json",
|
||||
{
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at_utc,
|
||||
"started_monotonic_ns": time.monotonic_ns(),
|
||||
"operation": "k1_live_mqtt_to_foxglove",
|
||||
"target": "owner-controlled K1 at redacted RFC1918 address",
|
||||
"requested_duration_seconds": duration_seconds,
|
||||
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
|
||||
"credential_storage": "none",
|
||||
},
|
||||
)
|
||||
notes = (
|
||||
"# K1 live visualization session\n\n"
|
||||
f"Started UTC: {started_at_utc}\n\n"
|
||||
"The local control API started a read-only MQTT subscription and Foxglove "
|
||||
"preview. Raw MQTT evidence is written before preview decoding. The target "
|
||||
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
|
||||
)
|
||||
notes_path = out_dir / "operator-notes.md"
|
||||
notes_path.write_text(notes, encoding="utf-8")
|
||||
notes_path.chmod(0o600)
|
||||
|
||||
|
||||
def _finalize_live_session(out_dir: Path, snapshot: RuntimeSnapshot) -> None:
|
||||
manifest_path = out_dir / "manifest.redacted.json"
|
||||
try:
|
||||
import json
|
||||
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
payload["completed_at_utc"] = utc_now_iso()
|
||||
payload["completed_monotonic_ns"] = time.monotonic_ns()
|
||||
payload["final_phase"] = snapshot["phase"]
|
||||
payload["aggregate_metrics"] = snapshot["metrics"]
|
||||
write_json_atomic(manifest_path, payload)
|
||||
except (OSError, ValueError):
|
||||
# The MQTT capture summary remains authoritative if final annotation fails.
|
||||
return
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Local-only control API for the K1 live console."""
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from bleak.exc import BleakError
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.ble.scanner import scan
|
||||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||
from k1link.mqtt import validate_private_ipv4
|
||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class BleScanRequest(BaseModel):
|
||||
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
device_id: str = Field(min_length=1, max_length=128)
|
||||
ssid: str = Field(min_length=1, max_length=128)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class LiveRequest(BaseModel):
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=4096)
|
||||
speed: float = Field(default=1.0, ge=0.0, le=100.0)
|
||||
loop: bool = False
|
||||
|
||||
|
||||
class ConsoleService:
|
||||
def __init__(self, repository_root: Path) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self._lock = threading.Lock()
|
||||
self._devices: list[dict[str, Any]] = []
|
||||
self._selected_device_id: str | None = None
|
||||
self._k1_ip: str | None = None
|
||||
self._operation_phase: str | None = None
|
||||
self._operation_message: str | None = None
|
||||
self.runtime = VisualizationRuntime()
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
runtime = self.runtime.snapshot()
|
||||
metrics = runtime["metrics"]
|
||||
with self._lock:
|
||||
operation_phase = self._operation_phase
|
||||
operation_message = self._operation_message
|
||||
devices = list(self._devices)
|
||||
selected_device_id = self._selected_device_id
|
||||
k1_ip = self._k1_ip
|
||||
|
||||
runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in {
|
||||
"starting_live",
|
||||
"stopping",
|
||||
"error",
|
||||
}
|
||||
if operation_phase is not None:
|
||||
phase = operation_phase
|
||||
message = operation_message
|
||||
elif runtime_active:
|
||||
phase = runtime["phase"]
|
||||
message = runtime["message"]
|
||||
elif k1_ip is not None:
|
||||
phase = "connected"
|
||||
message = runtime["message"]
|
||||
elif selected_device_id is not None:
|
||||
phase = "device_selected"
|
||||
message = "K1 selected; Wi-Fi credentials have not been written."
|
||||
else:
|
||||
phase = "idle"
|
||||
message = runtime["message"]
|
||||
|
||||
return {
|
||||
"phase": phase,
|
||||
"message": message,
|
||||
"devices": devices,
|
||||
"selected_device_id": selected_device_id,
|
||||
"k1_ip": k1_ip,
|
||||
"foxglove_ws_url": runtime["foxglove_ws_url"],
|
||||
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
|
||||
"source_mode": runtime["source_mode"],
|
||||
"metrics": {
|
||||
"pipeline_ms": metrics["mqtt_to_publish_ms"],
|
||||
"end_to_end_ms": metrics["mqtt_to_publish_ms"],
|
||||
"decode_ms": metrics["decode_publish_ms"],
|
||||
"frame_rate": metrics["pcl_fps"],
|
||||
"frame_rate_hz": metrics["pcl_fps"],
|
||||
"point_count": metrics["last_point_count"],
|
||||
"dropped_preview_frames": metrics["preview_dropped"],
|
||||
**metrics,
|
||||
},
|
||||
}
|
||||
|
||||
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
|
||||
self._set_operation("scanning", "Scanning for nearby K1 BLE advertisements.")
|
||||
try:
|
||||
result = await scan(duration_seconds)
|
||||
devices = [
|
||||
{
|
||||
"device_id": item["macos_uuid"],
|
||||
"name": item["local_name"] or item["name"],
|
||||
"rssi": item["rssi"],
|
||||
"address": None,
|
||||
"connectable": True,
|
||||
}
|
||||
for item in result["devices"]
|
||||
if item["k1_name_candidate"]
|
||||
]
|
||||
with self._lock:
|
||||
self._devices = devices
|
||||
if len(devices) == 1:
|
||||
self._selected_device_id = str(devices[0]["device_id"])
|
||||
self._operation_message = f"BLE scan complete: {len(devices)} K1 candidate(s)."
|
||||
finally:
|
||||
with self._lock:
|
||||
self._operation_phase = None
|
||||
return self.state()
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("device_id must come from the latest K1 BLE scan")
|
||||
self._set_operation(
|
||||
"provisioning",
|
||||
"Sending one explicitly requested Wi-Fi provisioning write.",
|
||||
)
|
||||
session_dir = _new_operation_session_dir(
|
||||
self.repository_root,
|
||||
"viewer_wifi_provisioning",
|
||||
)
|
||||
session_dir.mkdir(parents=True, exist_ok=False)
|
||||
password = request.password
|
||||
try:
|
||||
result = await provision_wifi_once(
|
||||
request.device_id,
|
||||
request.ssid,
|
||||
password,
|
||||
timeout_seconds=45.0,
|
||||
write_mode="auto",
|
||||
)
|
||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||
ipv4 = _provisioned_ipv4(result)
|
||||
write_json_atomic(
|
||||
session_dir / "manifest.redacted.json",
|
||||
{
|
||||
"schema_version": 1,
|
||||
"started_at_utc": result["started_at_utc"],
|
||||
"completed_at_utc": result["completed_at_utc"],
|
||||
"operation": "single_reviewed_wifi_provisioning_write",
|
||||
"profile_id": result["profile_id"],
|
||||
"outcome": result["outcome"],
|
||||
"k1_lan_address_observed": ipv4 is not None,
|
||||
"credentials_persisted_by_connector": False,
|
||||
},
|
||||
)
|
||||
if ipv4 is None:
|
||||
raise RuntimeError(
|
||||
"K1 did not report a non-AP LAN address; no automatic retry was made"
|
||||
)
|
||||
with self._lock:
|
||||
self._selected_device_id = request.device_id
|
||||
self._k1_ip = ipv4
|
||||
self._operation_message = "K1 joined the LAN and reported its private address."
|
||||
finally:
|
||||
password = ""
|
||||
with self._lock:
|
||||
self._operation_phase = None
|
||||
return self.state()
|
||||
|
||||
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
|
||||
target = host or self.state()["k1_ip"]
|
||||
if not isinstance(target, str) or not target:
|
||||
raise ValueError("live host is required until BLE provisioning reports a K1 address")
|
||||
target = validate_private_ipv4(target)
|
||||
out_dir = new_live_session_dir(self.repository_root)
|
||||
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds)
|
||||
return self.state()
|
||||
|
||||
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
|
||||
replay_path = Path(path).expanduser().resolve()
|
||||
if not replay_path.is_relative_to(self.repository_root):
|
||||
raise ValueError("replay path must remain inside this repository")
|
||||
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
|
||||
return self.state()
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
self.runtime.stop()
|
||||
return self.state()
|
||||
|
||||
def _set_operation(self, phase: str, message: str) -> None:
|
||||
with self._lock:
|
||||
self._operation_phase = phase
|
||||
self._operation_message = message
|
||||
|
||||
|
||||
service = ConsoleService(REPOSITORY_ROOT)
|
||||
app = FastAPI(
|
||||
title="NODE.DC K1 Live Console API",
|
||||
version=__version__,
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "ok",
|
||||
"service": "k1-live-console",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/state")
|
||||
def get_state() -> dict[str, Any]:
|
||||
return service.state()
|
||||
|
||||
|
||||
@app.post("/api/ble/scan")
|
||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await service.scan_ble(request.duration_seconds)
|
||||
except (BleakError, OSError, RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"BLE scan failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/api/connect")
|
||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await service.connect(request)
|
||||
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Wi-Fi provisioning failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/api/session/live")
|
||||
def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return service.start_live(request.host, request.duration_seconds)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/session/replay")
|
||||
def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return service.start_replay(request.path, request.speed, request.loop)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/session/stop")
|
||||
def stop_session() -> dict[str, Any]:
|
||||
return service.stop()
|
||||
|
||||
|
||||
@app.websocket("/api/events")
|
||||
async def events(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json({"state": service.state()})
|
||||
await asyncio.sleep(0.5)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "k1-viewer" / "dist"
|
||||
if frontend_dist.is_dir():
|
||||
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")
|
||||
|
||||
|
||||
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = repository_root / "sessions" / f"{stamp}_{suffix}"
|
||||
candidate = base
|
||||
serial = 1
|
||||
while candidate.exists():
|
||||
serial += 1
|
||||
candidate = base.with_name(f"{base.name}_{serial:02d}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
||||
observations = result.get("observations")
|
||||
if not isinstance(observations, list):
|
||||
return None
|
||||
for observation in reversed(observations):
|
||||
if not isinstance(observation, dict):
|
||||
continue
|
||||
status = observation.get("status")
|
||||
if not isinstance(status, dict):
|
||||
continue
|
||||
address = status.get("ipv4")
|
||||
if isinstance(address, str) and address != AP_FALLBACK_IPV4:
|
||||
try:
|
||||
return validate_private_ipv4(address)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from k1link.protocol.streams import (
|
||||
LegacyPoint,
|
||||
LegacyPointCloudFrame,
|
||||
LioPoint,
|
||||
LioPointCloudFrame,
|
||||
MqttHeader,
|
||||
)
|
||||
from k1link.viewer.foxglove_bridge import (
|
||||
POINT_STRIDE,
|
||||
BridgeMetrics,
|
||||
pack_legacy_point_cloud,
|
||||
pack_lio_point_cloud,
|
||||
)
|
||||
|
||||
|
||||
def test_lio_points_are_scaled_and_packed_with_uint8_intensity() -> None:
|
||||
frame = LioPointCloudFrame(
|
||||
header=MqttHeader(
|
||||
seq=1,
|
||||
stamp=2,
|
||||
scaler=1000,
|
||||
device_id="synthetic",
|
||||
session_id="synthetic",
|
||||
openapi_key=None,
|
||||
),
|
||||
compression=0,
|
||||
compressed_bytes=10,
|
||||
decompressed_bytes=20,
|
||||
points=(LioPoint(-1000, 2500, 3000, 0xAABBCC7F),),
|
||||
)
|
||||
|
||||
packed = pack_lio_point_cloud(frame)
|
||||
|
||||
assert packed.point_count == 1
|
||||
assert len(packed.data) == POINT_STRIDE
|
||||
assert struct.unpack("<fffB3x", packed.data) == (-1.0, 2.5, 3.0, 0x7F)
|
||||
|
||||
|
||||
def test_legacy_points_keep_verified_metric_fields() -> None:
|
||||
frame = LegacyPointCloudFrame(
|
||||
envelope=b"\x00" * 12,
|
||||
stride=16,
|
||||
points=(LegacyPoint(1.0, -2.0, 3.5, 1, 2, 3, 240),),
|
||||
)
|
||||
|
||||
packed = pack_legacy_point_cloud(frame)
|
||||
|
||||
assert struct.unpack("<fffB3x", packed.data) == (1.0, -2.0, 3.5, 240)
|
||||
|
||||
|
||||
def test_metrics_distinguish_preview_drops_and_pipeline_latency() -> None:
|
||||
metrics = BridgeMetrics()
|
||||
metrics.received(100)
|
||||
metrics.preview_dropped()
|
||||
metrics.record_latency(12.3456)
|
||||
metrics.published_pcl(42, 2_000_000_000, 1.2345)
|
||||
|
||||
snapshot = metrics.snapshot()
|
||||
|
||||
assert snapshot["messages_received"] == 1
|
||||
assert snapshot["payload_bytes"] == 100
|
||||
assert snapshot["preview_dropped"] == 1
|
||||
assert snapshot["last_point_count"] == 42
|
||||
assert snapshot["mqtt_to_publish_ms"] == 12.346
|
||||
assert snapshot["decode_publish_ms"] == 1.234
|
||||
|
|
@ -95,11 +95,13 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
|
|||
|
||||
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
observed = []
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "capture",
|
||||
duration_seconds=30,
|
||||
on_message_recorded=observed.append,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
|
|
@ -110,6 +112,10 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
|
|||
assert summary["message_count"] == 1
|
||||
assert summary["payload_bytes"] == len(fake.payload)
|
||||
assert summary["subscriptions"] == list(REPORT_TOPICS)
|
||||
assert len(observed) == 1
|
||||
assert observed[0].topic == fake.topic
|
||||
assert observed[0].payload == fake.payload
|
||||
assert observed[0].received_at_epoch_ns > 0
|
||||
|
||||
capture_dir = tmp_path / "capture"
|
||||
raw = (capture_dir / "mqtt.raw.k1mqtt").read_bytes()
|
||||
|
|
@ -122,14 +128,14 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
|
|||
assert raw[payload_start:] == fake.payload
|
||||
|
||||
records: list[dict[str, object]] = [
|
||||
json.loads(line)
|
||||
for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines()
|
||||
json.loads(line) for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines()
|
||||
]
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record["record_type"] == "message"
|
||||
assert record["topic"] == fake.topic
|
||||
assert record["payload_bytes"] == len(fake.payload)
|
||||
assert isinstance(record["received_at_epoch_ns"], int)
|
||||
assert record["payload_sha256"] == hashlib.sha256(fake.payload).hexdigest()
|
||||
assert record["raw_frame_offset"] == len(RAW_MAGIC)
|
||||
assert record["raw_payload_offset"] == payload_start
|
||||
|
|
@ -187,6 +193,49 @@ def test_capture_refuses_to_overwrite_existing_artifacts(tmp_path: Path) -> None
|
|||
assert raw.read_bytes() == b"existing evidence"
|
||||
|
||||
|
||||
def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
stop_checks = 0
|
||||
|
||||
def should_stop() -> bool:
|
||||
nonlocal stop_checks
|
||||
stop_checks += 1
|
||||
return stop_checks >= 4
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "capture",
|
||||
duration_seconds=30,
|
||||
should_stop=should_stop,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
assert summary["stop_reason"] == "external_stop"
|
||||
assert summary["message_count"] == 1
|
||||
assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload
|
||||
|
||||
|
||||
def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
capture_dir = tmp_path / "capture"
|
||||
|
||||
def fail_preview(_message: object) -> None:
|
||||
raise ValueError("synthetic preview failure")
|
||||
|
||||
with pytest.raises(CaptureError, match="preview callback failed") as error:
|
||||
capture_mqtt(
|
||||
"192.168.1.50",
|
||||
capture_dir,
|
||||
on_message_recorded=fail_preview,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
assert error.value.summary is not None
|
||||
assert error.value.summary["message_count"] == 1
|
||||
frames = list(iter_capture_frames(capture_dir / "mqtt.raw.k1mqtt"))
|
||||
assert frames[0].payload == fake.payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "message"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||
|
||||
|
||||
def _write_native(path: Path, topic: str, payload: bytes) -> None:
|
||||
topic_raw = topic.encode()
|
||||
path.write_bytes(
|
||||
RAW_MAGIC + FRAME_HEADER.pack(len(topic_raw), len(payload)) + topic_raw + payload
|
||||
)
|
||||
|
||||
|
||||
def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
_write_native(capture, "lixel/application/report/lio_pose", b"pose")
|
||||
(tmp_path / "mqtt.metadata.jsonl").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"record_type": "message",
|
||||
"sequence": 1,
|
||||
"received_at_epoch_ns": 123_000_000_000,
|
||||
"received_monotonic_ns": 456,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
assert detect_replay_format(capture) == "k1mqtt"
|
||||
message = list(iter_replay_messages(capture))[0]
|
||||
assert message.topic.endswith("lio_pose")
|
||||
assert message.payload == b"pose"
|
||||
assert message.received_at_epoch_ns == 123_000_000_000
|
||||
assert message.received_monotonic_ns == 456
|
||||
|
||||
|
||||
def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "payloads.tsv"
|
||||
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")
|
||||
|
||||
assert detect_replay_format(capture) == "legacy_tsv"
|
||||
message = list(iter_replay_messages(capture))[0]
|
||||
assert message.received_at_epoch_ns == 1_784_124_315_186_225_000
|
||||
assert message.payload == b"\x00\x01\xaa\xff"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
b"1\tonly\tthree\n",
|
||||
b"bad\ttopic\t1\t00\n",
|
||||
b"1\ttopic\t2\t00\n",
|
||||
b"1\ttopic\t1\tzz\n",
|
||||
],
|
||||
)
|
||||
def test_legacy_tsv_rejects_unreviewed_shapes(tmp_path: Path, line: bytes) -> None:
|
||||
capture = tmp_path / "payloads.tsv"
|
||||
capture.write_bytes(line)
|
||||
with pytest.raises(ReplayFormatError):
|
||||
list(iter_replay_messages(capture))
|
||||
177
uv.lock
177
uv.lock
|
|
@ -11,6 +11,28 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bleak"
|
||||
version = "3.0.2"
|
||||
|
|
@ -35,6 +57,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
|
|
@ -58,6 +92,60 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.139.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foxglove-sdk"
|
||||
version = "0.25.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/25/a0bb15179539bf144f8bf13b176518886191710b40fe4a8173b461d90313/foxglove_sdk-0.25.3.tar.gz", hash = "sha256:32f1066401c37538d478b4a8ecaaae4e9dd019f9f75d3ef47494bfe7d4273dee", size = 549908, upload-time = "2026-06-25T00:24:26.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/48/b8d36edbaeef75f52197fd08fe5e431cb73ab32b975377facb468da57b22/foxglove_sdk-0.25.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9667615b342651b3960dd82e6a8403678c1fc701799bf0b28e41be0392eb507d", size = 17812618, upload-time = "2026-06-25T00:24:06.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/49/2dee8063e2bf2d8cf0db97e732a66a1c75f1faa519ff87f86c9f735d0944/foxglove_sdk-0.25.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d88faf89da6da3d904b6aa1371b809f4d9dcabb8981fcedcfafccdf07b200a49", size = 16379537, upload-time = "2026-06-25T00:24:09.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3b/2bf924ee1ba5bd9eb49322d8c04850abc444e3323baba425d48112d4cbde/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcdcb054c198e892ac271c88e685e292d1ef8f1f21a14e2d3a9de5988474e60a", size = 2261344, upload-time = "2026-06-25T00:24:10.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/dc/1765ec5b19b0ddecdaff1dad1bc5a332668784e7d99924ebdf003d7e73ad/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:510c7fe47c45428e9d76eddfae1b9966b7976819a3a194a00f7e8d0f4b3eacc3", size = 2178418, upload-time = "2026-06-25T00:24:12.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/15/a9182e0d3bbee09261e79249a9d608af4739e6e25791bcd59b23b6522b34/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18d7dd5e61afc65163438b858a70bec3a30126de1664a97358d4403e20db5eb1", size = 2209079, upload-time = "2026-06-25T00:24:13.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/7b/82cfe8ee2bdef5a0f81c9a9993f587bb322848475f8b94d6d814fef9675a/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e00cdb87d0a1623cd60a66f7fd531bf492164433e313d4c7f77cb42fdd613420", size = 18620792, upload-time = "2026-06-25T00:24:15.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/37/6313a16cecd97b0b733967a92f85e4d1929cbbd521433a4db5e070aa850a/foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bcc894b88188d8169973cfbb1370300f671760adea9d6e9447e5a03b2289527d", size = 19220466, upload-time = "2026-06-25T00:24:17.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/68/120868313bea126b893432f0a0b5143e4042d85946a8aaa4d32039e6484d/foxglove_sdk-0.25.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:139bb1f2ae284673ecb17a1b47955989a58ab3a8639c1fd0f6d3f3afbbbd0fda", size = 2445762, upload-time = "2026-06-25T00:24:19.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/79/3d0744e87c6bd1d72417574df84d94d1b5711c2b0196a3325f7b52c9930d/foxglove_sdk-0.25.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3d9e671be05e7539a99d84dbd254581616f7ebae8ee8430766788dd6093d58a3", size = 2452345, upload-time = "2026-06-25T00:24:20.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/bd/93dd3cd685b4605877facc12b4f1b7ffb4894cd5040813c9c6fd262e2e55/foxglove_sdk-0.25.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2dbdef29d2b6115bbbc478f309ac4937fb8765b42681537db99a15282ebaac6f", size = 2468866, upload-time = "2026-06-25T00:24:21.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/ff/c5f4f8f75c4a6c8b0822da3bf8af8bcb3db27403439dc032c2d7f0b890dc/foxglove_sdk-0.25.3-cp310-abi3-win32.whl", hash = "sha256:a2a1716cade8a9dd842df18f7b1306a9931fa114f485fc552fc14c44ddba8351", size = 1537215, upload-time = "2026-06-25T00:24:22.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/e1/8ccb4a985c5baf82947e48cff18483c2125ea11e55e8a28950730ab6c065/foxglove_sdk-0.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:ac881ae307ba432766e6141d9098ced2059838226d225fbeb20de1c57d782a9f", size = 16496466, upload-time = "2026-06-25T00:24:24.906Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
|
|
@ -162,10 +250,13 @@ version = "0.1.0"
|
|||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "bleak" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "foxglove-sdk" },
|
||||
{ name = "lz4" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
|
|
@ -178,10 +269,13 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "bleak", specifier = "==3.0.2" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
||||
{ name = "lz4", specifier = ">=4.4,<5" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||
{ name = "rich", specifier = ">=13.9,<15" },
|
||||
{ name = "typer", specifier = ">=0.15,<1" },
|
||||
{ name = "uvicorn", specifier = ">=0.35,<1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
|
@ -227,6 +321,51 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
|
|
@ -346,6 +485,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.26.8"
|
||||
|
|
@ -370,6 +522,31 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.51.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-runtime"
|
||||
version = "3.2.1"
|
||||
|
|
|
|||
Loading…
Reference in New Issue