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> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" 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" /> <meta name="theme-color" content="#08090b" />
<title>K1 Live Console · NODE.DC</title> <title>K1 · Облако точек в реальном времени · NODE.DC</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

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

View File

@ -79,7 +79,7 @@ function unwrapState(payload: unknown): ConsoleState {
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload; const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
if (!isRecord(value)) { if (!isRecord(value)) {
throw new ApiError("Backend returned an invalid state snapshot."); throw new ApiError("Локальный сервер вернул некорректное состояние.");
} }
return value as ConsoleState; return value as ConsoleState;
@ -97,12 +97,8 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
...init?.headers, ...init?.headers,
}, },
}); });
} catch (error) { } catch {
throw new ApiError( throw new ApiError("Не удалось подключиться к локальному API K1.");
error instanceof Error
? `Cannot reach the local K1 API: ${error.message}`
: "Cannot reach the local K1 API.",
);
} }
const bodyText = await response.text(); const bodyText = await response.text();
@ -122,9 +118,9 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
? body.detail ? body.detail
: typeof body === "string" && body.trim() : typeof body === "string" && body.trim()
? body.trim() ? body.trim()
: response.statusText; : `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`;
throw new ApiError( throw new ApiError(
detail || `K1 API request failed with HTTP ${response.status}.`, detail || `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`,
response.status, response.status,
); );
} }
@ -149,7 +145,7 @@ export const api = {
async getHealth(): Promise<HealthResponse> { async getHealth(): Promise<HealthResponse> {
const payload = await requestJson("/api/health"); const payload = await requestJson("/api/health");
if (!isRecord(payload)) { if (!isRecord(payload)) {
throw new ApiError("Backend returned an invalid health response."); throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
} }
return payload as HealthResponse; return payload as HealthResponse;
}, },
@ -206,5 +202,5 @@ export function openEventSocket(
socket.addEventListener("error", () => onStatus("error")); socket.addEventListener("error", () => onStatus("error"));
socket.addEventListener("close", () => onStatus("closed")); 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; 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 { .metrics-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@ -483,6 +520,7 @@ input {
.foxglove-panel { .foxglove-panel {
position: relative; position: relative;
order: 2;
display: grid; display: grid;
min-height: 20rem; min-height: 20rem;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@ -565,6 +603,7 @@ input {
.monitor-grid { .monitor-grid {
display: grid; display: grid;
order: 3;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem; gap: 1.25rem;
} }
@ -575,6 +614,10 @@ input {
background: var(--k1-panel); background: var(--k1-panel);
} }
.session-panel {
order: 1;
}
.detail-list { .detail-list {
display: grid; display: grid;
gap: 0; gap: 0;
@ -700,6 +743,16 @@ input {
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto; 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 { .session-footer {
display: flex; display: flex;
align-items: center; align-items: center;
@ -719,6 +772,10 @@ input {
} }
@media (max-width: 1320px) { @media (max-width: 1320px) {
.quick-path {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metrics-grid { .metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@ -746,14 +803,6 @@ input {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.connection-panel {
order: 2;
}
.monitor-column {
order: 1;
}
.foxglove-panel { .foxglove-panel {
min-height: 18rem; min-height: 18rem;
} }
@ -801,6 +850,12 @@ input {
margin-top: 0.7rem; margin-top: 0.7rem;
} }
.quick-path {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin-top: 0.7rem;
}
.metric-card { .metric-card {
min-height: 8.2rem; min-height: 8.2rem;
} }
@ -838,6 +893,10 @@ input {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.quick-path {
grid-template-columns: 1fr;
}
.metrics-grid { .metrics-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

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

View File

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

View File

@ -45,7 +45,7 @@ class VisualizationRuntime:
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._phase: RuntimePhase = "idle" self._phase: RuntimePhase = "idle"
self._message = "Ready for a live scanner or replay capture." self._message = "Готово. Включите K1 и начните с поиска по Bluetooth."
self._source_mode: SourceMode = "idle" self._source_mode: SourceMode = "idle"
self._foxglove_ws_url: str | None = None self._foxglove_ws_url: str | None = None
self._foxglove_viewer_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: def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
resolved = path.expanduser().resolve() resolved = path.expanduser().resolve()
if not resolved.is_file(): 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: 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. # Validate the reviewed shape before changing runtime state.
iterator = iter_replay_messages(resolved) iterator = iter_replay_messages(resolved)
try: try:
next(iterator) next(iterator)
except StopIteration as exc: except StopIteration as exc:
raise ValueError("replay capture contains no messages") from exc raise ValueError("в записи нет сообщений") from exc
finally: finally:
iterator.close() iterator.close()
self._start( self._start(
source_mode="replay", source_mode="replay",
phase="replay", phase="replay",
message=f"Starting replay: {resolved.name}", message=f"Запускаем повтор записи: {resolved.name}",
target=lambda: self._run_replay(resolved, speed=speed, loop=loop), target=lambda: self._run_replay(resolved, speed=speed, loop=loop),
) )
@ -92,11 +92,11 @@ class VisualizationRuntime:
duration_seconds: float = 3600.0, duration_seconds: float = 3600.0,
) -> None: ) -> None:
if not math.isfinite(duration_seconds) or duration_seconds <= 0: 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( self._start(
source_mode="live", source_mode="live",
phase="starting_live", phase="starting_live",
message="Starting read-only MQTT capture and Foxglove bridge.", message="Запускаем приём MQTT и мост Foxglove.",
target=lambda: self._run_live( target=lambda: self._run_live(
host, host,
out_dir.expanduser().resolve(), out_dir.expanduser().resolve(),
@ -111,11 +111,11 @@ class VisualizationRuntime:
if thread is None or not thread.is_alive(): if thread is None or not thread.is_alive():
self._phase = "idle" self._phase = "idle"
self._source_mode = "idle" self._source_mode = "idle"
self._message = "No active visualization session." self._message = "Активного потока нет."
notify_only = True notify_only = True
else: else:
self._phase = "stopping" self._phase = "stopping"
self._message = "Stopping source after preserving queued evidence." self._message = "Останавливаем поток и сохраняем полученные данные."
self._stop_event.set() self._stop_event.set()
self._notify() self._notify()
if notify_only: if notify_only:
@ -133,7 +133,7 @@ class VisualizationRuntime:
) -> None: ) -> None:
with self._lock: with self._lock:
if self._thread is not None and self._thread.is_alive(): 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._stop_event = threading.Event()
self._metrics = BridgeMetrics() self._metrics = BridgeMetrics()
self._phase = phase self._phase = phase
@ -157,7 +157,7 @@ class VisualizationRuntime:
count = 0 count = 0
for message in iter_replay_messages(path): for message in iter_replay_messages(path):
if self._stop_event.is_set(): if self._stop_event.is_set():
return "Replay stopped by operator." return "Повтор записи остановлен."
source_ns = ( source_ns = (
message.received_monotonic_ns message.received_monotonic_ns
if message.received_monotonic_ns is not None 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) target_ns = replay_started_ns + int((source_ns - first_source_ns) / speed)
remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000 remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000
if remaining > 0 and self._stop_event.wait(remaining): if remaining > 0 and self._stop_event.wait(remaining):
return "Replay stopped by operator." return "Повтор записи остановлен."
put(message) put(message)
count += 1 count += 1
if not loop: if not loop:
return f"Replay completed: {count} MQTT messages." return f"Повтор завершён: обработано сообщений MQTT — {count}."
return "Replay stopped by operator." return "Повтор записи остановлен."
self._run_pipeline(produce, running_phase="replay") self._run_pipeline(produce, running_phase="replay")
@ -199,15 +199,12 @@ class VisualizationRuntime:
out_dir / "captures" / "mqtt_live", out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
on_ready=lambda: self._set_running( on_ready=lambda: self._set_running(
"live", "MQTT subscribed; waiting for K1 scan frames." "live", "Приём запущен. Теперь дважды нажмите физическую кнопку K1."
), ),
on_message_recorded=on_message, on_message_recorded=on_message,
should_stop=self._stop_event.is_set, should_stop=self._stop_event.is_set,
) )
return ( return f"Приём остановлен. Сохранено сообщений: {summary['message_count']}."
f"Live capture stopped: {summary['stop_reason']}; "
f"{summary['message_count']} messages preserved."
)
try: try:
self._run_pipeline(produce, running_phase="live") self._run_pipeline(produce, running_phase="live")
@ -251,7 +248,7 @@ class VisualizationRuntime:
self._foxglove_viewer_url = bridge.viewer_url self._foxglove_viewer_url = bridge.viewer_url
if self._phase != "stopping": if self._phase != "stopping":
self._phase = running_phase self._phase = running_phase
self._message = "Foxglove bridge ready; source is active." self._message = "Мост Foxglove готов; источник данных запущен."
publisher_ready.set() publisher_ready.set()
self._notify() self._notify()
while not source_done.is_set() or not messages.empty(): 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 = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
publisher.start() publisher.start()
if not publisher_ready.wait(timeout=15.0): 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() source_done.set()
self._stop_event.set() self._stop_event.set()
return return
if publisher_error: if publisher_error:
self._finish_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() source_done.set()
return return
final_message = "Session stopped." final_message = "Поток остановлен."
try: try:
final_message = producer(enqueue) final_message = producer(enqueue)
except CaptureError as exc: 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: 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: finally:
source_done.set() source_done.set()
publisher.join(timeout=15.0) publisher.join(timeout=15.0)
if publisher.is_alive(): if publisher.is_alive():
self._stop_event.set() self._stop_event.set()
self._finish_error("Publisher did not drain within 15 seconds.") self._finish_error("Очередь публикации не завершилась за 15 секунд.")
elif publisher_error: elif publisher_error:
self._finish_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": elif self.snapshot()["phase"] != "error":
self._finish_idle(final_message) self._finish_idle(final_message)

View File

@ -80,7 +80,7 @@ class ConsoleService:
message = runtime["message"] message = runtime["message"]
elif selected_device_id is not None: elif selected_device_id is not None:
phase = "device_selected" phase = "device_selected"
message = "K1 selected; Wi-Fi credentials have not been written." message = "K1 выбран. Теперь введите название и пароль Wi-Fi."
else: else:
phase = "idle" phase = "idle"
message = runtime["message"] message = runtime["message"]
@ -107,7 +107,7 @@ class ConsoleService:
} }
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: 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: try:
result = await scan(duration_seconds) result = await scan(duration_seconds)
devices = [ devices = [
@ -125,7 +125,9 @@ class ConsoleService:
self._devices = devices self._devices = devices
if len(devices) == 1: if len(devices) == 1:
self._selected_device_id = str(devices[0]["device_id"]) 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: finally:
with self._lock: with self._lock:
self._operation_phase = None self._operation_phase = None
@ -134,10 +136,10 @@ class ConsoleService:
async def connect(self, request: ConnectRequest) -> dict[str, Any]: async def connect(self, request: ConnectRequest) -> dict[str, Any]:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]} known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids: 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( self._set_operation(
"provisioning", "provisioning",
"Sending one explicitly requested Wi-Fi provisioning write.", "Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.",
) )
session_dir = _new_operation_session_dir( session_dir = _new_operation_session_dir(
self.repository_root, self.repository_root,
@ -170,12 +172,12 @@ class ConsoleService:
) )
if ipv4 is None: if ipv4 is None:
raise RuntimeError( raise RuntimeError(
"K1 did not report a non-AP LAN address; no automatic retry was made" "K1 не сообщил адрес в локальной сети; автоматического повтора не было"
) )
with self._lock: with self._lock:
self._selected_device_id = request.device_id self._selected_device_id = request.device_id
self._k1_ip = ipv4 self._k1_ip = ipv4
self._operation_message = "K1 joined the LAN and reported its private address." self._operation_message = "K1 подключён к Wi-Fi и сообщил локальный адрес."
finally: finally:
password = "" password = ""
with self._lock: with self._lock:
@ -185,7 +187,9 @@ class ConsoleService:
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
target = host or self.state()["k1_ip"] target = host or self.state()["k1_ip"]
if not isinstance(target, str) or not target: 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) target = validate_private_ipv4(target)
out_dir = new_live_session_dir(self.repository_root) out_dir = new_live_session_dir(self.repository_root)
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds) 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]: def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
replay_path = Path(path).expanduser().resolve() replay_path = Path(path).expanduser().resolve()
if not replay_path.is_relative_to(self.repository_root): 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) self.runtime.start_replay(replay_path, speed=speed, loop=loop)
return self.state() return self.state()
@ -238,7 +242,7 @@ async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
try: try:
return await service.scan_ble(request.duration_seconds) return await service.scan_ble(request.duration_seconds)
except (BleakError, OSError, RuntimeError, ValueError) as exc: 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") @app.post("/api/connect")
@ -246,7 +250,10 @@ async def connect(request: ConnectRequest) -> dict[str, Any]:
try: try:
return await service.connect(request) return await service.connect(request)
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc: 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") @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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.18"
@ -256,7 +271,7 @@ dependencies = [
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn" }, { name = "uvicorn", extra = ["standard"] },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -275,7 +290,7 @@ requires-dist = [
{ name = "paho-mqtt", specifier = ">=2.1,<3" }, { name = "paho-mqtt", specifier = ">=2.1,<3" },
{ name = "rich", specifier = ">=13.9,<15" }, { name = "rich", specifier = ">=13.9,<15" },
{ name = "typer", specifier = ">=0.15,<1" }, { name = "typer", specifier = ">=0.15,<1" },
{ name = "uvicorn", specifier = ">=0.35,<1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
] ]
[package.metadata.requires-dev] [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" }, { 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]] [[package]]
name = "rich" name = "rich"
version = "14.3.4" 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" }, { 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]] [[package]]
name = "winrt-runtime" name = "winrt-runtime"
version = "3.2.1" version = "3.2.1"