Wi-Fi over USB tab for firmware with the USB console (v13)

Writes / reads / removes the box's Wi-Fi over the serial port with the
usb_console commands (kota ping, wifi set|get|clear, health) and follows
the box through the restart until it joins the network. The password is
sent hex-encoded and never logged.

firmware/ now holds merged-binary-v13.bin (build 3ae273ec) instead of the
previous 1.0.16 build; error_ids.csv synced with the firmware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kkallasandClaude Opus 5.5 committed 2026-09-29 17:30:35 +02:00
1 parent 0d21c5c205
commit ab420220e1
11 files changed
+758

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
"""Commands to the box over the USB serial port (firmware with usb_console, api=1).
Protocol (kota-sensorboard main/usb_console.hpp): ASCII lines "kota ...",
one reply line each, logged as "KOTA: cmd <name> ok=1|0 [key=value ...]":
kota ping -> cmd ping ok=1 fw=<version> api=1
kota health -> cmd health ok=1 <health fields> | ok=0 err=not_ready
kota wifi get -> cmd wifi_get ok=1 ssid=<hex|-> password_set=0|1 mode=...
kota wifi set <ssid_hex> <pass_hex> -> cmd wifi_set ok=1 restart=1, then restart (usb_wifi_set)
kota wifi clear -> cmd wifi_clear ok=1 restart=1, then restart (usb_wifi_clear)
Firmware without the console never answers. The password is only ever sent
hex-encoded and is never written to the log by the box or by this tool.
WifiFlow runs a whole "write / clear Wi-Fi" job as a small state machine
driven by `feed(line)` and `tick(now)`, so it is testable without Qt or a box.
"""
from __future__ import annotations
import enum
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from ..core.logline import LineKind, LogLine
API_VERSION = 1
# Technician-facing text for the firmware's err= codes.
ERRORS = {
"bad_args": "The box did not understand the command (tool/firmware mismatch).",
"bad_hex": "The box could not decode the settings (tool/firmware mismatch).",
"bad_char": "The name or password contains a line break.",
"ssid_length": "The network name must be 1-32 bytes.",
"password_length": "The password must be 8-63 characters.",
"sd_not_mounted": "The box cannot read its SD card, so it cannot store the settings.",
"write_failed": "Writing ENV.TXT on the SD card failed.",
"verify_failed": "ENV.TXT did not read back correctly after writing.",
"unknown_command": "This firmware does not know the command.",
"not_ready": "The box is still starting; try again in a few seconds.",
}
def hex_utf8(text: str) -> str:
return text.encode("utf-8").hex()
def unhex_utf8(value: str) -> str | None:
if not value or value == "-":
return None
try:
return bytes.fromhex(value).decode("utf-8", errors="replace")
except ValueError:
return None
def validate_wifi(ssid: str, password: str) -> list[str]:
"""Same limits as the firmware (usb_console.cpp); empty list = OK."""
problems = []
if not ssid:
problems.append("Enter the Wi-Fi network name (SSID).")
elif len(ssid.encode("utf-8")) > 32:
problems.append("The network name is longer than 32 bytes.")
if not 8 <= len(password.encode("utf-8")) <= 63:
problems.append("The password must be 8-63 characters (WPA2). Open networks are not supported.")
if any(c in ssid + password for c in "\r\n\0"):
problems.append("The name or password contains a line break.")
return problems
def wifi_set_command(ssid: str, password: str) -> str:
problems = validate_wifi(ssid, password)
if problems:
raise ValueError(" ".join(problems))
return f"kota wifi set {hex_utf8(ssid)} {hex_utf8(password)}"
def loggable(command: str) -> str:
"""The command as it may appear in the session log: never the password."""
parts = command.split()
if parts[:3] == ["kota", "wifi", "set"] and len(parts) == 5:
return f"kota wifi set (network '{unhex_utf8(parts[3])}', password hidden)"
return command
@dataclass(frozen=True)
class Reply:
name: str # ping | health | wifi_get | wifi_set | wifi_clear | unknown
ok: bool
fields: dict[str, str]
@property
def error(self) -> str:
code = self.fields.get("err", "")
return ERRORS.get(code, f"The box reported an error ({code or 'unknown'}).")
def parse_reply(line: LogLine) -> Reply | None:
"""'I (5) KOTA: cmd wifi_set ok=1 restart=1' -> Reply('wifi_set', True, {...})."""
if line.kind != LineKind.KOTA or line.record != "cmd" or not line.words:
return None
return Reply(line.words[0], line.fields.get("ok") == "1", dict(line.fields))
@dataclass(frozen=True)
class WifiInfo:
"""What `kota wifi get` reported."""
ssid: str | None
password_set: bool
mode: str
@classmethod
def from_reply(cls, r: Reply) -> WifiInfo:
return cls(unhex_utf8(r.fields.get("ssid", "-")), r.fields.get("password_set") == "1",
r.fields.get("mode", "?"))
def describe(self) -> str:
mode = {"wifi": "local Wi-Fi from the SD card", "matter": "Matter (Wi-Fi given by the Matter app)",
"provisioning": "Wi-Fi setup portal"}.get(self.mode, self.mode)
net = f"network '{self.ssid}'" + (", password set" if self.password_set else ", NO password")
return f"Mode: {mode}. " + (net if self.ssid else "No network stored on the SD card.")
# ------------------------------------------------------------------ flows
class Step(str, enum.Enum):
PING = "Checking the box accepts USB commands"
READ = "Reading the Wi-Fi settings"
SEND = "Storing the settings on the box's SD card"
RESTART = "Waiting for the box to restart"
CONNECT = "Waiting for the box to join the Wi-Fi network"
DONE = "Done"
FAILED = "Failed"
@dataclass
class FlowEvent:
step: Step
text: str
ok: bool | None = None # None = in progress, True = finished OK, False = failed
PING_TIMEOUT_S = 3.0
REPLY_TIMEOUT_S = 8.0
RESTART_TIMEOUT_S = 40.0
CONNECT_TIMEOUT_S = 90.0
HEALTH_POLL_S = 5.0
@dataclass
class WifiFlow:
"""One technician job: "read", "set" (ssid+password) or "clear".
`send(text)` writes one command line to the box (returns False when the
port is not connected); `on_event` reports progress for the UI.
"""
action: str
send: Callable[[str], bool]
on_event: Callable[[FlowEvent], None]
ssid: str = ""
password: str = ""
now: Callable[[], float] = time.monotonic
step: Step = Step.PING
info: WifiInfo | None = None
_deadline: float = 0.0
_next_poll: float = 0.0
_events: list[FlowEvent] = field(default_factory=list)
@property
def finished(self) -> bool:
return self.step in (Step.DONE, Step.FAILED)
def start(self) -> None:
if self.action == "set":
problems = validate_wifi(self.ssid, self.password)
if problems:
return self._fail(" ".join(problems))
self._go(Step.PING, "kota ping", PING_TIMEOUT_S)
def cancel(self) -> None:
if not self.finished:
self._fail("Cancelled.")
# -------------------------------------------------------- driving
def feed(self, line: LogLine) -> None:
if self.finished:
return
if self.step == Step.RESTART and line.kind == LineKind.KOTA and line.record == "boot":
return self._on_boot(line.fields)
r = parse_reply(line)
if r is None:
return
if self.step == Step.PING and r.name == "ping":
if not r.ok or int(r.fields.get("api", "0") or 0) < API_VERSION:
return self._fail("The box answered, but its command version is not supported.")
self._emit(f"Firmware {r.fields.get('fw', '?')} accepts USB commands.", True)
if self.action == "read":
return self._go(Step.READ, "kota wifi get", REPLY_TIMEOUT_S)
if self.action == "set":
return self._go(Step.SEND, wifi_set_command(self.ssid, self.password), REPLY_TIMEOUT_S)
return self._go(Step.SEND, "kota wifi clear", REPLY_TIMEOUT_S)
if self.step == Step.READ and r.name == "wifi_get":
if not r.ok:
return self._fail(r.error)
self.info = WifiInfo.from_reply(r)
return self._finish(self.info.describe())
if self.step == Step.SEND and r.name in ("wifi_set", "wifi_clear"):
if not r.ok:
return self._fail(r.error)
self._emit("The box stored the settings and is restarting.", True)
self.step, self._deadline = Step.RESTART, self.now() + RESTART_TIMEOUT_S
return self._emit(Step.RESTART.value + "...")
if self.step == Step.CONNECT and r.name == "health" and r.ok:
return self._on_health(r.fields)
def tick(self) -> None:
"""Call periodically (e.g. every 0.5 s): timeouts and health polling."""
if self.finished:
return
t = self.now()
if self.step == Step.CONNECT and t >= self._next_poll:
self._next_poll = t + HEALTH_POLL_S
self.send("kota health")
if t < self._deadline:
return
if self.step == Step.PING:
self._fail("The box does not answer USB commands. It needs firmware with the USB console "
"(Flash firmware tab), or it is still starting - try again in 10 s.")
elif self.step == Step.RESTART:
self._fail("The box stored the settings but no restart was seen. Press Reset & capture in the "
"Serial monitor and check the Diagnosis panel.")
elif self.step == Step.CONNECT:
self._fail(f"The box did not join '{self.ssid}' within {int(CONNECT_TIMEOUT_S)} s. Check the network "
"name and password, that it is a 2.4 GHz network and that it is in range. The settings stay "
"on the box; it keeps retrying.")
else:
self._fail("The box did not answer. Check the USB connection and try again.")
# -------------------------------------------------------- internals
def _on_boot(self, f: dict[str, str]) -> None:
mode, last = f.get("mode", "?"), f.get("last_restart", "?")
want = "usb_wifi_set" if self.action == "set" else "usb_wifi_clear"
if last != want:
return self._fail(f"The box restarted for another reason ({last}); the settings may not be applied.")
self._emit(f"Restarted in {mode} mode.", True)
if self.action == "clear":
ok = mode == "matter"
return self._finish("Wi-Fi removed; the box uses Matter again (pair it in the Matter app if needed).") \
if ok else self._fail(f"The box restarted in {mode} mode instead of Matter.")
if mode != "wifi":
return self._fail(f"The box restarted in {mode} mode instead of local Wi-Fi.")
self.step = Step.CONNECT
self._deadline = self.now() + CONNECT_TIMEOUT_S
self._next_poll = self.now() + HEALTH_POLL_S
self._emit(Step.CONNECT.value + f" '{self.ssid}'...")
def _on_health(self, f: dict[str, str]) -> None:
if f.get("wifi") != "connected":
return
rssi = f.get("rssi", "?")
note = " - weak signal, consider moving the box or the router" if rssi.lstrip("-").isdigit() \
and int(rssi) < -75 else ""
self._finish(f"Connected to '{self.ssid}' (signal {rssi} dBm{note}). Data upload starts within a minute.")
def _go(self, step: Step, command: str, timeout: float) -> None:
self.step, self._deadline = step, self.now() + timeout
self._emit(step.value + "...")
if not self.send(command):
self._fail("The box is not connected. Connect it first.")
def _emit(self, text: str, ok: bool | None = None) -> None:
ev = FlowEvent(self.step, text, ok)
self._events.append(ev)
self.on_event(ev)
def _finish(self, text: str) -> None:
self.step = Step.DONE
self._emit(text, True)
def _fail(self, text: str) -> None:
self.step = Step.FAILED
self._emit(text, False)