- {device.name?.trim() || "Возможный K1"}
+
+ {device.name?.trim() || "Устройство без имени"}
+ {device.likely_k1 ? Возможно K1 : null}
+
{device.device_id}
@@ -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 будут сверху и получат метку, но окончательный выбор делаете вы.
}
disabled={!powerConfirmed || isBusy}
- onClick={() => void scan()}
+ onClick={() => {
+ setSelectedDeviceId("");
+ void scan();
+ }}
>
- {pendingAction === "scan" ? "Ищем K1 — 6 секунд…" : "Найти K1"}
+ {pendingAction === "scan"
+ ? "Сканируем Bluetooth — 6 секунд…"
+ : "Показать все BLE-устройства"}
{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]
+