feat: localize K1 console to Russian
This commit is contained in:
parent
6be96f0b85
commit
ea834e09e8
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: "Передача настроек Wi‑Fi",
|
||||
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>Передать Wi‑Fi</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">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</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 настройки Wi‑Fi"
|
||||
status={state?.k1_ip ? "Подключён" : "Не подключён"}
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<TextField
|
||||
label="Network name"
|
||||
label="Название сети Wi‑Fi"
|
||||
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="Пароль Wi‑Fi"
|
||||
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 к Wi‑Fi"}
|
||||
</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"
|
||||
? "ШАГИ 04–05 · ЗАПУСК ДАННЫХ"
|
||||
: "СЛУЖЕБНЫЙ РЕЖИМ · ПОВТОР ЗАПИСИ"}
|
||||
</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 к Wi‑Fi"}
|
||||
/>
|
||||
<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 к Wi‑Fi слева.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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 закрыта");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
121
uv.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue