feat: localize K1 console to Russian

This commit is contained in:
DCCONSTRUCTIONS 2026-07-15 22:08:18 +03:00
parent 6be96f0b85
commit ea834e09e8
9 changed files with 431 additions and 194 deletions

View File

@ -1,14 +1,14 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<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."
content="Локальная консоль подключения XGRIDS Lixel K1 и просмотра облака точек в реальном времени."
/>
<meta name="theme-color" content="#08090b" />
<title>K1 Live Console · NODE.DC</title>
<title>K1 · Облако точек в реальном времени · NODE.DC</title>
</head>
<body>
<div id="root"></div>

View File

@ -23,33 +23,33 @@ type ConsoleSection = "monitor" | "connect" | "session";
type SessionIntent = "live" | "replay";
const sectionItems = [
{ value: "monitor", label: "Monitor" },
{ value: "connect", label: "Connect" },
{ value: "session", label: "Session" },
{ value: "monitor", label: "Монитор" },
{ value: "connect", label: "Подключение" },
{ value: "session", label: "Поток" },
] as const;
const sessionItems = [
{ value: "live", label: "Live scanner" },
{ value: "replay", label: "Replay capture" },
{ value: "live", label: "Сканер в реальном времени" },
{ value: "replay", label: "Повтор записи" },
] 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",
idle: "Ожидание",
scanning: "Поиск по BLE",
device_selected: "K1 выбран",
provisioning: "Передача настроек WiFi",
connecting: "Подключение",
connected: "K1 подключён",
starting_live: "Запуск потока",
live: "Поток в реальном времени",
replay: "Повтор записи",
stopping: "Остановка",
error: "Ошибка",
};
function phaseLabel(phase: string | null | undefined): string {
if (!phase) return "No state";
return phaseLabels[phase] ?? phase.replaceAll("_", " ");
if (!phase) return "Нет состояния";
return phaseLabels[phase] ?? "Неизвестное состояние";
}
function phaseTone(phase: string | null | undefined): StatusTone {
@ -64,13 +64,22 @@ function phaseTone(phase: string | null | undefined): StatusTone {
function backendLabel(status: BackendStatus): string {
return {
checking: "Checking API",
online: "API online",
degraded: "API degraded",
offline: "API offline",
checking: "Проверка API",
online: "API работает",
degraded: "API работает частично",
offline: "API недоступен",
}[status];
}
function eventStatusLabel(status: string): string {
return {
connecting: "подключение",
open: "подключён",
closed: "закрыт",
error: "ошибка",
}[status] ?? "неизвестно";
}
function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning";
@ -97,7 +106,7 @@ function pipelineLatency(metrics: K1Metrics | undefined): number | null {
function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("en-US", {
return value.toLocaleString("ru-RU", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
@ -180,19 +189,19 @@ function DeviceRow({
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<strong>{device.name?.trim() || "K1 candidate"}</strong>
<strong>{device.name?.trim() || "Возможный K1"}</strong>
<code>{device.device_id}</code>
</div>
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} dBm`}</span>
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button
size="compact"
variant={selected ? "accent" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Selected" : "Select"}
{selected ? "Выбран" : "Выбрать"}
</Button>
</div>
</div>
@ -203,17 +212,17 @@ function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Recent measured pipeline latency samples">
<div className="latency-trace" aria-label="Последние измерения задержки потока">
{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`}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>No latency samples yet. The chart remains empty until the backend reports metrics.</p>
<p>Измерений пока нет. График появится после получения первых данных от K1.</p>
)}
</div>
);
@ -226,13 +235,13 @@ function FoxglovePanel({ state }: { state: ConsoleState | null }) {
<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>
<span className="section-eyebrow">ШАГ 06 · 3D-ПРОСМОТР</span>
<h2>Облако точек и траектория</h2>
<p>
Foxglove renders the live topics. This console only reports transport state and never
substitutes a simulated cloud when the scanner is silent.
Foxglove показывает реальные данные сканера. Если K1 ничего не передаёт, здесь не будет
подставного или сгенерированного облака.
</p>
<div className="topic-list" aria-label="Foxglove topics">
<div className="topic-list" aria-label="Темы Foxglove">
<code>/k1/points</code>
<code>/k1/pose</code>
<code>/k1/trajectory</code>
@ -240,7 +249,7 @@ function FoxglovePanel({ state }: { state: ConsoleState | null }) {
</div>
<div className="foxglove-panel__action">
<StatusBadge tone={viewerUrl ? "accent" : "neutral"}>
{viewerUrl ? "Viewer ready" : "Viewer URL unavailable"}
{viewerUrl ? "Просмотр готов" : "Сначала запустите поток"}
</StatusBadge>
<Button
variant="accent"
@ -248,7 +257,7 @@ function FoxglovePanel({ state }: { state: ConsoleState | null }) {
disabled={!viewerUrl}
onClick={() => viewerUrl && window.open(viewerUrl, "_blank", "noopener,noreferrer")}
>
Open Foxglove 3D
Открыть облако точек в Foxglove
</Button>
</div>
</GlassSurface>
@ -296,14 +305,15 @@ export default function App() {
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
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),
@ -350,10 +360,10 @@ export default function App() {
</span>
}
brandLabel="NODE.DC"
left={<span className="product-label">K1 LIVE CONSOLE</span>}
left={<span className="product-label">K1 · ОБЛАКО ТОЧЕК В РЕАЛЬНОМ ВРЕМЕНИ</span>}
center={
<HeaderNavigation
label="Console sections"
label="Разделы консоли"
value={activeSection}
items={sectionItems}
onChange={goToSection}
@ -361,7 +371,7 @@ export default function App() {
}
right={
<HeaderProfile>
<HeaderProfileButton onClick={() => void refresh()} title="Refresh API state">
<HeaderProfileButton onClick={() => void refresh()} title="Обновить состояние API">
<span className="api-dot" data-status={backendStatus} aria-hidden="true" />
{backendLabel(backendStatus)}
</HeaderProfileButton>
@ -383,15 +393,15 @@ export default function App() {
<aside className="error-banner" role="alert">
<Icon name="alert" size={18} />
<div>
<strong>Local API operation failed</strong>
<strong>Локальная операция завершилась ошибкой</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>
@ -399,42 +409,75 @@ export default function App() {
<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>
<span className="section-eyebrow">ЛОКАЛЬНЫЙ МОСТ K1 FOXGLOVE</span>
<h1>K1 выключен? Начните с первого шага.</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>
<span>{state?.message || "Ожидаем состояние локального сервера."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Live stream metrics">
<section className="quick-path" aria-label="Путь от выключенного K1 до облака точек">
<div>
<span>01</span>
<strong>Включить K1</strong>
<small>Дождаться ровного зелёного индикатора</small>
</div>
<div>
<span>02</span>
<strong>Найти по BLE</strong>
<small>Нажать «Найти K1» и выбрать сканер</small>
</div>
<div>
<span>03</span>
<strong>Передать WiFi</strong>
<small>Ввести сеть MacBook и подключить K1</small>
</div>
<div>
<span>04</span>
<strong>Запустить приём</strong>
<small>Поднять MQTT Foxglove поток</small>
</div>
<div>
<span>05</span>
<strong>Начать сканирование</strong>
<small>Дважды нажать физическую кнопку K1</small>
</div>
<div>
<span>06</span>
<strong>Открыть 3D</strong>
<small>Увидеть облако точек и траекторию</small>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="PIPELINE LATENCY"
eyebrow="ЗАДЕРЖКА ПОТОКА"
value={formatNumber(latency)}
unit="ms"
detail="MQTT receive → Foxglove publish"
unit="мс"
detail="Приём MQTT → публикация в Foxglove"
/>
<MetricCard
eyebrow="FRAME RATE"
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="fps"
detail="Latest backend measurement"
unit="кадр/с"
detail="Последнее измерение сервера"
/>
<MetricCard
eyebrow="POINTS / FRAME"
value={points === null ? "—" : points.toLocaleString("en-US", { maximumFractionDigits: 0 })}
detail="Published point count"
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Количество опубликованных точек"
/>
<MetricCard
eyebrow="DROPPED PREVIEW"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("en-US", { maximumFractionDigits: 0 })}
detail="Frames omitted before display"
eyebrow="ПРОПУЩЕНО В ПРОСМОТРЕ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Кадры пропущены только в визуализации"
/>
</section>
@ -442,8 +485,8 @@ export default function App() {
<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>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span>
<h2>Подключите K1 к сети</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
</header>
@ -451,26 +494,38 @@ export default function App() {
<div className="wizard-list">
<WizardStep
number="01"
title="Scanner power"
status={powerConfirmed ? "Confirmed" : "Manual check"}
title="Включите сканер"
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
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."
label="K1 включён, индикатор горит ровно зелёным"
description="Если K1 выключен: нажмите кнопку питания и дождитесь ровного зелёного света. Затем поставьте галочку."
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"}
title="Найдите K1 по Bluetooth (BLE)"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "K1 выбран"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Scanning is active discovery. It does not send provisioning writes.
Поиск занимает 6 секунд и ничего не записывает в настройки сканера.
</p>
<Button
width="full"
@ -479,7 +534,7 @@ export default function App() {
disabled={!powerConfirmed || isBusy}
onClick={() => void scan()}
>
{pendingAction === "scan" ? "Scanning for 6 seconds…" : "Scan for K1 devices"}
{pendingAction === "scan" ? "Ищем K1 — 6 секунд…" : "Найти K1"}
</Button>
<div className="device-list">
{devices.length ? (
@ -493,7 +548,8 @@ export default function App() {
))
) : (
<div className="empty-device-list">
No devices reported. Confirm scanner power, then run the real BLE scan.
K1 пока не найден. Убедитесь, что он включён и горит ровно зелёным, затем
нажмите «Найти K1».
</div>
)}
</div>
@ -501,41 +557,33 @@ export default function App() {
<WizardStep
number="03"
title="Local Wi-Fi"
status={credentialsReady ? "Ready" : "Required"}
tone={credentialsReady ? "success" : "neutral"}
title="Передайте K1 настройки WiFi"
status={state?.k1_ip ? "Подключён" : "Не подключён"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Network name"
label="Название сети WiFi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Workshop Wi-Fi"
placeholder="Сеть, к которой подключён MacBook"
/>
<TextField
label="Network password"
hint="Kept in memory only"
label="Пароль WiFi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Enter password"
placeholder="Введите пароль"
/>
</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>
<span>Сканер</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите K1"}</strong>
</div>
<Button
width="full"
@ -544,11 +592,11 @@ export default function App() {
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Provisioning…" : "Provision Wi-Fi & connect"}
{pendingAction === "connect" ? "Подключаем K1…" : "Подключить K1 к WiFi"}
</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.
Пароль передаётся только локальному серверу на этом MacBook, не записывается в
репозиторий и удаляется из формы после успешного подключения.
</p>
</WizardStep>
</div>
@ -561,25 +609,25 @@ export default function App() {
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">LIVE STATUS</span>
<h2>Transport</h2>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Подключение</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>
{backendLabel(backendStatus)}
</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Events socket">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatus}
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Source">{sourceLabel}</DetailRow>
<DetailRow label="K1 address">
<code>{state?.k1_ip || "Not reported"}</code>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес K1">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
<DetailRow label="Foxglove bridge">
<code>{state?.foxglove_ws_url || "Not reported"}</code>
<DetailRow label="Мост Foxglove">
<code>{state?.foxglove_ws_url || "Не запущен"}</code>
</DetailRow>
</dl>
</GlassSurface>
@ -587,17 +635,17 @@ export default function App() {
<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>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Задержка потока</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>ms</span>
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Oldest</span>
<span>Latest</span>
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
@ -605,8 +653,16 @@ export default function App() {
<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>
<span className="section-eyebrow">
{sessionIntent === "live"
? "ШАГИ 0405 · ЗАПУСК ДАННЫХ"
: "СЛУЖЕБНЫЙ РЕЖИМ · ПОВТОР ЗАПИСИ"}
</span>
<h2>
{sessionIntent === "live"
? "Запустите поток K1"
: "Повторите сохранённую запись"}
</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
@ -614,7 +670,7 @@ export default function App() {
</header>
<SegmentedControl
label="Data source"
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
@ -623,35 +679,40 @@ export default function App() {
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="K1 host override"
hint="Optional"
label="Адрес K1"
hint="Обычно заполняется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Use connected K1 address"}
placeholder={state?.k1_ip || "Сначала подключите K1 к WiFi"}
/>
<Button
variant="accent"
icon={<Icon name="activity" />}
disabled={isBusy}
disabled={isBusy || !liveTargetReady}
onClick={submitLive}
>
{pendingAction === "live" ? "Starting live…" : "Start live stream"}
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "Шаг 05: после запуска дважды нажмите физическую кнопку K1. Когда лидар зашумит и зелёный индикатор начнёт мигать, облако точек пойдёт в Foxglove."
: "Сначала выполните шаг 03: подключите K1 к Wi-Fi слева или укажите его локальный адрес."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Capture path"
hint="Backend-local path"
label="Путь к записи"
hint="Файл внутри репозитория"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../mqtt.raw.k1mqtt or capture.tsv"
placeholder="sessions/.../mqtt.raw.k1mqtt или capture.tsv"
/>
<TextField
label="Playback speed"
hint="Multiplier"
label="Скорость повтора"
hint="Множитель"
type="number"
min="0.1"
step="0.1"
@ -660,8 +721,8 @@ export default function App() {
/>
<Checker
checked={replayLoop}
label="Loop replay"
description="Restart the same capture after its last frame."
label="Повторять по кругу"
description="После последнего кадра начать запись заново."
onChange={setReplayLoop}
/>
<Button
@ -670,22 +731,22 @@ export default function App() {
disabled={isBusy || replayPath.trim().length === 0}
onClick={submitReplay}
>
{pendingAction === "replay" ? "Starting replay…" : "Start replay"}
{pendingAction === "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.
Статус меняется только после ответа локального сервера. Для реального потока
сначала подключите K1 к WiFi слева.
</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Stopping…" : "Stop session"}
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>

View File

@ -79,7 +79,7 @@ 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.");
throw new ApiError("Локальный сервер вернул некорректное состояние.");
}
return value as ConsoleState;
@ -97,12 +97,8 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
...init?.headers,
},
});
} catch (error) {
throw new ApiError(
error instanceof Error
? `Cannot reach the local K1 API: ${error.message}`
: "Cannot reach the local K1 API.",
);
} catch {
throw new ApiError("Не удалось подключиться к локальному API K1.");
}
const bodyText = await response.text();
@ -122,9 +118,9 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
? body.detail
: typeof body === "string" && body.trim()
? body.trim()
: response.statusText;
: `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`;
throw new ApiError(
detail || `K1 API request failed with HTTP ${response.status}.`,
detail || `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`,
response.status,
);
}
@ -149,7 +145,7 @@ 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.");
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
}
return payload as HealthResponse;
},
@ -206,5 +202,5 @@ export function openEventSocket(
socket.addEventListener("error", () => onStatus("error"));
socket.addEventListener("close", () => onStatus("closed"));
return () => socket.close(1000, "K1 console unmounted");
return () => socket.close(1000, "Консоль K1 закрыта");
}

View File

@ -211,6 +211,43 @@ input {
text-align: right;
}
.quick-path {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.7rem;
margin-top: 1rem;
}
.quick-path > div {
display: grid;
min-height: 6.8rem;
align-content: start;
gap: 0.42rem;
border: 1px solid var(--k1-hairline);
border-radius: 1rem;
background: var(--k1-panel-soft);
padding: 0.85rem;
}
.quick-path span {
color: rgb(var(--nodedc-accent-rgb));
font-size: 0.58rem;
font-weight: 850;
letter-spacing: 0.12em;
}
.quick-path strong {
color: var(--nodedc-text-primary);
font-size: 0.74rem;
line-height: 1.25;
}
.quick-path small {
color: var(--nodedc-text-muted);
font-size: 0.63rem;
line-height: 1.4;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@ -483,6 +520,7 @@ input {
.foxglove-panel {
position: relative;
order: 2;
display: grid;
min-height: 20rem;
grid-template-columns: minmax(0, 1fr) auto;
@ -565,6 +603,7 @@ input {
.monitor-grid {
display: grid;
order: 3;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
@ -575,6 +614,10 @@ input {
background: var(--k1-panel);
}
.session-panel {
order: 1;
}
.detail-list {
display: grid;
gap: 0;
@ -700,6 +743,16 @@ input {
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto;
}
.live-instruction {
grid-column: 1 / -1;
margin: 0;
border-left: 2px solid rgb(var(--nodedc-accent-rgb));
color: var(--nodedc-text-secondary);
padding: 0.15rem 0 0.15rem 0.75rem;
font-size: 0.7rem;
line-height: 1.5;
}
.session-footer {
display: flex;
align-items: center;
@ -719,6 +772,10 @@ input {
}
@media (max-width: 1320px) {
.quick-path {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -746,14 +803,6 @@ input {
grid-template-columns: 1fr;
}
.connection-panel {
order: 2;
}
.monitor-column {
order: 1;
}
.foxglove-panel {
min-height: 18rem;
}
@ -801,6 +850,12 @@ input {
margin-top: 0.7rem;
}
.quick-path {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin-top: 0.7rem;
}
.metric-card {
min-height: 8.2rem;
}
@ -838,6 +893,10 @@ input {
}
@media (max-width: 480px) {
.quick-path {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: 1fr;
}

View File

@ -20,7 +20,7 @@ function messageFor(error: unknown): string {
? `${error.message} (HTTP ${error.status})`
: error.message;
}
return error instanceof Error ? error.message : "The local K1 API request failed.";
return "Запрос к локальному API K1 завершился ошибкой.";
}
function measuredLatency(state: ConsoleState | null): number | null {

View File

@ -18,7 +18,7 @@ dependencies = [
"paho-mqtt>=2.1,<3",
"rich>=13.9,<15",
"typer>=0.15,<1",
"uvicorn>=0.35,<1",
"uvicorn[standard]>=0.35,<1",
]
[project.scripts]

View File

@ -45,7 +45,7 @@ class VisualizationRuntime:
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._message = "Готово. Включите K1 и начните с поиска по Bluetooth."
self._source_mode: SourceMode = "idle"
self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None
@ -65,22 +65,22 @@ class VisualizationRuntime:
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")
raise ValueError("файл записи не найден на этом MacBook")
if not math.isfinite(speed) or speed < 0:
raise ValueError("replay speed must be finite and non-negative")
raise ValueError("скорость повтора должна быть неотрицательным числом")
# 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
raise ValueError("в записи нет сообщений") from exc
finally:
iterator.close()
self._start(
source_mode="replay",
phase="replay",
message=f"Starting replay: {resolved.name}",
message=f"Запускаем повтор записи: {resolved.name}",
target=lambda: self._run_replay(resolved, speed=speed, loop=loop),
)
@ -92,11 +92,11 @@ class VisualizationRuntime:
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")
raise ValueError("длительность приёма должна быть больше нуля")
self._start(
source_mode="live",
phase="starting_live",
message="Starting read-only MQTT capture and Foxglove bridge.",
message="Запускаем приём MQTT и мост Foxglove.",
target=lambda: self._run_live(
host,
out_dir.expanduser().resolve(),
@ -111,11 +111,11 @@ class VisualizationRuntime:
if thread is None or not thread.is_alive():
self._phase = "idle"
self._source_mode = "idle"
self._message = "No active visualization session."
self._message = "Активного потока нет."
notify_only = True
else:
self._phase = "stopping"
self._message = "Stopping source after preserving queued evidence."
self._message = "Останавливаем поток и сохраняем полученные данные."
self._stop_event.set()
self._notify()
if notify_only:
@ -133,7 +133,7 @@ class VisualizationRuntime:
) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
raise RuntimeError("a visualization session is already active")
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
self._stop_event = threading.Event()
self._metrics = BridgeMetrics()
self._phase = phase
@ -157,7 +157,7 @@ class VisualizationRuntime:
count = 0
for message in iter_replay_messages(path):
if self._stop_event.is_set():
return "Replay stopped by operator."
return "Повтор записи остановлен."
source_ns = (
message.received_monotonic_ns
if message.received_monotonic_ns is not None
@ -169,12 +169,12 @@ class VisualizationRuntime:
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."
return "Повтор записи остановлен."
put(message)
count += 1
if not loop:
return f"Replay completed: {count} MQTT messages."
return "Replay stopped by operator."
return f"Повтор завершён: обработано сообщений MQTT — {count}."
return "Повтор записи остановлен."
self._run_pipeline(produce, running_phase="replay")
@ -199,15 +199,12 @@ class VisualizationRuntime:
out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds,
on_ready=lambda: self._set_running(
"live", "MQTT subscribed; waiting for K1 scan frames."
"live", "Приём запущен. Теперь дважды нажмите физическую кнопку K1."
),
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."
)
return f"Приём остановлен. Сохранено сообщений: {summary['message_count']}."
try:
self._run_pipeline(produce, running_phase="live")
@ -251,7 +248,7 @@ class VisualizationRuntime:
self._foxglove_viewer_url = bridge.viewer_url
if self._phase != "stopping":
self._phase = running_phase
self._message = "Foxglove bridge ready; source is active."
self._message = "Мост Foxglove готов; источник данных запущен."
publisher_ready.set()
self._notify()
while not source_done.is_set() or not messages.empty():
@ -278,33 +275,33 @@ class VisualizationRuntime:
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.")
self._finish_error("Мост Foxglove не запустился за 15 секунд.")
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]}"
f"Ошибка моста Foxglove: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
source_done.set()
return
final_message = "Session stopped."
final_message = "Поток остановлен."
try:
final_message = producer(enqueue)
except CaptureError as exc:
self._finish_error(f"Live MQTT capture failed: {exc}")
self._finish_error(f"Ошибка приёма MQTT: {exc}")
except (OSError, RuntimeError, ValueError) as exc:
self._finish_error(f"Source failed: {type(exc).__name__}: {exc}")
self._finish_error(f"Ошибка источника: {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.")
self._finish_error("Очередь публикации не завершилась за 15 секунд.")
elif publisher_error:
self._finish_error(
f"Publisher failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
elif self.snapshot()["phase"] != "error":
self._finish_idle(final_message)

View File

@ -80,7 +80,7 @@ class ConsoleService:
message = runtime["message"]
elif selected_device_id is not None:
phase = "device_selected"
message = "K1 selected; Wi-Fi credentials have not been written."
message = "K1 выбран. Теперь введите название и пароль Wi-Fi."
else:
phase = "idle"
message = runtime["message"]
@ -107,7 +107,7 @@ class ConsoleService:
}
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
self._set_operation("scanning", "Scanning for nearby K1 BLE advertisements.")
self._set_operation("scanning", "Ищем K1 по Bluetooth (BLE)…")
try:
result = await scan(duration_seconds)
devices = [
@ -125,7 +125,9 @@ class ConsoleService:
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)."
self._operation_message = (
f"Поиск завершён. Найдено устройств K1: {len(devices)}."
)
finally:
with self._lock:
self._operation_phase = None
@ -134,10 +136,10 @@ class ConsoleService:
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")
raise ValueError("сначала найдите и выберите K1 через Bluetooth")
self._set_operation(
"provisioning",
"Sending one explicitly requested Wi-Fi provisioning write.",
"Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.",
)
session_dir = _new_operation_session_dir(
self.repository_root,
@ -170,12 +172,12 @@ class ConsoleService:
)
if ipv4 is None:
raise RuntimeError(
"K1 did not report a non-AP LAN address; no automatic retry was made"
"K1 не сообщил адрес в локальной сети; автоматического повтора не было"
)
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."
self._operation_message = "K1 подключён к Wi-Fi и сообщил локальный адрес."
finally:
password = ""
with self._lock:
@ -185,7 +187,9 @@ class ConsoleService:
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")
raise ValueError(
"сначала подключите K1 к Wi-Fi или укажите его локальный адрес"
)
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)
@ -194,7 +198,7 @@ class ConsoleService:
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")
raise ValueError("файл записи должен находиться внутри репозитория")
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
return self.state()
@ -238,7 +242,7 @@ 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
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
@app.post("/api/connect")
@ -246,7 +250,10 @@ 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
raise HTTPException(
status_code=502,
detail=f"Ошибка подключения K1 к Wi-Fi: {exc}",
) from exc
@app.post("/api/session/live")

121
uv.lock
View File

@ -137,6 +137,21 @@ 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 = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
]
[[package]]
name = "idna"
version = "3.18"
@ -256,7 +271,7 @@ dependencies = [
{ name = "paho-mqtt" },
{ name = "rich" },
{ name = "typer" },
{ name = "uvicorn" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
@ -275,7 +290,7 @@ requires-dist = [
{ 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" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
]
[package.metadata.requires-dev]
@ -438,6 +453,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
]
[[package]]
name = "rich"
version = "14.3.4"
@ -547,6 +589,81 @@ 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.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
]
[[package]]
name = "websockets"
version = "16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" },
{ url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" },
{ url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" },
{ url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" },
{ url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" },
{ url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" },
{ url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" },
{ url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" },
{ url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" },
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
]
[[package]]
name = "winrt-runtime"
version = "3.2.1"