From 8b5b90d8af2580362d37c98295659e48b09bc9df Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 15 Jul 2026 22:18:32 +0300 Subject: [PATCH] fix: show all BLE devices in K1 console --- apps/k1-viewer/src/App.tsx | 45 ++++++++++++++++++++++++-------- apps/k1-viewer/src/api.ts | 1 + apps/k1-viewer/src/styles.css | 27 ++++++++++++++++++++ src/k1link/ble/scanner.py | 3 ++- src/k1link/web/app.py | 17 ++++++++----- tests/test_ble_scanner.py | 16 ++++++++++++ tests/test_web_console.py | 48 +++++++++++++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 tests/test_web_console.py diff --git a/apps/k1-viewer/src/App.tsx b/apps/k1-viewer/src/App.tsx index 38276e4..fcbdb58 100644 --- a/apps/k1-viewer/src/App.tsx +++ b/apps/k1-viewer/src/App.tsx @@ -185,11 +185,18 @@ function DeviceRow({ onSelect: () => void; }) { return ( -
+
@@ -293,8 +300,18 @@ export default function App() { const [replayLoop, setReplayLoop] = useState(false); useEffect(() => { - if (state?.selected_device_id) setSelectedDeviceId(state.selected_device_id); - }, [state?.selected_device_id]); + if (state?.selected_device_id) { + setSelectedDeviceId(state.selected_device_id); + return; + } + if ( + selectedDeviceId && + state?.devices && + !state.devices.some((device) => device.device_id === selectedDeviceId) + ) { + setSelectedDeviceId(""); + } + }, [selectedDeviceId, state?.devices, state?.selected_device_id]); const metrics = state?.metrics; const latency = pipelineLatency(metrics); @@ -508,12 +525,12 @@ export default function App() {

- Поиск занимает 6 секунд и ничего не записывает в настройки сканера. + Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Вероятные + K1 будут сверху и получат метку, но окончательный выбор делаете вы.

{devices.length ? ( @@ -548,8 +571,8 @@ export default function App() { )) ) : (
- K1 пока не найден. Убедитесь, что он включён и горит ровно зелёным, затем - нажмите «Найти K1». + Устройства пока не найдены. Убедитесь, что K1 включён и горит ровно + зелёным, затем запустите поиск ещё раз.
)}
diff --git a/apps/k1-viewer/src/api.ts b/apps/k1-viewer/src/api.ts index 0ee1909..65c221a 100644 --- a/apps/k1-viewer/src/api.ts +++ b/apps/k1-viewer/src/api.ts @@ -4,6 +4,7 @@ export interface BleDevice { rssi?: number | null; address?: string | null; connectable?: boolean | null; + likely_k1?: boolean | null; } export type SourceMode = "idle" | "live" | "replay"; diff --git a/apps/k1-viewer/src/styles.css b/apps/k1-viewer/src/styles.css index 476db2f..1c6115f 100644 --- a/apps/k1-viewer/src/styles.css +++ b/apps/k1-viewer/src/styles.css @@ -409,8 +409,11 @@ input { .device-list { display: grid; + max-height: 28rem; gap: 0.5rem; margin-top: 0.75rem; + overflow-y: auto; + padding-right: 0.2rem; } .device-row { @@ -445,6 +448,25 @@ input { gap: 0.2rem; } +.device-row__name { + display: flex; + min-width: 0; + align-items: center; + gap: 0.45rem; +} + +.device-row__name small { + flex: 0 0 auto; + border-radius: 999px; + background: var(--k1-accent-soft); + color: rgb(var(--nodedc-accent-rgb)); + padding: 0.2rem 0.42rem; + font-size: 0.5rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .device-row__identity strong { overflow: hidden; font-size: 0.74rem; @@ -467,6 +489,11 @@ input { height: 0.6rem; flex: 0 0 0.6rem; border-radius: 50%; + background: var(--nodedc-text-muted); + box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.035); +} + +.device-row[data-likely-k1="true"] .device-row__signal { background: rgb(var(--nodedc-success-rgb)); box-shadow: 0 0 0 0.25rem var(--k1-success-soft); } diff --git a/src/k1link/ble/scanner.py b/src/k1link/ble/scanner.py index 62c23b5..d2c0443 100644 --- a/src/k1link/ble/scanner.py +++ b/src/k1link/ble/scanner.py @@ -53,7 +53,8 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> service_uuid: data.hex() for service_uuid, data in sorted(advertisement.service_data.items()) }, - "k1_name_candidate": any(marker in normalized_name for marker in ("lixel", "xgrids", "k1")), + "k1_name_candidate": normalized_name.startswith("xgr-") + or any(marker in normalized_name for marker in ("lixel", "xgrids", "k1")), } diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 1eb27c9..1505fd9 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -81,6 +81,13 @@ class ConsoleService: elif selected_device_id is not None: phase = "device_selected" message = "K1 выбран. Теперь введите название и пароль Wi-Fi." + elif devices: + likely_count = sum(bool(item.get("likely_k1")) for item in devices) + phase = "idle" + message = ( + f"Найдено BLE-устройств: {len(devices)}. " + f"Возможных K1: {likely_count}. Выберите нужное устройство." + ) else: phase = "idle" message = runtime["message"] @@ -107,7 +114,7 @@ class ConsoleService: } async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: - self._set_operation("scanning", "Ищем K1 по Bluetooth (BLE)…") + self._set_operation("scanning", "Сканируем все устройства Bluetooth (BLE)…") try: result = await scan(duration_seconds) devices = [ @@ -117,16 +124,14 @@ class ConsoleService: "rssi": item["rssi"], "address": None, "connectable": True, + "likely_k1": item["k1_name_candidate"], } for item in result["devices"] - if item["k1_name_candidate"] ] with self._lock: self._devices = devices - if len(devices) == 1: - self._selected_device_id = str(devices[0]["device_id"]) self._operation_message = ( - f"Поиск завершён. Найдено устройств K1: {len(devices)}." + f"Поиск завершён. Найдено BLE-устройств: {len(devices)}." ) finally: with self._lock: @@ -136,7 +141,7 @@ 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("сначала найдите и выберите K1 через Bluetooth") + raise ValueError("сначала найдите и выберите устройство через Bluetooth") self._set_operation( "provisioning", "Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.", diff --git a/tests/test_ble_scanner.py b/tests/test_ble_scanner.py index 4bd6957..83fbec5 100644 --- a/tests/test_ble_scanner.py +++ b/tests/test_ble_scanner.py @@ -21,3 +21,19 @@ def test_advertisement_record_marks_k1_candidate() -> None: assert record["manufacturer_data_hex"] == {"123": "0102"} assert record["service_data_hex"] == {"service": "aa"} assert record["service_uuids"] == ["B", "a"] + + +def test_advertisement_record_marks_observed_xgr_serial_name_as_k1_candidate() -> None: + device = BLEDevice("F89438FA-55ED-85AD-EED7-734AC84746D8", "XGR-A46BE7", None) + advertisement = AdvertisementData( + local_name="XGR-A46BE7", + manufacturer_data={}, + service_data={}, + service_uuids=[], + tx_power=0, + rssi=-60, + platform_data=(), + ) + + record = advertisement_record(device, advertisement) + assert record["k1_name_candidate"] is True diff --git a/tests/test_web_console.py b/tests/test_web_console.py new file mode 100644 index 0000000..28a4c2c --- /dev/null +++ b/tests/test_web_console.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +from pytest import MonkeyPatch + +import k1link.web.app as web_app + + +def test_ble_scan_exposes_every_device_and_only_labels_likely_k1( + monkeypatch: MonkeyPatch, + tmp_path: Path, +) -> None: + async def fake_scan(duration_seconds: float) -> dict[str, Any]: + assert duration_seconds == 6.0 + return { + "devices": [ + { + "macos_uuid": "K1-UUID", + "name": "XGR-A46BE7", + "local_name": "XGR-A46BE7", + "rssi": -51, + "k1_name_candidate": True, + }, + { + "macos_uuid": "OTHER-UUID", + "name": "AirPods Pro", + "local_name": "AirPods Pro", + "rssi": -62, + "k1_name_candidate": False, + }, + ] + } + + monkeypatch.setattr(web_app, "scan", fake_scan) + service = web_app.ConsoleService(tmp_path) + + state = asyncio.run(service.scan_ble(6.0)) + + assert state["selected_device_id"] is None + assert [device["device_id"] for device in state["devices"]] == [ + "K1-UUID", + "OTHER-UUID", + ] + assert [device["likely_k1"] for device in state["devices"]] == [True, False] +