4 Commits
Author SHA1 Message Date
kkallas 22bd853ecf Technician tool v0.6
Imported from the Google Drive folder 004-technician_tool/v0.6.
2026-09-28 17:31:03 +02:00
kkallas f5b32e1554 Technician tool v0.5
Imported from the Google Drive folder 004-technician_tool/v0.5.
2026-09-26 18:30:51 +02:00
kkallas 72adfe1cec Technician tool v0.4
Imported from the Google Drive folder 004-technician_tool/v0.4.
2026-09-26 17:26:00 +02:00
kkallas e161941672 Technician tool v0.3
Imported from the Google Drive folder 004-technician_tool/v0.3.
2026-09-26 17:14:37 +02:00
42 changed files with 5486 additions and 16 deletions

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
# Firmware diagnostics improvement proposals
> **Status: draft proposal, AI-generated (Claude) for review by the firmware team.**
> Nothing here is implemented. The firmware source was only read for analysis;
> no firmware was changed. Every item links to an observation from bench
> testing box `A4:CB:8F:C9:46:F8` (firmware v1.0, build `45fa9d5b9`) or from the
> sample logs, so the value of each change can be judged.
## Why
Troubleshooting currently depends on reverse-engineering free-text log lines and
on a status LED whose states overlap. Observed problems:
| # | Observation | Effect for technicians |
|---|---|---|
| O1 | A new, uncommissioned box shows **green** for about 60 s, then purple | Looks healthy during installation |
| O2 | Three different states share **white** (SD error, factory reset, setup portal) | Needs serial or guesswork |
| O3 | **Blue** means "RTC did not answer", but the RTC is just the first I2C device | A loose I2C cable is misreported as an RTC fault |
| O4 | Every build reports `App version: 1.0` | Builds are told apart only by ELF SHA |
| O5 | Restarts after 150 s in an error state happen **without any log line** | A self-restart looks like a crash |
| O6 | With the RTC not running and no Internet, sensor tasks **loop forever** on `RTC is uninitialized` | An offline box records nothing, and it isn't obvious why |
| O7 | The OTA token is printed in plain text (`OTA token loaded from NVS: %s`, `received token: %s`) | Secrets end up in technician logs |
| O8 | Native USB loses output while no host is reading; a stale FIFO fragment is glued to the next line | The early boot after a power cycle is lost, and lines get corrupted |
| O9 | The microphone reports "shutdown state" (GPIO3) with no hint of the cause | A technician can't tell a switch from a fault |
| O10 | The deployed build logs some messages under different tags than the source (e.g. SD mount under `INFLUX_BUFFER`) | Parsers must ignore tags |
## Serial output
Priority: **P1** = large gain for little effort, **P2** = valuable, **P3** = nice to have.
### P1
1. **One structured boot line** with a fixed prefix, printed right after `app_init`:
```
KOTA: boot fw=1.0.15 build=45fa9d5b hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET mode=matter commissioned=0 last_restart=status_watchdog
```
The tool can then identify firmware, hardware and mode from a single line (fixes O4 and O10). Fill `PROJECT_VER` from `FIRMWARE_VERSION` (currently `15` in `pinout.hpp`) so every build has a unique version.
2. **Log every status change** inside `Status::set_status()`, with a reason argument:
```
KOTA: status OK -> CONNECTION_ERROR reason=influx_flush_failed
```
This is the exact LED colour. Today the tool has to infer it from about 15 different messages.
3. **Log the reason before every `esp_restart()`** and store it (RTC memory or NVS) so it can be printed at the next boot as `last_restart=`. Values: `status_watchdog_150s`, `out_of_resources_30s`, `button_factory_reset`, `setup_portal_toggle`, `ota_done`, `init_failed:<step>` (fixes O5).
4. **Stop printing secrets**: print only the first and last 4 characters of the OTA token, or `token=present` (fixes O7).
5. **Warn once and continue when the time is unset**, instead of the `while (now == 946684800)` loops in `device_status.cpp` and the sensors. Record data with a `time_valid=false` field or buffer it with uptime, then correct it after SNTP (fixes O6). This is a behaviour change, but the biggest data-loss risk found.
### P2
6. **I2C bus scan at boot**, before any sensor init:
```
KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66(missing) 0x76=BME688
```
Reading the SDA/SCL levels first detects a stuck-low bus (shorted or broken cable). The scan then separates "whole cable" from "one sensor" (fixes O3).
7. **RTC detail**: at init, print the DS1307 clock-halt (CH) bit and the raw time (`KOTA: rtc running=0 time=2000-01-01 (coin cell?)`). After SNTP sets the time, re-read it 2 s later to verify it's ticking.
8. **SD card detail**: card type, size, FAT type, free space, which `ENV.TXT` keys are present (names only, never values) and the buffer size in lines. Example: `KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=42`.
9. **Periodic health line** every 60 s. `DeviceStatus` already computes almost everything:
```
KOTA: health up=312s status=CONNECTION_ERROR wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=42 heap=81234 psram=6.1M last_upload=never ota=unregistered
```
10. **Matter commissioning status at boot**: `KOTA: matter fabrics=0 window=open` plus the QR payload and manual code, so the tool can show the QR code on screen when the sticker is missing.
11. **Microphone enable reason**: `KOTA: mic enabled=0 (GPIO3=1, expected 0)` once at boot, instead of a warning every 10 s (fixes O9).
12. **Stable error IDs** in messages, e.g. `[E-SD-01] Failed to initialize the card`, documented in one table shared by the firmware, the technician tool and the service docs.
### P3
13. **Console commands** over USB (the firmware already runs a `console` task): `diag` (full health dump), `i2cscan`, `sdinfo`, `ledtest` (cycles all colours so the technician can confirm the LED hardware), `version`, `bootlog`. The tool could then run checks without a reboot.
14. **Boot-log ring buffer** in RAM, dumped with `bootlog` or on first host connect. This recovers the early boot lost on native USB after a power-switch cycle (fixes O8).
15. **Button events**: log the press start and release with duration (`KOTA: button press 1.2s -> setup_portal`). This allows a safe button test and shows accidental factory resets.
## RGB LED
1. **Separate "not connected yet" from "OK"** (fixes O1): **cyan breathing** while the box is uncommissioned / advertising over BLE, fast cyan while a phone is connected, and a green flash when commissioning completes.
2. **Split the white states** with patterns (fixes O2):
- SD card / `ENV.TXT` error: white, fast blink (2 Hz)
- factory reset in progress: white, solid
- Wi-Fi setup portal: white, slow breathing
3. **Blink codes for red and blue** (fixes O3): N short pulses followed by a pause identify the part.
- Red: 1 = BME688, 2 = SEN66, 3 = AS7341, 4 = microphone, 5 = flicker, 6 = self-check failed
- Blue: 1 = RTC not responding, 2 = I2C bus stuck low
4. **Split purple**: solid = no network/DNS; blinking = Internet works but the server rejected the box (InfluxDB 401/404, OTA 401). These need different fixes (network vs SD card settings).
5. **Show the cause before a self-restart**: flash the error colour 3 times before `esp_restart()`. After boot, show the previous boot's error colour for 3 s (from RTC memory), so a reboot loop has a visible cause.
6. **Update progress**: yellow blinking while downloading (faster as progress grows); orange stays solid for the self-check.
7. **Brightness and distinguishability**: colours are set at 10 % (`255/10`). Yellow and orange, and white and cyan, are hard to tell apart in daylight. Prefer fewer colours plus patterns, and raise brightness for error states.
8. **Heartbeat LED (GPIO6)**: keep 1 Hz for "running", add a double-blink when offline, so the heartbeat alone gives a rough state even when the RGB LED is faulty.
## What changes in the technician tool
- The structured `KOTA:` lines (items 1, 2, 3, 9) would replace most regex rules in `rules.toml` and make the LED model exact rather than inferred.
- The console commands (item 13) would let guided checks test hardware directly (I2C scan, LED test) instead of asking the technician to power-cycle.
- The new LED patterns would add answers to the Quick check (e.g. "white, blinking fast") and remove the follow-up questions currently needed to separate the white states.
+30
View File
@@ -0,0 +1,30 @@
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is
004-technician_tool/v0.5 (a Python/PySide6 desktop app; it has only been tested on Windows so far).
Rules:
- Python only via uv or conda/mamba. Never use bare/system python or pip.
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin.
- Do not push anything to Git or GitLab. This is a local demo.
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
Before formatting, show me the exact disk (identifier, name, size, current files).
- Do not change code without telling me. Report bugs with evidence (commands and output).
Tests, in order. Report pass/fail with evidence for each:
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q`
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the
reset and whether the tool reconnects.
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself,
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output.
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT".
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical
to the template, and eject the card.
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine
if the page itself cannot prefill yet).
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
suggested fixes (file:line).
+2
View File
@@ -7,4 +7,6 @@ dependencies:
- python=3.12 - python=3.12
- pyserial>=3.5 - pyserial>=3.5
- pyside6>=6.7 - pyside6>=6.7
- esptool>=4.8,<5
- segno>=1.6
- pytest>=8 - pytest>=8
Binary file not shown.
+1 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool.""" """KOTA sensorbox technician tool."""
__version__ = "0.2.0" __version__ = "0.6.0"
+30 -1
View File
@@ -25,6 +25,7 @@ from ..core.logline import LineKind, LogLine
from .firmware import FirmwareInfo from .firmware import FirmwareInfo
RULES_PATH = Path(__file__).with_name("rules.toml") RULES_PATH = Path(__file__).with_name("rules.toml")
_MAC_LINE = re.compile(r"Device MAC address: ([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})")
class Severity(IntEnum): class Severity(IntEnum):
@@ -59,6 +60,7 @@ MILESTONES = [
("sd_mounted", "SD card mounted", False), ("sd_mounted", "SD card mounted", False),
("env_loaded", "ENV.TXT read (InfluxDB settings)", False), ("env_loaded", "ENV.TXT read (InfluxDB settings)", False),
("matter_started", "Matter started", True), ("matter_started", "Matter started", True),
("matter_commissioned", "Paired in Matter", True),
("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True), ("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True),
("sensors_ready", "All sensors started", False), ("sensors_ready", "All sensors started", False),
("wifi_connected", "Connected to Wi-Fi", False), ("wifi_connected", "Connected to Wi-Fi", False),
@@ -215,6 +217,7 @@ class Report:
boots: list[BootRecord] boots: list[BootRecord]
lines: int lines: int
mid_run: bool # no boot seen yet mid_run: bool # no boot seen yet
technician: list[str] = field(default_factory=list) # guided-check answers
def to_text(self) -> str: def to_text(self) -> str:
out = [f"VERDICT: {self.verdict}", out = [f"VERDICT: {self.verdict}",
@@ -243,6 +246,10 @@ class Report:
out.append("Resolved during this session:") out.append("Resolved during this session:")
for f in self.resolved: for f in self.resolved:
out.append(f" - {f.title}" + (f" (by: {f.resolved_by})" if f.resolved_by else "")) out.append(f" - {f.title}" + (f" (by: {f.resolved_by})" if f.resolved_by else ""))
if self.technician:
out.append("")
out.append("Technician checks:")
out += [f" - {t}" for t in self.technician]
return "\n".join(out) return "\n".join(out)
@@ -271,6 +278,9 @@ class DiagnosticsEngine:
self.lines = 0 self.lines = 0
self._tool_reset_at: float | None = None self._tool_reset_at: float | None = None
self._panic_tail = 0 # RAW lines after a panic line to keep self._panic_tail = 0 # RAW lines after a panic line to keep
self.technician: list[str] = []
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line
@property @property
def boot(self) -> BootRecord: def boot(self) -> BootRecord:
@@ -310,6 +320,10 @@ class DiagnosticsEngine:
self._crash_finding().evidence.append(text) self._crash_finding().evidence.append(text)
self.firmware.observe(text) self.firmware.observe(text)
if m := _MAC_LINE.search(text):
self.mac = m.group(1).upper()
if "Advertise commission parameter" in text:
self.matter_advert = text
matched = False matched = False
for rule in self.rules: for rule in self.rules:
m = rule.pattern.search(text) m = rule.pattern.search(text)
@@ -369,7 +383,22 @@ class DiagnosticsEngine:
# Reached milestones in the order they happened, then the pending ones. # Reached milestones in the order they happened, then the pending ones.
ms.sort(key=lambda m: (m[2] is None, m[2] or 0)) ms.sort(key=lambda m: (m[2] is None, m[2] or 0))
return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots, return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots,
earlier, resolved, list(self.boots), self.lines, mid_run) earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician))
def add_confirmed(self, key: str, severity: Severity, title: str, meaning: str,
actions: tuple[str, ...]) -> Finding:
"""A finding established by a guided check (technician answer + log)."""
f = self.findings.get(key)
if f is None:
f = self.findings[key] = Finding(key, key, severity, title, meaning, tuple(actions))
f.count += 1
f.boot = self.boot.index
f.resolved = False
f.evidence.append("confirmed by the technician during a guided check")
return f
def note_technician(self, text: str) -> None:
self.technician.append(text)
# ------------------------------------------------------------ internals # ------------------------------------------------------------ internals
def _new_boot(self, host_time: float, explicit: bool) -> None: def _new_boot(self, host_time: float, explicit: bool) -> None:
+6
View File
@@ -26,6 +26,12 @@ KNOWN: dict[str, KnownFirmware] = {
sd_wifi=False, sd_wifi=False,
setup_portal=False, setup_portal=False,
), ),
"c94ba0e9b": KnownFirmware(
name="v1.0 source build (Sep 26 2026)",
source="developer build, built by Krzysztof Kallas",
sd_wifi=True,
setup_portal=True,
),
} }
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})") _ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
+401
View File
@@ -0,0 +1,401 @@
"""Guided troubleshooting: executes the decision trees in guides.toml.
GUI-independent. The runner is fed the same LogLines as the diagnostics
engine, reads the engine's report to route and to skip questions the log
already answers, asks the host to perform tool actions (board reset), and
records every answer and conclusion in the engine so they appear in the
report and the session log.
"""
from __future__ import annotations
import re
import time
import tomllib
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from ..core.logline import LogLine
from .engine import LED, DiagnosticsEngine, Severity
GUIDES_PATH = Path(__file__).with_name("guides.toml")
# LED answers a technician can give (engine colours + a reboot-loop pattern).
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "OFF", "CYCLING"]
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
"CYCLING": "Keeps changing / goes dark every few seconds"}
STEP_TYPES = {"led", "question", "info", "wait", "route"}
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
"error": Severity.ERROR, "critical": Severity.CRITICAL}
@dataclass(frozen=True)
class Option:
label: str
next: str
@dataclass(frozen=True)
class Step:
id: str
type: str
text: str = ""
options: tuple[Option, ...] = ()
route: dict[str, str] = field(default_factory=dict) # led colour -> target
routes: dict[str, str] = field(default_factory=dict) # rule id -> target
default: str | None = None
next: str | None = None
tool_action: str | None = None
until: re.Pattern[str] | None = None
until_milestone: str | None = None
fail_on: re.Pattern[str] | None = None
timeout_s: float = 60.0
pass_on_timeout: bool = False
on_ok: str | None = None
on_fail: str | None = None
on_timeout: str | None = None
skip_if_finding: str | None = None
skip_unless_feature: str | None = None
skip_to: str | None = None
show_qr: bool = False # show the Matter pairing QR next to the step text
def targets(self) -> list[str]:
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x]
return t
@dataclass(frozen=True)
class End:
id: str
severity: str
title: str
text: str
actions: tuple[str, ...] = ()
@dataclass(frozen=True)
class Guide:
id: str
title: str
summary: str
recommend: tuple[str, ...]
start: str
steps: dict[str, Step]
ends: dict[str, End]
def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
data = tomllib.loads(path.read_text(encoding="utf-8"))
guides: dict[str, Guide] = {}
for g in data["guide"]:
steps = {}
for s in g.get("step", []):
steps[s["id"]] = Step(
id=s["id"], type=s["type"], text=s.get("text", ""),
options=tuple(Option(o["label"], o["next"]) for o in s.get("options", ())),
route=dict(s.get("route", {})), routes=dict(s.get("routes", {})),
default=s.get("default"), next=s.get("next"), tool_action=s.get("tool_action"),
until=re.compile(s["until"]) if "until" in s else None,
until_milestone=s.get("until_milestone"),
fail_on=re.compile(s["fail_on"]) if "fail_on" in s else None,
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
)
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
for e in g.get("end", [])}
guides[g["id"]] = Guide(g["id"], g["title"], g["summary"], tuple(g.get("recommend", ())),
g["start"], steps, ends)
_validate(guides)
return guides
def _validate(guides: dict[str, Guide]) -> None:
for g in guides.values():
if g.start not in g.steps:
raise ValueError(f"guide {g.id}: start step {g.start!r} missing")
for s in g.steps.values():
if s.type not in STEP_TYPES:
raise ValueError(f"{g.id}.{s.id}: unknown step type {s.type!r}")
if s.type == "led" and set(s.route) - set(LED_ANSWERS):
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {set(s.route) - set(LED_ANSWERS)}")
if s.type == "wait" and not (s.until or s.until_milestone or s.pass_on_timeout):
raise ValueError(f"{g.id}.{s.id}: wait step needs until/until_milestone/pass_on_timeout")
if s.tool_action not in (None, "reset"):
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
for t in s.targets():
kind, _, ref = t.partition(":")
if kind == "end" and ref not in g.ends:
raise ValueError(f"{g.id}.{s.id}: unknown end {ref!r}")
elif kind == "guide" and ref not in guides:
raise ValueError(f"{g.id}.{s.id}: unknown guide {ref!r}")
elif not ref and t not in g.steps:
raise ValueError(f"{g.id}.{s.id}: unknown step {t!r}")
for e in g.ends.values():
if e.severity not in END_SEVERITY:
raise ValueError(f"{g.id}.end.{e.id}: unknown severity {e.severity!r}")
def recommended(guides: dict[str, Guide], engine: DiagnosticsEngine) -> list[Guide]:
"""Guides matching the current root findings (in root order), then the LED."""
report = engine.report()
keys: list[str] = []
for rc in report.roots:
keys.append(rc.finding.rule_id)
keys += [c.rule_id for c in rc.consequences]
if report.led not in ("UNKNOWN",):
keys.append(f"led:{report.led}")
out: list[Guide] = [g for g in guides.values() if "always" in g.recommend]
for k in keys:
for g in guides.values():
if k in g.recommend and g not in out:
out.append(g)
return out
@dataclass
class View:
"""What the UI should show right now."""
guide: Guide | None = None
step: Step | None = None
text: str = ""
options: list[tuple[str, str]] = field(default_factory=list) # (key, label)
waiting: bool = False
remaining_s: float = 0.0
note: str = ""
end: End | None = None
can_back: bool = False
class GuideRunner:
def __init__(self, engine: DiagnosticsEngine, guides: dict[str, Guide] | None = None,
on_action: Callable[[str], bool] | None = None,
on_log: Callable[[str], None] | None = None,
clock: Callable[[], float] = time.monotonic):
self.engine = engine
self.guides = guides if guides is not None else load_guides()
self._on_action = on_action or (lambda _a: False)
self._on_log = on_log or (lambda _t: None)
self._clock = clock
self.cancel()
# ------------------------------------------------------------ control
def cancel(self) -> None:
self.guide: Guide | None = None
self.step: Step | None = None
self.end: End | None = None
self.note = ""
self._stack: list[tuple[str, str]] = [] # (guide id, step id) for Back
self._wait_started = 0.0
self._wait_boot = 0
self._wait_milestones: set[str] = set()
def start(self, guide_id: str) -> None:
self.cancel()
self._enter_guide(guide_id)
def answer(self, key: str) -> None:
"""Pick an option: LED colour name for led steps, option index otherwise."""
s = self.step
if s is None:
return
if s.type == "led":
label = LED_ANSWER_LABELS.get(key, key)
self._record(s, label)
self._check_led(key)
target = s.route.get(key)
elif s.type == "question":
opt = s.options[int(key)]
self._record(s, opt.label)
target = opt.next
else:
return
if target:
self._push()
self._go(target)
def proceed(self) -> None:
"""Continue on info steps; 'skip waiting' on wait steps."""
s = self.step
if s is None:
return
if s.type == "info" and s.next:
self._push()
self._go(s.next)
elif s.type == "wait":
self._record(s, "technician skipped the wait")
self._finish_wait("timeout")
def back(self) -> None:
# Never step back into a wait: re-entering it could restart the box.
while self._stack:
gid, sid = self._stack.pop()
if self.guides[gid].steps[sid].type != "wait":
self.guide, self.end, self.note = self.guides[gid], None, ""
self._enter_step(sid)
return
# --------------------------------------------------------------- feed
def feed(self, line: LogLine) -> None:
s = self.step
if s is None or s.type != "wait":
return
if s.fail_on and s.fail_on.search(line.text):
self._record(s, f"log: {line.text}")
self._finish_wait("fail")
elif s.until and s.until.search(line.text):
self._record(s, f"log: {line.text}")
self._finish_wait("ok")
elif s.until_milestone and self._milestone_new(s.until_milestone):
self._record(s, f"milestone reached: {s.until_milestone}")
self._finish_wait("ok")
def tick(self) -> None:
s = self.step
if s is not None and s.type == "wait" and self._clock() - self._wait_started >= s.timeout_s:
self._finish_wait("ok" if s.pass_on_timeout else "timeout")
# --------------------------------------------------------------- view
def view(self) -> View:
v = View(guide=self.guide, step=self.step, end=self.end, note=self.note, can_back=bool(self._stack))
s = self.step
if s is None:
return v
v.text = self._render(s.text)
if s.type == "led":
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
elif s.type == "question":
v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
elif s.type == "wait":
v.waiting = True
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
return v
# ---------------------------------------------------------- internals
def _push(self) -> None:
if self.guide and self.step:
self._stack.append((self.guide.id, self.step.id))
def _enter_guide(self, guide_id: str) -> None:
self.guide = self.guides[guide_id]
self._on_log(f"technician: started guide '{self.guide.title}'")
self._enter_step(self.guide.start)
def _go(self, target: str) -> None:
kind, _, ref = target.partition(":")
if kind == "end" and ref:
self._conclude(self.guide.ends[ref])
elif kind == "guide" and ref:
self._enter_guide(ref)
else:
self._enter_step(target)
def _enter_step(self, step_id: str) -> None:
s = self.guide.steps[step_id]
self.step, self.note = s, ""
if s.skip_if_finding and self._finding_active(s.skip_if_finding):
self._auto(s, f"skipped - log already shows {s.skip_if_finding}", s.skip_to)
return
if s.skip_unless_feature and not self._feature(s.skip_unless_feature):
self._auto(s, f"skipped - firmware has no {s.skip_unless_feature}", s.skip_to)
return
if s.type == "route":
target = self._route(s)
self._auto(s, f"routed by log findings -> {target}", target)
return
if s.type == "wait":
self._wait_started = self._clock()
self._wait_boot = self.engine.boot.index
self._wait_milestones = set(self.engine.milestones)
if s.tool_action and not self._on_action(s.tool_action):
self.note = "Could not restart the box from the tool - connect to it first, or switch it off and on."
# A milestone that is already reached satisfies the wait at once
# unless the step first restarts the box.
if s.until_milestone and not s.tool_action and s.until_milestone in self.engine.milestones:
self._record(s, f"already reached: {s.until_milestone}")
self._finish_wait("ok")
def _auto(self, s: Step, why: str, target: str | None) -> None:
self._on_log(f"technician guide: {s.id}: {why}")
if target:
self._go(target)
def _finish_wait(self, outcome: str) -> None:
s = self.step
target = {"ok": s.on_ok, "fail": s.on_fail or s.on_timeout, "timeout": s.on_timeout or s.on_fail}[outcome]
if target is None:
return
self._push()
self._go(target)
def _milestone_new(self, mid: str) -> bool:
if mid not in self.engine.milestones:
return False
return self.engine.boot.index != self._wait_boot or mid not in self._wait_milestones
def _route(self, s: Step) -> str:
report = self.engine.report()
for rc in report.roots:
for f in (rc.finding, *rc.consequences):
if f.rule_id in s.routes:
return s.routes[f.rule_id]
return s.default or "end:" + next(iter(self.guide.ends))
def _check_led(self, seen: str) -> None:
expected = self.engine.led
if expected in ("UNKNOWN", "OFF") or seen == "CYCLING" or seen == expected:
return
exp_label, see_label = LED[expected][0], LED_ANSWER_LABELS[seen]
self.engine.add_confirmed(
"tech.led_mismatch", Severity.WARNING,
f"LED shows {see_label}, but the log implies {exp_label}",
f"The firmware state in the log corresponds to {exp_label}, while the technician sees {see_label}. "
"Either the LED just changed (check again in a few seconds), the LED/its wiring is faulty, "
"or the box runs different firmware. Trust the log findings.",
("Look at the LED again for 10 seconds.", "If it stays different: note it for repair (LED board)."))
def _conclude(self, end: End) -> None:
self.step, self.end = None, end
self._on_log(f"technician guide result: {end.title}")
sev = END_SEVERITY[end.severity]
self.engine.note_technician(f"{self.guide.title}: {end.title}")
if sev is not None:
self.engine.add_confirmed(f"tech.{self.guide.id}.{end.id}", sev, end.title, end.text, end.actions)
def _record(self, s: Step, answer: str) -> None:
q = self._render(s.text)
self.engine.note_technician(f"{q} -> {answer}")
self._on_log(f"technician: {q[:80]} -> {answer}")
def _finding_active(self, rule_id: str) -> bool:
return any(f.rule_id == rule_id and f.active for f in self.engine.findings.values())
def _feature(self, name: str) -> bool:
fw = self.engine.firmware
if name == "sd_wifi":
return fw.supports_sd_wifi is not False
if name == "setup_portal":
if fw.known is not None:
return fw.known.setup_portal
if "setup_portal" in fw.evidence:
return True
return "legacy_v1" not in fw.evidence # unknown firmware: ask
return False
def _render(self, text: str) -> str:
report = self.engine.report()
sensors = sorted({f.title.removesuffix(" failed to start") for f in self.engine.findings.values()
if f.rule_id == "sensor.init_failed" and f.active})
ctx = {
"expected_led": LED[report.led][0],
"failing_sensors": ", ".join(sensors) or "see the Diagnosis tab",
"top_finding": report.roots[0].finding.title if report.roots else "none",
}
return text.format_map(_Fmt(ctx))
class _Fmt(dict):
def __missing__(self, key: str) -> str:
return "{" + key + "}"
+735
View File
@@ -0,0 +1,735 @@
# Guided troubleshooting: decision trees that combine technician answers
# with live log evidence (diag.guides.GuideRunner executes them).
#
# Guide: id, title, summary, recommend (rule ids / "led:<COLOUR>" / "always"),
# start (first step id), [[guide.step]], [[guide.end]]
# Step types:
# led technician picks the LED colour; `route` maps colour -> target.
# Colours: GREEN PURPLE RED BLUE YELLOW ORANGE WHITE OFF CYCLING
# The answer is compared with the colour the firmware log implies.
# question `options` = [{label, next}]
# info instruction + Continue (`next`)
# wait optional `tool_action` ("reset"), then watch the log:
# `until` (regex) / `until_milestone`, `fail_on` (regex),
# `timeout_s`; go to on_ok / on_fail / on_timeout.
# `pass_on_timeout = true`: reaching the timeout without fail_on
# counts as ok (used for "watch for N seconds").
# route automatic: first current root finding whose rule id is in
# `routes` decides; otherwise `default`.
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
# End severity: ok | info | warning | error | critical
# ================================================================= quick
[[guide]]
id = "quick"
title = "Quick check - start here"
summary = "Tell the tool what the LED shows; it picks the right checks."
recommend = ["always"]
start = "led"
[[guide.step]]
id = "led"
type = "led"
text = "Look at the RGB status LED on the box (no need to open it). Which colour is it?"
route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", OFF = "guide:power", CYCLING = "guide:reboot_loop" }
[[guide.step]]
id = "heartbeat"
type = "question"
text = "Is the small LED next to the RGB LED blinking about once per second?"
options = [
{ label = "Yes, blinking", next = "green_route" },
{ label = "No - steady on or off", next = "end:hung" },
]
[[guide.step]]
id = "green_route"
type = "route"
routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" }
default = "end:healthy"
[[guide.end]]
id = "healthy"
severity = "ok"
title = "Box looks healthy"
text = "Green LED, firmware running and no problems in the log."
actions = ["If the customer still reports missing data, keep the tool connected for 5 minutes and check that 'Data uploaded to InfluxDB' gets ticked."]
[[guide.end]]
id = "hung"
severity = "error"
title = "Firmware is not running normally"
text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen."
actions = [
"Switch the box off and on with the power switch.",
"If it happens again: save the session log and re-flash the official firmware (Flash firmware tab, App only).",
]
# =============================================================== network
[[guide]]
id = "network"
title = "Purple LED / no Internet"
summary = "Pairing in Matter, site Wi-Fi and Internet checks, with live confirmation."
recommend = ["led:PURPLE", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed"]
start = "paired"
[[guide.step]]
id = "paired"
type = "question"
skip_if_finding = "matter.not_commissioned"
skip_to = "pair_intro"
text = "Was this box already added to the customer's Matter app (Apple Home, Google Home, ...) at this site?"
options = [
{ label = "Yes, it was paired before", next = "internet" },
{ label = "No / not sure", next = "pair_intro" },
]
[[guide.step]]
id = "internet"
type = "question"
text = "Does the site Wi-Fi have working Internet? Check with a phone connected to the same Wi-Fi (open any website)."
options = [
{ label = "Yes, Internet works", next = "changed" },
{ label = "No Internet on the site Wi-Fi", next = "end:site_offline" },
]
[[guide.step]]
id = "changed"
type = "question"
text = "Was the Wi-Fi name or password changed, or the router replaced, since the box was installed?"
options = [
{ label = "Yes / possibly", next = "end:repair_needed" },
{ label = "No", next = "end:offline_unknown" },
]
[[guide.step]]
id = "pair_intro"
type = "info"
text = "The box is not on any Wi-Fi yet: it must be paired in Matter. You need: a phone with Bluetooth on, the customer's Matter app, the 2.4 GHz Wi-Fi password, and the QR sticker of this box. Keep the tool connected - it follows the pairing live."
next = "pair_start"
[[guide.step]]
id = "pair_start"
type = "wait"
text = "In the Matter app choose 'Add device' and scan the QR code shown here (the same as the sticker), or type the 11-digit manual code. Keep the phone within 1 m of the box."
show_qr = true
until = 'MAIN: Commissioning session started'
timeout_s = 300
on_ok = "pair_wifi"
on_timeout = "not_found"
[[guide.step]]
id = "not_found"
type = "question"
text = "The box has not seen the phone connect yet. What does the app show?"
options = [
{ label = "Still searching / let me try again", next = "pair_start" },
{ label = "'Device not found' or similar", next = "end:ble_not_found" },
]
[[guide.step]]
id = "pair_wifi"
type = "wait"
text = "The phone is connected to the box. Follow the app: choose the 2.4 GHz Wi-Fi network and enter its password."
until = 'MAIN: Commissioning complete|MAIN: Fabric is committed'
fail_on = 'Commissioning failed, fail safe timer expired'
timeout_s = 300
on_ok = "pair_time"
on_fail = "end:pair_failed"
on_timeout = "end:pair_failed"
[[guide.step]]
id = "pair_time"
type = "wait"
text = "Paired! The box is joining the Wi-Fi and getting the time from the Internet..."
until_milestone = "time_synced"
timeout_s = 120
on_ok = "pair_upload"
on_timeout = "end:joined_no_internet"
[[guide.step]]
id = "pair_upload"
type = "wait"
text = "Online. Waiting for the first measurements to reach the database (up to 4 minutes)..."
until = 'INFLUX: HTTP request performed, status code: 2\d\d'
fail_on = 'INFLUX: Failed to perform HTTP request: (401|403|404)'
timeout_s = 240
on_ok = "end:online"
on_fail = "end:influx_rejected"
on_timeout = "end:no_upload"
[[guide.end]]
id = "online"
severity = "ok"
title = "Box is paired and uploading data"
text = "Pairing, Internet time and the first upload all worked."
actions = [
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
"It should end green.",
]
[[guide.end]]
id = "site_offline"
severity = "warning"
title = "Site network has no Internet"
text = "The box cannot work until the site Wi-Fi reaches the Internet. This is not a box fault."
actions = ["Ask the customer to fix the Internet connection, then power-cycle the box."]
[[guide.end]]
id = "repair_needed"
severity = "warning"
title = "Box must be paired again"
text = "The box still remembers the old Wi-Fi. It needs a factory reset and new pairing."
actions = [
"Hold the Matter button for 5 seconds (LED turns white) - this erases the old pairing.",
"Remove the box from the customer's Matter app, then run this guide again and choose 'No / not sure'.",
]
[[guide.end]]
id = "offline_unknown"
severity = "error"
title = "Box is paired but cannot reach the Internet - cause unclear"
text = "The network works for other devices, but not for this box."
actions = [
"Check the router does not block new devices (guest isolation, MAC filter). Box MAC is shown in the port list.",
"Move the box closer to the router to rule out weak signal.",
"If nothing helps: factory reset (hold the Matter button 5 s) and pair again.",
]
[[guide.end]]
id = "ble_not_found"
severity = "error"
title = "Phone cannot find the box over Bluetooth"
text = "The box only accepts pairing for a limited time after start-up."
actions = [
"Switch the box off and on to reopen the pairing window, then try again within 10 minutes.",
"Check Bluetooth and location permission are on for the Matter app.",
"If the box was paired to another app before: hold the Matter button 5 s (factory reset) first.",
]
[[guide.end]]
id = "pair_failed"
severity = "error"
title = "Pairing did not finish"
text = "The phone connected but the box did not join the Wi-Fi."
actions = [
"Use a 2.4 GHz network (many routers have a separate 2.4 GHz name).",
"Re-enter the Wi-Fi password carefully.",
"Switch the box off and on, then pair again.",
]
[[guide.end]]
id = "joined_no_internet"
severity = "error"
title = "Box joined the Wi-Fi but has no Internet"
text = "Pairing worked, but the box cannot reach time or data servers."
actions = [
"Check the network allows outgoing connections (HTTPS, NTP) for new devices.",
"Try a phone hotspot to rule out the site network.",
]
[[guide.end]]
id = "influx_rejected"
severity = "critical"
title = "Database rejected the box (token / bucket)"
text = "The box is online but InfluxDB refused the upload. The SD card settings are wrong."
actions = ["Re-provision the SD card with the current ENV.TXT (SD card is inside the case)."]
[[guide.end]]
id = "no_upload"
severity = "warning"
title = "Online, but no upload seen yet"
text = "Uploads happen every minute; the first one can be delayed while the air sensor warms up."
actions = ["Keep the tool connected a few more minutes and watch the Diagnosis tab."]
# ================================================================= RTC / I2C
[[guide]]
id = "rtc_i2c"
title = "Blue LED - clock / I2C cable"
summary = "Re-seat the I2C cable and verify the start-up live."
recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors"]
start = "explain"
[[guide.step]]
id = "explain"
type = "info"
text = "Blue means the clock chip did not answer. It is the first chip on the shared I2C cable between the boards, so a loose cable is the most common cause. This check needs the case opened."
next = "reseat"
[[guide.step]]
id = "reseat"
type = "info"
text = "Switch the power OFF. Open the case. Unplug and firmly re-plug the I2C cable between the boards at both ends. Close the case and switch the power ON. Then press Continue."
next = "verify"
[[guide.step]]
id = "verify"
type = "wait"
text = "Watching the start-up (up to 60 s)..."
until_milestone = "sensors_ready"
fail_on = 'MAIN: Failed to initialize RTC'
timeout_s = 60
on_ok = "end:i2c_fixed"
on_fail = "damaged"
on_timeout = "damaged"
[[guide.step]]
id = "damaged"
type = "question"
text = "The clock still does not answer. Is the I2C cable or its connector visibly damaged (pinched, broken wire, bent pin)?"
options = [
{ label = "Yes, damaged", next = "end:replace_cable" },
{ label = "No, looks fine", next = "end:rtc_module" },
]
[[guide.end]]
id = "i2c_fixed"
severity = "ok"
title = "Fixed - all sensors start again"
text = "After re-seating the cable the clock and all sensors started."
actions = ["Make sure the cable is routed without strain before closing the case."]
[[guide.end]]
id = "replace_cable"
severity = "error"
title = "Replace the I2C cable"
text = "A damaged I2C cable stops the clock and all sensors."
actions = ["Replace the cable, then run this check again."]
[[guide.end]]
id = "rtc_module"
severity = "error"
title = "Clock (RTC) module is probably faulty"
text = "The cable is fine but the clock chip does not answer."
actions = ["Replace the RTC module, or send the box for repair with the session log."]
# ==================================================================== white
[[guide]]
id = "white"
title = "White LED - SD card / reset / setup mode"
summary = "Tell apart the three white states and check the SD card."
recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail"]
start = "button"
[[guide.step]]
id = "button"
type = "question"
text = "Did anyone hold the Matter button (outside the case) for about 5 seconds just before the LED turned white?"
options = [
{ label = "Yes", next = "end:factory_reset" },
{ label = "No / don't know", next = "portal" },
]
[[guide.step]]
id = "portal"
type = "question"
skip_unless_feature = "setup_portal"
skip_to = "sd_reseat"
text = "On a phone, look at the Wi-Fi list: is there a network called 'KotaSensorBox-....'?"
options = [
{ label = "Yes", next = "end:setup_mode" },
{ label = "No", next = "sd_reseat" },
]
[[guide.step]]
id = "sd_reseat"
type = "info"
text = "The SD card is probably not readable. This needs the case opened: switch the power OFF, open the case, push the SD card in until it clicks (or remove and re-insert it). Close the case, switch the power ON, then press Continue."
next = "sd_verify"
[[guide.step]]
id = "sd_verify"
type = "wait"
text = "Watching the SD card start-up (up to 60 s)..."
until = 'Read INFLUX_URL from \.env file'
fail_on = 'Failed to initialize the card|Failed to mount filesystem|Failed to read INFLUX_(URL|TOKEN)|Failed to open \.env file'
timeout_s = 60
on_ok = "end:sd_fixed"
on_fail = "end:sd_reprovision"
on_timeout = "end:sd_reprovision"
[[guide.end]]
id = "factory_reset"
severity = "info"
title = "White = factory reset in progress"
text = "The box erased its Matter pairing and restarts."
actions = ["Pair it again in the Matter app (Purple LED guide)."]
[[guide.end]]
id = "setup_mode"
severity = "info"
title = "White = Wi-Fi setup mode"
text = "The box is waiting for Wi-Fi settings on its own network."
actions = [
"Connect a phone to KotaSensorBox-.... and open http://sensorbox.local/ to enter the Wi-Fi,",
"or short-press the Matter button (< 2 s) to leave setup mode.",
]
[[guide.end]]
id = "sd_fixed"
severity = "ok"
title = "Fixed - SD card is read again"
text = "The box found the SD card and read ENV.TXT."
actions = ["Close the case and watch the Diagnosis tab for a minute."]
[[guide.end]]
id = "sd_reprovision"
severity = "critical"
title = "SD card is unreadable or has wrong settings"
text = "Even after re-seating, the card or its ENV.TXT cannot be used."
actions = [
"Re-provision the card with the tool (SD card tab) or use a new card.",
"The Diagnosis tab shows which step failed (card, file system or ENV.TXT keys).",
]
# ================================================================== sensors
[[guide]]
id = "sensors"
title = "Red LED - sensor or hardware fault"
summary = "Find the failing sensor and verify after re-seating its cable."
recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed"]
start = "which"
[[guide.step]]
id = "which"
type = "route"
routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "ota.selfcheck_failed" = "end:selfcheck", "sensor.selfcheck_failed" = "end:selfcheck", "sys.out_of_resources" = "end:oom" }
default = "capture"
[[guide.step]]
id = "capture"
type = "wait"
text = "The log does not show the cause yet. The tool restarts the box and records its start-up (45 s)..."
tool_action = "reset"
timeout_s = 45
pass_on_timeout = true
on_ok = "which_again"
[[guide.step]]
id = "which_again"
type = "route"
routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "sys.out_of_resources" = "end:oom" }
default = "end:red_unclear"
[[guide.step]]
id = "reseat"
type = "info"
text = "Failing: {failing_sensors}. This needs the case opened: switch the power OFF, open the case and re-seat the cable of that sensor. Close the case, switch the power ON, then press Continue."
next = "verify"
[[guide.step]]
id = "verify"
type = "wait"
text = "Watching the sensor start-up (up to 60 s)..."
until_milestone = "sensors_ready"
fail_on = 'MAIN: Failed to initialize'
timeout_s = 60
on_ok = "end:sensor_fixed"
on_fail = "end:sensor_replace"
on_timeout = "end:sensor_replace"
[[guide.end]]
id = "sensor_fixed"
severity = "ok"
title = "Fixed - all sensors start again"
text = "After re-seating, every sensor started."
actions = ["Close the case and watch the Diagnosis tab for a minute."]
[[guide.end]]
id = "sensor_replace"
severity = "error"
title = "Sensor still fails - replace it"
text = "The sensor does not start even with a re-seated cable."
actions = ["Replace the named sensor (or its cable) and run this check again."]
[[guide.end]]
id = "selfcheck"
severity = "error"
title = "A firmware update was rejected by its self-check"
text = "The box rolled back to its previous firmware because a sensor failed the test."
actions = ["Check the sensor named in the Diagnosis tab (inside the case), then power-cycle."]
[[guide.end]]
id = "oom"
severity = "error"
title = "Firmware ran out of memory"
text = "Not a hardware fault; the firmware restarts itself."
actions = ["Power-cycle. If it repeats, send the session log to the firmware team."]
[[guide.end]]
id = "red_unclear"
severity = "warning"
title = "Red LED without a clear cause in the log"
text = "The start-up did not show a failing sensor."
actions = ["Save the session log and send it to the firmware team."]
# ============================================================= reboot loop
[[guide]]
id = "reboot_loop"
title = "LED keeps changing / box restarts"
summary = "Record several start-ups and find the error just before each restart."
recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog"]
start = "record"
[[guide.step]]
id = "record"
type = "wait"
text = "The tool restarts the box and records 45 seconds of start-up..."
tool_action = "reset"
timeout_s = 45
pass_on_timeout = true
on_ok = "cause"
[[guide.step]]
id = "cause"
type = "route"
routes = { "rtc.init_failed" = "guide:rtc_i2c", "sd.card_fail" = "guide:white", "sd.mount_fail" = "guide:white", "sd.env_open_fail" = "guide:white", "influx.env_missing" = "guide:white", "influx.init_fail" = "guide:white", "sensor.init_failed" = "guide:sensors", "reboot.brownout" = "end:power_supply", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network", "influx.buffer_fail" = "guide:network" }
default = "end:loop_unclear"
[[guide.end]]
id = "power_supply"
severity = "error"
title = "Power supply is too weak or unstable"
text = "The chip reset itself because the voltage dropped."
actions = ["Check the power supply, cable and connectors; try another supply."]
[[guide.end]]
id = "loop_unclear"
severity = "warning"
title = "No clear restart cause in 45 s"
text = "The box did not show a known start-up error during the recording."
actions = ["Keep the tool connected for 5 minutes, then save the session log for the firmware team."]
# ================================================================== update
[[guide]]
id = "update"
title = "Yellow / orange LED - firmware update"
summary = "Wait for the update and its self-check to finish."
recommend = ["led:YELLOW", "led:ORANGE", "ota.updating", "ota.selfcheck"]
start = "wait"
[[guide.step]]
id = "wait"
type = "wait"
text = "A firmware update or its self-check is running. Do NOT switch the box off. Waiting up to 10 minutes..."
until = 'Passing selfcheck|OTA update successful'
fail_on = 'OTA update failed|Failing selfcheck'
timeout_s = 600
on_ok = "end:update_ok"
on_fail = "end:update_failed"
on_timeout = "end:update_slow"
[[guide.end]]
id = "update_ok"
severity = "ok"
title = "Update finished"
text = "The new firmware was installed or accepted."
actions = ["Wait for the green LED."]
[[guide.end]]
id = "update_failed"
severity = "error"
title = "Update failed"
text = "The box keeps its current firmware and retries later."
actions = ["Check the Wi-Fi is stable. The Diagnosis tab shows why it failed."]
[[guide.end]]
id = "update_slow"
severity = "warning"
title = "Update is taking very long"
text = "No end of the update was seen in 10 minutes."
actions = ["Check the Wi-Fi signal; leave the box powered and check again later."]
# =================================================================== power
[[guide]]
id = "power"
title = "LED off"
summary = "Power switch, heartbeat LED and USB connection."
recommend = ["led:OFF"]
start = "switch"
[[guide.step]]
id = "switch"
type = "question"
text = "Is the power switch on the outside of the case set to ON?"
options = [
{ label = "It was OFF - I switched it on", next = "boot" },
{ label = "It is ON", next = "heartbeat" },
]
[[guide.step]]
id = "boot"
type = "wait"
text = "Waiting for the box to start (up to 30 s)..."
until_milestone = "app_started"
timeout_s = 30
on_ok = "end:was_off"
on_timeout = "heartbeat"
[[guide.step]]
id = "heartbeat"
type = "question"
text = "Is the small LED next to the RGB LED blinking?"
options = [
{ label = "Yes, blinking", next = "end:rgb_led_fault" },
{ label = "No", next = "end:no_power" },
]
[[guide.end]]
id = "was_off"
severity = "ok"
title = "Box was switched off"
text = "It starts normally after switching on."
actions = ["Run the Quick check again once the LED shows a colour."]
[[guide.end]]
id = "rgb_led_fault"
severity = "warning"
title = "Firmware runs, but the RGB LED stays dark"
text = "The heartbeat LED shows the firmware is running; the RGB LED or its wiring is faulty."
actions = ["Use the Diagnosis tab instead of the LED; replace the LED board when convenient."]
[[guide.end]]
id = "no_power"
severity = "error"
title = "Box does not start"
text = "Neither LED is lit: no power or the firmware does not start."
actions = [
"Check the power supply and cable.",
"If the USB port shows up in this tool, re-flash the official firmware (Flash firmware tab, App only).",
]
# =================================================================== clock
[[guide]]
id = "clock"
title = "Clock not set"
summary = "Why readings are on hold and when the coin cell matters."
recommend = ["rtc.clock_not_running", "rtc.lost_after_sync"]
start = "internet"
[[guide.step]]
id = "internet"
type = "question"
text = "The box clock is not set, so readings are on hold. Will the box have Internet at its installation site?"
options = [
{ label = "Yes", next = "end:clock_auto" },
{ label = "No / not yet", next = "end:clock_offline" },
]
[[guide.end]]
id = "clock_auto"
severity = "info"
title = "Clock will be set automatically"
text = "On its first Internet connection the box sets its clock and starts recording."
actions = ["If the clock is lost again after every power-off, replace the RTC coin cell (inside the case)."]
[[guide.end]]
id = "clock_offline"
severity = "error"
title = "Box will not record data without Internet"
text = "Without the Internet the clock cannot be set, and the firmware holds all readings until it is."
actions = [
"Give the box Internet access (pair it in Matter).",
"Replace the RTC coin cell (inside the case) so the time survives power-offs.",
]
# ===================================================================== mic
[[guide]]
id = "mic"
title = "Microphone check"
summary = "Switched-off or silent microphone."
recommend = ["mic.disabled", "mic.silent", "mic.read_fail"]
start = "which"
[[guide.step]]
id = "which"
type = "route"
routes = { "mic.disabled" = "switch", "mic.silent" = "clap", "mic.read_fail" = "end:mic_faulty" }
default = "clap"
[[guide.step]]
id = "switch"
type = "question"
text = "The box reads its microphone switch as OFF. The switch is on the back of the case, just above the power switch (no need to open the box). Should this box measure noise?"
options = [
{ label = "Yes - I flipped the microphone switch", next = "switch_verify" },
{ label = "No - it is meant to be off", next = "end:mic_off_ok" },
]
[[guide.step]]
id = "switch_verify"
type = "wait"
# The firmware re-reads the switch before every reading (~10 s): no restart needed.
text = "Waiting for the next microphone reading (up to 30 s)..."
until = 'MICROPHONE: Read \d+ bytes'
fail_on = 'Microphone is in shutdown state'
timeout_s = 30
on_ok = "end:mic_on"
on_fail = "flip_back"
on_timeout = "flip_back"
[[guide.step]]
id = "flip_back"
type = "question"
text = "The box still reads the switch as OFF. Flip the microphone switch to the other position (it may have been ON already)."
options = [
{ label = "Done - I flipped it to the other position", next = "switch_verify2" },
{ label = "Leave it off", next = "end:mic_off_ok" },
]
[[guide.step]]
id = "switch_verify2"
type = "wait"
text = "Waiting for the next microphone reading (up to 30 s)..."
until = 'MICROPHONE: Read \d+ bytes'
fail_on = 'Microphone is in shutdown state'
timeout_s = 30
on_ok = "end:mic_on"
on_fail = "end:mic_switch_faulty"
on_timeout = "end:mic_switch_faulty"
[[guide.step]]
id = "clap"
type = "wait"
text = "Check the microphone switch (back of the case, above the power switch) is ON. Then clap your hands or speak loudly right next to the box for about 20 seconds..."
until = 'dB peak: (?!-inf)'
timeout_s = 45
on_ok = "end:mic_ok"
on_timeout = "end:mic_faulty"
[[guide.end]]
id = "mic_on"
severity = "ok"
title = "Microphone is on and recording"
text = "The firmware reads audio samples again."
actions = ["Leave the microphone switch in this position."]
[[guide.end]]
id = "mic_off_ok"
severity = "info"
title = "Microphone intentionally off"
text = "No noise data is recorded for this box; everything else works."
actions = []
[[guide.end]]
id = "mic_switch_faulty"
severity = "warning"
title = "Microphone switch has no effect"
text = "The box reads the microphone as OFF in both switch positions (enable line GPIO3)."
actions = ["Check the switch and its cable to the main board (inside the case); replace the switch if needed."]
[[guide.end]]
id = "mic_ok"
severity = "ok"
title = "Microphone works"
text = "It picked up sound - the earlier silence was just a very quiet room."
actions = []
[[guide.end]]
id = "mic_faulty"
severity = "error"
title = "Microphone records nothing"
text = "Even loud noise next to the box gives no signal, although the switch is on."
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
+9 -2
View File
@@ -72,6 +72,13 @@ severity = "ignore"
milestone = "wifi_connected" milestone = "wifi_connected"
clears = ["matter.not_commissioned", "wifi.disconnected"] clears = ["matter.not_commissioned", "wifi.disconnected"]
[[rule]]
id = "matter.commissioned"
pattern = 'MAIN: Commissioning complete|MAIN: Fabric is committed'
severity = "ignore"
milestone = "matter_commissioned"
clears = ["matter.not_commissioned", "matter.commissioning_failed"]
[[rule]] [[rule]]
id = "net.time_synced" id = "net.time_synced"
pattern = 'SNTP: Time synchronized' pattern = 'SNTP: Time synchronized'
@@ -551,8 +558,8 @@ id = "mic.disabled"
pattern = 'MICROPHONE: Microphone is in shutdown state' pattern = 'MICROPHONE: Microphone is in shutdown state'
severity = "info" severity = "info"
title = "Microphone is switched off - no noise measurements" title = "Microphone is switched off - no noise measurements"
meaning = "The firmware reads the microphone enable line (GPIO3) as 'off', which it treats as disabled on purpose. Noise levels are not recorded." meaning = "The microphone switch on the back of the case (just above the power switch) is OFF, so noise levels are not recorded."
actions = ["If this box should measure noise: check the microphone enable switch/jumper and its wiring (inside the case)."] actions = ["If this box should measure noise: flip the microphone switch (back of the case, above the power switch). No restart needed - it is picked up within about 10 s."]
[[rule]] [[rule]]
id = "gather.empty_reading" id = "gather.empty_reading"
View File
Whitespace-only changes.
+303
View File
@@ -0,0 +1,303 @@
"""Firmware images: validation, integrity and layout.
Two origins:
* factory - bundled with the tool (firmware/firmware.toml), SHA-256 locked;
* developer - a newer build the firmware team sends to technicians,
imported into a per-user library (~/KotaTechnicianTool/firmware).
Two kinds:
* merged - bootloader at 0x0 + partition table + app (like new_system.bin);
App-only or Full flashing;
* app - the application binary only (build/kota-sensorboard.bin);
App-only flashing, laid out like the factory image.
Every image is checked structurally (ESP32-S3, kota-sensorboard app
descriptor, segment checksum and the appended SHA-256) so a truncated or
corrupted file is refused before anything is written.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import json
import shutil
import struct
import tomllib
from dataclasses import dataclass
from pathlib import Path
FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware"
MANIFEST = FIRMWARE_DIR / "firmware.toml"
LIBRARY_DIR = Path.home() / "KotaTechnicianTool" / "firmware"
LIBRARY_INDEX = "library.json"
PT_OFFSET = 0x8000
PT_SIZE = 0xC00
ESP32S3_CHIP_ID = 9
IMAGE_MAGIC = 0xE9
APP_DESC_MAGIC = 0xABCD5432
FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"}
_MONTHS = {m: i for i, m in enumerate(
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)}
class ImageError(ValueError):
pass
@dataclass(frozen=True)
class Partition:
label: str
type: int
subtype: int
offset: int
size: int
@dataclass(frozen=True)
class AppInfo:
project: str
version: str
built: str # "Jun 29 2026 11:03:32" as in the app descriptor
idf: str
elf_sha: str # hex
@property
def built_at(self) -> dt.datetime | None:
try:
mon, day, year, hms = self.built.split()
h, m, s = (int(x) for x in hms.split(":"))
return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s)
except (ValueError, KeyError):
return None
@dataclass(frozen=True)
class FirmwareImage:
path: Path
name: str
notes: str
origin: str # "factory" | "developer"
kind: str # "merged" | "app"
sha256: str
flash_size: str
partitions: tuple[Partition, ...]
partition_table: bytes # expected table on the box
app_offset: int # where the app starts inside the file
app_length: int # exact app image length (from its structure)
app: AppInfo
built_by: str = "" # who compiled a developer build (shown in the UI)
@property
def data(self) -> bytes:
return self.path.read_bytes()
@property
def is_developer(self) -> bool:
return self.origin == "developer"
def partition(self, label: str) -> Partition:
for p in self.partitions:
if p.label == label:
return p
raise ImageError(f"partition {label!r} not in image")
def app_bytes(self) -> bytes:
return self.data[self.app_offset:self.app_offset + self.app_length]
def blank_otadata(self) -> bytes:
"""Erased otadata: the bootloader then boots ota_0."""
return b"\xff" * self.partition("otadata").size
def label(self) -> str:
if self.origin == "factory":
tag = "factory"
else:
tag = "developer build" + (f" - built by {self.built_by}" if self.built_by else "")
return f"{self.app.version} built {self.app.built} [{self.app.elf_sha[:9]}] - {tag}"
# ---------------------------------------------------------------- parsing
def parse_partition_table(raw: bytes) -> tuple[Partition, ...]:
parts = []
for i in range(0, len(raw) - 31, 32):
entry = raw[i:i + 32]
magic = entry[:2]
if magic == b"\xaa\x50":
_, ptype, sub, off, size = struct.unpack_from("<HBBII", entry)
label = entry[12:28].split(b"\0")[0].decode("ascii", "replace")
parts.append(Partition(label, ptype, sub, off, size))
elif magic in (b"\xeb\xeb", b"\xff\xff"): # MD5 entry / end
break
else:
raise ImageError(f"corrupt partition table entry at 0x{PT_OFFSET + i:x}")
return tuple(parts)
def app_image_length(data: bytes, offset: int = 0) -> int:
"""Validate an ESP app image at `offset`; return its exact length.
Checks the segment checksum and, when the header says so, the appended
SHA-256 - the same checks the bootloader performs before booting it.
"""
if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC:
raise ImageError(f"no firmware image at 0x{offset:x}")
segments = data[offset + 1]
chip_id = struct.unpack_from("<H", data, offset + 12)[0]
if chip_id != ESP32S3_CHIP_ID:
raise ImageError(f"image is for chip id {chip_id}, not ESP32-S3")
hash_appended = data[offset + 23] == 1
pos, checksum = offset + 24, 0xEF
for _ in range(segments):
if pos + 8 > len(data):
raise ImageError("image is truncated (segment header missing)")
_, seg_len = struct.unpack_from("<II", data, pos)
pos += 8
if pos + seg_len > len(data):
raise ImageError("image is truncated (segment data missing)")
for b in data[pos:pos + seg_len]:
checksum ^= b
pos += seg_len
pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block
if pos >= len(data) or data[pos] != checksum:
raise ImageError("image checksum is wrong - the file is corrupted")
pos += 1
if hash_appended:
if pos + 32 > len(data):
raise ImageError("image is truncated (SHA-256 missing)")
if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]:
raise ImageError("image SHA-256 does not match its content - the file is corrupted")
pos += 32
return pos - offset
def _app_info(data: bytes, offset: int) -> AppInfo:
d = data[offset + 0x20:offset + 0x20 + 256]
if len(d) < 256 or struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC:
raise ImageError("firmware has no ESP-IDF app descriptor")
s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace")
info = AppInfo(project=s(48, 32), version=s(16, 32), built=f"{s(96, 16)} {s(80, 16)}",
idf=s(112, 32), elf_sha=d[144:176].hex())
if info.project != "kota-sensorboard":
raise ImageError(f"firmware is {info.project!r}, not kota-sensorboard")
return info
def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "factory",
reference: FirmwareImage | None = None, built_by: str = "") -> FirmwareImage:
"""Validate a merged or app-only image. App-only images need a `reference`
(the factory image) that provides the flash layout."""
data = path.read_bytes()
if len(data) >= PT_OFFSET + PT_SIZE and data[0] == IMAGE_MAGIC and data[PT_OFFSET:PT_OFFSET + 2] == b"\xaa\x50":
app_image_length(data, 0) # bootloader must be intact
table = data[PT_OFFSET:PT_OFFSET + PT_SIZE]
parts = parse_partition_table(table)
labels = {p.label for p in parts}
for need in ("nvs", "otadata", "ota_0"):
if need not in labels:
raise ImageError(f"partition table has no {need!r} partition")
ota0 = next(p for p in parts if p.label == "ota_0")
kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?")
elif data[:1] == bytes([IMAGE_MAGIC]):
if reference is None:
raise ImageError("app-only image needs the factory image as layout reference")
kind, app_off = "app", 0
parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size
ota0 = next(p for p in parts if p.label == "ota_0")
else:
raise ImageError("not an ESP32 firmware file")
length = app_image_length(data, app_off)
if length > ota0.size:
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
app = _app_info(data, app_off)
return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(),
flash_size, parts, table, app_off, length, app, built_by)
# ------------------------------------------------------------- catalogue
def bundled_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
"""Images shipped with the tool whose file exists and matches its SHA-256.
Factory images first; bundled developer builds keep their origin/built_by."""
if not manifest.exists():
return []
out = []
for e in tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []):
path = manifest.parent / e["file"]
if not path.exists():
continue
origin = e.get("origin", "factory")
if origin not in ("factory", "developer"):
raise ImageError(f"{path.name}: unknown origin {origin!r} in the manifest")
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), origin, built_by=e.get("built_by", ""))
if img.sha256 != e["sha256"].lower():
raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt")
out.append(img)
return sorted(out, key=lambda i: i.origin != "factory")
def factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
"""Only the official factory image(s)."""
return [i for i in bundled_images(manifest) if i.origin == "factory"]
class FirmwareLibrary:
"""Developer builds imported by the technician (persist across tool versions)."""
def __init__(self, root: Path = LIBRARY_DIR):
self.root = root
def _index(self) -> list[dict]:
f = self.root / LIBRARY_INDEX
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else []
def _save(self, entries: list[dict]) -> None:
self.root.mkdir(parents=True, exist_ok=True)
(self.root / LIBRARY_INDEX).write_text(json.dumps(entries, indent=2), encoding="utf-8")
def images(self, reference: FirmwareImage | None) -> tuple[list[FirmwareImage], list[str]]:
"""(valid images, problems) - a changed or broken file is reported, not offered."""
imgs, problems = [], []
for e in self._index():
path = self.root / e["file"]
try:
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "developer", reference,
e.get("built_by", ""))
if img.sha256 != e["sha256"]:
raise ImageError("file changed since it was imported")
imgs.append(img)
except (OSError, ImageError) as exc:
problems.append(f"{e.get('original', e['file'])}: {exc}")
return imgs, problems
def import_file(self, src: Path, reference: FirmwareImage | None, notes: str = "",
built_by: str = "") -> FirmwareImage:
img = inspect_image(src, origin="developer", reference=reference) # validate before copying
entries = self._index()
existing = next((e for e in entries if e["sha256"] == img.sha256), None)
if existing:
return inspect_image(self.root / existing["file"], existing["name"], existing["notes"],
"developer", reference, existing.get("built_by", ""))
self.root.mkdir(parents=True, exist_ok=True)
fname = f"{img.app.elf_sha[:9]}_{img.sha256[:8]}.bin"
shutil.copyfile(src, self.root / fname)
built_by = built_by.strip()
name = f"Developer build {img.app.version} ({img.app.built})" + (f", built by {built_by}" if built_by else "")
entries.append({"file": fname, "sha256": img.sha256, "name": name, "original": src.name,
"imported": dt.datetime.now().isoformat(timespec="seconds"), "notes": notes,
"built_by": built_by})
self._save(entries)
return inspect_image(self.root / fname, name, notes, "developer", reference, built_by)
def remove(self, sha256: str) -> None:
entries = self._index()
keep = [e for e in entries if e["sha256"] != sha256]
for e in entries:
if e["sha256"] == sha256:
(self.root / e["file"]).unlink(missing_ok=True)
self._save(keep)
# Backwards-compatible name used by earlier callers/tests.
official_images = factory_images
+104
View File
@@ -0,0 +1,104 @@
"""Flash plans: which esptool steps run, in which order.
App-only (default, repairs): check the box has the same partition table,
then write the app to ota_0 and erase otadata so ota_0 boots. NVS is kept:
Matter pairing, OTA registration and Wi-Fi survive.
Full (new or wiped boards): write the whole factory image at 0x0, the same
as the factory flash_all.bat. This erases NVS: pairing and OTA registration
are lost.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from pathlib import Path
from .image import PT_OFFSET, PT_SIZE, FirmwareImage
BAUD = "460800"
class Mode(str, enum.Enum):
APP_ONLY = "app_only"
FULL = "full"
@dataclass
class Step:
kind: str # "backup" | "read_pt" | "write"
title: str
args: list[str] # esptool arguments (no interpreter prefix)
est_s: int # rough duration for the UI
output: Path | None = None # file produced by the step (backup / table)
@dataclass
class Plan:
mode: Mode
image: FirmwareImage
port: str
steps: list[Step] = field(default_factory=list)
workdir: Path | None = None
def summary(self) -> list[str]:
return [f"{i}. {s.title} (~{_dur(s.est_s)})" for i, s in enumerate(self.steps, 1)]
def _dur(s: int) -> str:
return f"{s // 60} min" if s >= 90 else f"{s} s"
def _common(port: str, after: str) -> list[str]:
return ["--chip", "esp32s3", "--port", port, "--baud", BAUD,
"--before", "default_reset", "--after", after]
def build_plan(image: FirmwareImage, mode: Mode, port: str, workdir: Path,
backup_to: Path | None = None) -> Plan:
workdir.mkdir(parents=True, exist_ok=True)
plan = Plan(mode, image, port, workdir=workdir)
if backup_to is not None:
size = {"4MB": "0x400000", "8MB": "0x800000", "16MB": "0x1000000"}.get(image.flash_size, "0x800000")
plan.steps.append(Step(
"backup", f"Back up the box's complete flash to {backup_to.name}",
_common(port, "no_reset") + ["read_flash", "0", size, str(backup_to)],
est_s=200 if size == "0x800000" else 100, output=backup_to))
write_opts = ["write_flash", "--flash_mode", "keep", "--flash_freq", "keep", "--flash_size", "keep"]
if mode == Mode.APP_ONLY:
pt_file = workdir / "box_partition_table.bin"
plan.steps.append(Step(
"read_pt", "Check the box has the same partition layout",
_common(port, "no_reset") + ["read_flash", hex(PT_OFFSET), hex(PT_SIZE), str(pt_file)],
est_s=5, output=pt_file))
app_file = workdir / "app_ota0.bin"
ota_file = workdir / "otadata_blank.bin"
app_file.write_bytes(image.app_bytes())
ota_file.write_bytes(image.blank_otadata())
otadata, ota0 = image.partition("otadata"), image.partition("ota_0")
plan.steps.append(Step(
"write", "Write the firmware app (keeps pairing, OTA registration, settings)",
_common(port, "hard_reset") + write_opts + [
hex(otadata.offset), str(ota_file), hex(ota0.offset), str(app_file)],
est_s=20))
else:
if image.kind != "merged":
raise ValueError("Full mode needs a complete image (bootloader + partition table + app); "
"this file contains the app only - use App-only mode.")
plan.steps.append(Step(
"write", "Write the complete image (erases pairing and OTA registration)",
_common(port, "hard_reset") + write_opts + ["0x0", str(image.path)],
est_s=25))
return plan
def check_partition_table(box_table: bytes, image: FirmwareImage) -> str | None:
"""None if App-only is safe, otherwise the reason it is not."""
if box_table.strip(b"\xff") == b"":
return "The box has no partition table (blank or erased chip). Use Full mode."
if box_table != image.partition_table:
return ("The box uses a different partition layout than this image. "
"App-only could leave it unbootable - use Full mode.")
return None
+118
View File
@@ -0,0 +1,118 @@
"""Runs a flash plan: one esptool subprocess per step, in a background thread.
A subprocess (instead of calling esptool in-process) keeps esptool's
stdout/sys.exit handling away from the GUI and lets the tool read progress
line by line. Callbacks are invoked from the worker thread.
"""
from __future__ import annotations
import re
import subprocess
import sys
import threading
from collections.abc import Callable
from dataclasses import dataclass
from .plan import Plan, check_partition_table
_PROGRESS = re.compile(r"\((\d{1,3}) ?%\)")
# esptool output -> technician-facing hint
HINTS = [
(re.compile(r"could not open port|Access is denied|PermissionError|Resource busy", re.I),
"The USB port is in use. Close other programs that use the sensorbox port (serial monitors, Arduino IDE) and try again."),
(re.compile(r"Failed to connect to ESP32-S3|No serial data received|Wrong boot mode", re.I),
"The box did not enter flashing mode by itself. Open the case, hold the BOOT button, press and release RESET, "
"release BOOT, then press Start again."),
(re.compile(r"device reports readiness to read but returned no data|The port disappeared|device disconnected", re.I),
"The USB connection dropped. Use a short data-capable USB cable directly on the laptop (no hub) and try again."),
(re.compile(r"Invalid head of packet|timed out", re.I),
"Communication errors. Try another USB cable or port."),
]
@dataclass
class Result:
ok: bool
message: str
hint: str = ""
failed_step: int | None = None
class FlashRunner:
def __init__(self, plan: Plan,
on_step: Callable[[int, str], None],
on_progress: Callable[[int], None],
on_output: Callable[[str], None],
on_done: Callable[[Result], None],
esptool_cmd: list[str] | None = None):
self.plan = plan
self._on_step, self._on_progress = on_step, on_progress
self._on_output, self._on_done = on_output, on_done
self._cmd = esptool_cmd or [sys.executable, "-m", "esptool"]
self._thread: threading.Thread | None = None
def start(self) -> None:
self._thread = threading.Thread(target=self._run, name="flash", daemon=True)
self._thread.start()
def join(self, timeout: float | None = None) -> None:
if self._thread:
self._thread.join(timeout)
def _run(self) -> None:
try:
self._on_done(self._execute())
except Exception as exc: # never leave the UI waiting
self._on_done(Result(False, f"Internal error: {exc}"))
def _execute(self) -> Result:
for i, step in enumerate(self.plan.steps):
self._on_step(i, step.title)
self._on_progress(0)
code, output = self._esptool(step.args)
if code != 0:
return Result(False, f"Step {i + 1} failed: {step.title}", _hint(output), i)
if step.kind == "read_pt":
problem = check_partition_table(step.output.read_bytes(), self.plan.image)
if problem:
return Result(False, problem, failed_step=i)
self._on_progress(100)
return Result(True, "Firmware written and verified by esptool.")
def _esptool(self, args: list[str]) -> tuple[int, str]:
cmd = self._cmd + args
self._on_output("$ esptool " + " ".join(args))
flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
creationflags=flags)
collected: list[str] = []
buf = b""
assert proc.stdout is not None
while chunk := proc.stdout.read1(4096): # returns as soon as bytes arrive
buf += chunk
# esptool redraws progress with '\r'; treat it as a line break.
*lines, buf = re.split(rb"[\r\n]", buf)
for raw in lines:
self._line(raw, collected)
if buf:
self._line(buf, collected)
return proc.wait(), "\n".join(collected)
def _line(self, raw: bytes, collected: list[str]) -> None:
text = raw.decode("utf-8", "replace").strip()
if not text:
return
collected.append(text)
if m := _PROGRESS.search(text):
self._on_progress(min(int(m.group(1)), 100))
return # progress lines are not worth listing one by one
self._on_output(text)
def _hint(output: str) -> str:
for pattern, hint in HINTS:
if pattern.search(output):
return hint
return "See the esptool output below. Save it and send it to the firmware team if the problem repeats."
View File
Whitespace-only changes.
+96
View File
@@ -0,0 +1,96 @@
"""Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code.
Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code):
bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit.
The sensorbox firmware uses the Matter SDK example commissionable data
(CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises
VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is
exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332).
VID/PID/discriminator are taken from the box's own log when available; the
passcode is never logged, so the known default is used.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-."
RENDEZVOUS_BLE = 1 << 1
DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware
DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840)
# chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
_ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)")
@dataclass(frozen=True)
class SetupPayload:
vendor_id: int = DEFAULT["vendor_id"]
product_id: int = DEFAULT["product_id"]
discriminator: int = DEFAULT["discriminator"]
passcode: int = DEFAULT_PASSCODE
rendezvous: int = RENDEZVOUS_BLE
flow: int = 0 # standard commissioning flow
version: int = 0
from_log: bool = False # VID/PID/discriminator read from this box's log
def qr_text(self) -> str:
fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2),
(self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)]
value, shift = 0, 0
for v, bits in fields:
if not 0 <= v < (1 << bits):
raise ValueError(f"field value {v} does not fit in {bits} bits")
value |= v << shift
shift += bits
return "MT:" + _base38(value.to_bytes(shift // 8, "little"))
def manual_code(self) -> str:
short = self.discriminator >> 8 # upper 4 bits
chunk1 = short >> 2 # vid_pid_present = 0
chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF)
chunk3 = self.passcode >> 14
digits = f"{chunk1}{chunk2:05d}{chunk3:04d}"
return digits + str(verhoeff_check(digits))
def manual_code_pretty(self) -> str:
c = self.manual_code()
return f"{c[:4]}-{c[4:7]}-{c[7:]}"
def _base38(data: bytes) -> str:
out = []
for i in range(0, len(data), 3):
chunk = data[i:i + 3]
value = int.from_bytes(chunk, "little")
for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]):
value, rem = divmod(value, 38)
out.append(BASE38[rem])
return "".join(out)
_D = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]]
_P = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]]
_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
def verhoeff_check(digits: str) -> int:
c = 0
for i, d in enumerate(reversed(digits)):
c = _D[c][_P[(i + 1) % 8][int(d)]]
return _INV[c]
def payload_from_log(lines: list[str]) -> SetupPayload:
"""Use the box's advertised VID/PID/discriminator if seen; defaults otherwise."""
for text in reversed(lines):
if m := _ADVERTISE.search(text):
return SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
return SetupPayload()
View File
Whitespace-only changes.
+128
View File
@@ -0,0 +1,128 @@
"""Find SD cards (removable disks) on Windows and macOS.
Safety first: only USB/SD/MMC-attached disks that are not the system or
boot disk and not larger than MAX_CARD_BYTES are ever offered. Parsers are
pure functions over the OS tool output so they can be tested without
hardware.
"""
from __future__ import annotations
import json
import plistlib
import subprocess
import sys
from dataclasses import dataclass, field
MAX_CARD_BYTES = 256 * 1024**3 # larger "removable" disks are probably not SD cards
CARD_BUSES = {"USB", "SD", "MMC"}
@dataclass
class Volume:
mount: str # "E:\\" on Windows, "/Volumes/KOTA" on macOS ("" if not mounted)
filesystem: str # "FAT32", "exFAT", "MS-DOS FAT32", ...
label: str
@dataclass
class Disk:
id: str # Windows disk number / macOS "/dev/disk4"
name: str
size: int
bus: str
volumes: list[Volume] = field(default_factory=list)
has_media: bool = True
@property
def size_text(self) -> str:
return f"{self.size / 1e9:.1f} GB" if self.size else "no card"
@property
def label(self) -> str:
vols = ", ".join(f"{v.mount or '-'} {v.filesystem} '{v.label}'" for v in self.volumes) or "no volume"
return f"Disk {self.id}: {self.name} - {self.size_text} ({self.bus}) - {vols}"
# ------------------------------------------------------------------ Windows
WIN_LIST_PS = r"""
$ErrorActionPreference = 'SilentlyContinue'
# Filter by bus BEFORE touching partitions: querying volumes of every
# internal disk made this take >30 s on a machine with several HDDs.
$out = @(Get-Disk | Where-Object { -not $_.IsSystem -and -not $_.IsBoot -and
@('USB','SD','MMC') -contains [string]$_.BusType } | ForEach-Object {
$d = $_
$vols = @(Get-Partition -DiskNumber $d.Number | ForEach-Object {
$v = $_ | Get-Volume
[pscustomobject]@{ DriveLetter = [string]$_.DriveLetter; FileSystem = [string]$v.FileSystem; Label = [string]$v.FileSystemLabel }
})
[pscustomobject]@{ Number = $d.Number; FriendlyName = $d.FriendlyName; Size = [int64]$d.Size;
BusType = [string]$d.BusType; Volumes = $vols }
})
ConvertTo-Json -InputObject $out -Depth 4 -Compress
"""
def parse_windows(json_text: str) -> list[Disk]:
data = json.loads(json_text or "[]")
if isinstance(data, dict): # ConvertTo-Json quirk for one element
data = [data]
disks = []
for d in data:
if str(d.get("BusType")) not in CARD_BUSES:
continue
vols = d.get("Volumes") or []
if isinstance(vols, dict):
vols = [vols]
volumes = [Volume(f"{v['DriveLetter']}:\\" if v.get("DriveLetter") and v["DriveLetter"] not in ("\x00", "") else "",
v.get("FileSystem") or "", v.get("Label") or "") for v in vols]
size = int(d.get("Size") or 0)
disks.append(Disk(str(d["Number"]), d.get("FriendlyName") or "Disk", size, str(d["BusType"]),
[v for v in volumes if v.mount or v.filesystem], has_media=size > 0))
return disks
# -------------------------------------------------------------------- macOS
def parse_macos(list_plist: bytes, infos: dict[str, bytes]) -> list[Disk]:
"""`diskutil list -plist physical` + `diskutil info -plist <disk>` for each."""
listing = plistlib.loads(list_plist)
disks = []
for entry in listing.get("AllDisksAndPartitions", []):
ident = entry.get("DeviceIdentifier", "")
info = plistlib.loads(infos[ident]) if ident in infos else {}
protocol = str(info.get("BusProtocol", ""))
removable = bool(info.get("RemovableMedia") or info.get("Removable") or info.get("Ejectable"))
internal = bool(info.get("Internal", True))
is_root = any(p.get("MountPoint") == "/" for p in entry.get("Partitions", []) + entry.get("APFSVolumes", []))
if ident == "disk0" or is_root or not (removable or not internal or protocol in ("USB", "Secure Digital", "SD")):
continue
vols = [Volume(p.get("MountPoint", ""), p.get("Content", ""), p.get("VolumeName", ""))
for p in entry.get("Partitions", [])]
size = int(entry.get("Size") or info.get("TotalSize") or 0)
bus = "SD" if "Secure Digital" in protocol or protocol == "SD" else (protocol or "USB")
disks.append(Disk(f"/dev/{ident}", info.get("MediaName", "Disk"), size, bus, vols, has_media=size > 0))
return disks
# ------------------------------------------------------------------ public
def list_cards() -> tuple[list[Disk], list[Disk]]:
"""(cards that may be formatted, readers/disks shown but not offered)."""
if sys.platform == "win32":
# Driver-level listing (milliseconds, 1.5 s worst case) instead of
# Get-Disk/WMI, which stalled 5-21 s on an empty card-reader slot.
from .win_disks import list_disks
disks = list_disks(CARD_BUSES)
elif sys.platform == "darwin":
lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout
infos = {}
for e in plistlib.loads(lst).get("AllDisksAndPartitions", []):
ident = e.get("DeviceIdentifier", "")
infos[ident] = subprocess.run(["diskutil", "info", "-plist", ident], capture_output=True, timeout=30).stdout
disks = parse_macos(lst, infos)
else:
return [], []
ok = [d for d in disks if d.has_media and 0 < d.size <= MAX_CARD_BYTES]
return ok, [d for d in disks if d not in ok]
+151
View File
@@ -0,0 +1,151 @@
"""ENV.TXT for the sensorbox SD card.
Parsing mirrors EnvironmentService::read_env_var in the firmware exactly:
lines read with a 512-byte buffer, split at the FIRST '=', key compared
verbatim (no trimming), CR/LF removed from the value only, first match wins.
So the checks below report what would actually break a box - e.g. a UTF-8
BOM (Windows Notepad) hides the first key, and "KEY = value" is not "KEY".
The firmware FAT driver has no long-file-name support: the file must be
the 8.3 name ENV.TXT (case-insensitive) in the card root.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
ENV_NAME = "ENV.TXT"
BUFFER_NAME = "BUFFER.TXT" # offline measurements waiting for upload
LINE_BUF = 512 # char line[512] in the firmware
REQUIRED = ("INFLUX_URL", "INFLUX_TOKEN")
OPTIONAL = ("SSID", "PASSWORD")
_BOM = b"\xef\xbb\xbf"
@dataclass
class Issue:
level: str # "error" | "warning" | "info"
text: str
@dataclass
class EnvConfig:
influx_url: str = ""
influx_token: str = ""
ssid: str = ""
password: str = ""
@property
def local_wifi(self) -> bool:
return bool(self.ssid or self.password)
def lines(self) -> list[tuple[str, str]]:
out = [("INFLUX_TOKEN", self.influx_token), ("INFLUX_URL", self.influx_url)]
if self.local_wifi:
out += [("SSID", self.ssid), ("PASSWORD", self.password)]
return out
def render(self) -> bytes:
# LF only, no BOM, no quoting: exactly what the firmware parser expects.
return "".join(f"{k}={v}\n" for k, v in self.lines()).encode("utf-8")
def problems(self) -> list[Issue]:
issues: list[Issue] = []
for key, value in self.lines():
if any(c in value for c in "\r\n"):
issues.append(Issue("error", f"{key} contains a line break."))
if value != value.strip():
issues.append(Issue("error", f"{key} starts or ends with a space; the box would use the space too."))
if len(f"{key}={value}".encode()) >= LINE_BUF - 1:
issues.append(Issue("error", f"{key} is too long for the box (max {LINE_BUF - 2} bytes per line)."))
url = self.influx_url.strip()
if not url:
issues.append(Issue("error", "INFLUX_URL is empty."))
elif not re.match(r"^https?://", url):
issues.append(Issue("error", "INFLUX_URL must start with https://"))
elif not ("/api/v2/write" in url and "bucket=" in url and "org=" in url):
issues.append(Issue("error", "INFLUX_URL must be a write URL: .../api/v2/write?bucket=...&org=..."))
elif url.startswith("http://"):
issues.append(Issue("warning", "INFLUX_URL uses http:// (unencrypted)."))
if not self.influx_token:
issues.append(Issue("error", "INFLUX_TOKEN is empty."))
elif re.search(r"\s", self.influx_token):
issues.append(Issue("error", "INFLUX_TOKEN contains spaces."))
elif len(self.influx_token) < 20:
issues.append(Issue("warning", "INFLUX_TOKEN looks too short."))
if self.local_wifi:
if not self.ssid or not self.password:
issues.append(Issue("error", "Wi-Fi needs both SSID and PASSWORD (the box ignores one without the other)."))
if len(self.ssid.encode()) > 32:
issues.append(Issue("error", "SSID is longer than 32 bytes."))
pw = self.password
if pw and not (8 <= len(pw) <= 63 or re.fullmatch(r"[0-9A-Fa-f]{64}", pw)):
issues.append(Issue("error", "Wi-Fi PASSWORD must be 8-63 characters (WPA2)."))
return issues
@dataclass
class EnvCheck:
values: dict[str, str]
issues: list[Issue]
@property
def ok(self) -> bool:
return not any(i.level == "error" for i in self.issues)
def masked(self) -> dict[str, str]:
out = {}
for k, v in self.values.items():
out[k] = (v[:4] + "..." + v[-4:] if len(v) > 12 else "***") if k in ("INFLUX_TOKEN", "PASSWORD") else v
return out
def parse_env(raw: bytes) -> EnvCheck:
"""Read ENV.TXT the way the firmware does and report what it would see."""
issues: list[Issue] = []
if raw.startswith(_BOM):
issues.append(Issue("error", "File starts with a UTF-8 BOM (saved by Notepad?): the box cannot read the first key. "
"Re-write the card with the tool."))
text = raw.decode("utf-8", errors="replace")
values: dict[str, str] = {}
for n, line in enumerate(text.split("\n"), 1):
if len(line.encode()) >= LINE_BUF - 1:
issues.append(Issue("error", f"Line {n} is longer than the box can read."))
if "=" not in line:
continue
key, value = line.split("=", 1)
value = value.replace("\r", "")
if key != key.strip():
issues.append(Issue("error", f"Line {n}: spaces around the key '{key.strip()}' - the box will not find it."))
if key in values:
issues.append(Issue("warning", f"{key} appears twice; the box uses the first one."))
continue
values[key] = value
for key in REQUIRED:
if key not in values:
issues.append(Issue("error", f"{key} is missing - the box stops at start-up (white LED)."))
elif not values[key]:
issues.append(Issue("error", f"{key} is empty."))
if values.get("HTTP_SERVER") == "true":
issues.append(Issue("warning", "HTTP_SERVER=true: a box with newer firmware starts in Wi-Fi setup mode."))
if bool(values.get("SSID")) != bool(values.get("PASSWORD")):
issues.append(Issue("warning", "Only one of SSID / PASSWORD is set; the box ignores Wi-Fi settings."))
elif values.get("SSID"):
issues.append(Issue("info", "Wi-Fi settings present: newer firmware uses this Wi-Fi instead of Matter "
"(the factory v1.0 firmware ignores them)."))
return EnvCheck(values, issues)
def load_template(path: Path) -> EnvConfig:
"""Settings from an existing ENV.TXT (e.g. 002-SD_card_format/ENV.txt)."""
v = parse_env(path.read_bytes()).values
return EnvConfig(v.get("INFLUX_URL", ""), v.get("INFLUX_TOKEN", ""), v.get("SSID", ""), v.get("PASSWORD", ""))
def find_case_insensitive(root: Path, name: str) -> Path | None:
for p in root.iterdir() if root.is_dir() else ():
if p.is_file() and p.name.lower() == name.lower():
return p
return None
+135
View File
@@ -0,0 +1,135 @@
"""FAT32 + MBR layout for an SD card, as raw byte writes (no OS formatter).
Follows the Microsoft FAT specification (fatgen103): BPB at sector 0,
FSInfo at 1, backup boot sector at 6, two FATs, root directory in cluster 2
holding the volume label. Cluster sizes follow Windows' defaults. The
partition starts at 1 MiB (SD erase-block friendly), MBR type 0x0C
(FAT32 LBA), which is what the firmware's FatFs expects.
Pure functions: the result is a list of (offset, bytes) writes plus zero
ranges relative to the start of the DISK, so it can be tested on a buffer.
"""
from __future__ import annotations
import math
import struct
from dataclasses import dataclass
PART_START = 1024 * 1024
FAT32_MAX_BYTES = 32 * 1024**3 # Windows convention, and all the box needs
MIN_CLUSTERS, MAX_CLUSTERS = 65525, 0x0FFFFFF5
RESERVED = 32
NUM_FATS = 2
MEDIA = 0xF8
@dataclass(frozen=True)
class Fat32Layout:
bytes_per_sector: int
part_start: int # bytes from disk start
part_sectors: int
sectors_per_cluster: int
fat_sectors: int
clusters: int
@property
def data_start(self) -> int: # sectors from partition start
return RESERVED + NUM_FATS * self.fat_sectors
def _spc(sectors: int, bps: int) -> int:
size = sectors * bps
for limit, cluster in ((260 * 1024**2, 512), (8 * 1024**3, 4096), (16 * 1024**3, 8192),
(32 * 1024**3, 16384)):
if size <= limit:
return max(1, cluster // bps)
return max(1, 32768 // bps)
def plan(disk_bytes: int, bps: int = 512) -> Fat32Layout:
part_bytes = min(disk_bytes - PART_START, FAT32_MAX_BYTES)
part_bytes -= part_bytes % (1024 * 1024) # whole MiB
sectors = part_bytes // bps
if sectors <= 0:
raise ValueError("card is too small")
spc = _spc(sectors, bps)
fat = 1
for _ in range(20): # converge FAT size <-> cluster count
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
need = math.ceil((clusters + 2) * 4 / bps)
if need == fat:
break
fat = need
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
if not MIN_CLUSTERS <= clusters < MAX_CLUSTERS:
raise ValueError(f"card size gives {clusters} clusters - not valid for FAT32")
return Fat32Layout(bps, PART_START, sectors, spc, fat, clusters)
def _boot_sector(lay: Fat32Layout, label: bytes, vol_id: int) -> bytes:
bps = lay.bytes_per_sector
b = bytearray(bps)
b[0:3] = b"\xEB\x58\x90"
b[3:11] = b"MSWIN4.1"
struct.pack_into("<HBHBHHBHHHII", b, 11, bps, lay.sectors_per_cluster, RESERVED, NUM_FATS, 0, 0,
MEDIA, 0, 63, 255, lay.part_start // bps, lay.part_sectors)
struct.pack_into("<IHHIHH", b, 36, lay.fat_sectors, 0, 0, 2, 1, 6)
struct.pack_into("<BBBI", b, 64, 0x80, 0, 0x29, vol_id)
b[71:82] = label
b[82:90] = b"FAT32 "
b[90:93] = b"\xF4\xEB\xFD" # not bootable: hlt loop
b[510:512] = b"\x55\xAA"
return bytes(b)
def _fsinfo(lay: Fat32Layout) -> bytes:
b = bytearray(lay.bytes_per_sector)
struct.pack_into("<I", b, 0, 0x41615252)
struct.pack_into("<III", b, 484, 0x61417272, lay.clusters - 1, 3) # root uses cluster 2
struct.pack_into("<I", b, 508, 0xAA550000)
return bytes(b)
def mbr(lay: Fat32Layout, signature: int) -> bytes:
b = bytearray(512)
struct.pack_into("<I", b, 0x1B8, signature)
entry = struct.pack("<B3sB3sII", 0x00, b"\xFE\xFF\xFF", 0x0C, b"\xFE\xFF\xFF",
lay.part_start // lay.bytes_per_sector, lay.part_sectors)
b[0x1BE:0x1BE + 16] = entry
b[510:512] = b"\x55\xAA"
return bytes(b[:512]) + bytes(lay.bytes_per_sector - 512)
def volume_label(text: str) -> bytes:
label = text.upper().encode("ascii", "replace")[:11]
return label.ljust(11, b" ")
def build(lay: Fat32Layout, label: str, vol_id: int, disk_signature: int,
disk_bytes: int) -> tuple[list[tuple[int, int]], list[tuple[int, bytes]]]:
"""(zero ranges, writes) in disk byte offsets; apply zero ranges first."""
bps, p = lay.bytes_per_sector, lay.part_start
lab = volume_label(label)
fat_bytes = lay.fat_sectors * bps
fat1 = p + RESERVED * bps
fat2 = fat1 + fat_bytes
root = p + lay.data_start * bps
cluster = lay.sectors_per_cluster * bps
zeros = [
(0, PART_START), # old MBR / GPT header and gap
(disk_bytes - PART_START, PART_START), # backup GPT at the end of the disk
(p, RESERVED * bps), # reserved region
(fat1, NUM_FATS * fat_bytes), # both FATs
(root, cluster), # root directory cluster
]
boot, info = _boot_sector(lay, lab, vol_id), _fsinfo(lay)
first_fat = struct.pack("<III", 0x0FFFFF00 | MEDIA, 0x0FFFFFFF, 0x0FFFFFFF)
label_entry = lab + bytes([0x08]) + bytes(20) # 32-byte volume-label directory entry
writes = [
(p, boot), (p + bps, info), (p + 6 * bps, boot), (p + 7 * bps, info),
(fat1, first_fat), (fat2, first_fat),
(root, label_entry),
(0, mbr(lay, disk_signature)), # last: the partition appears only now
]
return zeros, writes
+260
View File
@@ -0,0 +1,260 @@
"""Format an SD card for the sensorbox and write / check ENV.TXT.
Card format required by the firmware: MBR partition table, FAT32 (its FAT
driver has no exFAT and no long file names), ENV.TXT in the root.
Windows: formatting needs administrator rights, so kota_tech.sd.win_format
runs elevated (UAC prompt). It re-checks the disk (number, size, bus, not the
Windows drive) right before erasing it, so a card swapped after the
technician confirmed is not touched, and it writes MBR + FAT32 itself
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
get a 32 GB FAT32 partition (Windows convention; plenty for the box).
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
"""
from __future__ import annotations
import json
import os
import plistlib
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from .disks import Disk
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
VOLUME_LABEL = "KOTA"
@dataclass
class Outcome:
ok: bool
message: str
mount: str = ""
log: list[str] = field(default_factory=list)
@dataclass
class CardReport:
mount: str
filesystem: str
env: EnvCheck | None
buffered_lines: int | None # measurements waiting on the card for upload
problems: list[str]
# ----------------------------------------------------------------- Windows
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
def _workdir() -> Path:
# Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a
# file written there by the ELEVATED script is then unreadable for this
# (non-elevated) process. A normally inherited ACL works for both.
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
work.mkdir(parents=True, exist_ok=False)
return work
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
"""(program, parameters, working dir) for the elevated helper."""
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
f'--label {VOLUME_LABEL} --result "{result}"'
return sys.executable, params, str(root)
def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None:
"""ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
import ctypes
from ctypes import wintypes
class SHELLEXECUTEINFOW(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
if not shell32.ShellExecuteExW(ctypes.byref(info)):
return None # ERROR_CANCELLED (1223) or failure
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
code = wintypes.DWORD()
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
k32.CloseHandle(info.hProcess)
return code.value
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
result = _workdir() / "result.json"
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
t = time.perf_counter()
code = _run_elevated(*win_format_command(disk, result))
if code is None:
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
try:
res = json.loads(result.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
"Check the card with 'Check card'.", log=log)
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
log.append(f"{res.get('message', '')} ({time.perf_counter() - t:.1f} s incl. permission prompt; {steps})")
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
def _eject_windows(mount: str) -> bool:
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does.
Not Shell.Application InvokeVerb('Eject'): it fails silently because the
verb is named 'E&ject' (and is localised on non-English Windows).
"""
import ctypes
from ctypes import wintypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateFileW.restype = wintypes.HANDLE
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808
handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
if handle in (None, wintypes.HANDLE(-1).value):
return False
ret = wintypes.DWORD()
def ioctl(code: int, buf=None, size: int = 0) -> bool:
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
try:
k32.FlushFileBuffers(handle)
for _ in range(10): # Explorer/indexer may hold it briefly
if ioctl(FSCTL_LOCK_VOLUME):
break
time.sleep(0.5)
else:
return False
if not ioctl(FSCTL_DISMOUNT_VOLUME):
return False
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
return True
finally:
k32.CloseHandle(handle)
# ------------------------------------------------------------------- macOS
def _format_macos(disk: Disk, log: list[str]) -> Outcome:
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
proc = subprocess.run(["diskutil", "eraseDisk", "FAT32", VOLUME_LABEL, "MBRFormat", disk.id],
capture_output=True, text=True, timeout=600)
log += [l for l in proc.stdout.splitlines() if l.strip()]
if proc.returncode != 0:
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
+ " (is the card's write-protect switch on?)", log=log)
part = f"{disk.id}s1"
for _ in range(20): # wait for the volume to mount
info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>")
if info.get("MountPoint"):
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
time.sleep(0.5)
return Outcome(False, "Formatted, but the card did not mount.", log=log)
def _eject_macos(disk_id: str) -> bool:
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
# ------------------------------------------------------------------ common
def write_env(mount: str, env: EnvConfig) -> Outcome:
"""Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back."""
problems = [i.text for i in env.problems() if i.level == "error"]
if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems))
root = Path(mount)
if not root.is_dir():
return Outcome(False, f"Card not found at {mount}.")
old = find_case_insensitive(root, ENV_NAME)
if old is not None and old.name != ENV_NAME:
old.unlink() # replace env.txt / Env.txt variants
target = root / ENV_NAME
data = env.render()
with open(target, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
if target.read_bytes() != data:
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
check = parse_env(target.read_bytes())
if not check.ok:
return Outcome(False, "ENV.TXT was written but does not validate: "
+ " ".join(i.text for i in check.issues if i.level == "error"))
return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount)
def format_and_write(disk: Disk, env: EnvConfig, eject_card: bool = True) -> Outcome:
problems = [i.text for i in env.problems() if i.level == "error"]
if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems))
log: list[str] = []
if sys.platform == "win32":
fmt = _format_windows(disk, log)
elif sys.platform == "darwin":
fmt = _format_macos(disk, log)
else:
return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
if not fmt.ok:
return fmt
wr = write_env(fmt.mount, env)
log.append(wr.message)
if not wr.ok:
return Outcome(False, wr.message, fmt.mount, log)
if eject_card:
log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else
"Could not eject the card automatically - use 'Safely remove' before pulling it out.")
return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log)
def eject(mount: str, disk: Disk | None = None) -> bool:
"""True if the card was dismounted and can be pulled out safely."""
if sys.platform == "win32":
return _eject_windows(mount)
if sys.platform == "darwin" and disk is not None:
return _eject_macos(disk.id)
return False
def check_card(mount: str, filesystem: str = "") -> CardReport:
"""What the box would find on this card (values masked in the UI)."""
root = Path(mount)
problems: list[str] = []
fs = filesystem.upper()
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"):
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
env_file = find_case_insensitive(root, ENV_NAME)
env = parse_env(env_file.read_bytes()) if env_file else None
if env_file is None:
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
elif len(env_file.name.split(".")[0]) > 8:
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
buf = find_case_insensitive(root, BUFFER_NAME)
lines = None
if buf is not None:
with open(buf, "rb") as fh:
lines = sum(1 for ln in fh if ln.strip())
return CardReport(mount, filesystem, env, lines, problems)
+161
View File
@@ -0,0 +1,161 @@
"""Fast Windows card discovery straight from the storage drivers (ctypes).
Why not Get-Disk / WMI: every fresh PowerShell/WMI query enumerates ALL
disks, which wakes sleeping hard drives - measured 1.6 to 21 s per listing
on a PC with two SATA HDDs, freezing the UI each time. Here each physical
drive is opened with access 0 (no admin needed, no I/O to the medium) and
only its cached device descriptor is read; size and volumes are queried only
for USB/SD/MMC disks. A full listing takes milliseconds.
"""
from __future__ import annotations
import concurrent.futures
import ctypes
import string
from ctypes import wintypes
from .disks import Disk, Volume
IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400
IOCTL_STORAGE_GET_DEVICE_NUMBER = 0x002D1080
IOCTL_DISK_GET_DRIVE_GEOMETRY_EX = 0x000700A0
BUS_TYPES = {1: "SCSI", 2: "ATAPI", 3: "ATA", 4: "1394", 7: "USB", 8: "RAID", 10: "SAS", 11: "SATA",
12: "SD", 13: "MMC", 14: "Virtual", 15: "FileBackedVirtual", 17: "NVMe", 18: "SCM", 19: "UFS"}
SHARE_RW, OPEN_EXISTING = 0x3, 3
DRIVE_REMOVABLE, DRIVE_FIXED = 2, 3
MAX_DRIVES = 32
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_k32.CreateFileW.restype = wintypes.HANDLE
_k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
_k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
_k32.GetDriveTypeW.argtypes = [wintypes.LPCWSTR]
_k32.GetVolumeInformationW.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD),
ctypes.POINTER(wintypes.DWORD), wintypes.LPWSTR, wintypes.DWORD]
_INVALID = wintypes.HANDLE(-1).value
class _Query(ctypes.Structure): # STORAGE_PROPERTY_QUERY (StorageDeviceProperty, standard)
_fields_ = [("PropertyId", wintypes.DWORD), ("QueryType", wintypes.DWORD), ("Extra", ctypes.c_ubyte * 4)]
class _DevNumber(ctypes.Structure): # STORAGE_DEVICE_NUMBER
_fields_ = [("DeviceType", wintypes.DWORD), ("DeviceNumber", wintypes.DWORD),
("PartitionNumber", wintypes.DWORD)]
def _open(path: str):
h = _k32.CreateFileW(path, 0, SHARE_RW, None, OPEN_EXISTING, 0, None)
return None if h in (None, _INVALID) else h
def _ioctl(h, code: int, inbuf=None, insize: int = 0, outsize: int = 1024) -> bytes | None:
out = ctypes.create_string_buffer(outsize)
got = wintypes.DWORD()
ok = _k32.DeviceIoControl(h, code, inbuf, insize, out, outsize, ctypes.byref(got), None)
return out.raw[:got.value] if ok else None
def _descriptor(h) -> tuple[str, str] | None:
"""(bus, "vendor product") from STORAGE_DEVICE_DESCRIPTOR - served from the driver's cache."""
q = _Query(0, 0)
raw = _ioctl(h, IOCTL_STORAGE_QUERY_PROPERTY, ctypes.byref(q), ctypes.sizeof(q))
if not raw or len(raw) < 32:
return None
vendor_off, product_off = int.from_bytes(raw[12:16], "little"), int.from_bytes(raw[16:20], "little")
bus = int.from_bytes(raw[28:32], "little")
def text(off: int) -> str:
if not off or off >= len(raw):
return ""
return raw[off:].split(b"\0", 1)[0].decode("ascii", "replace").strip()
return BUS_TYPES.get(bus, f"bus{bus}"), " ".join(t for t in (text(vendor_off), text(product_off)) if t) or "Disk"
def _size(h) -> int:
raw = _ioctl(h, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, outsize=256) # fails when no card is inserted
return int.from_bytes(raw[24:32], "little") if raw and len(raw) >= 32 else 0
def _volumes_by_disk() -> dict[int, list[Volume]]:
found: dict[int, list[Volume]] = {}
mask = _k32.GetLogicalDrives()
for i, letter in enumerate(string.ascii_uppercase):
if not mask & (1 << i) or _k32.GetDriveTypeW(f"{letter}:\\") not in (DRIVE_REMOVABLE, DRIVE_FIXED):
continue
h = _open(f"\\\\.\\{letter}:")
if h is None:
continue
try:
raw = _ioctl(h, IOCTL_STORAGE_GET_DEVICE_NUMBER, outsize=ctypes.sizeof(_DevNumber))
finally:
_k32.CloseHandle(h)
if raw and len(raw) >= 8:
num = int.from_bytes(raw[4:8], "little")
found.setdefault(num, []).append(Volume(f"{letter}:\\", "", ""))
return found
def _fill_volume_info(v: Volume) -> None:
name, fs = ctypes.create_unicode_buffer(64), ctypes.create_unicode_buffer(32)
serial, maxlen, flags = wintypes.DWORD(), wintypes.DWORD(), wintypes.DWORD()
if _k32.GetVolumeInformationW(v.mount, name, 64, ctypes.byref(serial), ctypes.byref(maxlen),
ctypes.byref(flags), fs, 32):
v.filesystem, v.label = fs.value, name.value
MEDIA_TIMEOUT_S = 1.5
# Slot queries still running from an earlier listing (an empty card-reader
# slot can block ~13 s on its first media query). Never start a second one.
_pending: dict[int, concurrent.futures.Future] = {}
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="sd-media")
def _media_size(n: int) -> int:
h = _open(f"\\\\.\\PhysicalDrive{n}")
if h is None:
return 0
try:
return _size(h)
finally:
_k32.CloseHandle(h)
def list_disks(buses: set[str]) -> list[Disk]:
"""Disks on the given buses (e.g. {"USB", "SD", "MMC"}), with size and volumes.
Media queries run in parallel with a short timeout: a slot that does not
answer in time is reported as "no card" instead of freezing the caller.
"""
candidates: list[tuple[int, tuple[str, str]]] = []
for n in range(MAX_DRIVES):
h = _open(f"\\\\.\\PhysicalDrive{n}")
if h is None:
continue
try:
desc = _descriptor(h)
finally:
_k32.CloseHandle(h)
if desc is not None and desc[0] in buses: # internal disks: only the cached descriptor is read
candidates.append((n, desc))
for n, _ in candidates:
if n not in _pending or _pending[n].done():
_pending[n] = _pool.submit(_media_size, n)
concurrent.futures.wait([_pending[n] for n, _ in candidates], timeout=MEDIA_TIMEOUT_S)
volumes: dict[int, list[Volume]] | None = None
disks = []
for n, (bus, name) in candidates:
fut = _pending[n]
size = fut.result() if fut.done() and fut.exception() is None else 0
if size and volumes is None:
volumes = _volumes_by_disk()
vols = (volumes or {}).get(n, []) if size else []
for v in vols:
_fill_volume_info(v)
disks.append(Disk(str(n), name, size, bus, vols, has_media=size > 0))
return disks
+192
View File
@@ -0,0 +1,192 @@
"""Elevated Windows SD-card formatter (runs as: python -m kota_tech.sd.win_format ...).
Replaces Clear-Disk / New-Partition / Format-Volume: every Storage cmdlet
goes through the storage provider, which stalls ~13 s on an empty card
reader slot, several times per format. This helper touches only the chosen
disk through its own driver:
1. guard: bus is USB/SD/MMC, size is exactly the one the technician
confirmed, and the disk does not hold the Windows drive;
2. lock + dismount the card's volumes;
3. raw writes: zero old MBR/GPT, FAT32 structures (kota_tech.sd.fat32),
then the new MBR last;
4. unlock, let Windows re-read the disk, wait for the FAT32 'KOTA' volume;
5. read-only `chkdsk` of the new volume.
The result (with per-step timings) goes to a JSON file for the non-elevated
tool, which then writes ENV.TXT through the normal file API.
"""
from __future__ import annotations
import argparse
import ctypes
import json
import os
import random
import subprocess
import sys
import time
from ctypes import wintypes
from . import fat32, win_disks
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
FSCTL_LOCK_VOLUME, FSCTL_UNLOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x0009001C, 0x00090020
IOCTL_DISK_UPDATE_PROPERTIES = 0x00070140
CARD_BUSES = {"USB", "SD", "MMC"}
CHUNK = 1024 * 1024
k32 = win_disks._k32
k32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
k32.SetFilePointerEx.argtypes = [wintypes.HANDLE, ctypes.c_longlong, ctypes.POINTER(ctypes.c_longlong), wintypes.DWORD]
k32.VirtualAlloc.restype = wintypes.LPVOID
k32.VirtualAlloc.argtypes = [wintypes.LPVOID, ctypes.c_size_t, wintypes.DWORD, wintypes.DWORD]
class FormatError(RuntimeError):
pass
def _open_rw(path: str):
h = k32.CreateFileW(path, GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
if h in (None, win_disks._INVALID):
raise FormatError(f"cannot open {path} (error {ctypes.get_last_error()})")
return h
def _ioctl(h, code: int) -> bool:
got = wintypes.DWORD()
return bool(k32.DeviceIoControl(h, code, None, 0, None, 0, ctypes.byref(got), None))
class RawDisk:
"""Sector-aligned writes through a page-aligned buffer (device alignment rules)."""
def __init__(self, number: int, bps: int):
self.h = _open_rw(rf"\\.\PhysicalDrive{number}")
self.bps = bps
self.buf = k32.VirtualAlloc(None, CHUNK, 0x3000, 0x04) # MEM_COMMIT|RESERVE, RW
def write(self, offset: int, data: bytes) -> None:
if offset % self.bps or len(data) % self.bps:
raise FormatError("unaligned write")
pos = 0
while pos < len(data):
n = min(CHUNK, len(data) - pos)
ctypes.memmove(self.buf, data[pos:pos + n], n)
self._raw(offset + pos, n)
pos += n
def zero(self, offset: int, length: int) -> None:
ctypes.memset(self.buf, 0, CHUNK)
pos = 0
while pos < length:
n = min(CHUNK, length - pos)
self._raw(offset + pos, n)
pos += n
def _raw(self, offset: int, n: int) -> None:
if not k32.SetFilePointerEx(self.h, offset, None, 0):
raise FormatError(f"seek failed (error {ctypes.get_last_error()})")
done = wintypes.DWORD()
if not k32.WriteFile(self.h, self.buf, n, ctypes.byref(done), None) or done.value != n:
raise FormatError(f"write at {offset} failed (error {ctypes.get_last_error()}) - write-protected card?")
def close(self) -> None:
k32.FlushFileBuffers(self.h)
_ioctl(self.h, IOCTL_DISK_UPDATE_PROPERTIES) # re-read the new partition table
k32.CloseHandle(self.h)
def format_card(number: int, expected_size: int, label: str, timings: dict) -> str:
t0 = time.perf_counter()
disk = next((d for d in win_disks.list_disks(CARD_BUSES) if d.id == str(number)), None)
if disk is None:
raise FormatError("Refusing: the disk is not a USB/SD/MMC card reader.")
if disk.size != expected_size:
raise FormatError("Refusing: the disk changed since it was selected (card swapped?).")
system = os.environ.get("SystemDrive", "C:").upper().rstrip("\\") + "\\"
if any(v.mount.upper() == system for v in disk.volumes):
raise FormatError("Refusing: this disk holds the Windows drive.")
timings["guard"] = time.perf_counter() - t0
t = time.perf_counter()
locked = []
for v in disk.volumes:
h = _open_rw(rf"\\.\{v.mount[0]}:")
for _ in range(20):
if _ioctl(h, FSCTL_LOCK_VOLUME):
break
time.sleep(0.25)
else:
k32.CloseHandle(h)
raise FormatError(f"{v.mount} is in use by another program - close it and try again.")
_ioctl(h, FSCTL_DISMOUNT_VOLUME)
locked.append(h)
timings["lock_volumes"] = time.perf_counter() - t
t = time.perf_counter()
lay = fat32.plan(disk.size)
zeros, writes = fat32.build(lay, label, random.getrandbits(32), random.getrandbits(32), disk.size)
raw = RawDisk(number, lay.bytes_per_sector)
try:
for off, n in zeros:
raw.zero(off, n)
for off, data in writes: # MBR is last: the partition appears complete
raw.write(off, data)
finally:
for h in locked:
_ioctl(h, FSCTL_UNLOCK_VOLUME)
k32.CloseHandle(h)
raw.close()
timings["write"] = time.perf_counter() - t
t = time.perf_counter()
drive = ""
for _ in range(80): # Windows mounts the new volume by itself
d = next((x for x in win_disks.list_disks(CARD_BUSES) if x.id == str(number)), None)
vol = next((v for v in (d.volumes if d else []) if v.filesystem.upper() == "FAT32"), None)
if vol and vol.label.upper() == label.upper():
drive = vol.mount
break
time.sleep(0.25)
timings["mount"] = time.perf_counter() - t
if not drive:
raise FormatError("Formatted, but Windows did not mount the card - remove and re-insert it.")
t = time.perf_counter()
chk = subprocess.run(["chkdsk", drive.rstrip("\\")], capture_output=True, text=True, timeout=120,
creationflags=subprocess.CREATE_NO_WINDOW)
timings["chkdsk"] = time.perf_counter() - t
if chk.returncode != 0:
tail = " ".join(chk.stdout.split()[-30:])
raise FormatError(f"chkdsk reported problems on the new file system: {tail}")
return drive
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--disk", type=int, required=True)
ap.add_argument("--size", type=int, required=True)
ap.add_argument("--label", default="KOTA")
ap.add_argument("--result", required=True)
a = ap.parse_args(argv)
timings: dict[str, float] = {}
result = {"ok": False, "message": "", "drive": "", "timings": timings}
try:
result["drive"] = format_card(a.disk, a.size, a.label, timings)
result["ok"] = True
result["message"] = "Formatted FAT32 (MBR), checked with chkdsk."
except FormatError as exc:
result["message"] = str(exc)
except Exception as exc: # report everything to the tool
result["message"] = f"Unexpected error: {exc!r}"
with open(a.result, "w", encoding="utf-8") as fh:
json.dump(result, fh)
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
View File
Whitespace-only changes.
+101
View File
@@ -0,0 +1,101 @@
"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script).
The tool opens the box's service page in the technician's browser:
<base><MAC>#kota-report=<base64url(JSON)>
The service page (v1.1.0+) reads the fragment and pre-fills status and note;
the technician checks it, adds the location and saves - so the report is
authored by their own Google login and the tool holds no credentials.
The fragment is never sent to any server (redirect host or Apps Script).
"""
from __future__ import annotations
import base64
import json
import re
from .. import __version__
from ..diag.engine import Report, Severity
DEFAULT_BASE = "https://service.kota-app.com/"
NOTE_LIMIT = 4000 # the service form accepts 5000; leave room for the technician
# Status names exactly as in the service system (Code.gs STATUSES).
OK = "Działa poprawnie"
CHECK = "Do sprawdzenia"
IN_SERVICE = "W trakcie serwisu"
HARDWARE = "Błąd hardware"
SOFTWARE = "Błąd software"
POWER = "Problem z zasilaniem"
CONNECTIVITY = "Problem z łącznością"
# Root-finding rule id prefix -> service status (first match wins).
_STATUS_BY_RULE = [
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE),
("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE),
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
("rtc.clock_not_running", CHECK), ("reboot.", CHECK),
]
_MAC = re.compile(r"^[0-9A-F]{12}$")
def normalize_mac(mac: str) -> str:
return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper()
def service_status(report: Report) -> str:
"""Suggested status; the technician can change it in the form."""
problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING]
if not problems:
return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK
rid = problems[0].finding.rule_id
for prefix, status in _STATUS_BY_RULE:
if rid.startswith(prefix):
return status
return CHECK
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}",
f"Firmware: {report.firmware.describe()}",
f"Expected LED: {report.led.capitalize()}"]
reached = [label for _, label, t in report.milestones if t is not None]
missing = [label for _, label, t in report.milestones if t is None]
if reached:
lines.append("OK: " + ", ".join(reached))
if missing:
lines.append("Not reached: " + ", ".join(missing))
for rc in report.roots:
f = rc.finding
lines.append(f"- [{f.severity.label}] {f.title}")
for c in rc.consequences:
lines.append(f" -> {c.title}")
if report.technician:
lines.append("Technician checks:")
lines += [f" * {t}" for t in report.technician[-12:]]
text = "\n".join(lines)
return text if len(text) <= limit else text[:limit - 20].rstrip() + "\n...(shortened)"
def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str:
mac = normalize_mac(mac)
if not _MAC.match(mac):
raise ValueError(f"not a box MAC address: {mac!r}")
payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report),
"tool": f"v{__version__}"}
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
base = base if base.endswith("/") else base + "/"
return f"{base}{mac}#kota-report={frag}"
def decode_fragment(url: str) -> dict:
"""Inverse of report_url (used by tests and for troubleshooting)."""
frag = url.split("#kota-report=", 1)[1]
return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8"))
+4 -2
View File
@@ -40,7 +40,8 @@ HINTS = [
"reboot name the cause.", "reboot name the cause.",
"<b>Small LED next to the RGB LED</b> blinks once per second while the " "<b>Small LED next to the RGB LED</b> blinks once per second while the "
"firmware runs. Not blinking = firmware hung or not running.", "firmware runs. Not blinking = firmware hung or not running.",
"<b>Outside the case</b>: power switch and the Matter button. Holding the " "<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
"(off = no noise measurements), and the Matter button. Holding the "
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter " "Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
"network and must be commissioned again with the sticker QR code).", "network and must be commissioned again with the sticker QR code).",
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. " "<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
@@ -55,7 +56,8 @@ STEPS = [
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press " "Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"<b>Connect</b>.", "<b>Connect</b>.",
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.", "Press <b>Reset &amp; capture</b> to record a full boot log from the first line.",
"Note the RGB LED colour and compare with the table below.", "Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
] ]
+4
View File
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QPushButton,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QTextBrowser, QTextBrowser,
@@ -99,6 +100,9 @@ class DiagnosticsPanel(QWidget):
lay.addLayout(led_row) lay.addLayout(led_row)
lay.addWidget(_heading("Firmware")) lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware) lay.addWidget(self.firmware)
self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
lay.addWidget(_heading("Start-up progress (current boot)")) lay.addWidget(_heading("Start-up progress (current boot)"))
lay.addWidget(self.milestones) lay.addWidget(self.milestones)
lay.addWidget(_heading("Findings - root causes first; click one for details")) lay.addWidget(_heading("Findings - root causes first; click one for details"))
+398
View File
@@ -0,0 +1,398 @@
"""Flash firmware tab: factory image or imported developer builds; App-only by default."""
from __future__ import annotations
import datetime as dt
import tempfile
from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QFileDialog,
QInputDialog,
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from ..core.session import SESSIONS_DIR
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, bundled_images
from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result
from ..serialio.ports import PortInfo, list_serial_ports
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
VERIFY_TIMEOUT_MS = 45_000
HELP = (
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
)
class _Bridge(QObject):
step = Signal(int, str)
progress = Signal(int)
output = Signal(str)
done = Signal(object)
class FlashTab(QWidget):
def __init__(self, serial_tab, parent: QWidget | None = None):
super().__init__(parent)
self.serial_tab = serial_tab
self._images: list[FirmwareImage] = []
self._ports: list[PortInfo] = [] # before any signal can call _update()
self._runner: FlashRunner | None = None
self._port: PortInfo | None = None
self._image: FirmwareImage | None = None
# 1. box
box = QGroupBox("1. Sensorbox")
self.port_combo = QComboBox(minimumWidth=360)
refresh = QPushButton("Refresh")
refresh.clicked.connect(self.refresh_ports)
self.box_fw = QLabel(wordWrap=True)
bl = QVBoxLayout(box)
row = QHBoxLayout()
row.addWidget(self.port_combo)
row.addWidget(refresh)
row.addStretch(1)
bl.addLayout(row)
bl.addWidget(self.box_fw)
# 2. firmware
fwbox = QGroupBox("2. Firmware")
self.image_combo = QComboBox(minimumWidth=360)
self.import_btn = QPushButton("Import firmware file...")
self.import_btn.setToolTip("Add a newer build the firmware team sent you (.bin)")
self.import_btn.clicked.connect(self._import)
self.remove_btn = QPushButton("Remove from list")
self.remove_btn.clicked.connect(self._remove)
self.image_info = QLabel(wordWrap=True)
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
fl = QVBoxLayout(fwbox)
fl.addWidget(self.image_combo)
brow = QHBoxLayout()
brow.addWidget(self.import_btn)
brow.addWidget(self.remove_btn)
brow.addStretch(1)
fl.addLayout(brow)
fl.addWidget(self.image_info)
self._library = FirmwareLibrary()
self._reference: FirmwareImage | None = None
self._problems: list[str] = []
# 3. mode
modebox = QGroupBox("3. What to write")
self.rb_app = QRadioButton("App only (recommended for repairs)")
self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)")
self.mode_group = QButtonGroup(self)
self.mode_group.addButton(self.rb_app)
self.mode_group.addButton(self.rb_full)
self.rb_app.setChecked(True)
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
"settings stored on the box.", wordWrap=True)
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
"registration</b> - the box must be paired again afterwards.", wordWrap=True)
for n in (app_note, full_note):
n.setContentsMargins(24, 0, 0, 6)
n.setStyleSheet("color:#555")
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
ml = QVBoxLayout(modebox)
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
ml.addWidget(w)
# 4. run
runbox = QGroupBox("4. Flash")
self.plan_label = QLabel(wordWrap=True)
self.start_btn = QPushButton("Start flashing")
self.start_btn.setMinimumHeight(36)
self.start_btn.clicked.connect(self._start)
self.step_label = QLabel()
self.bar = QProgressBar()
self.result = QLabel(wordWrap=True)
self.result.setMargin(8)
self.result.setTextFormat(Qt.TextFormat.RichText)
self.details_btn = QPushButton("Show esptool output")
self.details_btn.setCheckable(True)
self.output = QPlainTextEdit(readOnly=True)
self.output.setVisible(False)
self.details_btn.toggled.connect(self.output.setVisible)
rl = QVBoxLayout(runbox)
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
rl.addWidget(w)
help_lbl = QLabel(HELP, wordWrap=True)
help_lbl.setStyleSheet("color:#555")
rl.addWidget(help_lbl)
rl.addStretch(1) # keep the run column packed at the top
left = QVBoxLayout()
for w in (box, fwbox, modebox):
left.addWidget(w)
left.addStretch(1)
lay = QHBoxLayout(self)
lay.addLayout(left, 1)
lay.addWidget(runbox, 1)
self._bridge = _Bridge()
self._bridge.step.connect(self._on_step)
self._bridge.progress.connect(self.bar.setValue)
self._bridge.output.connect(self.output.appendPlainText)
self._bridge.done.connect(self._on_done)
self._verify_timer = QTimer(self, interval=500)
self._verify_timer.timeout.connect(self._check_verify)
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
sig.connect(self._update)
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
self.image_combo.currentIndexChanged.connect(self._update)
self.port_combo.currentIndexChanged.connect(self._update)
self._load_images()
self.refresh_ports()
self._show_result("", None)
# ------------------------------------------------------------ setup
def _load_images(self, select_sha: str | None = None) -> None:
self._problems = []
try:
bundled = bundled_images()
except ImageError as exc:
bundled = []
self._problems.append(f"bundled image: {exc}")
factory = [i for i in bundled if not i.is_developer]
self._reference = factory[0] if factory else None
dev, problems = self._library.images(self._reference)
dev += [i for i in bundled if i.is_developer]
self._problems += problems
# Newest developer builds first, then the factory image.
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
self._images = dev + factory
self.image_combo.blockSignals(True)
self.image_combo.clear()
for img in self._images:
self.image_combo.addItem(img.label())
if not self._images:
self.image_combo.addItem("No firmware image available - import one")
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), None)
if idx is None:
idx = len(dev) if factory else 0 # default: the factory image
self.image_combo.setCurrentIndex(idx)
self.image_combo.blockSignals(False)
self._update()
def _import(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "Import firmware from the firmware team", str(Path.home()),
"Firmware (*.bin);;All files (*)")
if not path:
return
built_by, ok = QInputDialog.getText(self, "Who built this firmware?",
"Name of the developer who built this image (shown next to it):")
if not ok:
return
try:
img = self._library.import_file(Path(path), self._reference, built_by=built_by)
except (ImageError, OSError) as exc:
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
return
self._load_images(select_sha=img.sha256)
kind = "complete image" if img.kind == "merged" else "app only"
QMessageBox.information(
self, "Firmware imported",
f"{img.name}\nbuild {img.app.elf_sha[:9]}, {kind}\n\nFile fingerprint (SHA-256):\n{img.sha256}\n\n"
"Compare it with the fingerprint the firmware team sent you before flashing.")
def _remove(self) -> None:
img = self._selected_image()
if img and img.is_developer and QMessageBox.question(
self, "Remove firmware", f"Remove {img.name} from this computer?") == QMessageBox.StandardButton.Yes:
self._library.remove(img.sha256)
self._load_images()
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
self._ports = list_serial_ports()
self.port_combo.clear()
for p in self._ports:
self.port_combo.addItem(p.label, p.device)
if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
self._update()
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
self.refresh_ports()
super().showEvent(event)
# ------------------------------------------------------------ state
def _selected_image(self) -> FirmwareImage | None:
i = self.image_combo.currentIndex()
return self._images[i] if 0 <= i < len(self._images) else None
def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None)
def _update(self) -> None:
img = self._selected_image()
info = ""
if img:
a = img.app
origin = ("<b>Factory image</b> (bundled with the tool)" if not img.is_developer else
"<b style='color:#c77c02'>Developer build</b> - only flash builds you received "
"from the firmware team")
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
info = (f"{origin}<br>{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}, {kind}<br>"
f"<small>build {a.elf_sha[:9]} &middot; file SHA-256 {img.sha256[:16]}... "
f"&middot; checksum OK</small>" + (f"<br><small>{img.notes}</small>" if img.notes else ""))
if self._problems:
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
+ "; ".join(self._problems) + "</span>")
self.image_info.setText(info)
self.remove_btn.setEnabled(bool(img and img.is_developer) and self._runner is None)
# An app-only file has no bootloader/layout: Full mode is impossible.
full_ok = bool(img and img.kind == "merged")
self.rb_full.setEnabled(full_ok and self._runner is None)
if not full_ok and self.rb_full.isChecked():
self.rb_app.setChecked(True)
self.box_fw.setText(self._box_text(img))
self.confirm_full.setVisible(self.rb_full.isChecked())
ok = bool(img and self._selected_port() and self._runner is None
and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
self.start_btn.setEnabled(ok)
if img and self._selected_port():
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
if mode == Mode.APP_ONLY:
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
else:
steps += ["Write the complete factory image (~25 s)"]
steps += ["Restart the box and verify the new firmware boots"]
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
else:
self.plan_label.setText("Select a sensorbox and a firmware image.")
def _box_text(self, img: FirmwareImage | None) -> str:
fw = self.serial_tab.engine.firmware
if not fw.elf_sha:
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
msg = f"Box currently runs: {fw.describe()}" + (f", built {fw.built}" if fw.built else "")
if not img:
return msg
if fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha):
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
box_built, img_built = _parse_built(fw.built), img.app.built_at
if box_built and img_built and img_built > box_built:
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
if box_built and img_built and img_built < box_built:
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
"probably updated over OTA). The box updates itself again once online.</b>")
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
# ------------------------------------------------------------ flash
def _start(self) -> None:
img, port = self._selected_image(), self._selected_port()
if not img or not port:
return
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
else "the COMPLETE factory image (pairing and OTA registration are erased)")
if QMessageBox.question(self, "Flash firmware",
f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n"
"Keep the box powered and the USB cable connected until it finishes.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
) != QMessageBox.StandardButton.Ok:
return
backup_to = None
if self.backup.isChecked():
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
self._port, self._image = port, img
self.serial_tab.release_port() # esptool needs the port exclusively
self.output.clear()
self.output.appendPlainText("\n".join(plan.summary()))
self._show_result("", None)
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
self._bridge.output.emit, self._bridge.done.emit)
self._set_busy(True)
self._runner.start()
def _set_busy(self, busy: bool) -> None:
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full, self.import_btn):
w.setEnabled(not busy)
self._update()
def _on_step(self, i: int, title: str) -> None:
n = len(self._runner.plan.steps) if self._runner else 0
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
def _on_done(self, res: Result) -> None:
self._runner = None
if not res.ok:
self._set_busy(False)
self.step_label.setText("Stopped.")
hint = f"<p>{res.hint}</p>" if res.hint else ""
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
self.details_btn.setChecked(True)
return
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
self.bar.setValue(0)
self._show_result("Firmware written. Waiting for the box to start...", "info")
self.serial_tab.connect_port(self._port, capture=True)
self._verify_ms = 0
self._verify_timer.start()
def _check_verify(self) -> None:
self._verify_ms += self._verify_timer.interval()
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
fw = self.serial_tab.engine.firmware
want = self._image.app.elf_sha[:9]
done = fw.elf_sha is not None and fw.elf_sha.startswith(want)
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
self._verify_timer.stop()
self._set_busy(False)
self.bar.setValue(100)
if done:
self.step_label.setText("Done.")
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
"Check the Diagnosis panel in the Serial monitor.", "ok")
else:
seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen"
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
"Open the Serial monitor and press Reset & capture.", "warning")
def _show_result(self, html: str, kind: str | None) -> None:
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
self.result.setVisible(bool(html))
if html:
border, bg = colours[kind or "info"]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
def _parse_built(s: str | None) -> dt.datetime | None:
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
if not s:
return None
try:
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
except ValueError:
return None
+225
View File
@@ -0,0 +1,225 @@
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QProgressBar,
QPushButton,
QVBoxLayout,
QWidget,
)
from ..diag.engine import LED, DiagnosticsEngine
from ..diag.guides import GuideRunner, recommended
from ..matter.payload import SetupPayload
from .matter_qr import MatterQrWidget
END_STYLE = { # end severity -> (border, background)
"ok": ("#2e9e44", "#e3f4e7"),
"info": ("#2e6da4", "#e3edf7"),
"warning": ("#c77c02", "#fdf0d6"),
"error": ("#d9534f", "#f9e0df"),
"critical": ("#b03a2e", "#f6d5d1"),
}
def _swatch(key: str, size: int = 22) -> QIcon:
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
pm = QPixmap(size, size)
pm.fill(QColor(0, 0, 0, 0))
p = QPainter(pm)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
if key == "CYCLING":
g = QLinearGradient(0, 0, size, 0)
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
g.setColorAt(stop, QColor(c))
p.setBrush(QBrush(g))
else:
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
p.setPen(QColor("#777777"))
p.drawEllipse(1, 1, size - 2, size - 2)
p.end()
return QIcon(pm)
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class GuidePanel(QWidget):
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
matter_payload=None):
super().__init__(parent)
self.runner, self.engine = runner, engine
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
self._shown_key: tuple | None = None
# --- picker (no guide running)
self.picker = QWidget()
pl = QVBoxLayout(self.picker)
pl.setContentsMargins(0, 0, 0, 0)
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
self.guide_list = QListWidget()
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
pl.addWidget(self.guide_list, 1)
self.start_btn = QPushButton("Start selected check")
self.start_btn.clicked.connect(self._start_selected)
pl.addWidget(self.start_btn)
# --- running guide
self.card = QFrame()
self.card.setFrameShape(QFrame.Shape.StyledPanel)
cl = QVBoxLayout(self.card)
self.guide_title = QLabel()
self.guide_title.setStyleSheet("color:#666")
self.step_text = QLabel(wordWrap=True)
f = self.step_text.font()
f.setPointSize(f.pointSize() + 2)
self.step_text.setFont(f)
self.step_text.setTextFormat(Qt.TextFormat.RichText)
self.options = QVBoxLayout()
self.wait_bar = QProgressBar()
self.wait_bar.setTextVisible(False)
self.wait_label = QLabel()
self.note = QLabel(wordWrap=True)
self.note.setStyleSheet("color:#b03a2e")
self.continue_btn = QPushButton("Continue")
self.continue_btn.clicked.connect(self._proceed)
self.skip_btn = QPushButton("Skip waiting / it did not work")
self.skip_btn.clicked.connect(self._proceed)
self.end_box = QLabel(wordWrap=True)
self.end_box.setTextFormat(Qt.TextFormat.RichText)
self.end_box.setMargin(10)
self.qr = MatterQrWidget(SetupPayload(), 220)
for w in (self.guide_title, self.step_text, self.qr):
cl.addWidget(w)
cl.addLayout(self.options)
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
cl.addWidget(w)
cl.addStretch(1)
nav = QHBoxLayout()
self.back_btn = QPushButton("← Back")
self.back_btn.clicked.connect(self._back)
self.done_btn = QPushButton("Back to checks list")
self.done_btn.clicked.connect(self._cancel)
nav.addWidget(self.back_btn)
nav.addStretch(1)
nav.addWidget(self.done_btn)
cl.addLayout(nav)
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 0, 0, 0)
lay.addWidget(self.picker, 1)
lay.addWidget(self.card, 1)
self.refresh()
# ------------------------------------------------------------ actions
def _start_selected(self) -> None:
it = self.guide_list.currentItem() or self.guide_list.item(0)
if it:
self._start(it.data(Qt.ItemDataRole.UserRole))
def _start(self, guide_id: str) -> None:
self.runner.start(guide_id)
self.refresh()
def _answer(self, key: str) -> None:
self.runner.answer(key)
self.refresh()
def _proceed(self) -> None:
self.runner.proceed()
self.refresh()
def _back(self) -> None:
self.runner.back()
self.refresh()
def _cancel(self) -> None:
self.runner.cancel()
self.refresh()
# ------------------------------------------------------------- render
def refresh(self) -> None:
"""Called after every interaction and periodically (timeouts, countdown)."""
self.runner.tick()
v = self.runner.view()
running = v.guide is not None
self.picker.setVisible(not running)
self.card.setVisible(running)
if not running:
self._fill_picker()
self._shown_key = None
return
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
if key != self._shown_key:
self._shown_key = key
self._render_step(v)
if v.waiting:
total = v.step.timeout_s
self.wait_bar.setMaximum(int(total))
self.wait_bar.setValue(int(total - v.remaining_s))
m, s = divmod(int(v.remaining_s), 60)
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
self.note.setText(v.note)
self.note.setVisible(bool(v.note))
self.back_btn.setEnabled(v.can_back)
def _fill_picker(self) -> None:
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
if [w[0] for w in wanted] == current:
return
self.guide_list.clear()
for gid, title, summary in wanted:
it = QListWidgetItem(f"{title}\n {summary}")
it.setData(Qt.ItemDataRole.UserRole, gid)
self.guide_list.addItem(it)
self.guide_list.setCurrentRow(0)
def _render_step(self, v) -> None:
self.guide_title.setText(v.guide.title)
while self.options.count():
w = self.options.takeAt(0).widget()
if w:
# Detach now; deleteLater alone leaves it visible until the
# event loop processes the deferred delete.
w.hide()
w.setParent(None)
w.deleteLater()
is_end = v.end is not None
self.step_text.setVisible(not is_end)
self.step_text.setText(_esc(v.text))
show_qr = bool(v.step and v.step.show_qr)
if show_qr:
self.qr.set_payload(self._matter_payload())
self.qr.setVisible(show_qr)
for opt_key, label in v.options:
b = QPushButton(label)
b.setMinimumHeight(34)
if v.step and v.step.type == "led":
b.setIcon(_swatch(opt_key))
b.setIconSize(QSize(22, 22))
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
self.options.addWidget(b)
step_type = v.step.type if v.step else None
self.continue_btn.setVisible(step_type == "info")
self.skip_btn.setVisible(step_type == "wait")
self.wait_bar.setVisible(step_type == "wait")
self.wait_label.setVisible(step_type == "wait")
self.end_box.setVisible(is_end)
if is_end:
e = v.end
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
+ (f"<ol>{acts}</ol>" if acts else ""))
+120 -4
View File
@@ -2,15 +2,17 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QFileDialog, QFileDialog,
QHBoxLayout, QHBoxLayout,
QInputDialog,
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QSplitter, QSplitter,
@@ -22,13 +24,20 @@ from PySide6.QtWidgets import (
from .. import __version__ from .. import __version__
from ..core.logfile import read_log_file from ..core.logfile import read_log_file
from ..core.logline import LineKind, LogLine from ..core.logline import LineKind, LogLine, host_event
from ..core.session import SessionLog from ..core.session import SessionLog
from ..diag.engine import DiagnosticsEngine from ..diag.engine import DiagnosticsEngine
from ..diag.guides import GuideRunner
from ..serialio.manager import SerialManager, State from ..serialio.manager import SerialManager, State
from ..serialio.ports import PortInfo, list_serial_ports from ..serialio.ports import PortInfo, list_serial_ports
from ..matter.payload import SetupPayload, payload_from_log
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
from .cheatsheet import cheatsheet_html from .cheatsheet import cheatsheet_html
from .diag_panel import DiagnosticsPanel from .diag_panel import DiagnosticsPanel
from .flash_tab import FlashTab
from .matter_qr import MatterQrDialog
from .guide_panel import GuidePanel
from .sd_tab import SdTab
MAX_LINES = 20000 MAX_LINES = 20000
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}} LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
@@ -63,6 +72,7 @@ class SerialTab(QWidget):
self._counts = {"E": 0, "W": 0, "boots": 0} self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine = DiagnosticsEngine() self._engine = DiagnosticsEngine()
self._diag_dirty = True self._diag_dirty = True
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
# --- controls # --- controls
self.port_combo = QComboBox(minimumWidth=320) self.port_combo = QComboBox(minimumWidth=320)
@@ -80,6 +90,14 @@ class SerialTab(QWidget):
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it") self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
self.copy_btn = QPushButton("Copy report") self.copy_btn = QPushButton("Copy report")
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)") self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
self.service_btn = QPushButton("Service report...")
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
"filled in; check it and press Save there")
self._settings = QSettings("KOTA", "TechnicianTool")
self.auto_service = QCheckBox("Open service report on disconnect")
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
self._active_port: PortInfo | None = None
row1 = QHBoxLayout() row1 = QHBoxLayout()
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn, for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
@@ -88,7 +106,7 @@ class SerialTab(QWidget):
row1.addStretch(1) row1.addStretch(1)
row2 = QHBoxLayout() row2 = QHBoxLayout()
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn, for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
self.analyze_btn, self.copy_btn): self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service):
row2.addWidget(w) row2.addWidget(w)
row2.addStretch(1) row2.addStretch(1)
@@ -102,9 +120,13 @@ class SerialTab(QWidget):
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
self.diag = DiagnosticsPanel() self.diag = DiagnosticsPanel()
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
self.side_tabs = QTabWidget()
self.side_tabs.addTab(self.diag, "Diagnosis")
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self.view) split.addWidget(self.view)
split.addWidget(self.diag) split.addWidget(self.side_tabs)
split.setStretchFactor(0, 3) split.setStretchFactor(0, 3)
split.setStretchFactor(1, 2) split.setStretchFactor(1, 2)
split.setSizes([700, 480]) split.setSizes([700, 480])
@@ -126,6 +148,8 @@ class SerialTab(QWidget):
self.folder_btn.clicked.connect(self._open_folder) self.folder_btn.clicked.connect(self._open_folder)
self.analyze_btn.clicked.connect(self._analyze_file) self.analyze_btn.clicked.connect(self._analyze_file)
self.copy_btn.clicked.connect(self._copy_report) self.copy_btn.clicked.connect(self._copy_report)
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
# Report refresh is throttled; lines can arrive at >100/s. # Report refresh is throttled; lines can arrive at >100/s.
self._diag_timer = QTimer(self, interval=400) self._diag_timer = QTimer(self, interval=400)
@@ -173,16 +197,74 @@ class SerialTab(QWidget):
port = self._selected_port() port = self._selected_port()
if port is None: if port is None:
return return
self._connect(port)
def _connect(self, port: PortInfo) -> None:
self._counts = {"E": 0, "W": 0, "boots": 0} self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset() self._engine.reset()
self._runner.cancel()
self._diag_dirty = True self._diag_dirty = True
self._session.start(port.mac or port.device) self._session.start(port.mac or port.device)
self._active_port = port
self._manager.connect(port) self._manager.connect(port)
def matter_payload(self) -> SetupPayload:
"""Pairing payload from this box's log (VID/PID/discriminator), defaults otherwise."""
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [])
# ---- service system -------------------------------------------------
def _service_base(self) -> str:
return str(self._settings.value("service/base", DEFAULT_BASE))
def _open_service_report(self, auto: bool) -> None:
mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "")
if len(mac) != 12:
if not auto:
QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. "
"Connect to the box and press Reset & capture first.")
return
url = report_url(mac, self._engine.report(), self._service_base())
QDesktopServices.openUrl(QUrl(url))
# Log that it happened, not the report itself (it is already in the session log).
self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there")
# ---- hand-off with the flash tab ------------------------------------
@property
def engine(self) -> DiagnosticsEngine:
return self._engine
def release_port(self) -> None:
"""Stop monitoring so esptool can open the port exclusively."""
if self._manager.is_running:
self._no_auto_service = True # a flash hand-off is not the end of a session
self._manager.disconnect() # joins the reader thread
QCoreApplication.processEvents() # deliver its queued final lines/state now
self._no_auto_service = False
def connect_port(self, port: PortInfo, capture: bool = True) -> None:
"""Monitor `port`; with capture, restart the box once connected to record its boot."""
self.release_port()
idx = self.port_combo.findData(port.device)
if idx < 0:
self.port_combo.addItem(port.label, port.device)
self._ports.append(port)
idx = self.port_combo.count() - 1
self.port_combo.setCurrentIndex(idx)
self._reset_on_connect = capture
self._connect(port)
def _on_state(self, state: State, detail: str) -> None: def _on_state(self, state: State, detail: str) -> None:
running = state != State.DISCONNECTED running = state != State.DISCONNECTED
if not running and self._manager.is_running:
return # stale event from a session that was already replaced
if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
self._reset_on_connect = False
QTimer.singleShot(300, self._manager.reset_board)
if not running and self._session.path is not None: if not running and self._session.path is not None:
self._session.write_report(self._engine.report().to_text()) self._session.write_report(self._engine.report().to_text())
if (self.auto_service.isChecked() and self._engine.lines
and not getattr(self, "_no_auto_service", False)):
self._open_service_report(auto=True)
self._session.stop() self._session.stop()
self.connect_btn.setText("Disconnect" if running else "Connect") self.connect_btn.setText("Disconnect" if running else "Connect")
self.reset_btn.setEnabled(state == State.CONNECTED) self.reset_btn.setEnabled(state == State.CONNECTED)
@@ -206,6 +288,7 @@ class SerialTab(QWidget):
def _on_line(self, line: LogLine) -> None: def _on_line(self, line: LogLine) -> None:
self._session.write(line) self._session.write(line)
self._engine.feed(line) self._engine.feed(line)
self._runner.feed(line)
self._diag_dirty = True self._diag_dirty = True
if line.kind == LineKind.BOOT: if line.kind == LineKind.BOOT:
self._counts["boots"] += 1 self._counts["boots"] += 1
@@ -240,6 +323,21 @@ class SerialTab(QWidget):
if self._diag_dirty: if self._diag_dirty:
self._diag_dirty = False self._diag_dirty = False
self.diag.show_report(self._engine.report()) self.diag.show_report(self._engine.report())
self.guide.refresh() # countdowns and timeouts need a steady tick
# ---- guided troubleshooting hooks ------------------------------------
def _guide_action(self, action: str) -> bool:
if action == "reset" and self._manager.state == State.CONNECTED:
self._manager.reset_board()
return True
return False
def _guide_log(self, text: str) -> None:
# Technician answers go into the session log and the log view.
line = host_event(text)
self._session.write(line)
self._append(line)
self._diag_dirty = True
def _copy_report(self) -> None: def _copy_report(self) -> None:
QGuiApplication.clipboard().setText(self._engine.report().to_text()) QGuiApplication.clipboard().setText(self._engine.report().to_text())
@@ -252,6 +350,7 @@ class SerialTab(QWidget):
lines = read_log_file(Path(path)) lines = read_log_file(Path(path))
self.view.clear() self.view.clear()
self._engine.reset() self._engine.reset()
self._runner.cancel()
self._counts = {"E": 0, "W": 0, "boots": 0} self._counts = {"E": 0, "W": 0, "boots": 0}
for line in lines: for line in lines:
self._engine.feed(line) self._engine.feed(line)
@@ -290,9 +389,26 @@ class MainWindow(QMainWindow):
guide = QTextBrowser(openExternalLinks=True) guide = QTextBrowser(openExternalLinks=True)
guide.setHtml(cheatsheet_html()) guide.setHtml(cheatsheet_html())
self.serial_tab = SerialTab() self.serial_tab = SerialTab()
self.flash_tab = FlashTab(self.serial_tab)
tabs.addTab(guide, "Quick start") tabs.addTab(guide, "Quick start")
tabs.addTab(self.serial_tab, "Serial monitor") tabs.addTab(self.serial_tab, "Serial monitor")
tabs.addTab(self.flash_tab, "Flash firmware")
self.sd_tab = SdTab(self.serial_tab)
tabs.addTab(self.sd_tab, "SD card")
self.setCentralWidget(tabs) self.setCentralWidget(tabs)
settings_menu = self.menuBar().addMenu("Settings")
settings_menu.addAction("Service system address...", self._edit_service_base)
def _edit_service_base(self) -> None:
st = self.serial_tab
text, ok = QInputDialog.getText(
self, "Service system address",
"Address of the KOTA service system (the box MAC is added at the end):",
text=st._service_base())
if ok and text.strip().startswith(("https://", "http://")):
st._settings.setValue("service/base", text.strip())
elif ok:
QMessageBox.warning(self, "Service system address", "The address must start with https://")
def closeEvent(self, event): # noqa: N802 (Qt naming) def closeEvent(self, event): # noqa: N802 (Qt naming)
self.serial_tab.shutdown() self.serial_tab.shutdown()
+72
View File
@@ -0,0 +1,72 @@
"""Matter pairing QR code on screen, so the technician can scan it from the laptop."""
from __future__ import annotations
import segno
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
from ..matter.payload import SetupPayload
def qr_pixmap(text: str, size: int = 260) -> QPixmap:
"""Black-on-white QR with the quiet zone phones need (4 modules)."""
qr = segno.make(text, error="m")
matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)]
n = len(matrix)
module = max(1, size // n)
img = QImage(n * module, n * module, QImage.Format.Format_RGB32)
img.fill(QColor("white"))
p = QPainter(img)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("black"))
for y, row in enumerate(matrix):
for x, dark in enumerate(row):
if dark:
p.drawRect(x * module, y * module, module, module)
p.end()
return QPixmap.fromImage(img)
class MatterQrWidget(QWidget):
def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None):
super().__init__(parent)
self.image = QLabel()
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code = QLabel()
self.code.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
f = self.code.font()
f.setPointSize(f.pointSize() + 4)
f.setBold(True)
self.code.setFont(f)
self.note = QLabel(wordWrap=True)
self.note.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.note.setStyleSheet("color:#555")
lay = QVBoxLayout(self)
lay.addWidget(self.image)
lay.addWidget(self.code)
lay.addWidget(self.note)
self._size = size
self.set_payload(payload)
def set_payload(self, payload: SetupPayload) -> None:
self.payload = payload
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
src = ("from this box's log" if payload.from_log else
"standard code of the v1.0 firmware - connect the box to confirm")
self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
f"Discriminator {payload.discriminator} ({src}). Same as the sticker on the box.")
class MatterQrDialog(QDialog):
def __init__(self, payload: SetupPayload, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Matter pairing code")
lay = QVBoxLayout(self)
lay.addWidget(MatterQrWidget(payload, 320))
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+321
View File
@@ -0,0 +1,321 @@
"""SD card tab: format a card for the sensorbox and write / check ENV.TXT."""
from __future__ import annotations
import threading
from pathlib import Path
from PySide6.QtCore import QObject, QSettings, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from ..sd.disks import Disk, list_cards
from ..sd.envfile import EnvConfig, load_template
from ..sd.provision import Outcome, check_card, format_and_write, write_env
# The shared settings file the factory script uses, next to the tool folders.
DEFAULT_TEMPLATE = Path(__file__).resolve().parents[4] / "002-SD_card_format" / "ENV.txt"
class _Bridge(QObject):
done = Signal(object)
listed = Signal(object)
def _secret_field() -> tuple[QLineEdit, QCheckBox]:
edit = QLineEdit()
edit.setEchoMode(QLineEdit.EchoMode.Password)
show = QCheckBox("show")
show.toggled.connect(lambda on: edit.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
return edit, show
class SdTab(QWidget):
def __init__(self, serial_tab, parent: QWidget | None = None):
super().__init__(parent)
self.serial_tab = serial_tab
self._settings = QSettings("KOTA", "TechnicianTool")
self._cards: list[Disk] = []
self._busy = False
self._listing = False
# --- 1. settings
sbox = QGroupBox("1. Settings written to the card (ENV.TXT)")
self.template_label = QLabel(wordWrap=True)
load_btn = QPushButton("Load settings from ENV file...")
load_btn.clicked.connect(self._choose_template)
self.url = QLineEdit()
self.token, show_token = _secret_field()
form = QFormLayout()
form.addRow("INFLUX_URL", self.url)
trow = QHBoxLayout()
trow.addWidget(self.token, 1)
trow.addWidget(show_token)
form.addRow("INFLUX_TOKEN", trow)
self.wifi_box = QGroupBox("Local Wi-Fi instead of Matter (optional)")
self.wifi_box.setCheckable(True)
self.wifi_box.setChecked(False)
self.ssid = QLineEdit()
self.password, show_pw = _secret_field()
wform = QFormLayout(self.wifi_box)
wform.addRow("SSID", self.ssid)
prow = QHBoxLayout()
prow.addWidget(self.password, 1)
prow.addWidget(show_pw)
wform.addRow("PASSWORD", prow)
self.wifi_note = QLabel(wordWrap=True)
self.wifi_note.setStyleSheet("color:#555")
wform.addRow(self.wifi_note)
self.issues = QLabel(wordWrap=True)
sl = QVBoxLayout(sbox)
sl.addWidget(self.template_label)
sl.addWidget(load_btn, 0, Qt.AlignmentFlag.AlignLeft)
sl.addLayout(form)
sl.addWidget(self.wifi_box)
sl.addWidget(self.issues)
# --- 2. card
cbox = QGroupBox("2. SD card (inside the sensorbox case)")
self.card_list = QListWidget()
self.card_list.setMinimumHeight(80)
self.others = QLabel(wordWrap=True)
self.others.setStyleSheet("color:#666")
refresh = QPushButton("Refresh cards")
refresh.clicked.connect(self.refresh_cards)
cl = QVBoxLayout(cbox)
cl.addWidget(QLabel("Insert the card into the laptop's reader. Only removable cards are listed."))
cl.addWidget(self.card_list)
cl.addWidget(self.others)
cl.addWidget(refresh, 0, Qt.AlignmentFlag.AlignLeft)
# --- 3. actions
abox = QGroupBox("3. Action")
self.format_btn = QPushButton("Format card and write ENV.TXT")
self.format_btn.setMinimumHeight(34)
self.format_btn.setToolTip("Erases the card: FAT32 (MBR), label KOTA, then writes and verifies ENV.TXT")
self.write_btn = QPushButton("Write ENV.TXT only (keep card contents)")
self.write_btn.setToolTip("Keeps buffered measurements (BUFFER.TXT) on the card")
self.check_btn = QPushButton("Check card")
self.check_btn.setToolTip("Show what the box would read from this card")
self.format_btn.clicked.connect(self._format)
self.write_btn.clicked.connect(self._write_only)
self.check_btn.clicked.connect(self._check)
self.result = QLabel(wordWrap=True)
self.result.setTextFormat(Qt.TextFormat.RichText)
self.result.setMargin(8)
self.log = QPlainTextEdit(readOnly=True)
self.log.setMaximumBlockCount(500)
al = QVBoxLayout(abox)
for w in (self.format_btn, self.write_btn, self.check_btn, self.result, self.log):
al.addWidget(w)
left = QVBoxLayout()
left.addWidget(sbox)
left.addStretch(1)
right = QVBoxLayout()
right.addWidget(cbox)
right.addWidget(abox, 1)
lay = QHBoxLayout(self)
lay.addLayout(left, 1)
lay.addLayout(right, 1)
self._bridge = _Bridge()
self._bridge.done.connect(self._on_done)
self._bridge.listed.connect(self._on_listed)
for w in (self.url, self.token, self.ssid, self.password):
w.textChanged.connect(self._update)
self.wifi_box.toggled.connect(self._update)
self.card_list.currentRowChanged.connect(self._update)
path = Path(self._settings.value("sd/template", str(DEFAULT_TEMPLATE)))
self._load_template(path, quiet=True)
self._show_result("", "")
self._update()
# ---------------------------------------------------------- settings
def _choose_template(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "ENV file with the InfluxDB settings",
str(DEFAULT_TEMPLATE.parent), "Text (*.txt *.env);;All files (*)")
if path:
self._load_template(Path(path))
def _load_template(self, path: Path, quiet: bool = False) -> None:
try:
env = load_template(path)
except OSError as exc:
self.template_label.setText("No settings file loaded - load the ENV file the firmware team gave you.")
if not quiet:
QMessageBox.warning(self, "Cannot read file", str(exc))
return
self._settings.setValue("sd/template", str(path))
self.template_label.setText(f"Loaded from <code>{path}</code>")
self.url.setText(env.influx_url)
self.token.setText(env.influx_token)
# Wi-Fi is decided per box by the technician, never copied silently.
self._update()
def _env(self) -> EnvConfig:
wifi = self.wifi_box.isChecked()
return EnvConfig(self.url.text(), self.token.text(),
self.ssid.text() if wifi else "", self.password.text() if wifi else "")
def _update(self) -> None:
env = self._env()
issues = env.problems()
colour = {"error": "#b03a2e", "warning": "#c77c02", "info": "#2e6da4"}
self.issues.setText("<br>".join(f"<span style='color:{colour[i.level]}'>{i.level.upper()}: {i.text}</span>"
for i in issues) or "<span style='color:#2e9e44'>Settings OK.</span>")
fw = self.serial_tab.engine.firmware
support = fw.supports_sd_wifi
self.wifi_note.setText(
"Only for boxes that should NOT use Matter. The box then joins this Wi-Fi directly. "
+ {True: "The connected box's firmware supports this.",
False: "<b>The connected box runs the factory v1.0 firmware, which ignores these settings "
"(it only uses Matter).</b> Newer firmware (after an OTA update) uses them.",
None: "Supported by newer firmware only; the factory v1.0 firmware ignores it."}[support])
errors = any(i.level == "error" for i in issues)
card = self._selected_card()
mounted = bool(card and card.volumes and card.volumes[0].mount)
self.format_btn.setEnabled(not self._busy and not errors and card is not None)
self.write_btn.setEnabled(not self._busy and not errors and mounted)
self.check_btn.setEnabled(not self._busy and mounted)
# -------------------------------------------------------------- cards
def showEvent(self, event): # noqa: N802
self.refresh_cards()
super().showEvent(event)
def refresh_cards(self) -> None:
"""List cards in the background: the UI never waits for a slow card reader."""
if self._busy or self._listing:
return
self._listing = True
self.others.setText("Looking for SD cards...")
def worker():
try:
result = list_cards()
except Exception as exc: # show it, never leave the tab stuck
result = ([], [], str(exc))
self._bridge.listed.emit(result)
threading.Thread(target=worker, daemon=True, name="sd-list").start()
def _on_listed(self, result) -> None:
self._listing = False
self._cards, others = result[0], result[1]
if len(result) > 2:
self.others.setText(f"Could not list cards: {result[2]}")
self._update()
return
self.card_list.clear()
for d in self._cards:
self.card_list.addItem(QListWidgetItem(d.label))
if self._cards:
self.card_list.setCurrentRow(0)
empty = [d for d in others if not d.has_media]
big = [d for d in others if d.has_media]
notes = []
if empty:
notes.append("Card reader found but no card inserted: " + ", ".join(d.name for d in empty) + ".")
if big:
notes.append("Not offered (too large to be an SD card): " + ", ".join(d.label for d in big) + ".")
if not self._cards and not empty:
notes.append("No SD card or card reader found.")
self.others.setText(" ".join(notes))
self._update()
def _selected_card(self) -> Disk | None:
i = self.card_list.currentRow()
return self._cards[i] if 0 <= i < len(self._cards) else None
# ------------------------------------------------------------ actions
def _format(self) -> None:
card = self._selected_card()
if card is None:
return
box = QMessageBox(QMessageBox.Icon.Warning, "Format SD card",
f"ALL DATA ON THIS CARD WILL BE ERASED:\n\n{card.label}\n\n"
"Buffered measurements on the card are lost too. Continue?",
QMessageBox.StandardButton.Cancel, self)
erase = box.addButton("Erase and format", QMessageBox.ButtonRole.DestructiveRole)
box.exec()
if box.clickedButton() is not erase:
return
env = self._env()
self._run(lambda: format_and_write(card, env), f"Formatting {card.name}...")
def _write_only(self) -> None:
card = self._selected_card()
if card and card.volumes:
env, mount = self._env(), card.volumes[0].mount
self._run(lambda: write_env(mount, env), f"Writing ENV.TXT to {mount}...")
def _check(self) -> None:
card = self._selected_card()
if not (card and card.volumes):
return
vol = card.volumes[0]
rep = check_card(vol.mount, vol.filesystem)
lines = [f"Card {vol.mount} ({vol.filesystem or '?'})"]
if rep.env:
lines += [f" {k}={v}" for k, v in rep.env.masked().items()]
lines += [f" {i.level.upper()}: {i.text}" for i in rep.env.issues]
lines += [f" PROBLEM: {p}" for p in rep.problems]
if rep.buffered_lines is not None:
lines.append(f" {rep.buffered_lines} measurement lines buffered on the card, waiting for upload.")
self.log.appendPlainText("\n".join(lines))
ok = not rep.problems and rep.env is not None and rep.env.ok
self._show_result("Card is OK for the sensorbox." if ok else "Card has problems - see below. "
"Use 'Write ENV.TXT only' or 'Format card'.", "ok" if ok else "error")
def _run(self, job, what: str) -> None:
self._busy = True
self._update()
self._show_result(what, "info")
def worker():
try:
out = job()
except Exception as exc: # report, never leave the tab busy
out = Outcome(False, f"Unexpected error: {exc}")
self._bridge.done.emit(out)
threading.Thread(target=worker, daemon=True).start()
def _on_done(self, out: Outcome) -> None:
self._busy = False
for line in out.log:
self.log.appendPlainText(line)
self.log.appendPlainText(out.message)
if out.ok:
self._show_result(f"<b>{out.message}</b><br>Insert the next card and press Refresh cards to "
"provision another one.", "ok")
else:
self._show_result(f"<b>{out.message}</b>", "error")
self.refresh_cards()
def _show_result(self, html: str, kind: str) -> None:
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"), "error": ("#d9534f", "#f9e0df")}
self.result.setVisible(bool(html))
if html:
border, bg = colours[kind]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
+4 -2
View File
@@ -1,15 +1,17 @@
[project] [project]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.2.0" version = "0.6.0"
description = "KOTA sensorbox technician diagnostic tool (demo)" description = "KOTA sensorbox technician diagnostic tool (demo)"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
dependencies = [ dependencies = [
"pyserial>=3.5", "pyserial>=3.5",
"PySide6-Essentials>=6.7", "PySide6-Essentials>=6.7",
"esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands
"segno>=1.6", # QR code for Matter pairing (pure Python)
] ]
[dependency-groups] [dependency-groups]
dev = ["pytest>=8"] dev = ["pytest>=8", "pyfatfs>=1.0", "setuptools<81"] # pyfatfs (independent FAT32 check) needs pkg_resources
[tool.uv] [tool.uv]
package = false package = false
+1 -1
View File
@@ -3,7 +3,7 @@
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
# Keep the virtual environment out of the (Google Drive) tool folder. # Keep the virtual environment out of the (Google Drive) tool folder.
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.2" export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.6"
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
if ! command -v uv >/dev/null 2>&1; then if ! command -v uv >/dev/null 2>&1; then
+1 -1
View File
@@ -3,7 +3,7 @@ setlocal
cd /d "%~dp0" cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder. rem Keep the virtual environment out of the (Google Drive) tool folder.
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.2" set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.6"
set "PATH=%USERPROFILE%\.local\bin;%PATH%" set "PATH=%USERPROFILE%\.local\bin;%PATH%"
where uv >nul 2>nul where uv >nul 2>nul
+103
View File
@@ -0,0 +1,103 @@
"""FAT32/MBR layout: field-level checks against the FAT spec on an in-memory
disk, plus an independent FAT implementation (pyfatfs) mounting the result."""
import struct
import pytest
from kota_tech.sd import fat32
MiB, GiB = 1024**2, 1024**3
def make_disk(size: int, label="KOTA") -> tuple[bytearray, fat32.Fat32Layout]:
lay = fat32.plan(size)
disk = bytearray(b"\xAA" * size) # old garbage everywhere
zeros, writes = fat32.build(lay, label, 0x12345678, 0xCAFEBABE, size)
for off, n in zeros:
disk[off:off + n] = bytes(n)
for off, data in writes:
disk[off:off + len(data)] = data
return disk, lay
@pytest.fixture(scope="module")
def small():
return make_disk(96 * MiB)
def test_mbr_has_one_fat32_lba_partition(small):
disk, lay = small
assert disk[510:512] == b"\x55\xAA"
status, ptype, lba, count = struct.unpack_from("<B3xB3xII", disk, 0x1BE)
assert (status, ptype, lba) == (0, 0x0C, fat32.PART_START // 512)
assert count == lay.part_sectors
assert disk[0x1CE:0x1FE] == bytes(48) # other entries empty
assert struct.unpack_from("<I", disk, 0x1B8)[0] == 0xCAFEBABE
def test_boot_sector_fields(small):
disk, lay = small
b = disk[fat32.PART_START:fat32.PART_START + 512]
bps, spc, rsvd, nfats, root_ent, tot16, media, fat16 = struct.unpack_from("<HBHBHHBH", b, 11)
assert (bps, spc, rsvd, nfats, root_ent, tot16, media, fat16) == (512, lay.sectors_per_cluster, 32, 2, 0, 0, 0xF8, 0)
hidden, tot32, fatsz, _, _, root_clus, fsinfo, bkboot = struct.unpack_from("<IIIHHIHH", b, 28)
assert hidden == fat32.PART_START // 512 and tot32 == lay.part_sectors and fatsz == lay.fat_sectors
assert (root_clus, fsinfo, bkboot) == (2, 1, 6)
assert b[66] == 0x29 and b[71:82] == b"KOTA " and b[82:90] == b"FAT32 " and b[510:] == b"\x55\xAA"
assert disk[fat32.PART_START + 6 * 512:fat32.PART_START + 7 * 512] == b # backup boot sector
def test_fat_is_large_enough_and_clusters_valid(small):
_, lay = small
assert fat32.MIN_CLUSTERS <= lay.clusters
assert lay.fat_sectors * 512 // 4 >= lay.clusters + 2
assert lay.data_start + lay.clusters * lay.sectors_per_cluster <= lay.part_sectors
def test_fsinfo_and_fat_entries(small):
disk, lay = small
info = disk[fat32.PART_START + 512:fat32.PART_START + 1024]
assert struct.unpack_from("<I", info, 0)[0] == 0x41615252
assert struct.unpack_from("<III", info, 484) == (0x61417272, lay.clusters - 1, 3)
assert struct.unpack_from("<I", info, 508)[0] == 0xAA550000
for fat in range(2):
off = fat32.PART_START + (32 + fat * lay.fat_sectors) * 512
assert struct.unpack_from("<III", disk, off) == (0x0FFFFFF8, 0x0FFFFFFF, 0x0FFFFFFF)
assert disk[off + 12:off + lay.fat_sectors * 512] == bytes(lay.fat_sectors * 512 - 12)
def test_root_holds_only_the_label(small):
disk, lay = small
root = fat32.PART_START + lay.data_start * 512
assert disk[root:root + 11] == b"KOTA " and disk[root + 11] == 0x08
assert disk[root + 32:root + lay.sectors_per_cluster * 512] == bytes(lay.sectors_per_cluster * 512 - 32)
@pytest.mark.parametrize("size,spc", [(4 * GiB, 8), (15 * GiB, 16), (31_457_280_000, 32), (64 * GiB, 32)])
def test_real_card_sizes(size, spc):
lay = fat32.plan(size)
assert lay.sectors_per_cluster == spc
assert lay.part_sectors * 512 <= min(size - fat32.PART_START, 32 * GiB)
assert fat32.MIN_CLUSTERS <= lay.clusters < fat32.MAX_CLUSTERS
def test_too_small_card_refused():
with pytest.raises(ValueError):
fat32.plan(20 * MiB)
def test_independent_fat_implementation_mounts_it(small, tmp_path):
pyfat = pytest.importorskip("pyfatfs.PyFat")
disk, lay = small
img = tmp_path / "part.img"
img.write_bytes(bytes(disk[fat32.PART_START:fat32.PART_START + lay.part_sectors * 512]))
fs = pyfat.PyFat()
fs.open(str(img))
assert fs.fat_type == pyfat.PyFat.FAT_TYPE_FAT32
assert fs.bpb_header["BPB_TotSec32"] == lay.part_sectors
fs.close()
from pyfatfs.PyFatFS import PyFatFS
with PyFatFS(str(img)) as ffs: # write + read back a file like ENV.TXT
ffs.writebytes("/ENV.TXT", b"INFLUX_URL=x\n")
assert ffs.readbytes("/ENV.TXT") == b"INFLUX_URL=x\n"
+274
View File
@@ -0,0 +1,274 @@
"""Flashing: image validation on the real official image, plan building,
and the runner against a fake esptool (no hardware)."""
import hashlib
import sys
import threading
from pathlib import Path
import pytest
from kota_tech.flash.image import ImageError, inspect_image, official_images
from kota_tech.flash.plan import Mode, build_plan, check_partition_table
from kota_tech.flash.runner import FlashRunner
FW = Path(__file__).resolve().parents[1] / "firmware" / "new_system.bin"
@pytest.fixture(scope="module")
def image():
imgs = official_images()
assert len(imgs) == 1
return imgs[0]
# ------------------------------------------------------------------ image
def test_official_image_is_the_deployed_v1_build(image):
assert image.app.project == "kota-sensorboard"
assert image.app.elf_sha.startswith("45fa9d5b9") # same as the live board
assert image.flash_size == "8MB"
assert image.partition("ota_0").offset == 0x40000
assert image.partition("otadata").offset == 0x31000
assert image.partition("nvs").offset == 0x10000
def test_app_bytes_are_a_complete_app_image(image):
app = image.app_bytes()
assert app[0] == 0xE9
assert len(app) == len(image.data) - 0x40000 # ends where the file ends
assert len(image.blank_otadata()) == 0x2000 and set(image.blank_otadata()) == {0xFF}
def test_modified_image_is_refused(tmp_path):
data = bytearray(FW.read_bytes())
data[-1] ^= 0xFF
bad = tmp_path / "new_system.bin"
bad.write_bytes(data)
manifest = tmp_path / "firmware.toml"
manifest.write_text(f'[[image]]\nfile = "new_system.bin"\nsha256 = "{hashlib.sha256(FW.read_bytes()).hexdigest()}"\n')
with pytest.raises(ImageError, match="SHA-256"):
official_images(manifest)
@pytest.mark.parametrize("patch,msg", [
((0, 0x00), "not an ESP32 firmware"),
((12, 0x05), "not ESP32-S3"),
])
def test_wrong_images_are_rejected(tmp_path, patch, msg):
data = bytearray(FW.read_bytes())
data[patch[0]] = patch[1]
p = tmp_path / "x.bin"
p.write_bytes(data)
with pytest.raises(ImageError, match=msg):
inspect_image(p)
# ------------------------------------------------------------------- plan
def test_app_only_plan_keeps_nvs(image, tmp_path):
plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path)
kinds = [s.kind for s in plan.steps]
assert kinds == ["read_pt", "write"]
write = plan.steps[-1].args
offsets = [a for a in write if a.startswith("0x")]
assert offsets == ["0x31000", "0x40000"] # otadata + ota_0 only
assert "0x10000" not in write and "0x0" not in write # NVS / bootloader untouched
assert write[write.index("--after") + 1] == "hard_reset"
assert (tmp_path / "app_ota0.bin").read_bytes() == image.app_bytes()
def test_full_plan_with_backup(image, tmp_path):
backup = tmp_path / "bk.bin"
plan = build_plan(image, Mode.FULL, "COM5", tmp_path, backup_to=backup)
assert [s.kind for s in plan.steps] == ["backup", "write"]
assert plan.steps[0].args[-3:] == ["0", "0x800000", str(backup)]
assert plan.steps[0].args[plan.steps[0].args.index("--after") + 1] == "no_reset"
assert plan.steps[1].args[-2:] == ["0x0", str(image.path)]
assert "Back up" in plan.summary()[0]
def test_partition_table_check(image):
assert check_partition_table(image.partition_table, image) is None
assert "blank" in check_partition_table(b"\xff" * 0xC00, image)
other = bytearray(image.partition_table)
other[40] ^= 1
assert "different partition layout" in check_partition_table(bytes(other), image)
# ----------------------------------------------------------------- runner
FAKE = """
import sys, pathlib
args = sys.argv[1:]
if "read_flash" in args:
out = pathlib.Path(args[-1])
src = pathlib.Path(r"{fw}").read_bytes()
off, size = int(args[-3], 0), int(args[-2], 0)
{pt_line}
print("Read ... (50 %)", end="\\r"); print("Read ... (100 %)")
sys.exit(0)
if "write_flash" in args:
for p in (10, 60, 100):
print(f"Writing at 0x00040000... ({{p}} %)", end="\\r", flush=True)
print(); print("Hash of data verified.")
sys.exit({write_code})
"""
def _fake(tmp_path, write_code=0, pt="same", fail_text=""):
pt_line = {"same": "out.write_bytes(src[off:off+size])",
"blank": "out.write_bytes(b'\\xff' * size)"}[pt]
script = FAKE.format(fw=FW, pt_line=pt_line, write_code=write_code)
if fail_text:
script = script.replace('print("Hash of data verified.")', f'print("{fail_text}")')
f = tmp_path / "fake_esptool.py"
f.write_text(script)
return [sys.executable, str(f)]
def _run(plan, cmd):
steps, progress, output, done = [], [], [], threading.Event()
result = {}
r = FlashRunner(plan, lambda i, t: steps.append(i), progress.append, output.append,
lambda res: (result.setdefault("r", res), done.set()), esptool_cmd=cmd)
r.start()
assert done.wait(30)
return result["r"], steps, progress, output
def test_runner_app_only_success(image, tmp_path):
plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path)
res, steps, progress, output = _run(plan, _fake(tmp_path))
assert res.ok, res
assert steps == [0, 1]
assert 60 in progress and progress[-1] == 100
assert any("Hash of data verified" in o for o in output)
def test_runner_refuses_app_only_on_blank_chip(image, tmp_path):
plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path)
res, steps, *_ = _run(plan, _fake(tmp_path, pt="blank"))
assert not res.ok and "Full mode" in res.message
assert steps == [0] # never reached the write step
def test_runner_reports_connect_failure_with_hint(image, tmp_path):
plan = build_plan(image, Mode.FULL, "COM5", tmp_path)
cmd = _fake(tmp_path, write_code=2,
fail_text="A fatal error occurred: Failed to connect to ESP32-S3: No serial data received.")
res, *_ = _run(plan, cmd)
assert not res.ok and res.failed_step == 0
assert "BOOT" in res.hint
# ------------------------------------------------- developer builds (v0.5)
from kota_tech.flash.image import FirmwareLibrary, app_image_length # noqa: E402
def _app_only_file(image, tmp_path, name="kota-sensorboard.bin"):
p = tmp_path / name
p.write_bytes(image.app_bytes()) # what a developer build/ folder contains
return p
def test_integrity_check_on_factory_image(image):
d = image.data
assert app_image_length(d, 0) > 0 # bootloader
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
@pytest.mark.parametrize("damage,msg", [
(lambda b: b[:len(b) // 2], "truncated"),
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
])
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
p = tmp_path / "broken.bin"
p.write_bytes(damage(FW.read_bytes()))
with pytest.raises(ImageError, match=msg):
inspect_image(p, origin="developer")
def test_import_app_only_developer_build(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
src = _app_only_file(image, tmp_path)
img = lib.import_file(src, reference=image)
assert img.is_developer and img.kind == "app"
assert img.partition("ota_0").offset == 0x40000 # layout from the factory image
assert img.app_bytes() == image.app_bytes()
listed, problems = lib.images(image)
assert [i.sha256 for i in listed] == [img.sha256] and problems == []
assert lib.import_file(src, reference=image).sha256 == img.sha256 # re-import: no duplicate
assert len(lib.images(image)[0]) == 1
def test_app_only_build_cannot_be_flashed_full(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
plan = build_plan(img, Mode.APP_ONLY, "COM5", tmp_path / "w")
assert [s.kind for s in plan.steps] == ["read_pt", "write"]
with pytest.raises(ValueError, match="app only"):
build_plan(img, Mode.FULL, "COM5", tmp_path / "w")
def test_import_merged_developer_build(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
src = tmp_path / "dev_full.bin"
src.write_bytes(image.data)
img = lib.import_file(src, reference=image)
assert img.kind == "merged" and img.is_developer
assert [s.kind for s in build_plan(img, Mode.FULL, "COM5", tmp_path / "w").steps] == ["write"]
def test_library_detects_file_changed_after_import(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
stored = tmp_path / "lib" / img.path.name
data = bytearray(stored.read_bytes())
data[-40] ^= 1
stored.write_bytes(data)
listed, problems = lib.images(image)
assert listed == [] and problems
def test_library_remove(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
lib.remove(img.sha256)
assert lib.images(image) == ([], []) and not img.path.exists()
def test_non_firmware_file_refused(tmp_path, image):
p = tmp_path / "notes.bin"
p.write_bytes(b"hello firmware team")
with pytest.raises(ImageError, match="not an ESP32 firmware"):
FirmwareLibrary(tmp_path / "lib").import_file(p, reference=image)
# ------------------------------------------- bundled developer build (v0.6)
from kota_tech.flash.image import bundled_images # noqa: E402
def test_bundled_developer_build_is_labelled_and_not_default():
imgs = bundled_images()
assert [i.origin for i in imgs] == ["factory", "developer"] # factory first
dev = imgs[1]
assert dev.built_by == "Krzysztof Kallas" and "built by Krzysztof Kallas" in dev.label()
assert dev.kind == "merged" and dev.app.elf_sha.startswith("c94ba0e9b")
assert dev.partition_table == imgs[0].partition_table # App-only safe on v1.0 boxes
assert [i.origin for i in factory_images_only()] == ["factory"]
def factory_images_only():
from kota_tech.flash.image import factory_images
return factory_images()
def test_import_records_who_built_it(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image, built_by=" Anna Nowak ")
assert img.built_by == "Anna Nowak" and "built by Anna Nowak" in img.label()
again, _ = lib.images(image)
assert again[0].built_by == "Anna Nowak"
+244
View File
@@ -0,0 +1,244 @@
"""Guided troubleshooting: runner + engine with simulated technician
answers, simulated board output and a fake clock."""
from pathlib import Path
import pytest
from kota_tech.core.logfile import read_log_file
from kota_tech.core.logline import parse_line
from kota_tech.diag.engine import DiagnosticsEngine, Severity
from kota_tech.diag.guides import LED_ANSWERS, GuideRunner, load_guides, recommended
FIX = Path(__file__).parent / "fixtures"
BOOT = ["ESP-ROM:esp32s3-20210327", "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)",
"I (1299) app_init: Project name: kota-sensorboard",
"I (1317) app_init: ELF file SHA256: 45fa9d5b9..."]
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
@pytest.fixture
def rig():
engine, clock, actions, log = DiagnosticsEngine(), Clock(), [], []
runner = GuideRunner(engine, on_action=lambda a: actions.append(a) or True,
on_log=log.append, clock=clock)
def board(*lines):
for ln in lines:
parsed = parse_line(ln)
engine.feed(parsed)
runner.feed(parsed)
return engine, runner, clock, actions, board, log
def _bench(engine):
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.txt"):
engine.feed(ln)
def test_all_guides_load_and_validate():
guides = load_guides()
assert "quick" in guides and len(guides) >= 8
led = guides["quick"].steps["led"]
assert set(led.route) == set(LED_ANSWERS) # every colour has a route
def test_recommendations_follow_findings(rig):
engine, *_ = rig
_bench(engine)
ids = [g.id for g in recommended(load_guides(), engine)]
assert ids[0] == "quick"
assert ids.index("network") < ids.index("clock") # root order preserved
assert "mic" in ids
def test_purple_uncommissioned_box_skips_to_pairing_and_follows_it_live(rig):
engine, runner, clock, _, board, log = rig
_bench(engine) # log: not commissioned, LED purple
runner.start("quick")
runner.answer("PURPLE")
# "Was it paired before?" is skipped: the log already shows it is not.
assert runner.step.id == "pair_intro"
runner.proceed()
assert runner.view().waiting and runner.step.id == "pair_start"
board("I (90000) MAIN: Commissioning session started")
assert runner.step.id == "pair_wifi"
board("I (95000) MAIN: Commissioning complete")
assert runner.step.id == "pair_time"
board("I (99000) SNTP: Time synchronized: Sat Sep 26 16:00:00 2026")
assert runner.step.id == "pair_upload"
board("I (120000) INFLUX: HTTP request performed, status code: 204")
assert runner.end.id == "online" and runner.end.severity == "ok"
assert any("Paired" in t or "paired" in t for t in engine.technician)
assert "matter.not_commissioned" not in {rc.finding.rule_id for rc in engine.report().roots}
assert any(l.startswith("technician:") for l in log)
def test_pairing_timeout_asks_what_the_app_shows(rig):
engine, runner, clock, _, board, _ = rig
_bench(engine)
runner.start("network")
runner.proceed() # pair_intro -> pair_start
clock.t += 301
runner.tick()
assert runner.step.id == "not_found"
runner.answer("1")
assert runner.end.id == "ble_not_found"
f = engine.findings["tech.network.ble_not_found"]
assert f.severity == Severity.ERROR and f.active
def test_led_mismatch_is_recorded(rig):
engine, runner, *_ = rig
_bench(engine) # log implies PURPLE
runner.start("quick")
runner.answer("GREEN")
f = engine.findings["tech.led_mismatch"]
assert "Green" in f.title and "Purple" in f.title
assert runner.step.id == "heartbeat"
def test_led_match_records_nothing(rig):
engine, runner, *_ = rig
_bench(engine)
runner.start("quick")
runner.answer("PURPLE")
assert "tech.led_mismatch" not in engine.findings
def test_blue_reseat_fixed_after_power_cycle(rig):
engine, runner, clock, _, board, _ = rig
board(*BOOT, "E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT")
runner.start("quick")
runner.answer("BLUE")
runner.proceed()
runner.proceed() # -> verify (technician power-cycles)
assert runner.step.id == "verify"
board(*BOOT, "I (14800) temperature_sensor: Range [20°C ~ 100°C], error < 2°C")
assert runner.end.id == "i2c_fixed"
def test_blue_still_failing_then_module(rig):
engine, runner, clock, _, board, _ = rig
board(*BOOT, "E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT")
runner.start("rtc_i2c")
runner.proceed()
runner.proceed()
board(*BOOT, "E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT")
assert runner.step.id == "damaged"
runner.answer("1")
assert runner.end.id == "rtc_module"
def test_white_skips_portal_question_on_v1_firmware(rig):
engine, runner, clock, _, board, _ = rig
board(*BOOT) # known v1.0: no setup portal
runner.start("white")
runner.answer("1") # nobody held the button
assert runner.step.id == "sd_reseat"
def test_white_asks_portal_question_on_unknown_firmware(rig):
engine, runner, clock, _, board, _ = rig
board("ESP-ROM:esp32s3-20210327", "I (1317) app_init: ELF file SHA256: 0123456789...")
runner.start("white")
runner.answer("1")
assert runner.step.id == "portal"
def test_red_routes_to_failing_sensor_and_names_it(rig):
engine, runner, clock, _, board, _ = rig
board(*BOOT, "E (14000) MAIN: Failed to initialize SEN66: ESP_ERR_TIMEOUT")
runner.start("sensors")
assert runner.step.id == "reseat"
assert "SEN66" in runner.view().text
runner.proceed()
board(*BOOT, "E (14000) MAIN: Failed to initialize SEN66: ESP_ERR_TIMEOUT")
assert runner.end.id == "sensor_replace"
def test_reboot_loop_resets_board_then_routes_by_cause(rig):
engine, runner, clock, actions, board, _ = rig
runner.start("reboot_loop")
assert actions == ["reset"]
board(*BOOT, "E (1519) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT). x")
clock.t += 46
runner.tick()
assert runner.guide.id == "white"
def test_reset_action_failure_is_shown(rig):
engine = DiagnosticsEngine()
runner = GuideRunner(engine, on_action=lambda a: False, clock=Clock())
runner.start("reboot_loop")
assert "connect" in runner.view().note.lower()
def test_mic_switch_flip_verified_without_restart(rig):
engine, runner, clock, actions, board, _ = rig
_bench(engine)
runner.start("mic")
assert runner.step.id == "switch"
assert "above the power switch" in runner.view().text
runner.answer("0") # flipped the external switch
assert actions == [] # the firmware re-reads it; no reset
board("I (95000) MICROPHONE: Read 300000 bytes in 4936 ms")
assert runner.end.id == "mic_on"
def test_mic_switch_no_effect_in_both_positions(rig):
engine, runner, clock, actions, board, _ = rig
_bench(engine)
runner.start("mic")
runner.answer("0")
board("W (95000) MICROPHONE: Microphone is in shutdown state, skipping sample collection")
assert runner.step.id == "flip_back"
runner.answer("0")
board("W (105000) MICROPHONE: Microphone is in shutdown state, skipping sample collection")
assert runner.end.id == "mic_switch_faulty"
def test_mic_clap_test_detects_dead_mic(rig):
engine, runner, clock, _, board, _ = rig
board(*BOOT, *["I (20036) MICROPHONE: Sum SQ: 0.000000, RMS pressure: 0.000000, "
"Normalized: 0.000000, dB peak: -inf"] * 2)
runner.start("mic")
assert runner.step.id == "clap"
board("I (30036) MICROPHONE: Sum SQ: 0.000000, RMS pressure: 0.000000, Normalized: 0.000000, dB peak: -inf")
clock.t += 46
runner.tick()
assert runner.end.id == "mic_faulty"
def test_mic_clap_test_passes_on_sound(rig):
engine, runner, clock, _, board, _ = rig
runner.start("mic")
board("I (30036) MICROPHONE: Sum SQ: 12.5, RMS pressure: 0.02, Normalized: 0.1, dB peak: 71.3")
assert runner.end.id == "mic_ok"
def test_back_never_reenters_a_wait(rig):
engine, runner, clock, actions, board, _ = rig
runner.start("sensors") # route -> capture (wait, resets board)
assert runner.step.id == "capture" and actions == ["reset"]
clock.t += 46
runner.tick() # -> which_again -> end:red_unclear
runner.back()
assert actions == ["reset"] # no second reset
def test_skip_wait_and_report_contains_technician_checks(rig):
engine, runner, clock, _, board, _ = rig
runner.start("update")
runner.proceed() # technician skips the wait
assert runner.end.id == "update_slow"
text = engine.report().to_text()
assert "Technician checks:" in text and "Update is taking very long" in text
+42
View File
@@ -0,0 +1,42 @@
"""Matter onboarding payload, checked against the real sticker of the bench box
(QR decoded from matter_commissioning_qr.png: MT:Y.K9042C00KA0648G00)."""
from pathlib import Path
import segno
from kota_tech.core.logfile import read_log_file
from kota_tech.matter.payload import SetupPayload, payload_from_log, verhoeff_check
STICKER_QR = "MT:Y.K9042C00KA0648G00"
STICKER_MANUAL = "34970112332" # printed as 3497-011-2332
def test_default_payload_matches_sticker():
p = SetupPayload()
assert p.qr_text() == STICKER_QR
assert p.manual_code() == STICKER_MANUAL
assert p.manual_code_pretty() == "3497-011-2332"
def test_payload_from_real_boot_log():
lines = [ln.text for ln in read_log_file(Path(__file__).parent / "fixtures" / "serial_log_20260917_162548.txt")]
p = payload_from_log(lines)
assert p.from_log and (p.vendor_id, p.product_id, p.discriminator) == (0xFFF1, 0x8000, 3840)
assert p.qr_text() == STICKER_QR
def test_other_discriminator_changes_both_codes():
p = SetupPayload(discriminator=1234)
assert p.qr_text() != STICKER_QR and p.manual_code() != STICKER_MANUAL
assert p.qr_text().startswith("MT:") and len(p.qr_text()) == 3 + 19
def test_verhoeff():
assert verhoeff_check("3497011233") == 2
assert verhoeff_check("236") == 3 # textbook example
def test_qr_renders():
qr = segno.make(STICKER_QR, error="m")
assert qr.symbol_size(border=0)[0] >= 21
+172
View File
@@ -0,0 +1,172 @@
"""SD provisioning: ENV.TXT (firmware-exact parsing), disk discovery parsers,
the elevated Windows format script, and write/check on a stand-in card."""
import plistlib
import pytest
import sys
from pathlib import Path
from kota_tech.sd.disks import MAX_CARD_BYTES, Disk, Volume, parse_macos, parse_windows
from kota_tech.sd.envfile import EnvConfig, parse_env
from kota_tech.sd.provision import check_card, win_format_command, write_env
URL = "https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota"
TOKEN = "T" * 86 + "==" # shape of an InfluxDB token, not a real one
def cfg(**kw):
base = dict(influx_url=URL, influx_token=TOKEN)
base.update(kw)
return EnvConfig(**base)
# ------------------------------------------------------------------ ENV.TXT
def test_render_matches_the_factory_file_format():
raw = cfg().render()
assert raw == f"INFLUX_TOKEN={TOKEN}\nINFLUX_URL={URL}\n".encode()
assert not raw.startswith(b"\xef\xbb\xbf") and b"\r" not in raw
assert parse_env(raw).ok
def test_wifi_only_written_when_both_set():
raw = cfg(ssid="Site WiFi", password="correct horse").render().decode()
assert "SSID=Site WiFi\n" in raw and "PASSWORD=correct horse\n" in raw
assert any("both SSID and PASSWORD" in i.text for i in cfg(ssid="x").problems())
@pytest.mark.parametrize("kw,fragment", [
(dict(influx_url="influx.kota-app.com"), "https://"),
(dict(influx_url="https://influx.kota-app.com/"), "write URL"),
(dict(influx_token=""), "INFLUX_TOKEN is empty"),
(dict(influx_token="abc def" * 5), "spaces"),
(dict(influx_token=TOKEN + " "), "space"),
(dict(ssid="s" * 33, password="12345678"), "32 bytes"),
(dict(ssid="net", password="short"), "8-63"),
])
def test_invalid_settings_are_errors(kw, fragment):
errors = [i.text for i in cfg(**kw).problems() if i.level == "error"]
assert any(fragment in e for e in errors), errors
def test_parser_mirrors_firmware():
raw = b"\xef\xbb\xbfINFLUX_TOKEN=abc\r\nINFLUX_URL = x\nINFLUX_URL=u1\nINFLUX_URL=u2\nHTTP_SERVER=true\n"
chk = parse_env(raw)
texts = " ".join(i.text for i in chk.issues)
assert "BOM" in texts # first key unreadable
assert "spaces around the key 'INFLUX_URL'" in texts
assert chk.values["INFLUX_URL"] == "u1" # first match wins
assert "appears twice" in texts and "setup mode" in texts
assert "INFLUX_TOKEN is missing" in texts # hidden behind the BOM
assert not chk.ok
def test_masking():
chk = parse_env(cfg(ssid="net", password="secretpassword").render())
m = chk.masked()
assert TOKEN not in m["INFLUX_TOKEN"] and m["INFLUX_TOKEN"].startswith("TTTT")
assert "secret" not in m["PASSWORD"]
# ------------------------------------------------------------- disk lists
WIN_JSON = """[{"Number":0,"FriendlyName":"ST500DM002-1BC142","Size":500107862016,"BusType":"SATA",
"Volumes":[{"DriveLetter":"D","FileSystem":"NTFS","Label":"WINRE"}]},
{"Number":4,"FriendlyName":"Mass Storage Device","Size":0,"BusType":"USB","Volumes":[]},
{"Number":5,"FriendlyName":"SDXC Card","Size":31914983424,"BusType":"SD",
"Volumes":{"DriveLetter":"E","FileSystem":"FAT32","Label":"KOTA"}}]"""
def test_parse_windows_keeps_only_card_buses():
disks = parse_windows(WIN_JSON)
assert [d.id for d in disks] == ["4", "5"] # SATA data disk never listed
reader, card = disks
assert not reader.has_media and reader.size_text == "no card"
assert card.volumes[0].mount == "E:\\" and card.volumes[0].filesystem == "FAT32"
def test_parse_windows_single_object_quirk():
one = '{"Number":5,"FriendlyName":"SD","Size":8000000000,"BusType":"USB","Volumes":null}'
assert [d.id for d in parse_windows(one)] == ["5"]
def test_parse_macos_excludes_system_disk():
listing = plistlib.dumps({"AllDisksAndPartitions": [
{"DeviceIdentifier": "disk0", "Size": 500_000_000_000, "Partitions": []},
{"DeviceIdentifier": "disk3", "Size": 994_000_000_000, "Partitions": [], "APFSVolumes": [{"MountPoint": "/"}]},
{"DeviceIdentifier": "disk4", "Size": 15_931_539_456,
"Partitions": [{"DeviceIdentifier": "disk4s1", "MountPoint": "/Volumes/KOTA", "Content": "DOS_FAT_32",
"VolumeName": "KOTA"}]},
]})
infos = {"disk0": plistlib.dumps({"Internal": True, "BusProtocol": "PCI-Express"}),
"disk3": plistlib.dumps({"Internal": True}),
"disk4": plistlib.dumps({"Internal": True, "RemovableMedia": True, "BusProtocol": "Secure Digital",
"MediaName": "SD Card Reader"})}
disks = parse_macos(listing, infos)
assert [d.id for d in disks] == ["/dev/disk4"] and disks[0].bus == "SD"
assert disks[0].volumes[0].mount == "/Volumes/KOTA"
# -------------------------------------------------------- format script
def test_elevated_helper_command(tmp_path):
disk = Disk("5", "SDXC Card", 63_864_569_856, "SD")
prog, params, cwd = win_format_command(disk, tmp_path / "r.json")
assert params.startswith("-m kota_tech.sd.win_format --disk 5 --size 63864569856 --label KOTA")
assert (Path(cwd) / "kota_tech").is_dir() # helper is importable from there
@pytest.mark.skipif(sys.platform != "win32", reason="Windows formatter")
@pytest.mark.parametrize("disks,msg", [
([], "not a USB/SD/MMC"), # disk vanished / internal
([Disk("5", "SD", 1_000, "SD")], "changed since it was selected"), # swapped card
([Disk("5", "SD", 31_457_280_000, "SD", [Volume("C:\\", "NTFS", "")])], "Windows drive"),
])
def test_formatter_guard_refuses_before_writing(monkeypatch, disks, msg):
from kota_tech.sd import win_format
monkeypatch.setattr(win_format.win_disks, "list_disks", lambda buses: disks)
monkeypatch.setattr(win_format, "RawDisk", lambda *a: pytest.fail("must not open the disk for writing"))
with pytest.raises(win_format.FormatError, match=msg):
win_format.format_card(5, 31_457_280_000, "KOTA", {})
def test_large_disks_are_not_cards():
assert MAX_CARD_BYTES == 256 * 1024**3
# ----------------------------------------------- write / check on a card
def test_write_env_replaces_variants_and_keeps_buffered_data(tmp_path):
card = tmp_path / "card"
card.mkdir()
(card / "env.txt").write_bytes(b"INFLUX_URL=old\n")
(card / "BUFFER.TXT").write_bytes(b"line1\nline2\n\n")
out = write_env(str(card), cfg())
assert out.ok, out.message
names = sorted(p.name for p in card.iterdir())
assert names == ["BUFFER.TXT", "ENV.TXT"]
rep = check_card(str(card), "FAT32")
assert rep.env.ok and rep.problems == [] and rep.buffered_lines == 2
def test_write_env_refuses_invalid_settings(tmp_path):
assert not write_env(str(tmp_path), cfg(influx_token="")).ok
assert not (tmp_path / "ENV.TXT").exists()
def test_check_card_reports_missing_env_and_wrong_fs(tmp_path):
rep = check_card(str(tmp_path), "exFAT")
texts = " ".join(rep.problems)
assert "needs FAT32" in texts and "No ENV.TXT" in texts
def test_elevated_result_folder_is_not_owner_only(tmp_path, monkeypatch):
# Regression: tempfile.mkdtemp() made the elevated script's result unreadable.
import kota_tech.sd.provision as prov
monkeypatch.setattr(prov, "WORK_ROOT", tmp_path / "tmp")
work = prov._workdir()
assert work.parent == tmp_path / "tmp" and work.is_dir()
(work / "result.json").write_text('{"ok": true}', encoding="utf-8")
assert (work / "result.json").read_text(encoding="utf-8")
+91
View File
@@ -0,0 +1,91 @@
"""Service-system hand-off: status mapping, note, and the #kota-report fragment
(decoded exactly as the v1.1.0 service page does: base64url + UTF-8 JSON)."""
import base64
import json
from pathlib import Path
import pytest
from kota_tech.core.logfile import read_log_file
from kota_tech.core.logline import parse_line
from kota_tech.diag.engine import DiagnosticsEngine
from kota_tech.service.report import (
CONNECTIVITY, HARDWARE, OK, decode_fragment, normalize_mac, report_url, service_note, service_status,
)
FIX = Path(__file__).parent / "fixtures"
def bench():
e = DiagnosticsEngine()
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.txt"):
e.feed(ln)
return e
def test_engine_reads_mac_from_boot_log():
assert bench().mac == "A4:CB:8F:C9:46:F8"
def test_bench_box_is_a_connectivity_problem():
e = bench()
assert service_status(e.report()) == CONNECTIVITY
note = service_note(e.report())
assert note.startswith("[KOTA Technician Tool")
assert "not commissioned in Matter" in note and "-> No Internet" in note
assert "Firmware: v1.0" in note
def test_hardware_and_healthy_statuses():
e = DiagnosticsEngine()
for ln in ["ESP-ROM:esp32s3-20210327", "I (1299) app_init: Project name: kota-sensorboard",
"E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT"]:
e.feed(parse_line(ln))
assert service_status(e.report()) == HARDWARE
ok = DiagnosticsEngine()
for ln in ["ESP-ROM:esp32s3-20210327", "I (1299) app_init: Project name: kota-sensorboard",
"I (31000) INFLUX: HTTP request performed, status code: 204"]:
ok.feed(parse_line(ln))
assert service_status(ok.report()) == OK
def test_url_shape_and_payload_roundtrip():
e = bench()
url = report_url(e.mac, e.report(), "https://service.kota-app.com")
assert url.startswith("https://service.kota-app.com/A4CB8FC946F8#kota-report=")
assert "?" not in url # nothing in the query: the fragment is never sent to a server
data = decode_fragment(url)
assert data["v"] == 1 and data["mac"] == "A4CB8FC946F8" and data["status"] == CONNECTIVITY
def test_fragment_decodes_like_the_service_page():
# Mirror of index.html applyToolPrefill: base64url -> pad -> bytes -> UTF-8 -> JSON
e = bench()
e.note_technician("Zażółć gęślą jaźń -> Purple") # non-ASCII survives
url = report_url("a4:cb:8f:c9:46:f8", e.report())
frag = url.split("#kota-report=")[1]
assert set(frag) <= set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") # page regex
padded = frag.replace("-", "+").replace("_", "/")
padded += "==="[(len(padded) + 3) % 4:]
data = json.loads(base64.b64decode(padded).decode("utf-8"))
assert "Zażółć gęślą jaźń" in data["note"]
def test_note_is_limited_and_no_secrets():
e = bench()
for i in range(400):
e.note_technician(f"check {i} " + "x" * 50)
note = service_note(e.report(), limit=4000)
assert len(note) <= 4000
assert "INFLUX_TOKEN" not in note
@pytest.mark.parametrize("bad", ["", "A4CB8F", "zz:cb:8f:c9:46:f8"])
def test_invalid_mac_rejected(bad):
with pytest.raises(ValueError):
report_url(bad, bench().report())
def test_normalize_mac():
assert normalize_mac("a4:cb:8f:c9:46:f8") == "A4CB8FC946F8"
Generated
+357 -2
View File
@@ -1,6 +1,138 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.11, <3.14" requires-python = ">=3.11, <3.14"
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
"platform_machine != 'x86_64' or sys_platform != 'darwin'",
]
[[package]]
name = "appdirs"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
]
[[package]]
name = "argcomplete"
version = "3.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" },
]
[[package]]
name = "bitarray"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/f7/6765577df59e2345036e435f7e983e1c291d67b7d76a51918eff04ad1494/bitarray-3.11.0.tar.gz", hash = "sha256:bf19437ec00ec3d40aef82eaeedc14cf4000be9b635c4f5049796506e6630dd8", size = 174911, upload-time = "2026-09-01T15:21:52.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/55/aeb2eb3870a31257a29d3830f7d5804253d2070727ef6475629455491d33/bitarray-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:470940879d8553fecddc3cf306b7d4b69fa0f5b7a6e3790b238fe553578061c1", size = 170436, upload-time = "2026-09-01T15:19:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/fb/b4/38b6131ecd500f8f853367339b9e95ad4644d47729bcbf0fa2c5e4c06a91/bitarray-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56a2aa49e900aa927c147328e571da8a38a50ae99277ed093290845498dedefc", size = 166780, upload-time = "2026-09-01T15:19:55.173Z" },
{ url = "https://files.pythonhosted.org/packages/eb/28/d1e0493106911b08d3d328e463aa140ca76fcab6cdcda06912ffb59d53cf/bitarray-3.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55370f00c52b5e46800fee0218a27a1b99db6e09113c1b804190c039e4350ea1", size = 380740, upload-time = "2026-09-01T15:19:56.253Z" },
{ url = "https://files.pythonhosted.org/packages/90/85/1ebb39e36b7102358942609583aa5a027b7e1eb83e140ad3fba86e53adba/bitarray-3.11.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:155205159f3c38180a705253341b8200414c0198e86a9b9e2db49010236ff785", size = 404566, upload-time = "2026-09-01T15:19:57.474Z" },
{ url = "https://files.pythonhosted.org/packages/24/8d/e94bfa36a7cadcd16415afe531e0a01c6e2dc2bb11095c6c52fca384c642/bitarray-3.11.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c49fc5f57eea2e859a3f2936a1fed84510af6f7464572b641485de2d22148cb", size = 418899, upload-time = "2026-09-01T15:19:58.547Z" },
{ url = "https://files.pythonhosted.org/packages/2a/3a/859196ab78c6fa0aa8f04155308dd314265c8c27e09350bcd3894fc333d8/bitarray-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41545b21384fd91767d294a5b3526c6306f7db577cf59ba72b51498e90bd52a7", size = 383414, upload-time = "2026-09-01T15:19:59.649Z" },
{ url = "https://files.pythonhosted.org/packages/75/fc/5fafae3bee337c31372293fcc6259960e9bcaf89825f07798514213c5686/bitarray-3.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:afe8ee837c085eeeaead62429f260a2de3a0602d1a34bcdead8b9f0388ef6114", size = 378658, upload-time = "2026-09-01T15:20:00.751Z" },
{ url = "https://files.pythonhosted.org/packages/23/c8/39a1e59453b99a67ee449d9d0c378f5e282ec4c00645127a9aaa4a5adba7/bitarray-3.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4bddbc76e619a36d2abad14713c4dd970a8fa46a75a85ebbb626a2f26f1821d8", size = 401990, upload-time = "2026-09-01T15:20:01.951Z" },
{ url = "https://files.pythonhosted.org/packages/87/aa/add67250e559e24fdc585921062e388395e0b47f40c6aa13e1ee493a13d4/bitarray-3.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:027766e71da6063cb51fcd4c51f031ac14d5088ecb55e4500ffd692d78630dd3", size = 399837, upload-time = "2026-09-01T15:20:03.202Z" },
{ url = "https://files.pythonhosted.org/packages/6e/80/c758fa566da3180e43fba1a9bc2c84b29fc5e25ce0149155378e89ee4172/bitarray-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:116a23f655f394f80f41fdc03368862485ac05534a56991eefb9f18f72e17f17", size = 380673, upload-time = "2026-09-01T15:20:04.461Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8a/fcb7bef53c2aacdbc2ca07f498e5249ab7d87dbfed666f184bb8d16fec0f/bitarray-3.11.0-cp311-cp311-win32.whl", hash = "sha256:30597fa6f4116eb6e374568d4f4de7e220462368ec7ce37d93403871bdf27a8c", size = 160605, upload-time = "2026-09-01T15:20:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/3d3f3f7846432bc1f630c99fa5cd595674b5318c77c774b6c028bdbf05ad/bitarray-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7b16df7ddef03e30622d894cce86a560b7888415ddcac470eb99940a5adf1a35", size = 171179, upload-time = "2026-09-01T15:20:06.579Z" },
{ url = "https://files.pythonhosted.org/packages/54/20/c8cb10050d1afed77e0511861ba49d56724fd846b4a0b596f52e7ebe5711/bitarray-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:ec3d8b99a92220bdba382acf4eff370d6a6cda34a5ce936726cb9e8cf2427c47", size = 166363, upload-time = "2026-09-01T15:20:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/26/74/c878e209fd15fdf0460763199892fc4e74c6a7345f5161ad673af586f2fe/bitarray-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:93a7f9f9f848ec79f19fdd39f3b8f750d9abf8e65cd6fb3a045df192de23855d", size = 170727, upload-time = "2026-09-01T15:20:08.703Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f8/4463653a8c909796c8f8931d0fd64344c6745a144e432d0310435197b0ed/bitarray-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3889cf12ae0c04076772c8be66655bfe5925003b44dec056b569a68a121f7e93", size = 166746, upload-time = "2026-09-01T15:20:09.719Z" },
{ url = "https://files.pythonhosted.org/packages/03/30/edaec4a0fffd04ee98b1425a597dc1d350c4def690b0a34815a75e5c2c33/bitarray-3.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:823638d0a900cea53c173fb31f5f1f4b913065a92fa3406d9b20eb7b7ebf189d", size = 383620, upload-time = "2026-09-01T15:20:10.78Z" },
{ url = "https://files.pythonhosted.org/packages/83/1d/8fba2e884d5e5e7fa6828b4ca461e819259bac0f6f99073388157ea1c6d6/bitarray-3.11.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:301763ffde9d288c5a58dda51c5290bc1081c2bc0faf266c4aef227ca59004d5", size = 406908, upload-time = "2026-09-01T15:20:12.061Z" },
{ url = "https://files.pythonhosted.org/packages/8d/71/414853d582267d8e397ea5bf894503236d81660c5d7a34b2a0d0ec6e9f2d/bitarray-3.11.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:664f02e6e2c63fcc1c02718d2daa21b04f77a18ef3850a9b348597c0c8a0f222", size = 422031, upload-time = "2026-09-01T15:20:13.157Z" },
{ url = "https://files.pythonhosted.org/packages/9a/1c/35b3f34fee6f91f708eb81af03438afc137b2e5fc0e27ec11d948287f64c/bitarray-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52741a1d24a3723a8151259906cf1befeaf3d4ccf7d3e27e8be4334590b59bcd", size = 387692, upload-time = "2026-09-01T15:20:14.377Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7ebe3e04548cee84fba250425935a500ff02e7fef1b020ed182a3f5227c4/bitarray-3.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a1093eb1a756f08c7bdeb22969fb463aa62f036dc191ad371af5e292108c611", size = 381169, upload-time = "2026-09-01T15:20:15.515Z" },
{ url = "https://files.pythonhosted.org/packages/fe/48/cdf80b633f326451de801504f8718dd71ce63f534d29348822896d9c5bd2/bitarray-3.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ab0988829e4ed10fa971842e9fbf77595b8a4557a078a3589b0754f06417ddff", size = 404500, upload-time = "2026-09-01T15:20:16.804Z" },
{ url = "https://files.pythonhosted.org/packages/54/cf/a64cab1bb0f48101ac45fb45a7f0d000af76e26ed0ab26b52573140a9a64/bitarray-3.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c4cabc2439ab3ce76881037741bf41e9fafda217ff721fdee92701e5fe3c8b9c", size = 402903, upload-time = "2026-09-01T15:20:18.127Z" },
{ url = "https://files.pythonhosted.org/packages/79/b8/c3937572587a0503ea0a05ae61543916d6d93ffe48c26532cad9c4bfd9e4/bitarray-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9db61968efb834dacda6bcd7ef1f8a033253a53ac31426a684a28a7e4c9f2f29", size = 384582, upload-time = "2026-09-01T15:20:19.481Z" },
{ url = "https://files.pythonhosted.org/packages/29/13/824648ef8f3fc14f56c4f9903e6c06979385f0c3a4d76371f6ccd8e9d1b2/bitarray-3.11.0-cp312-cp312-win32.whl", hash = "sha256:97cd5913000e4e56d211f7db053284d1f753aed34838368769bf084474a76f0a", size = 160902, upload-time = "2026-09-01T15:20:21.037Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c4/0a1608187e6d21b30c341ebcce6f75efbc8fe289f87ad58aa4149ce59828/bitarray-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:5db38c0a36d07190c3629c65dab2504233a56b1c35d8b1cc3d7369943d028711", size = 171542, upload-time = "2026-09-01T15:20:22.114Z" },
{ url = "https://files.pythonhosted.org/packages/a8/59/f21b457eb6ab683ccf755093550ec1408e8ec8a66b980653c99740b85552/bitarray-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9aafe7dc6d568470f5c297d0b15acddd3afc1677c983f0460b28f8cac4e9360c", size = 166561, upload-time = "2026-09-01T15:20:23.177Z" },
{ url = "https://files.pythonhosted.org/packages/2d/3d/578c4b63b3c1f206dfe7f7f60e941ca2d0aafd9da43943c076c01e976ec0/bitarray-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ac322a81c3c938c13093d8d77c419093f50118c29fbfa9eb80813d93af1a2d95", size = 170422, upload-time = "2026-09-01T15:20:24.673Z" },
{ url = "https://files.pythonhosted.org/packages/9a/30/3f982be25cf0c858351490b6d0cf6168e8668bb81e3daac25d8a3b2b8e44/bitarray-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ece8caff2fb31d6c69430d3dfc0efff70083e66b012f7f5de60cfe5dca98ba87", size = 166475, upload-time = "2026-09-01T15:20:25.649Z" },
{ url = "https://files.pythonhosted.org/packages/78/ad/f94c267873f0c80705dcef602af0a8d3f42838197e8fff01033f2a8084c9/bitarray-3.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:962dc3e5622a08bb1c1ad429a9f7dace7efadd7fef1fcdf1628a6db142333256", size = 381856, upload-time = "2026-09-01T15:20:26.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ce/e9ba5d080302c4d6fa269bccc7ad6ed4a5ad36a1dc75baae334714bbc183/bitarray-3.11.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de25b8c76fe9fe7d9c6bce2832096f020133e1d21e2841abd7280937bf90e56e", size = 405246, upload-time = "2026-09-01T15:20:27.888Z" },
{ url = "https://files.pythonhosted.org/packages/56/4c/cce77df63010304704eb802d884d9b11c84f63a02d7c4081a41225e25343/bitarray-3.11.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86dfaab663a2ad16ed2f6252116cf034cfc84282aa744f19a9a66542e85c9ca9", size = 420083, upload-time = "2026-09-01T15:20:29.213Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1f/2c7b1a995af9814b6ec5ee69333a0a6131924ed0f3f10ffbd73c22c0a6ad/bitarray-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5605c8e74f95f77dd1b93c8c83d6fefe0d41d2704683d4a72152ca5284e2546d", size = 385572, upload-time = "2026-09-01T15:20:30.604Z" },
{ url = "https://files.pythonhosted.org/packages/36/58/53845d1ddbe6077b61e7f39f586f7f35f18ce7dc47f178331e4fd64f59e1/bitarray-3.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d62e73fcdd273d07b1e9699de0e66e4d367b28c554c86bfe7c92029337c2a414", size = 379323, upload-time = "2026-09-01T15:20:31.69Z" },
{ url = "https://files.pythonhosted.org/packages/06/66/b67544886466ede3d26e58c43d9841cc427d4fdcd81ded4087313687241a/bitarray-3.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9808734d1e5fd2839660a9db1abfe93e9d64b2aa9ed59aa480193ad93e8d620e", size = 402087, upload-time = "2026-09-01T15:20:33.113Z" },
{ url = "https://files.pythonhosted.org/packages/93/a7/4ffc5984f3f4a12458adbceff60def49752f74807945771428f97694aec0/bitarray-3.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8b77aa18131418b869be882df1c5e229fb02f90861783bbe638d9ebe296e9209", size = 401000, upload-time = "2026-09-01T15:20:34.658Z" },
{ url = "https://files.pythonhosted.org/packages/e8/15/2d5dd4107e49b2c0040bc9cc48ee5af7f45772fedae3bc54fa987aeeedf2/bitarray-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d084fa59c94d3fec7eac6793e08162fa697aa4e70b9f519618c29d3e54eda752", size = 382431, upload-time = "2026-09-01T15:20:36.013Z" },
{ url = "https://files.pythonhosted.org/packages/4d/8b/8d95483688bcf297654c87b65ef1847fad794adb083d294da65ac275d4fc/bitarray-3.11.0-cp313-cp313-win32.whl", hash = "sha256:387b64dc54be3732a0988bf3731fc06d7d4f52dc9470d1a5479e8ce2b67d368b", size = 160760, upload-time = "2026-09-01T15:20:37.241Z" },
{ url = "https://files.pythonhosted.org/packages/d2/9b/b10ebf5e9a479815d2d54908617c0c55b255965389d3132927c9cd9ccf86/bitarray-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:b4afbb464a1db2d37858bd3b87bd59a76b4aae5e29ec007a9fd00bf5a911e1da", size = 171278, upload-time = "2026-09-01T15:20:38.646Z" },
{ url = "https://files.pythonhosted.org/packages/16/3a/b0a29fc2379c567eb49730022c5913ad85b2f9ccda1b841737f756e1fb87/bitarray-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:36da946f64e10e28b23f345fde90f4d14e9c2e9013364fe04d9b2013e7291285", size = 166426, upload-time = "2026-09-01T15:20:39.862Z" },
]
[[package]]
name = "bitstring"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bitarray" },
{ name = "tibs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/d3/de6fe4e7065df8c2f1ac1766f5fdccbe75bc18af2cf2dbeecd34d68e1518/bitstring-4.4.0.tar.gz", hash = "sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37", size = 255209, upload-time = "2026-03-10T20:29:14.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/02/1a870bab76f2896d827aa4963be95e56675ffa1453e53525d13c43036edf/bitstring-4.4.0-py3-none-any.whl", hash = "sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737", size = 76846, upload-time = "2026-03-10T20:29:12.832Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
@@ -11,6 +143,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "cryptography"
version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
{ url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]]
name = "esptool"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete", marker = "sys_platform != 'win32'" },
{ name = "bitstring" },
{ name = "cryptography" },
{ name = "ecdsa" },
{ name = "intelhex" },
{ name = "pyserial" },
{ name = "pyyaml" },
{ name = "reedsolo" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/19/f68b27fd43564d69277e956d13b8c2b352e7f1af4fb5d6e5c43e5419e61c/esptool-4.12.0.tar.gz", hash = "sha256:d797f6529d582173508baf0572542ddadf53aae8c92c84ea9f420f242464103c", size = 463840, upload-time = "2026-07-14T08:36:51.891Z" }
[[package]]
name = "fs"
version = "2.4.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "appdirs" },
{ name = "setuptools" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@@ -20,28 +239,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "intelhex"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/37/1e7522494557d342a24cb236e2aec5d078fac8ed03ad4b61372586406b01/intelhex-2.3.0.tar.gz", hash = "sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093", size = 44513, upload-time = "2020-10-20T20:35:51.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/78/79461288da2b13ed0a13deb65c4ad1428acb674b95278fa9abf1cefe62a2/intelhex-2.3.0-py2.py3-none-any.whl", hash = "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", size = 50914, upload-time = "2020-10-20T20:35:50.162Z" },
]
[[package]] [[package]]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.1.0" version = "0.6.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "esptool" },
{ name = "pyserial" }, { name = "pyserial" },
{ name = "pyside6-essentials" }, { name = "pyside6-essentials" },
{ name = "segno" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pyfatfs" },
{ name = "pytest" }, { name = "pytest" },
{ name = "setuptools" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "esptool", specifier = "~=4.8" },
{ name = "pyserial", specifier = ">=3.5" }, { name = "pyserial", specifier = ">=3.5" },
{ name = "pyside6-essentials", specifier = ">=6.7" }, { name = "pyside6-essentials", specifier = ">=6.7" },
{ name = "segno", specifier = ">=1.6" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }] dev = [
{ name = "pyfatfs", specifier = ">=1.0" },
{ name = "pytest", specifier = ">=8" },
{ name = "setuptools", specifier = "<81" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
@@ -61,6 +299,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pyfatfs"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/3f/d08f1dbc44a7eef9c7fb355b83423fbd15bb3e487c250479a2c179cb39bf/pyfatfs-1.1.0.tar.gz", hash = "sha256:9725ccd0a4da1c09c27358abbf10f08c043ac84210af576803e087f51a2b30e0", size = 55459, upload-time = "2024-03-03T20:50:43.227Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/de/feb531b25eb2315aa73a7dba335c253ee0075ff7f55b6fe742fb6d492ade/pyfatfs-1.1.0-py3-none-any.whl", hash = "sha256:7401fd39d9e92f531b2abea5b5d1d2c19d5461f4ba9ce8819b1ba35aea73c65d", size = 37932, upload-time = "2024-03-03T20:50:41.426Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.21.0" version = "2.21.0"
@@ -110,6 +369,70 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ 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" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
]
[[package]]
name = "reedsolo"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/61/a67338cbecf370d464e71b10e9a31355f909d6937c3a8d6b17dd5d5beb5e/reedsolo-1.7.0.tar.gz", hash = "sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732", size = 59723, upload-time = "2023-01-17T05:10:19.733Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" },
]
[[package]]
name = "segno"
version = "1.6.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/2e/b396f750c53f570055bf5a9fc1ace09bed2dff013c73b7afec5702a581ba/segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3", size = 1628586, upload-time = "2025-03-12T22:12:53.324Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" },
]
[[package]]
name = "setuptools"
version = "80.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" },
]
[[package]] [[package]]
name = "shiboken6" name = "shiboken6"
version = "6.11.2" version = "6.11.2"
@@ -121,3 +444,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" }, { url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" }, { url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "tibs"
version = "0.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/cd/6cf028decf1c2df4d26077dd5d0532587d93d4917233d5e004133166a940/tibs-0.5.7.tar.gz", hash = "sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb", size = 78255, upload-time = "2026-03-12T13:06:29.79Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/2d/de2c579d3eea0f18212b5b16decb04568b7a0ef912d00581a77492609d4e/tibs-0.5.7-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c", size = 411352, upload-time = "2026-03-12T13:06:52.016Z" },
{ url = "https://files.pythonhosted.org/packages/74/71/4c21ccc5c2e1672f9cd91ed2c46604c250cffd9d386113772dded128b5cf/tibs-0.5.7-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44", size = 383971, upload-time = "2026-03-12T13:06:50.143Z" },
{ url = "https://files.pythonhosted.org/packages/38/85/399940ac5393772792a209911a5efa42cf55cf621771e48b863211ac5a2a/tibs-0.5.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f", size = 416256, upload-time = "2026-03-12T13:06:24.222Z" },
{ url = "https://files.pythonhosted.org/packages/02/94/481a73e74d398949f57d297b1809a10a951d252e7ec94b6715ed952ce500/tibs-0.5.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f", size = 428003, upload-time = "2026-03-12T13:06:23.064Z" },
{ url = "https://files.pythonhosted.org/packages/9b/e0/72db1760a7f7fec1d5f3690e0855fbbccbcf0a4a2fd318c9d71f3b33f3a7/tibs-0.5.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98", size = 455589, upload-time = "2026-03-12T13:06:53.144Z" },
{ url = "https://files.pythonhosted.org/packages/3e/26/9cd3395914bf705d6ae1e9a6c323f727e9dc88fef716327ce7f486e0b55a/tibs-0.5.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3", size = 459266, upload-time = "2026-03-12T13:06:21.678Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3b/267f19a008d13c704dc0b044138a56239272a43531ccb05464129d0fbd01/tibs-0.5.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41", size = 423466, upload-time = "2026-03-12T13:06:41.212Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d4/424ae3515e0e013ad83186074bf3beb53399b9052c00da703415ccc316ca/tibs-0.5.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e", size = 452080, upload-time = "2026-03-12T13:06:32.112Z" },
{ url = "https://files.pythonhosted.org/packages/b0/15/ab80beba83a134745439d33763e1d3b017f994abeb9c309a3ac9fd94e90e/tibs-0.5.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54", size = 592311, upload-time = "2026-03-12T13:06:47.807Z" },
{ url = "https://files.pythonhosted.org/packages/4c/21/f5cf41c15431e63aeaefb494e714d48d9e9061b4e01fcc01d1987e2e5faa/tibs-0.5.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392", size = 703400, upload-time = "2026-03-12T13:06:16.968Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ec/b3bdb7dcc3de8513c5678a685f4e25bb85ef48526d7d535ddc592f9e8602/tibs-0.5.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452", size = 664623, upload-time = "2026-03-12T13:06:48.894Z" },
{ url = "https://files.pythonhosted.org/packages/a4/71/7b85af3ad1b2cd9871c8f50ba0eb17e54e12481b467678535e58aced0d98/tibs-0.5.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b", size = 635199, upload-time = "2026-03-12T13:06:34.798Z" },
{ url = "https://files.pythonhosted.org/packages/b9/63/60220fb502beb857306afd4a5bac4a8617ae496f3b1f4968d127380fdefe/tibs-0.5.7-cp38-abi3-win32.whl", hash = "sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2", size = 288454, upload-time = "2026-03-12T13:06:30.978Z" },
{ url = "https://files.pythonhosted.org/packages/46/ab/aab78827ba7e0d65fe346b86d1d61e0792c38d5f9b7547e0f71b7027c835/tibs-0.5.7-cp38-abi3-win_amd64.whl", hash = "sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2", size = 304135, upload-time = "2026-03-12T13:06:35.884Z" },
{ url = "https://files.pythonhosted.org/packages/48/59/e9e6a610928a4bcbf04f0ac1436ee320aa8cbe95181f1aa32687c50e858b/tibs-0.5.7-cp38-abi3-win_arm64.whl", hash = "sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7", size = 289272, upload-time = "2026-03-12T13:06:19.247Z" },
]