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
+24 -27
View File
@@ -45,7 +45,7 @@ class VisualizationRuntime:
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._phase: RuntimePhase = "idle"
self._message = "Ready for a live scanner or replay capture."
self._message = "Готово. Включите K1 и начните с поиска по Bluetooth."
self._source_mode: SourceMode = "idle"
self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None
@@ -65,22 +65,22 @@ class VisualizationRuntime:
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise ValueError("replay path must be an existing backend-local file")
raise ValueError("файл записи не найден на этом MacBook")
if not math.isfinite(speed) or speed < 0:
raise ValueError("replay speed must be finite and non-negative")
raise ValueError("скорость повтора должна быть неотрицательным числом")
# Validate the reviewed shape before changing runtime state.
iterator = iter_replay_messages(resolved)
try:
next(iterator)
except StopIteration as exc:
raise ValueError("replay capture contains no messages") from exc
raise ValueError("в записи нет сообщений") from exc
finally:
iterator.close()
self._start(
source_mode="replay",
phase="replay",
message=f"Starting replay: {resolved.name}",
message=f"Запускаем повтор записи: {resolved.name}",
target=lambda: self._run_replay(resolved, speed=speed, loop=loop),
)
@@ -92,11 +92,11 @@ class VisualizationRuntime:
duration_seconds: float = 3600.0,
) -> None:
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError("live duration must be finite and greater than zero")
raise ValueError("длительность приёма должна быть больше нуля")
self._start(
source_mode="live",
phase="starting_live",
message="Starting read-only MQTT capture and Foxglove bridge.",
message="Запускаем приём MQTT и мост Foxglove.",
target=lambda: self._run_live(
host,
out_dir.expanduser().resolve(),
@@ -111,11 +111,11 @@ class VisualizationRuntime:
if thread is None or not thread.is_alive():
self._phase = "idle"
self._source_mode = "idle"
self._message = "No active visualization session."
self._message = "Активного потока нет."
notify_only = True
else:
self._phase = "stopping"
self._message = "Stopping source after preserving queued evidence."
self._message = "Останавливаем поток и сохраняем полученные данные."
self._stop_event.set()
self._notify()
if notify_only:
@@ -133,7 +133,7 @@ class VisualizationRuntime:
) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
raise RuntimeError("a visualization session is already active")
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
self._stop_event = threading.Event()
self._metrics = BridgeMetrics()
self._phase = phase
@@ -157,7 +157,7 @@ class VisualizationRuntime:
count = 0
for message in iter_replay_messages(path):
if self._stop_event.is_set():
return "Replay stopped by operator."
return "Повтор записи остановлен."
source_ns = (
message.received_monotonic_ns
if message.received_monotonic_ns is not None
@@ -169,12 +169,12 @@ class VisualizationRuntime:
target_ns = replay_started_ns + int((source_ns - first_source_ns) / speed)
remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000
if remaining > 0 and self._stop_event.wait(remaining):
return "Replay stopped by operator."
return "Повтор записи остановлен."
put(message)
count += 1
if not loop:
return f"Replay completed: {count} MQTT messages."
return "Replay stopped by operator."
return f"Повтор завершён: обработано сообщений MQTT — {count}."
return "Повтор записи остановлен."
self._run_pipeline(produce, running_phase="replay")
@@ -199,15 +199,12 @@ class VisualizationRuntime:
out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds,
on_ready=lambda: self._set_running(
"live", "MQTT subscribed; waiting for K1 scan frames."
"live", "Приём запущен. Теперь дважды нажмите физическую кнопку K1."
),
on_message_recorded=on_message,
should_stop=self._stop_event.is_set,
)
return (
f"Live capture stopped: {summary['stop_reason']}; "
f"{summary['message_count']} messages preserved."
)
return f"Приём остановлен. Сохранено сообщений: {summary['message_count']}."
try:
self._run_pipeline(produce, running_phase="live")
@@ -251,7 +248,7 @@ class VisualizationRuntime:
self._foxglove_viewer_url = bridge.viewer_url
if self._phase != "stopping":
self._phase = running_phase
self._message = "Foxglove bridge ready; source is active."
self._message = "Мост Foxglove готов; источник данных запущен."
publisher_ready.set()
self._notify()
while not source_done.is_set() or not messages.empty():
@@ -278,33 +275,33 @@ class VisualizationRuntime:
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
publisher.start()
if not publisher_ready.wait(timeout=15.0):
self._finish_error("Foxglove bridge did not start within 15 seconds.")
self._finish_error("Мост Foxglove не запустился за 15 секунд.")
source_done.set()
self._stop_event.set()
return
if publisher_error:
self._finish_error(
f"Foxglove bridge failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
f"Ошибка моста Foxglove: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
source_done.set()
return
final_message = "Session stopped."
final_message = "Поток остановлен."
try:
final_message = producer(enqueue)
except CaptureError as exc:
self._finish_error(f"Live MQTT capture failed: {exc}")
self._finish_error(f"Ошибка приёма MQTT: {exc}")
except (OSError, RuntimeError, ValueError) as exc:
self._finish_error(f"Source failed: {type(exc).__name__}: {exc}")
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
finally:
source_done.set()
publisher.join(timeout=15.0)
if publisher.is_alive():
self._stop_event.set()
self._finish_error("Publisher did not drain within 15 seconds.")
self._finish_error("Очередь публикации не завершилась за 15 секунд.")
elif publisher_error:
self._finish_error(
f"Publisher failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
elif self.snapshot()["phase"] != "error":
self._finish_idle(final_message)
+18 -11
View File
@@ -80,7 +80,7 @@ class ConsoleService:
message = runtime["message"]
elif selected_device_id is not None:
phase = "device_selected"
message = "K1 selected; Wi-Fi credentials have not been written."
message = "K1 выбран. Теперь введите название и пароль Wi-Fi."
else:
phase = "idle"
message = runtime["message"]
@@ -107,7 +107,7 @@ class ConsoleService:
}
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
self._set_operation("scanning", "Scanning for nearby K1 BLE advertisements.")
self._set_operation("scanning", "Ищем K1 по Bluetooth (BLE)…")
try:
result = await scan(duration_seconds)
devices = [
@@ -125,7 +125,9 @@ class ConsoleService:
self._devices = devices
if len(devices) == 1:
self._selected_device_id = str(devices[0]["device_id"])
self._operation_message = f"BLE scan complete: {len(devices)} K1 candidate(s)."
self._operation_message = (
f"Поиск завершён. Найдено устройств K1: {len(devices)}."
)
finally:
with self._lock:
self._operation_phase = None
@@ -134,10 +136,10 @@ class ConsoleService:
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("device_id must come from the latest K1 BLE scan")
raise ValueError("сначала найдите и выберите K1 через Bluetooth")
self._set_operation(
"provisioning",
"Sending one explicitly requested Wi-Fi provisioning write.",
"Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.",
)
session_dir = _new_operation_session_dir(
self.repository_root,
@@ -170,12 +172,12 @@ class ConsoleService:
)
if ipv4 is None:
raise RuntimeError(
"K1 did not report a non-AP LAN address; no automatic retry was made"
"K1 не сообщил адрес в локальной сети; автоматического повтора не было"
)
with self._lock:
self._selected_device_id = request.device_id
self._k1_ip = ipv4
self._operation_message = "K1 joined the LAN and reported its private address."
self._operation_message = "K1 подключён к Wi-Fi и сообщил локальный адрес."
finally:
password = ""
with self._lock:
@@ -185,7 +187,9 @@ class ConsoleService:
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
target = host or self.state()["k1_ip"]
if not isinstance(target, str) or not target:
raise ValueError("live host is required until BLE provisioning reports a K1 address")
raise ValueError(
"сначала подключите K1 к Wi-Fi или укажите его локальный адрес"
)
target = validate_private_ipv4(target)
out_dir = new_live_session_dir(self.repository_root)
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds)
@@ -194,7 +198,7 @@ class ConsoleService:
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
replay_path = Path(path).expanduser().resolve()
if not replay_path.is_relative_to(self.repository_root):
raise ValueError("replay path must remain inside this repository")
raise ValueError("файл записи должен находиться внутри репозитория")
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
return self.state()
@@ -238,7 +242,7 @@ async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
try:
return await service.scan_ble(request.duration_seconds)
except (BleakError, OSError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"BLE scan failed: {exc}") from exc
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
@app.post("/api/connect")
@@ -246,7 +250,10 @@ async def connect(request: ConnectRequest) -> dict[str, Any]:
try:
return await service.connect(request)
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"Wi-Fi provisioning failed: {exc}") from exc
raise HTTPException(
status_code=502,
detail=f"Ошибка подключения K1 к Wi-Fi: {exc}",
) from exc
@app.post("/api/session/live")