Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72adfe1cec | ||
|
|
e161941672 | ||
|
|
e8b72b743d |
No files matched your search
@@ -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.
|
||||
@@ -7,4 +7,5 @@ dependencies:
|
||||
- python=3.12
|
||||
- pyserial>=3.5
|
||||
- pyside6>=6.7
|
||||
- esptool>=4.8,<5
|
||||
- pytest>=8
|
||||
@@ -0,0 +1,11 @@
|
||||
# Official firmware images the technician tool may flash.
|
||||
# Boxes normally receive new firmware over OTA; this is the factory image a
|
||||
# box updates itself from once it is online. Add a new image only when the
|
||||
# firmware team releases one: copy the .bin here and add an entry with its
|
||||
# SHA-256 (the tool refuses files that do not match).
|
||||
|
||||
[[image]]
|
||||
file = "new_system.bin"
|
||||
sha256 = "1d58a8f4bb60f66009bd6df32e6e860c545c0c668de955af60d049ee3b9a212d"
|
||||
name = "v1.0 factory image (built Jun 29 2026)"
|
||||
notes = "Official factory image. Once online, the box registers for OTA and updates itself."
|
||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
"""KOTA sensorbox technician tool."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.4.0"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Load saved logs for offline analysis.
|
||||
|
||||
Accepts both raw serial captures and this tool's session logs, whose lines
|
||||
look like "[14:02:11.532] I (1234) TAG: msg" and whose host events look
|
||||
like "[14:02:11.532] --- reset issued by tool ---".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .logline import LineKind, LogLine, parse_line, split_glued
|
||||
|
||||
_SESSION_PREFIX = re.compile(r"^\[(\d\d):(\d\d):(\d\d)\.(\d{3})\] ")
|
||||
_HOST_EVENT = re.compile(r"^--- .* ---$")
|
||||
|
||||
|
||||
def read_log_file(path: Path) -> list[LogLine]:
|
||||
text = path.read_bytes().decode("utf-8", errors="replace")
|
||||
out: list[LogLine] = []
|
||||
for i, raw in enumerate(text.splitlines()):
|
||||
host_time = i * 0.001 # raw capture: keep order, no wall clock
|
||||
if m := _SESSION_PREFIX.match(raw):
|
||||
h, mi, s, ms = (int(g) for g in m.groups())
|
||||
host_time = h * 3600 + mi * 60 + s + ms / 1000
|
||||
raw = raw[m.end():]
|
||||
if _HOST_EVENT.match(raw):
|
||||
out.append(LogLine(raw, LineKind.HOST, host_time))
|
||||
continue
|
||||
out.extend(parse_line(part, host_time) for part in split_glued(raw))
|
||||
return out
|
||||
+36
-30
@@ -76,65 +76,71 @@ class LineFramer:
|
||||
|
||||
A partial line is released after `idle_flush_s` without new bytes so
|
||||
prompts / unterminated output are not held back forever.
|
||||
|
||||
Lines are also split where a new log line starts in the middle
|
||||
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
|
||||
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
|
||||
line while no host reads, releasing it glued in front of the next live
|
||||
output. After a (re)open, the fragment in front of the first such split
|
||||
is stale and dropped. Timing cannot identify it, because the FIFO only
|
||||
flushes when the board next prints, possibly seconds later.
|
||||
"""
|
||||
|
||||
# Stale FIFO bytes arrive immediately after open; anything later is live.
|
||||
STALE_WINDOW_S = 0.25
|
||||
|
||||
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
|
||||
drop_first_partial: bool = False, now: float | None = None):
|
||||
drop_first_partial: bool = False):
|
||||
self._buf = bytearray()
|
||||
self._last_rx = 0.0
|
||||
self.idle_flush_s = idle_flush_s
|
||||
self.max_line = max_line
|
||||
# The S3 USB-Serial-JTAG FIFO keeps the tail of an old line while no
|
||||
# host is reading and flushes it on open; drop bytes up to the first
|
||||
# newline after a (re)connect so it is not glued to a fresh line.
|
||||
# Only bytes arriving within STALE_WINDOW_S of open are considered
|
||||
# stale, otherwise an idle board's next boot banner would be lost.
|
||||
self._dropping = drop_first_partial
|
||||
self._drop_deadline = (time.monotonic() if now is None else now) + self.STALE_WINDOW_S
|
||||
self._first_line_pending = drop_first_partial
|
||||
|
||||
def cancel_drop(self) -> None:
|
||||
self._dropping = False
|
||||
self._first_line_pending = False
|
||||
|
||||
def feed(self, data: bytes, now: float | None = None) -> list[str]:
|
||||
now = time.monotonic() if now is None else now
|
||||
if self._dropping and now > self._drop_deadline:
|
||||
self._dropping = False
|
||||
if data:
|
||||
self._buf.extend(data)
|
||||
self._last_rx = now
|
||||
if self._dropping:
|
||||
i = self._buf.find(b"\n")
|
||||
if i < 0:
|
||||
return []
|
||||
del self._buf[: i + 1]
|
||||
self._dropping = False
|
||||
out: list[str] = []
|
||||
while (i := self._buf.find(b"\n")) >= 0:
|
||||
out.append(self._decode(self._buf[:i]))
|
||||
out += self._emit(self._buf[:i])
|
||||
del self._buf[: i + 1]
|
||||
if len(self._buf) >= self.max_line:
|
||||
out.append(self._decode(self._buf))
|
||||
out += self._emit(self._buf)
|
||||
self._buf.clear()
|
||||
return out
|
||||
|
||||
def poll_idle(self, now: float | None = None) -> list[str]:
|
||||
now = time.monotonic() if now is None else now
|
||||
if self._buf and now - self._last_rx >= self.idle_flush_s:
|
||||
line = self._decode(self._buf)
|
||||
self._buf.clear()
|
||||
return [line]
|
||||
return self.flush()
|
||||
return []
|
||||
|
||||
def flush(self) -> list[str]:
|
||||
if not self._buf:
|
||||
return []
|
||||
line = self._decode(self._buf)
|
||||
out = self._emit(self._buf)
|
||||
self._buf.clear()
|
||||
return [line]
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _decode(b: bytes | bytearray) -> str:
|
||||
return bytes(b).decode("utf-8", errors="replace")
|
||||
def _emit(self, raw: bytes | bytearray) -> list[str]:
|
||||
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
|
||||
if self._first_line_pending:
|
||||
self._first_line_pending = False
|
||||
if len(parts) > 1:
|
||||
parts = parts[1:] # stale FIFO fragment from before the open
|
||||
return parts
|
||||
|
||||
|
||||
# Start of a log line (optionally ANSI-coloured) or ROM banner.
|
||||
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
|
||||
|
||||
|
||||
def split_glued(text: str) -> list[str]:
|
||||
# Left-to-right scan so an ANSI colour prefix stays with its line.
|
||||
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
|
||||
if not cuts:
|
||||
return [text]
|
||||
bounds = [0, *cuts, len(text)]
|
||||
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
|
||||
@@ -33,6 +33,13 @@ class SessionLog:
|
||||
self._fh.write(f"[{ts}] {line.text}\n")
|
||||
self._fh.flush()
|
||||
|
||||
def write_report(self, text: str) -> None:
|
||||
"""Append the diagnosis at the end of the session (for the firmware team)."""
|
||||
if self._fh is None:
|
||||
return
|
||||
self._fh.write("\n===== Diagnosis at end of session =====\n" + text + "\n")
|
||||
self._fh.flush()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,576 @@
|
||||
"""Diagnostics engine: parsed log lines -> findings, milestones, LED, verdict.
|
||||
|
||||
Line-level knowledge lives in rules.toml. This module adds what a single
|
||||
line cannot express:
|
||||
* boot tracking (ROM banner, reset reason, uptime drops when the banner
|
||||
was missed) and classification of why the previous boot ended;
|
||||
* the status-LED model (Status::set_status / set_status_conditional);
|
||||
* crash capture (panic / ESP_ERROR_CHECK output);
|
||||
* root-cause grouping via `explained_by` chains.
|
||||
|
||||
Findings are session-wide (a reboot loop must keep its cause visible);
|
||||
milestones and the LED are per boot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from .firmware import FirmwareInfo
|
||||
|
||||
RULES_PATH = Path(__file__).with_name("rules.toml")
|
||||
|
||||
|
||||
class Severity(IntEnum):
|
||||
IGNORE = -1
|
||||
INFO = 1
|
||||
WARNING = 2
|
||||
ERROR = 3
|
||||
CRITICAL = 4
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.name.capitalize()
|
||||
|
||||
|
||||
# name -> (label, swatch colour)
|
||||
LED = {
|
||||
"UNKNOWN": ("Unknown", "#888888"),
|
||||
"OFF": ("Off", "#444444"),
|
||||
"GREEN": ("Green", "#2e9e44"),
|
||||
"PURPLE": ("Purple", "#8e44ad"),
|
||||
"RED": ("Red", "#d64541"),
|
||||
"BLUE": ("Blue", "#2f6fd6"),
|
||||
"YELLOW": ("Yellow", "#e6c229"),
|
||||
"ORANGE": ("Orange", "#f08c1a"),
|
||||
"WHITE": ("White", "#f4f4f4"),
|
||||
}
|
||||
|
||||
# (id, label, optional) - optional milestones are listed only once reached.
|
||||
MILESTONES = [
|
||||
("rom_boot", "Board booted", False),
|
||||
("app_started", "Firmware started", False),
|
||||
("sd_mounted", "SD card mounted", False),
|
||||
("env_loaded", "ENV.TXT read (InfluxDB settings)", False),
|
||||
("matter_started", "Matter started", True),
|
||||
("matter_commissioned", "Paired in Matter", True),
|
||||
("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True),
|
||||
("sensors_ready", "All sensors started", False),
|
||||
("wifi_connected", "Connected to Wi-Fi", False),
|
||||
("time_synced", "Clock set from the Internet", False),
|
||||
("data_uploaded", "Data uploaded to InfluxDB", False),
|
||||
("ota_registered", "Registered for firmware updates", False),
|
||||
("ota_checked", "Checked for firmware updates", False),
|
||||
("ota_update_started", "Firmware update started", True),
|
||||
("ota_update_done", "Firmware update installed", True),
|
||||
("ota_update_accepted", "Updated firmware accepted", True),
|
||||
]
|
||||
|
||||
# Findings that stop start-up; a crash/reboot right after them is their effect.
|
||||
_INIT_FAILURES = [
|
||||
"rtc.init_failed", "sensor.init_failed", "sd.card_fail", "sd.mount_fail",
|
||||
"sd.env_open_fail", "influx.env_missing", "influx.init_fail",
|
||||
]
|
||||
_NETWORK_ROOTS = [
|
||||
"influx.buffer_fail", "ota.check_failed", "net.dns_fail",
|
||||
"matter.not_commissioned", "wifi.disconnected",
|
||||
]
|
||||
_RESET_BY_TOOL_WINDOW_S = 5.0
|
||||
_SHORT_BOOT_MS = 60_000
|
||||
|
||||
|
||||
class _Fmt(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "?"
|
||||
|
||||
|
||||
def _fmt(s: str, groups: dict[str, str]) -> str:
|
||||
return s.format_map(_Fmt(groups)) if s else s
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
id: str
|
||||
pattern: re.Pattern[str]
|
||||
severity: Severity
|
||||
title: str = ""
|
||||
meaning: str = ""
|
||||
actions: tuple[str, ...] = ()
|
||||
explained_by: tuple[str, ...] = ()
|
||||
milestone: str | None = None
|
||||
clears: tuple[str, ...] = ()
|
||||
led: str | None = None
|
||||
led_from: str | None = None
|
||||
split_by: str | None = None
|
||||
min_count: int = 1
|
||||
min_uptime_ms: int = 0
|
||||
|
||||
|
||||
def load_rules(path: Path = RULES_PATH) -> list[Rule]:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
rules = []
|
||||
for r in data["rule"]:
|
||||
rules.append(Rule(
|
||||
id=r["id"],
|
||||
pattern=re.compile(r["pattern"]),
|
||||
severity=Severity[r["severity"].upper()],
|
||||
title=r.get("title", ""),
|
||||
meaning=r.get("meaning", ""),
|
||||
actions=tuple(r.get("actions", ())),
|
||||
explained_by=tuple(r.get("explained_by", ())),
|
||||
milestone=r.get("milestone"),
|
||||
clears=tuple(r.get("clears", ())),
|
||||
led=r.get("led"),
|
||||
led_from=r.get("led_from"),
|
||||
split_by=r.get("split_by"),
|
||||
min_count=int(r.get("min_count", 1)),
|
||||
min_uptime_ms=int(r.get("min_uptime_ms", 0)),
|
||||
))
|
||||
_validate(rules)
|
||||
return rules
|
||||
|
||||
|
||||
def _validate(rules: list[Rule]) -> None:
|
||||
ids = [r.id for r in rules]
|
||||
dupes = [i for i, n in Counter(ids).items() if n > 1]
|
||||
if dupes:
|
||||
raise ValueError(f"duplicate rule ids: {dupes}")
|
||||
known = set(ids)
|
||||
for r in rules:
|
||||
for ref in (*r.explained_by, *r.clears):
|
||||
if ref not in known:
|
||||
raise ValueError(f"{r.id}: unknown rule id {ref!r}")
|
||||
for colour in (r.led, r.led_from):
|
||||
if colour and colour not in LED:
|
||||
raise ValueError(f"{r.id}: unknown LED colour {colour!r}")
|
||||
if r.severity != Severity.IGNORE and not r.title:
|
||||
raise ValueError(f"{r.id}: visible rule needs a title")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
key: str # rule id, or "rule id:split value"
|
||||
rule_id: str
|
||||
severity: Severity
|
||||
title: str
|
||||
meaning: str
|
||||
actions: tuple[str, ...]
|
||||
explained_by: tuple[str, ...] = ()
|
||||
min_count: int = 1
|
||||
count: int = 0
|
||||
first_uptime_ms: int | None = None
|
||||
last_uptime_ms: int | None = None
|
||||
last_seen: float = 0.0
|
||||
boot: int = 0
|
||||
resolved: bool = False
|
||||
resolved_by: str | None = None
|
||||
evidence: deque[str] = field(default_factory=lambda: deque(maxlen=3))
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.count >= self.min_count and not self.resolved
|
||||
|
||||
|
||||
@dataclass
|
||||
class BootRecord:
|
||||
index: int
|
||||
host_time: float
|
||||
explicit: bool # ROM banner seen (vs. inferred)
|
||||
reset_reason: str | None = None
|
||||
tool_reset: bool = False
|
||||
last_uptime_ms: int = 0
|
||||
saw_log: bool = False
|
||||
led_at_end: str | None = None
|
||||
panic_lines: list[str] = field(default_factory=list)
|
||||
recent_errors: deque[str] = field(default_factory=lambda: deque(maxlen=5))
|
||||
intentional_restart: bool = False # factory reset / setup-mode toggle
|
||||
|
||||
|
||||
@dataclass
|
||||
class RootCause:
|
||||
finding: Finding
|
||||
consequences: list[Finding]
|
||||
|
||||
@property
|
||||
def severity(self) -> Severity:
|
||||
return max([self.finding.severity, *(c.severity for c in self.consequences)])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
verdict: str
|
||||
verdict_severity: Severity | None # None = healthy / nothing found
|
||||
led: str
|
||||
led_reason: str
|
||||
firmware: FirmwareInfo
|
||||
milestones: list[tuple[str, str, int | None]] # (id, label, uptime_ms)
|
||||
roots: list[RootCause]
|
||||
earlier: list[Finding] # seen only in previous boots
|
||||
resolved: list[Finding]
|
||||
boots: list[BootRecord]
|
||||
lines: int
|
||||
mid_run: bool # no boot seen yet
|
||||
technician: list[str] = field(default_factory=list) # guided-check answers
|
||||
|
||||
def to_text(self) -> str:
|
||||
out = [f"VERDICT: {self.verdict}",
|
||||
f"Expected status LED: {LED[self.led][0]}"
|
||||
+ (f" (last change: {self.led_reason})" if self.led_reason else ""),
|
||||
f"Firmware: {self.firmware.describe()}",
|
||||
f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}",
|
||||
"", "Milestones (current boot):"]
|
||||
for _, label, t in self.milestones:
|
||||
out.append(f" [{'x' if t is not None else ' '}] {label}"
|
||||
+ (f" @ {t / 1000:.1f} s" if t is not None else ""))
|
||||
out.append("")
|
||||
out.append("Findings:" if self.roots else "Findings: none")
|
||||
for rc in self.roots:
|
||||
out += _finding_text(rc.finding, " ")
|
||||
for c in rc.consequences:
|
||||
out.append(" consequence:")
|
||||
out += _finding_text(c, " ")
|
||||
if self.earlier:
|
||||
out.append("")
|
||||
out.append("Seen in earlier boots only:")
|
||||
for f in self.earlier:
|
||||
out.append(f" - [{f.severity.label}] {f.title} (x{f.count})")
|
||||
if self.resolved:
|
||||
out.append("")
|
||||
out.append("Resolved during this session:")
|
||||
for f in self.resolved:
|
||||
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)
|
||||
|
||||
|
||||
def _finding_text(f: Finding, ind: str) -> list[str]:
|
||||
lines = [f"{ind}- [{f.severity.label}] {f.title} (x{f.count})", f"{ind} {f.meaning}"]
|
||||
lines += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)]
|
||||
if f.evidence:
|
||||
lines.append(f"{ind} log: {f.evidence[-1]}")
|
||||
return lines
|
||||
|
||||
|
||||
class DiagnosticsEngine:
|
||||
def __init__(self, rules: list[Rule] | None = None):
|
||||
self.rules = rules if rules is not None else load_rules()
|
||||
self._rule_by_id = {r.id: r for r in self.rules}
|
||||
self.reset()
|
||||
|
||||
# ---------------------------------------------------------------- API
|
||||
def reset(self) -> None:
|
||||
self.findings: dict[str, Finding] = {}
|
||||
self.boots: list[BootRecord] = [BootRecord(0, 0.0, explicit=False)]
|
||||
self.milestones: dict[str, int] = {}
|
||||
self.led = "UNKNOWN"
|
||||
self.led_reason = ""
|
||||
self.firmware = FirmwareInfo()
|
||||
self.lines = 0
|
||||
self._tool_reset_at: float | None = None
|
||||
self._panic_tail = 0 # RAW lines after a panic line to keep
|
||||
self.technician: list[str] = []
|
||||
|
||||
@property
|
||||
def boot(self) -> BootRecord:
|
||||
return self.boots[-1]
|
||||
|
||||
def feed(self, line: LogLine) -> None:
|
||||
self.lines += 1
|
||||
text = line.text
|
||||
if line.kind == LineKind.HOST:
|
||||
if "reset issued by tool" in text:
|
||||
self._tool_reset_at = line.host_time
|
||||
return
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._new_boot(line.host_time, explicit=True)
|
||||
self.milestones["rom_boot"] = 0
|
||||
return
|
||||
if line.kind == LineKind.RESET:
|
||||
self.boot.reset_reason = line.reset_reason
|
||||
if line.reset_reason and "POWERON" in line.reset_reason:
|
||||
self.led, self.led_reason = "OFF", "power-on - firmware not started yet"
|
||||
return
|
||||
if line.kind == LineKind.LOG and line.uptime_ms is not None:
|
||||
b = self.boot
|
||||
# Uptime jumped back to a fresh-boot value: the ROM banner was
|
||||
# missed (port reconnect, capture started late).
|
||||
if b.saw_log and line.uptime_ms + 3000 < b.last_uptime_ms and line.uptime_ms < 10_000:
|
||||
self._new_boot(line.host_time, explicit=False)
|
||||
b = self.boot
|
||||
b.saw_log = True
|
||||
b.last_uptime_ms = max(b.last_uptime_ms, line.uptime_ms)
|
||||
|
||||
if line.kind == LineKind.PANIC:
|
||||
self._on_panic(line)
|
||||
elif self._panic_tail and line.kind == LineKind.RAW:
|
||||
self.boot.panic_lines.append(text)
|
||||
self._panic_tail -= 1
|
||||
self._crash_finding().evidence.append(text)
|
||||
|
||||
self.firmware.observe(text)
|
||||
matched = False
|
||||
for rule in self.rules:
|
||||
m = rule.pattern.search(text)
|
||||
if not m:
|
||||
continue
|
||||
matched = True
|
||||
if rule.min_uptime_ms and (line.uptime_ms or 0) < rule.min_uptime_ms:
|
||||
continue
|
||||
groups = {k: v for k, v in m.groupdict().items() if v is not None}
|
||||
if rule.milestone and rule.milestone not in self.milestones:
|
||||
self.milestones[rule.milestone] = line.uptime_ms or 0
|
||||
if rule.led:
|
||||
self._set_led(rule.led, rule.led_from, text)
|
||||
for cid in rule.clears:
|
||||
for f in self.findings.values():
|
||||
if f.rule_id == cid and not f.resolved:
|
||||
f.resolved = True
|
||||
f.resolved_by = rule.milestone or rule.id
|
||||
if rule.id in ("sys.factory_reset",) or "Short press:" in text:
|
||||
self.boot.intentional_restart = True
|
||||
if rule.severity != Severity.IGNORE:
|
||||
self._hit(rule, groups, line)
|
||||
|
||||
self._stateful(line)
|
||||
if line.level == "E":
|
||||
self.boot.recent_errors.append(text)
|
||||
if not matched and line.tag:
|
||||
self._hit_other(line)
|
||||
|
||||
def report(self) -> Report:
|
||||
all_f = list(self.findings.values()) + self._reboot_findings()
|
||||
cur = self.boot.index
|
||||
active = [f for f in all_f if f.active]
|
||||
current = [f for f in active if f.boot == cur]
|
||||
earlier = [f for f in active if f not in current]
|
||||
resolved = [f for f in all_f if f.resolved and f.count >= f.min_count]
|
||||
roots = _group(current)
|
||||
# Worst consequence first, then the root's own severity, then order seen.
|
||||
roots.sort(key=lambda rc: (-rc.severity, -rc.finding.severity, rc.finding.first_uptime_ms or 0))
|
||||
|
||||
mid_run = not any(b.explicit for b in self.boots) and self.boot.index == 0
|
||||
if self.lines == 0:
|
||||
verdict, vsev = "Waiting for data from the sensorbox...", None
|
||||
elif roots and roots[0].severity >= Severity.WARNING:
|
||||
vsev = roots[0].severity
|
||||
prefix = "Problem" if vsev >= Severity.ERROR else "Attention"
|
||||
verdict = f"{prefix}: {roots[0].finding.title}"
|
||||
elif "data_uploaded" in self.milestones:
|
||||
verdict, vsev = "Healthy: data is reaching InfluxDB", None
|
||||
else:
|
||||
verdict, vsev = "No problems found so far", None
|
||||
if mid_run and self.lines:
|
||||
verdict += " (connected mid-run - press Reset & capture for a full check)"
|
||||
|
||||
ms = [(mid, label, self.milestones.get(mid)) for mid, label, optional in MILESTONES
|
||||
if not optional or mid in self.milestones]
|
||||
# Reached milestones in the order they happened, then the pending ones.
|
||||
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,
|
||||
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
|
||||
def _new_boot(self, host_time: float, explicit: bool) -> None:
|
||||
prev = self.boot
|
||||
# An inferred boot right after a banner-started boot is the same boot.
|
||||
if not explicit and prev.explicit and not prev.saw_log:
|
||||
return
|
||||
prev.led_at_end = self.led
|
||||
b = BootRecord(prev.index + 1, host_time, explicit)
|
||||
if self._tool_reset_at is not None and host_time - self._tool_reset_at <= _RESET_BY_TOOL_WINDOW_S:
|
||||
b.tool_reset = True
|
||||
self._tool_reset_at = None
|
||||
self.boots.append(b)
|
||||
self.milestones = {}
|
||||
# The WS2812 latches its colour through a software reset until
|
||||
# Status::init() rewrites it; only a power-on turns it dark.
|
||||
self.led_reason = "restarting - LED keeps its last colour until the firmware starts"
|
||||
self.firmware = FirmwareInfo()
|
||||
self._panic_tail = 0
|
||||
|
||||
def _set_led(self, colour: str, led_from: str | None, text: str) -> None:
|
||||
if led_from and self.led != led_from:
|
||||
return
|
||||
self.led = colour
|
||||
self.led_reason = text
|
||||
|
||||
def _hit(self, rule: Rule, groups: dict[str, str], line: LogLine) -> None:
|
||||
key = rule.id
|
||||
if rule.split_by and rule.split_by in groups:
|
||||
key = f"{rule.id}:{groups[rule.split_by]}"
|
||||
f = self.findings.get(key)
|
||||
if f is None:
|
||||
f = self.findings[key] = Finding(
|
||||
key, rule.id, rule.severity, _fmt(rule.title, groups), _fmt(rule.meaning, groups),
|
||||
tuple(_fmt(a, groups) for a in rule.actions), rule.explained_by, rule.min_count)
|
||||
self._touch(f, line)
|
||||
|
||||
def _touch(self, f: Finding, line: LogLine) -> None:
|
||||
f.count += 1
|
||||
# Uptime restarts every boot, so "first seen" is per current boot.
|
||||
if f.first_uptime_ms is None or f.boot != self.boot.index:
|
||||
f.first_uptime_ms = line.uptime_ms
|
||||
f.last_uptime_ms = line.uptime_ms
|
||||
f.last_seen = line.host_time
|
||||
f.boot = self.boot.index
|
||||
f.evidence.append(line.text)
|
||||
if f.resolved:
|
||||
f.resolved, f.resolved_by = False, None # problem came back
|
||||
|
||||
def _custom(self, key: str, severity: Severity, title: str, meaning: str,
|
||||
actions: tuple[str, ...], explained_by: tuple[str, ...] = ()) -> Finding:
|
||||
f = self.findings.get(key)
|
||||
if f is None:
|
||||
f = self.findings[key] = Finding(key, key, severity, title, meaning, actions, explained_by)
|
||||
return f
|
||||
|
||||
def _hit_other(self, line: LogLine) -> None:
|
||||
f = self._custom(
|
||||
f"other.{line.tag}", Severity.INFO, f"Unrecognised error messages from {line.tag}",
|
||||
"These errors are not in the rule set yet; they may be harmless.",
|
||||
("If the box misbehaves, save the session log and send it to the firmware team.",))
|
||||
self._touch(f, line)
|
||||
|
||||
def _crash_finding(self) -> Finding:
|
||||
return self._custom(
|
||||
"sys.crash", Severity.CRITICAL, "Firmware stopped with an error and restarts",
|
||||
"The firmware hit a fatal error. The error printed just before it is the real cause.",
|
||||
("See the grouped cause; otherwise save the session log for the firmware team.",),
|
||||
tuple(_INIT_FAILURES))
|
||||
|
||||
def _on_panic(self, line: LogLine) -> None:
|
||||
b = self.boot
|
||||
if len(b.panic_lines) < 8:
|
||||
b.panic_lines.append(line.text)
|
||||
self._panic_tail = 4 # file/func/expression lines follow ESP_ERROR_CHECK
|
||||
f = self._crash_finding()
|
||||
if f.count == 0 and b.recent_errors:
|
||||
f.meaning += f" Last error before it: {b.recent_errors[-1]}"
|
||||
self._touch(f, line)
|
||||
|
||||
def _stateful(self, line: LogLine) -> None:
|
||||
# RTC set by SNTP but reads "uninitialized" again later: the chip
|
||||
# does not keep time (crystal/module fault), not just a flat cell.
|
||||
synced = self.milestones.get("time_synced")
|
||||
if (synced is not None and line.uptime_ms is not None
|
||||
and line.uptime_ms > synced + 10_000
|
||||
and "RTC is uninitialized" in line.text):
|
||||
f = self._custom(
|
||||
"rtc.lost_after_sync", Severity.ERROR,
|
||||
"Clock stops again after being set from the Internet",
|
||||
"The clock was set from the Internet but stopped running shortly after. The RTC module or its crystal is faulty.",
|
||||
("Replace the RTC module (inside the case).",))
|
||||
self._touch(f, line)
|
||||
|
||||
def _reboot_findings(self) -> list[Finding]:
|
||||
"""Classify why each earlier boot ended (needs the next boot's reset reason)."""
|
||||
out: list[Finding] = []
|
||||
short_unexpected: list[BootRecord] = []
|
||||
for prev, nxt in zip(self.boots, self.boots[1:]):
|
||||
if not prev.saw_log and prev.index == 0:
|
||||
continue # nothing captured before the first boot
|
||||
reason = nxt.reset_reason or "unknown"
|
||||
if nxt.tool_reset or prev.intentional_restart:
|
||||
continue
|
||||
key, sev, title, meaning, actions, expl = None, Severity.INFO, "", "", (), ()
|
||||
if "USB_UART_CHIP_RESET" in reason:
|
||||
key, title = "reboot.usb", "Box was reset over USB"
|
||||
meaning = "The board was reset through its USB port (a flashing tool, a serial program, or this tool in an earlier session)."
|
||||
elif "POWERON" in reason:
|
||||
key, title = "reboot.power", "Box was switched off and on"
|
||||
meaning = "Power was interrupted (power switch, cable or supply)."
|
||||
actions = ("If nobody switched it off: check the power supply and cable.",)
|
||||
elif "BROWN" in reason:
|
||||
key, sev, title = "reboot.brownout", Severity.ERROR, "Power supply voltage dropped (brown-out)"
|
||||
meaning = "The supply voltage fell too low and the chip reset itself."
|
||||
actions = ("Check the power supply, cable and connectors.",)
|
||||
elif "WDT" in reason:
|
||||
key, sev, title = "reboot.watchdog", Severity.ERROR, "Firmware froze and was restarted by the watchdog"
|
||||
meaning = "The firmware stopped responding; the hardware watchdog restarted it."
|
||||
actions = ("Save the session log for the firmware team.",)
|
||||
elif prev.panic_lines:
|
||||
pass # covered by sys.crash
|
||||
elif prev.led_at_end in ("PURPLE", "RED") and prev.last_uptime_ms >= 140_000:
|
||||
key, title = "reboot.status_watchdog", "Box restarted itself after 2.5 minutes of errors"
|
||||
meaning = "By design the firmware restarts when it stays in an error state (purple/red) for 150 s."
|
||||
actions = ("Fix the grouped cause; the restarts stop by themselves.",)
|
||||
expl = tuple(_NETWORK_ROOTS) + ("sys.out_of_resources",)
|
||||
else:
|
||||
key, sev, title = "reboot.unexpected", Severity.WARNING, "Unexpected restart"
|
||||
meaning = f"The firmware restarted (reset reason {reason})."
|
||||
actions = ("Watch whether it repeats; save the session log for the firmware team.",)
|
||||
if key:
|
||||
out.append(self._reboot_finding(key, sev, title, meaning, actions, expl, nxt))
|
||||
if prev.last_uptime_ms < _SHORT_BOOT_MS and "POWERON" not in reason:
|
||||
short_unexpected.append(prev)
|
||||
if len(short_unexpected) >= 2:
|
||||
avg = sum(b.last_uptime_ms for b in short_unexpected) / len(short_unexpected) / 1000
|
||||
f = self._reboot_finding(
|
||||
"reboot.loop", Severity.CRITICAL, "Reboot loop",
|
||||
f"The box restarted {len(short_unexpected)} times, about every {avg:.0f} s. "
|
||||
"The error printed just before each restart is the cause.",
|
||||
("Fix the grouped cause below.",), tuple(_INIT_FAILURES), self.boot)
|
||||
f.count = len(short_unexpected)
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
def _reboot_finding(self, key, sev, title, meaning, actions, expl, boot: BootRecord) -> Finding:
|
||||
f = Finding(key, key, sev, title, meaning, actions, expl)
|
||||
f.count, f.boot, f.last_seen = 1, self.boot.index, boot.host_time
|
||||
if boot.reset_reason:
|
||||
f.evidence.append(f"reset reason: {boot.reset_reason}")
|
||||
return f
|
||||
|
||||
|
||||
def _group(findings: list[Finding]) -> list[RootCause]:
|
||||
"""Attach each finding to the root of its explained_by chain."""
|
||||
by_rule: dict[str, list[Finding]] = {}
|
||||
for f in findings:
|
||||
by_rule.setdefault(f.rule_id, []).append(f)
|
||||
|
||||
def parent(f: Finding) -> Finding | None:
|
||||
for rid in f.explained_by:
|
||||
if by_rule.get(rid):
|
||||
return by_rule[rid][0]
|
||||
return None
|
||||
|
||||
def root(f: Finding) -> Finding:
|
||||
seen = {f.key}
|
||||
while (p := parent(f)) is not None and p.key not in seen:
|
||||
seen.add(p.key)
|
||||
f = p
|
||||
return f
|
||||
|
||||
roots: dict[str, RootCause] = {}
|
||||
for f in findings:
|
||||
r = root(f)
|
||||
rc = roots.setdefault(r.key, RootCause(r, []))
|
||||
if r is not f:
|
||||
rc.consequences.append(f)
|
||||
for rc in roots.values():
|
||||
rc.consequences.sort(key=lambda c: (-c.severity, c.first_uptime_ms or 0))
|
||||
return list(roots.values())
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Firmware identification from the boot log.
|
||||
|
||||
The app version string is "1.0" in every build seen so far, so builds are
|
||||
told apart by the ELF SHA256 prefix printed by app_init. Boxes receive new
|
||||
firmware over OTA; an unknown SHA usually means a box has already updated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownFirmware:
|
||||
name: str
|
||||
source: str
|
||||
sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
|
||||
setup_portal: bool # short press -> Wi-Fi setup portal
|
||||
|
||||
|
||||
KNOWN: dict[str, KnownFirmware] = {
|
||||
"45fa9d5b9": KnownFirmware(
|
||||
name="v1.0 (built Jun 29 2026)",
|
||||
source="factory image new_system.bin",
|
||||
sd_wifi=False,
|
||||
setup_portal=False,
|
||||
),
|
||||
}
|
||||
|
||||
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
||||
_VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)")
|
||||
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
|
||||
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)")
|
||||
# Evidence of features, for builds not in KNOWN.
|
||||
_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ")
|
||||
_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ")
|
||||
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag.
|
||||
_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareInfo:
|
||||
elf_sha: str | None = None
|
||||
version: str | None = None
|
||||
built: str | None = None
|
||||
idf: str | None = None
|
||||
evidence: set[str] = field(default_factory=set)
|
||||
|
||||
def observe(self, text: str) -> None:
|
||||
if m := _ELF.search(text):
|
||||
self.elf_sha = m.group("sha")
|
||||
elif m := _VER.search(text):
|
||||
self.version = m.group("v")
|
||||
elif m := _BUILT.search(text):
|
||||
self.built = m.group("t").strip()
|
||||
elif m := _IDF.search(text):
|
||||
self.idf = m.group("v")
|
||||
if _FEATURE_SD_WIFI.search(text):
|
||||
self.evidence.add("sd_wifi")
|
||||
if _FEATURE_PORTAL.search(text):
|
||||
self.evidence.add("setup_portal")
|
||||
if _LEGACY_SD_TAG.search(text):
|
||||
self.evidence.add("legacy_v1")
|
||||
|
||||
@property
|
||||
def known(self) -> KnownFirmware | None:
|
||||
if not self.elf_sha:
|
||||
return None
|
||||
return next((k for sha, k in KNOWN.items() if self.elf_sha.startswith(sha)), None)
|
||||
|
||||
@property
|
||||
def supports_sd_wifi(self) -> bool | None:
|
||||
"""True/False when determinable, None when unknown (no boot seen)."""
|
||||
if k := self.known:
|
||||
return k.sd_wifi
|
||||
if "sd_wifi" in self.evidence:
|
||||
return True
|
||||
if "legacy_v1" in self.evidence:
|
||||
return False
|
||||
return None
|
||||
|
||||
def describe(self) -> str:
|
||||
if k := self.known:
|
||||
return f"{k.name} - {k.source}"
|
||||
if self.elf_sha:
|
||||
built = f", built {self.built}" if self.built else ""
|
||||
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
|
||||
if "legacy_v1" in self.evidence:
|
||||
return "Probably v1.0 factory image (press Reset & capture to confirm)"
|
||||
return "Not identified yet - press Reset & capture"
|
||||
@@ -0,0 +1,400 @@
|
||||
"""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
|
||||
|
||||
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"),
|
||||
)
|
||||
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 + "}"
|
||||
@@ -0,0 +1,734 @@
|
||||
# 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 on the sticker (or type the 11-digit code printed under it). Keep the phone within 1 m of the box."
|
||||
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 tab, coming in v0.5) 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."]
|
||||
@@ -0,0 +1,572 @@
|
||||
# Log signatures for the KOTA sensorbox firmware.
|
||||
#
|
||||
# Derived from the firmware source (log strings, Status::set_status calls) and
|
||||
# checked against real captures (tests/fixtures). Patterns match the cleaned
|
||||
# line text, not the tag: the deployed v1.0 build logs some messages under
|
||||
# different tags than the source (e.g. SD mount under INFLUX_BUFFER).
|
||||
#
|
||||
# Fields:
|
||||
# id unique, "<category>.<name>"
|
||||
# pattern Python regex (search). Named groups can be used in `title`.
|
||||
# severity critical | error | warning | info | ignore
|
||||
# ignore = never shown (benign ESP_LOGE lines, LED-only rules)
|
||||
# title short technician-facing headline
|
||||
# meaning what it means, in plain words
|
||||
# actions what to do, in order (prefer no-unscrewing actions first)
|
||||
# explained_by list of rule ids; while one of them is active this finding
|
||||
# is shown as a consequence of it instead of a root cause
|
||||
# milestone boot milestone reached when the line is seen
|
||||
# clears finding ids resolved when this line is seen
|
||||
# led status LED colour this line sets (firmware Status::set_status)
|
||||
# led_from only change the LED if it currently has this colour
|
||||
# (Status::set_status_conditional)
|
||||
# split_by named group; one finding per distinct value (e.g. per sensor)
|
||||
# min_count finding becomes active only after this many occurrences
|
||||
# min_uptime_ms ignore occurrences earlier than this uptime
|
||||
|
||||
# ---------------------------------------------------------------- milestones
|
||||
|
||||
[[rule]]
|
||||
id = "boot.app_started"
|
||||
pattern = 'app_init: Project name:\s+kota-sensorboard'
|
||||
severity = "ignore"
|
||||
milestone = "app_started"
|
||||
led = "GREEN" # Status::init() -> STATUS_OK
|
||||
|
||||
[[rule]]
|
||||
id = "boot.sd_mounted"
|
||||
pattern = 'SD card mounted successfully'
|
||||
severity = "ignore"
|
||||
milestone = "sd_mounted"
|
||||
|
||||
[[rule]]
|
||||
id = "boot.env_loaded"
|
||||
pattern = 'Read INFLUX_URL from \.env file'
|
||||
severity = "ignore"
|
||||
milestone = "env_loaded"
|
||||
|
||||
[[rule]]
|
||||
id = "boot.matter_started"
|
||||
pattern = 'ESP-Matter started successfully'
|
||||
severity = "ignore"
|
||||
milestone = "matter_started"
|
||||
|
||||
[[rule]]
|
||||
id = "boot.local_wifi_mode"
|
||||
pattern = 'SSID/PASSWORD found in \.env'
|
||||
severity = "ignore"
|
||||
milestone = "local_wifi_mode"
|
||||
|
||||
[[rule]]
|
||||
id = "boot.sensors_ready"
|
||||
# DeviceStatus::init installs the temperature sensor; it is the last init
|
||||
# step, and any earlier init failure aborts before reaching it.
|
||||
pattern = 'temperature_sensor: Range \['
|
||||
severity = "ignore"
|
||||
milestone = "sensors_ready"
|
||||
|
||||
[[rule]]
|
||||
id = "net.wifi_connected"
|
||||
pattern = 'sta ip: \d|got ip:|WIFI_SERVICE: Got IP address|IPv4 address changed|Interface IP Address changed'
|
||||
severity = "ignore"
|
||||
milestone = "wifi_connected"
|
||||
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]]
|
||||
id = "net.time_synced"
|
||||
pattern = 'SNTP: Time synchronized'
|
||||
severity = "ignore"
|
||||
milestone = "time_synced"
|
||||
clears = ["rtc.clock_not_running", "net.dns_fail"]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.upload_ok"
|
||||
pattern = 'INFLUX: HTTP request performed, status code: 2\d\d'
|
||||
severity = "ignore"
|
||||
milestone = "data_uploaded"
|
||||
clears = ["influx.buffer_fail", "net.dns_fail", "net.tls_fail"]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.write_ok"
|
||||
pattern = 'Successfully wrote to Influx'
|
||||
severity = "ignore"
|
||||
milestone = "data_uploaded"
|
||||
led = "GREEN"
|
||||
led_from = "PURPLE"
|
||||
|
||||
[[rule]]
|
||||
id = "ota.registered"
|
||||
pattern = 'Device registered successfully|OTA token loaded from NVS'
|
||||
severity = "ignore"
|
||||
milestone = "ota_registered"
|
||||
clears = ["ota.registration_failed"]
|
||||
|
||||
[[rule]]
|
||||
id = "ota.checked"
|
||||
pattern = 'OTA_CLIENT: No update available'
|
||||
severity = "ignore"
|
||||
milestone = "ota_checked"
|
||||
clears = ["ota.check_failed"]
|
||||
led = "GREEN"
|
||||
led_from = "PURPLE"
|
||||
|
||||
# ----------------------------------------------------------- benign E lines
|
||||
|
||||
[[rule]]
|
||||
id = "benign.mac"
|
||||
pattern = 'ID: Device MAC address'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.attr_cb"
|
||||
pattern = 'MAIN: Attribute update callback'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.zcl"
|
||||
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.connect_chain"
|
||||
# Transport-level echo of a failure that a more specific rule reports.
|
||||
pattern = 'esp-tls: Failed to open new connection|transport_base: Failed to open a new connection|HTTP_CLIENT: Connection failed, sock < 0|INFLUX: Failed to perform HTTP request: 0,|INFLUX_BUFFER: Failed to write chunk to Influx|OTA_CLIENT: Error ESP_ERR_HTTP_CONNECT at'
|
||||
severity = "ignore"
|
||||
|
||||
# ------------------------------------------------------------------ network
|
||||
|
||||
[[rule]]
|
||||
id = "matter.not_commissioned"
|
||||
pattern = 'CHIPoBLE advertising started|MAIN: Commissioning window opened'
|
||||
severity = "warning"
|
||||
title = "Box is not connected to Wi-Fi (not commissioned in Matter)"
|
||||
meaning = "The box is waiting to be added to a Matter network over Bluetooth. Until then it has no Wi-Fi, so it cannot upload data or receive firmware updates."
|
||||
actions = [
|
||||
"Add the box in the customer's Matter app (Apple Home, Google Home, ...) by scanning the QR sticker, or enter the manual code printed under it.",
|
||||
"Use a 2.4 GHz Wi-Fi network; keep the phone close to the box during pairing.",
|
||||
"If pairing keeps failing: hold the Matter button 5 s (factory reset) and try again.",
|
||||
]
|
||||
milestone = "ble_advertising"
|
||||
|
||||
[[rule]]
|
||||
id = "matter.commissioning_failed"
|
||||
pattern = 'Commissioning failed, fail safe timer expired'
|
||||
severity = "error"
|
||||
title = "Matter pairing attempt failed"
|
||||
meaning = "A phone started adding the box to Matter but did not finish in time."
|
||||
actions = [
|
||||
"Retry pairing from the Matter app with the phone next to the box.",
|
||||
"Check the Wi-Fi is 2.4 GHz and the password entered in the app is correct.",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "net.dns_fail"
|
||||
pattern = '''couldn't get hostname for :(?P<host>[\w.-]+)'''
|
||||
severity = "error"
|
||||
title = "No Internet: server names cannot be resolved"
|
||||
meaning = "The box could not look up a server name. Either it has no Wi-Fi connection, or the network has no working Internet/DNS."
|
||||
actions = [
|
||||
"Check the site Wi-Fi has Internet (open a website on a phone connected to the same network).",
|
||||
"If the box was never paired: commission it in Matter first.",
|
||||
"Check the router does not block new devices (guest isolation, MAC filtering).",
|
||||
]
|
||||
explained_by = ["matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"]
|
||||
|
||||
[[rule]]
|
||||
id = "net.tls_fail"
|
||||
pattern = 'mbedtls_ssl_handshake returned|Failed to verify peer certificate|esp-tls-mbedtls: .*[Hh]andshake'
|
||||
severity = "error"
|
||||
title = "Secure connection to the server failed"
|
||||
meaning = "The box reached the server but the encrypted (HTTPS) connection failed. Common causes: wrong clock on the box, or a network that intercepts HTTPS (corporate proxy/firewall)."
|
||||
actions = [
|
||||
"Try another network (e.g. phone hotspot) to rule out a firewall.",
|
||||
"Check the clock finding below: HTTPS needs the correct date.",
|
||||
]
|
||||
explained_by = ["rtc.clock_not_running"]
|
||||
|
||||
[[rule]]
|
||||
id = "wifi.disconnected"
|
||||
pattern = 'WiFi disconnected, retrying in'
|
||||
severity = "error"
|
||||
min_count = 3
|
||||
title = "Box cannot connect to the configured Wi-Fi"
|
||||
meaning = "The Wi-Fi name/password from the SD card does not work: wrong password, network out of range, or not 2.4 GHz."
|
||||
actions = [
|
||||
"Check SSID and PASSWORD in ENV.TXT (re-provision the SD card with the tool).",
|
||||
"Make sure the network is 2.4 GHz and in range.",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "wifi.setup_portal"
|
||||
pattern = 'WiFi provisioning portal ready: connect to "(?P<ssid>[^"]+)"'
|
||||
severity = "warning"
|
||||
title = "Box is in Wi-Fi setup mode (network {ssid})"
|
||||
meaning = "The box is hosting its own Wi-Fi network for configuration instead of connecting to the site Wi-Fi."
|
||||
actions = [
|
||||
"Connect a phone to {ssid} and open http://sensorbox.local/ to enter the Wi-Fi details,",
|
||||
"or short-press the Matter button (< 2 s) to leave setup mode.",
|
||||
]
|
||||
led = "WHITE"
|
||||
|
||||
# ------------------------------------------------------------------- InfluxDB
|
||||
|
||||
[[rule]]
|
||||
id = "influx.buffer_fail"
|
||||
pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer'
|
||||
severity = "error"
|
||||
title = "Measurements cannot be uploaded to InfluxDB"
|
||||
meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple and the box restarts itself after about 2.5 minutes in this state."
|
||||
actions = [
|
||||
"Fix the network problem first (see the finding this is grouped under).",
|
||||
"Buffered data is uploaded automatically once the connection works.",
|
||||
]
|
||||
explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.purple"
|
||||
pattern = 'Failed to write chunk with \d+ retries to Influx|Failed to flush buffer before writing|Failed to append new entry to buffer'
|
||||
severity = "ignore"
|
||||
led = "PURPLE"
|
||||
|
||||
[[rule]]
|
||||
id = "influx.auth"
|
||||
pattern = 'INFLUX: Failed to perform HTTP request: (?P<code>401|403)'
|
||||
severity = "critical"
|
||||
title = "InfluxDB rejected the token (HTTP {code})"
|
||||
meaning = "The box reaches the database but the INFLUX_TOKEN on the SD card is wrong or revoked."
|
||||
actions = [
|
||||
"Re-provision the SD card with the current ENV.TXT (SD card is inside the case).",
|
||||
"If all boxes show this: the shared token was rotated - update ENV.TXT for everyone.",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.not_found"
|
||||
pattern = 'INFLUX: Failed to perform HTTP request: (?P<code>404)'
|
||||
severity = "critical"
|
||||
title = "InfluxDB bucket or organisation not found (HTTP 404)"
|
||||
meaning = "INFLUX_URL on the SD card points to a bucket/org that does not exist."
|
||||
actions = ["Re-provision the SD card with the correct ENV.TXT."]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.server_error"
|
||||
pattern = 'INFLUX: Failed to perform HTTP request: (?P<code>5\d\d)'
|
||||
severity = "warning"
|
||||
title = "InfluxDB server error (HTTP {code})"
|
||||
meaning = "The database server has a problem. The box is fine; data is buffered on the SD card."
|
||||
actions = ["Nothing to fix on the box. Report to the backend team if it persists."]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.bad_request"
|
||||
pattern = 'INFLUX: Bad request, check line protocol format'
|
||||
severity = "warning"
|
||||
title = "InfluxDB refused some measurements (HTTP 400)"
|
||||
meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)."
|
||||
actions = ["Save the session log and send it to the firmware team."]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.env_missing"
|
||||
split_by = "key"
|
||||
pattern = 'Failed to read (?P<key>INFLUX_URL|INFLUX_TOKEN) from \.env file'
|
||||
severity = "critical"
|
||||
title = "ENV.TXT on the SD card is missing {key}"
|
||||
meaning = "The box cannot start without the InfluxDB settings; it shows white and restarts."
|
||||
actions = ["Re-provision the SD card with the tool (SD card is inside the case)."]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.init_fail"
|
||||
pattern = 'MAIN: Failed to initialize Influx buffer'
|
||||
severity = "critical"
|
||||
title = "Data upload could not be initialised"
|
||||
meaning = "Start-up stopped because the InfluxDB settings could not be read from the SD card. The box restarts."
|
||||
actions = ["Re-provision the SD card with the tool (SD card is inside the case)."]
|
||||
explained_by = ["influx.env_missing", "sd.mount_fail", "sd.card_fail", "sd.env_open_fail"]
|
||||
led = "WHITE"
|
||||
|
||||
# ----------------------------------------------------------------- SD card
|
||||
|
||||
[[rule]]
|
||||
id = "sd.card_fail"
|
||||
pattern = 'Failed to initialize the card \((?P<err>[^)]+)\)'
|
||||
severity = "critical"
|
||||
title = "SD card not detected ({err})"
|
||||
meaning = "The SD card does not respond: missing, not seated, or damaged. The box shows white and restarts."
|
||||
actions = [
|
||||
"Open the case and re-seat the SD card.",
|
||||
"Try a freshly provisioned card.",
|
||||
]
|
||||
led = "WHITE"
|
||||
|
||||
[[rule]]
|
||||
id = "sd.mount_fail"
|
||||
pattern = 'Failed to mount filesystem'
|
||||
severity = "critical"
|
||||
title = "SD card is not formatted correctly"
|
||||
meaning = "The card responds but has no readable FAT32 file system."
|
||||
actions = ["Re-provision the card with the tool (FAT32, MBR) - SD card is inside the case."]
|
||||
led = "WHITE"
|
||||
|
||||
[[rule]]
|
||||
id = "sd.env_open_fail"
|
||||
pattern = 'Failed to open \.env file for reading'
|
||||
severity = "critical"
|
||||
title = "ENV.TXT not found on the SD card"
|
||||
meaning = "The card is readable but the configuration file is missing."
|
||||
actions = ["Re-provision the card with the tool (SD card is inside the case)."]
|
||||
|
||||
[[rule]]
|
||||
id = "sd.write_fail"
|
||||
pattern = 'Failed to (create buffer file|open buffer file for appending)'
|
||||
severity = "error"
|
||||
title = "Cannot write to the SD card"
|
||||
meaning = "Offline data cannot be stored: the card is full, write-protected or failing."
|
||||
actions = ["Replace / re-provision the SD card (inside the case)."]
|
||||
|
||||
# --------------------------------------------------------------- RTC / I2C
|
||||
|
||||
[[rule]]
|
||||
id = "rtc.init_failed"
|
||||
pattern = 'MAIN: Failed to initialize RTC: (?P<err>\S+)'
|
||||
severity = "critical"
|
||||
title = "Clock chip (RTC) or I2C bus not responding ({err})"
|
||||
meaning = "The RTC is the first chip the firmware talks to over I2C, so this also appears when the I2C cable between the boards is loose or broken. LED blue, the box restarts."
|
||||
actions = [
|
||||
"Open the case and check the I2C cable/connector between the boards.",
|
||||
"If the cable is fine: the RTC module is faulty.",
|
||||
]
|
||||
led = "BLUE"
|
||||
|
||||
[[rule]]
|
||||
id = "rtc.clock_not_running"
|
||||
pattern = 'RTC is uninitialized, using current time as timestamp'
|
||||
severity = "warning"
|
||||
min_count = 5
|
||||
title = "Clock is not set - sensor readings are on hold"
|
||||
meaning = "The clock chip answers but its clock is not running (it reads 1 Jan 2000; typical after its coin cell was removed or is empty), and there is no Internet time yet. Readings are held until the time is set, so no data is recorded."
|
||||
actions = [
|
||||
"Give the box Internet access: the clock is set automatically on the first connection.",
|
||||
"If this comes back after every power-off: replace the RTC coin cell (inside the case).",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "i2c.errors"
|
||||
pattern = 'I2C (read|write) failed: (?P<err>\S+)|Failed to add I2C device'
|
||||
severity = "error"
|
||||
min_count = 2
|
||||
title = "I2C bus communication errors"
|
||||
meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor."
|
||||
actions = ["Open the case and check the I2C cable/connectors between the boards."]
|
||||
|
||||
# ------------------------------------------------------------------ sensors
|
||||
|
||||
[[rule]]
|
||||
id = "sensor.init_failed"
|
||||
split_by = "sensor"
|
||||
pattern = 'MAIN: Failed to initialize (?P<sensor>BME688|SEN66|AS7341|Microphone|Flicker sensor|DeviceStatus): (?P<err>.+)'
|
||||
severity = "critical"
|
||||
title = "{sensor} failed to start"
|
||||
meaning = "Start-up stopped at the {sensor} sensor ({err}). LED red, the box restarts."
|
||||
actions = [
|
||||
"Open the case and check the {sensor} connector/cable.",
|
||||
"If several sensors fail in turn: check the shared I2C cable.",
|
||||
]
|
||||
led = "RED"
|
||||
|
||||
[[rule]]
|
||||
id = "sensor.selfcheck_failed"
|
||||
split_by = "sensor"
|
||||
pattern = '(?P<sensor>BME688|SEN66|AS7341|Microphone|Flicker sensor) self check failed'
|
||||
severity = "error"
|
||||
title = "{sensor} failed the post-update self-check"
|
||||
meaning = "After a firmware update the {sensor} did not work; the box rolls back to the previous firmware."
|
||||
actions = ["Check the {sensor} connector (inside the case), then power-cycle."]
|
||||
|
||||
[[rule]]
|
||||
id = "sen66.not_ready"
|
||||
pattern = 'SEN66: (Failed while awaiting sensor ready|Self-check failed: Sensor not ready|Failed to read measurement)'
|
||||
severity = "error"
|
||||
title = "Air-quality sensor (SEN66) not delivering data"
|
||||
meaning = "The SEN66 answers but never reports a measurement."
|
||||
actions = ["Check the SEN66 cable (inside the case); the sensor may be faulty."]
|
||||
|
||||
[[rule]]
|
||||
id = "bme688.read_fail"
|
||||
pattern = 'Failed to get (BME688 )?data from BME688|No new data available from BME688'
|
||||
severity = "warning"
|
||||
min_count = 3
|
||||
title = "Temperature/humidity sensor (BME688) read errors"
|
||||
meaning = "The BME688 repeatedly fails to return a reading."
|
||||
actions = ["Check the BME688 connection (inside the case)."]
|
||||
|
||||
[[rule]]
|
||||
id = "as7341.read_fail"
|
||||
pattern = 'Failed to read (spectral|basic counts) data'
|
||||
severity = "warning"
|
||||
min_count = 2
|
||||
title = "Light sensor (AS7341) read errors"
|
||||
meaning = "The AS7341 repeatedly fails to return a reading."
|
||||
actions = ["Check the AS7341 connection (inside the case)."]
|
||||
|
||||
[[rule]]
|
||||
id = "mic.silent"
|
||||
pattern = 'MICROPHONE: .*dB peak: -inf'
|
||||
severity = "warning"
|
||||
min_count = 2
|
||||
title = "Microphone records pure silence"
|
||||
meaning = "Every audio sample is zero. A real room is never perfectly silent, so the microphone or its wiring is likely faulty."
|
||||
actions = [
|
||||
"Make some noise near the box and watch whether this finding keeps growing.",
|
||||
"If it does: check the microphone board/cable (inside the case).",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "mic.read_fail"
|
||||
pattern = 'Failed to read samples'
|
||||
severity = "error"
|
||||
title = "Microphone read error"
|
||||
meaning = "The audio interface returned an error."
|
||||
actions = ["Check the microphone board/cable (inside the case)."]
|
||||
|
||||
[[rule]]
|
||||
id = "gather.stalled"
|
||||
pattern = 'tasks still running, waiting'
|
||||
severity = "warning"
|
||||
min_uptime_ms = 180000
|
||||
min_count = 5
|
||||
title = "Sensor readings are stuck"
|
||||
meaning = "A sensor has not finished its reading for minutes (the first 2 minutes of warm-up are normal)."
|
||||
actions = ["See the grouped cause, or note which sensor stopped logging and check its cable."]
|
||||
explained_by = ["rtc.clock_not_running", "sen66.not_ready"]
|
||||
|
||||
# ---------------------------------------------------------- OTA / firmware
|
||||
|
||||
[[rule]]
|
||||
id = "ota.registration_failed"
|
||||
pattern = 'Device registration failed'
|
||||
severity = "warning"
|
||||
title = "Box is not registered for firmware updates"
|
||||
meaning = "The box could not reach the update server (bin.kota-app.com) to register, so it will not receive new firmware."
|
||||
actions = ["Fix the network problem; registration is retried automatically every few seconds."]
|
||||
explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"]
|
||||
|
||||
[[rule]]
|
||||
id = "ota.check_failed"
|
||||
pattern = 'Failed to check for updates|OTA Update check failed with status code: (?P<code>\d+)'
|
||||
severity = "error"
|
||||
title = "Firmware update check failed"
|
||||
meaning = "The update server could not be reached or refused the request. LED purple."
|
||||
actions = ["Fix the network problem; the check is retried every 15 minutes."]
|
||||
explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected"]
|
||||
led = "PURPLE"
|
||||
|
||||
[[rule]]
|
||||
id = "ota.unauthorized"
|
||||
pattern = 'Unauthorized, clearing saved token'
|
||||
severity = "warning"
|
||||
title = "Update server did not accept the box - it will re-register"
|
||||
meaning = "The saved update token was rejected; the box registers again automatically."
|
||||
actions = ["Nothing to do unless it repeats. Then report the box MAC to the backend team."]
|
||||
|
||||
[[rule]]
|
||||
id = "ota.updating"
|
||||
pattern = 'New update available, starting OTA update'
|
||||
severity = "info"
|
||||
title = "Firmware update is downloading - do not switch off"
|
||||
meaning = "The box found new firmware and is installing it (LED yellow). It restarts when done."
|
||||
actions = ["Wait. Do not switch the power off."]
|
||||
milestone = "ota_update_started"
|
||||
led = "YELLOW"
|
||||
|
||||
[[rule]]
|
||||
id = "ota.update_failed"
|
||||
pattern = 'OTA update failed: (?P<err>\S+)'
|
||||
severity = "error"
|
||||
title = "Firmware update failed ({err})"
|
||||
meaning = "The download or installation was interrupted; the box keeps its current firmware and retries later."
|
||||
actions = ["Check the network is stable; the update is retried automatically."]
|
||||
|
||||
[[rule]]
|
||||
id = "ota.update_ok"
|
||||
pattern = 'OTA update successful, rebooting'
|
||||
severity = "info"
|
||||
title = "Firmware update installed - restarting"
|
||||
meaning = "New firmware was installed. After the restart it runs a self-check (LED orange, ~2.5 min)."
|
||||
actions = ["Wait for green."]
|
||||
milestone = "ota_update_done"
|
||||
|
||||
[[rule]]
|
||||
id = "ota.selfcheck"
|
||||
pattern = 'Device is in selfcheck mode'
|
||||
severity = "info"
|
||||
title = "Post-update self-check running (~2.5 min)"
|
||||
meaning = "The new firmware is testing all sensors before it is accepted (LED orange)."
|
||||
actions = ["Wait. Do not switch off."]
|
||||
led = "ORANGE"
|
||||
|
||||
[[rule]]
|
||||
id = "ota.selfcheck_failed"
|
||||
pattern = 'Failing selfcheck: (?P<reason>.+)'
|
||||
severity = "error"
|
||||
title = "Update self-check failed: {reason}"
|
||||
meaning = "The new firmware rejected itself; the box rolls back to the previous version."
|
||||
actions = ["Check the named sensor (inside the case), then power-cycle."]
|
||||
led = "RED"
|
||||
|
||||
[[rule]]
|
||||
id = "ota.selfcheck_passed"
|
||||
pattern = 'Passing selfcheck'
|
||||
severity = "ignore"
|
||||
milestone = "ota_update_accepted"
|
||||
led = "GREEN"
|
||||
|
||||
[[rule]]
|
||||
id = "ota.reset_during_update"
|
||||
pattern = 'Device was reset during update'
|
||||
severity = "warning"
|
||||
title = "Box was switched off during a firmware update"
|
||||
meaning = "An update was interrupted; the box reports it and retries."
|
||||
actions = ["Leave the box powered while the LED is yellow or orange."]
|
||||
|
||||
# --------------------------------------------------------------- system
|
||||
|
||||
[[rule]]
|
||||
id = "sys.out_of_resources"
|
||||
pattern = 'Failed to create (task for sensor|gather task|main gather task)'
|
||||
severity = "error"
|
||||
title = "Firmware ran out of memory"
|
||||
meaning = "The firmware could not start an internal task (LED red); it restarts itself after 30 s."
|
||||
actions = ["Power-cycle. If it repeats, save the session log for the firmware team."]
|
||||
led = "RED"
|
||||
|
||||
[[rule]]
|
||||
id = "sys.factory_reset"
|
||||
pattern = 'Factory reset button pressed|Factory resetting device'
|
||||
severity = "warning"
|
||||
title = "Factory reset triggered with the Matter button"
|
||||
meaning = "The box forgets its Matter network and must be paired again with the QR sticker."
|
||||
actions = ["Re-commission the box in the Matter app."]
|
||||
led = "WHITE"
|
||||
|
||||
[[rule]]
|
||||
id = "mic.disabled"
|
||||
pattern = 'MICROPHONE: Microphone is in shutdown state'
|
||||
severity = "info"
|
||||
title = "Microphone is switched off - no noise measurements"
|
||||
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: flip the microphone switch (back of the case, above the power switch). No restart needed - it is picked up within about 10 s."]
|
||||
|
||||
[[rule]]
|
||||
id = "gather.empty_reading"
|
||||
pattern = 'SENSOR_GATHERER: Failed to get line protocol from sensor'
|
||||
severity = "warning"
|
||||
min_count = 2
|
||||
title = "A sensor returned no data"
|
||||
meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor."
|
||||
actions = ["Check the log lines just before this message for the sensor name."]
|
||||
explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"]
|
||||
Whitespace-only changes.
@@ -0,0 +1,152 @@
|
||||
"""Official firmware images: validation and layout.
|
||||
|
||||
Only images listed in firmware/firmware.toml with a matching SHA-256 are
|
||||
offered for flashing: boxes normally get new firmware over OTA, so the tool
|
||||
only ever writes the official factory image.
|
||||
|
||||
The factory image is a merged ESP32-S3 image (bootloader at 0x0, partition
|
||||
table at 0x8000, app at ota_0). App-only flashing takes the app out of it
|
||||
and writes it to ota_0; the layout comes from the image's own partition
|
||||
table, not from constants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
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"
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
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
|
||||
idf: str
|
||||
elf_sha: str # hex
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwareImage:
|
||||
path: Path
|
||||
name: str
|
||||
notes: str
|
||||
sha256: str
|
||||
flash_size: str
|
||||
partitions: tuple[Partition, ...]
|
||||
app: AppInfo
|
||||
|
||||
@property
|
||||
def data(self) -> bytes:
|
||||
return self.path.read_bytes()
|
||||
|
||||
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")
|
||||
|
||||
@property
|
||||
def partition_table(self) -> bytes:
|
||||
return self.data[PT_OFFSET:PT_OFFSET + PT_SIZE]
|
||||
|
||||
def app_bytes(self) -> bytes:
|
||||
"""The application image stored in the ota_0 region of the merged image."""
|
||||
# No stripping of trailing 0xFF: the image ends in a SHA-256 digest
|
||||
# that may legitimately end in 0xFF. The merged file ends with the app.
|
||||
ota0 = self.partition("ota_0")
|
||||
return self.data[ota0.offset:ota0.offset + ota0.size]
|
||||
|
||||
def blank_otadata(self) -> bytes:
|
||||
"""Erased otadata: the bootloader then boots ota_0."""
|
||||
return b"\xff" * self.partition("otadata").size
|
||||
|
||||
|
||||
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_info(data: bytes, offset: int) -> AppInfo:
|
||||
if len(data) < offset + 0x20 + 256 or data[offset] != IMAGE_MAGIC:
|
||||
raise ImageError(f"no application image at 0x{offset:x}")
|
||||
d = data[offset + 0x20:offset + 0x20 + 256]
|
||||
if struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC:
|
||||
raise ImageError("application has no ESP-IDF app descriptor")
|
||||
s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace")
|
||||
return 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())
|
||||
|
||||
|
||||
def inspect_image(path: Path, name: str = "", notes: str = "") -> FirmwareImage:
|
||||
data = path.read_bytes()
|
||||
if len(data) < PT_OFFSET + PT_SIZE or data[0] != IMAGE_MAGIC:
|
||||
raise ImageError("not a merged ESP32 image (no bootloader at offset 0)")
|
||||
chip_id = struct.unpack_from("<H", data, 12)[0]
|
||||
if chip_id != ESP32S3_CHIP_ID:
|
||||
raise ImageError(f"image is for chip id {chip_id}, not ESP32-S3")
|
||||
flash_size = FLASH_SIZES.get(data[3] >> 4, "?")
|
||||
parts = parse_partition_table(data[PT_OFFSET:PT_OFFSET + PT_SIZE])
|
||||
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")
|
||||
app = _app_info(data, ota0.offset)
|
||||
if app.project != "kota-sensorboard":
|
||||
raise ImageError(f"image is {app.project!r}, not kota-sensorboard")
|
||||
return FirmwareImage(path, name or path.name, notes, hashlib.sha256(data).hexdigest(),
|
||||
flash_size, parts, app)
|
||||
|
||||
|
||||
def official_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
||||
"""Images from the manifest whose file exists and matches its SHA-256."""
|
||||
if not manifest.exists():
|
||||
return []
|
||||
entries = tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", [])
|
||||
out = []
|
||||
for e in entries:
|
||||
path = manifest.parent / e["file"]
|
||||
if not path.exists():
|
||||
continue
|
||||
img = inspect_image(path, e.get("name", ""), e.get("notes", ""))
|
||||
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 out
|
||||
@@ -0,0 +1,101 @@
|
||||
"""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:
|
||||
plan.steps.append(Step(
|
||||
"write", "Write the complete factory 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
|
||||
@@ -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."
|
||||
@@ -40,7 +40,8 @@ HINTS = [
|
||||
"reboot name the cause.",
|
||||
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
|
||||
"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 "
|
||||
"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. "
|
||||
@@ -55,7 +56,8 @@ STEPS = [
|
||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||
"<b>Connect</b>.",
|
||||
"Press <b>Reset & 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.",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Diagnostics side panel: renders a diag.engine.Report.
|
||||
|
||||
Layout: verdict banner, expected LED, firmware, start-up milestones, a
|
||||
findings tree (root causes; their consequences as children; titles only)
|
||||
and a wrapped detail pane for the selected finding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextBrowser,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, Finding, Report, Severity
|
||||
|
||||
SEV_COLOR = {
|
||||
Severity.CRITICAL: "#b03a2e",
|
||||
Severity.ERROR: "#d9534f",
|
||||
Severity.WARNING: "#c77c02",
|
||||
Severity.INFO: "#2e6da4",
|
||||
}
|
||||
# verdict banner (background, text) by verdict severity; None = healthy
|
||||
BANNER = {
|
||||
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
|
||||
Severity.ERROR: ("#f9dedc", "#8a2019"),
|
||||
Severity.WARNING: ("#fdebc8", "#6b4400"),
|
||||
None: ("#d8f0dd", "#1b5e2a"),
|
||||
}
|
||||
_KEY = Qt.ItemDataRole.UserRole
|
||||
|
||||
|
||||
def _heading(text: str) -> QLabel:
|
||||
lbl = QLabel(text)
|
||||
f = lbl.font()
|
||||
f.setBold(True)
|
||||
lbl.setFont(f)
|
||||
return lbl
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class DiagnosticsPanel(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setMinimumWidth(380)
|
||||
|
||||
self.verdict = QLabel(wordWrap=True)
|
||||
self.verdict.setMargin(10)
|
||||
vf = self.verdict.font()
|
||||
vf.setPointSize(vf.pointSize() + 2)
|
||||
vf.setBold(True)
|
||||
self.verdict.setFont(vf)
|
||||
|
||||
self.led_swatch = QLabel()
|
||||
self.led_swatch.setFixedSize(22, 22)
|
||||
self.led_text = QLabel(wordWrap=True)
|
||||
led_row = QHBoxLayout()
|
||||
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
|
||||
led_row.addWidget(self.led_text, 1)
|
||||
|
||||
self.firmware = QLabel(wordWrap=True)
|
||||
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
|
||||
self.milestones = QListWidget()
|
||||
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setIndentation(16)
|
||||
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||
self.detail = QTextBrowser()
|
||||
self.detail.setOpenExternalLinks(False)
|
||||
findings = QSplitter(Qt.Orientation.Vertical)
|
||||
findings.addWidget(self.tree)
|
||||
findings.addWidget(self.detail)
|
||||
findings.setSizes([220, 260])
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.verdict)
|
||||
lay.addWidget(_heading("Status LED should be"))
|
||||
lay.addLayout(led_row)
|
||||
lay.addWidget(_heading("Firmware"))
|
||||
lay.addWidget(self.firmware)
|
||||
lay.addWidget(_heading("Start-up progress (current boot)"))
|
||||
lay.addWidget(self.milestones)
|
||||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||||
lay.addWidget(findings, 1)
|
||||
|
||||
self._findings: dict[str, Finding] = {}
|
||||
self._selected: str | None = None
|
||||
# key -> expanded, chosen by the user; survives the rebuild on refresh.
|
||||
self._expanded: dict[str, bool] = {}
|
||||
self.tree.currentItemChanged.connect(self._on_select)
|
||||
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
|
||||
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def show_report(self, r: Report) -> None:
|
||||
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
||||
if r.lines == 0:
|
||||
bg, fg = "#e6e6e6", "#333333"
|
||||
self.verdict.setText(r.verdict)
|
||||
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
||||
|
||||
name, colour = LED[r.led]
|
||||
self.led_swatch.setStyleSheet(
|
||||
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
||||
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
|
||||
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
||||
self.led_text.setText(f"<b>{name}</b>{ask}{reason}")
|
||||
|
||||
fw = r.firmware
|
||||
sd = {True: "reads Wi-Fi settings from the SD card",
|
||||
False: "does not read Wi-Fi settings from the SD card",
|
||||
None: "Wi-Fi-from-SD support unknown"}
|
||||
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
||||
|
||||
self.milestones.clear()
|
||||
for _, label, t in r.milestones:
|
||||
done = t is not None
|
||||
it = QListWidgetItem(("✔ " if done else "○ ") + label
|
||||
+ (f" ({t / 1000:.1f} s)" if done else ""))
|
||||
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
|
||||
self.milestones.addItem(it)
|
||||
rows = min(max(self.milestones.count(), 1), 15)
|
||||
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
|
||||
|
||||
self._fill_tree(r)
|
||||
|
||||
def _fill_tree(self, r: Report) -> None:
|
||||
self.tree.blockSignals(True)
|
||||
self.tree.clear()
|
||||
self._findings = {}
|
||||
if not r.roots:
|
||||
it = QTreeWidgetItem(["No problems detected in the log so far."])
|
||||
it.setForeground(0, QBrush(QColor("#1b7a33")))
|
||||
self.tree.addTopLevelItem(it)
|
||||
for rc in r.roots:
|
||||
top = self._item(rc.finding, bold=True)
|
||||
self.tree.addTopLevelItem(top)
|
||||
for c in rc.consequences:
|
||||
top.addChild(self._item(c, prefix="↳ "))
|
||||
top.setExpanded(self._expanded.get(rc.finding.key, True))
|
||||
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
|
||||
("Resolved during this session", "grp:resolved", r.resolved)):
|
||||
if not items:
|
||||
continue
|
||||
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
|
||||
grp.setData(0, _KEY, key)
|
||||
grp.setForeground(0, QBrush(QColor("#666666")))
|
||||
self.tree.addTopLevelItem(grp)
|
||||
for f in items:
|
||||
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
|
||||
grp.setExpanded(self._expanded.get(key, False))
|
||||
# Keep the selection across refreshes; default to the top root cause.
|
||||
target = self._selected if self._selected in self._findings else (
|
||||
r.roots[0].finding.key if r.roots else None)
|
||||
self.tree.blockSignals(False)
|
||||
self._select(target)
|
||||
|
||||
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
|
||||
if key := item.data(0, _KEY):
|
||||
self._expanded[key] = expanded
|
||||
|
||||
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
|
||||
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
|
||||
it.setData(0, _KEY, f.key)
|
||||
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
|
||||
it.setToolTip(0, f.title)
|
||||
if bold:
|
||||
font = it.font(0)
|
||||
font.setBold(True)
|
||||
it.setFont(0, font)
|
||||
self._findings[f.key] = f
|
||||
return it
|
||||
|
||||
def _select(self, key: str | None) -> None:
|
||||
if key is None:
|
||||
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
|
||||
return
|
||||
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
|
||||
for it in match:
|
||||
if it.data(0, _KEY) == key:
|
||||
self.tree.setCurrentItem(it)
|
||||
return
|
||||
|
||||
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
|
||||
key = cur.data(0, _KEY) if cur else None
|
||||
f = self._findings.get(key or "")
|
||||
if f is None:
|
||||
return
|
||||
self._selected = key
|
||||
self.detail.setHtml(_detail_html(f))
|
||||
|
||||
|
||||
def _detail_html(f: Finding) -> str:
|
||||
colour = SEV_COLOR.get(f.severity, "#333")
|
||||
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
||||
seen = f"Seen {f.count}x"
|
||||
if f.first_uptime_ms is not None:
|
||||
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
|
||||
if f.resolved and f.resolved_by:
|
||||
seen += f" - resolved ({_esc(f.resolved_by)})"
|
||||
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
||||
return (
|
||||
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
||||
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
||||
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
||||
+ f"<p style='color:#666'>{seen}</p>"
|
||||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||
if evidence else "")
|
||||
)
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Flash firmware tab: official image only, 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,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..core.session import SESSIONS_DIR
|
||||
from ..flash.image import FirmwareImage, ImageError, official_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 (official images only)")
|
||||
self.image_combo = QComboBox(minimumWidth=360)
|
||||
self.image_info = QLabel(wordWrap=True)
|
||||
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
fl = QVBoxLayout(fwbox)
|
||||
fl.addWidget(self.image_combo)
|
||||
fl.addWidget(self.image_info)
|
||||
|
||||
# 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) -> None:
|
||||
try:
|
||||
self._images = official_images()
|
||||
except ImageError as exc:
|
||||
self._images = []
|
||||
self.image_info.setText(f"<span style='color:#b03a2e'><b>Firmware file problem:</b> {exc}</span>")
|
||||
self.image_combo.clear()
|
||||
for img in self._images:
|
||||
self.image_combo.addItem(img.name)
|
||||
if not self._images:
|
||||
self.image_combo.addItem("No official firmware image found in the firmware folder")
|
||||
|
||||
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()
|
||||
if img:
|
||||
a = img.app
|
||||
self.image_info.setText(
|
||||
f"{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}<br>"
|
||||
f"<small>build {a.elf_sha[:9]} · file SHA-256 {img.sha256[:12]}... verified</small><br>"
|
||||
f"<small>{img.notes}</small>")
|
||||
fw = self.serial_tab.engine.firmware
|
||||
if fw.elf_sha and img:
|
||||
same = fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha)
|
||||
msg = f"Box currently runs: {fw.describe()}"
|
||||
if same:
|
||||
msg += "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
|
||||
else:
|
||||
msg += ("<br><b style='color:#c77c02'>The box runs a different build (probably updated over OTA). "
|
||||
"Flashing puts the older factory image back; the box updates itself again once online.</b>")
|
||||
else:
|
||||
msg = "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
|
||||
self.box_fw.setText(msg)
|
||||
|
||||
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.")
|
||||
|
||||
# ------------------------------------------------------------ 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.rb_full, self.backup, self.confirm_full):
|
||||
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)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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
|
||||
|
||||
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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class GuidePanel(QWidget):
|
||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.runner, self.engine = runner, engine
|
||||
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)
|
||||
for w in (self.guide_title, self.step_text):
|
||||
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))
|
||||
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 ""))
|
||||
+130
-6
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QTextCharFormat, QTextCursor
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -11,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
@@ -18,11 +21,17 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .. import __version__
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from ..core.logfile import read_log_file
|
||||
from ..core.logline import LineKind, LogLine, host_event
|
||||
from ..core.session import SessionLog
|
||||
from ..diag.engine import DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner
|
||||
from ..serialio.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .flash_tab import FlashTab
|
||||
from .guide_panel import GuidePanel
|
||||
|
||||
MAX_LINES = 20000
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
@@ -55,6 +64,9 @@ class SerialTab(QWidget):
|
||||
self._session = SessionLog()
|
||||
self._ports: list[PortInfo] = []
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine = DiagnosticsEngine()
|
||||
self._diag_dirty = True
|
||||
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
|
||||
|
||||
# --- controls
|
||||
self.port_combo = QComboBox(minimumWidth=320)
|
||||
@@ -68,6 +80,10 @@ class SerialTab(QWidget):
|
||||
self.clear_btn = QPushButton("Clear view")
|
||||
self.save_btn = QPushButton("Save view as...")
|
||||
self.folder_btn = QPushButton("Open logs folder")
|
||||
self.analyze_btn = QPushButton("Analyze log file...")
|
||||
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
|
||||
self.copy_btn = QPushButton("Copy report")
|
||||
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
||||
@@ -75,7 +91,8 @@ class SerialTab(QWidget):
|
||||
row1.addWidget(w)
|
||||
row1.addStretch(1)
|
||||
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):
|
||||
row2.addWidget(w)
|
||||
row2.addStretch(1)
|
||||
|
||||
@@ -88,11 +105,23 @@ class SerialTab(QWidget):
|
||||
self.view.setFont(mono)
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.diag = DiagnosticsPanel()
|
||||
self.guide = GuidePanel(self._runner, self._engine)
|
||||
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.addWidget(self.view)
|
||||
split.addWidget(self.side_tabs)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 2)
|
||||
split.setSizes([700, 480])
|
||||
|
||||
self.status = QLabel()
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(row1)
|
||||
layout.addLayout(row2)
|
||||
layout.addWidget(self.view, 1)
|
||||
layout.addWidget(split, 1)
|
||||
layout.addWidget(self.status)
|
||||
|
||||
# --- wiring
|
||||
@@ -103,6 +132,13 @@ class SerialTab(QWidget):
|
||||
self.clear_btn.clicked.connect(self.view.clear)
|
||||
self.save_btn.clicked.connect(self._save_view)
|
||||
self.folder_btn.clicked.connect(self._open_folder)
|
||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
||||
self.copy_btn.clicked.connect(self._copy_report)
|
||||
|
||||
# Report refresh is throttled; lines can arrive at >100/s.
|
||||
self._diag_timer = QTimer(self, interval=400)
|
||||
self._diag_timer.timeout.connect(self._refresh_diag)
|
||||
self._diag_timer.start()
|
||||
|
||||
# Auto-detect a box being plugged in while idle.
|
||||
self._poll = QTimer(self, interval=2000)
|
||||
@@ -145,19 +181,56 @@ class SerialTab(QWidget):
|
||||
port = self._selected_port()
|
||||
if port is None:
|
||||
return
|
||||
self._connect(port)
|
||||
|
||||
def _connect(self, port: PortInfo) -> None:
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._manager.connect(port)
|
||||
|
||||
# ---- 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._manager.disconnect() # joins the reader thread
|
||||
QCoreApplication.processEvents() # deliver its queued final lines/state now
|
||||
|
||||
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:
|
||||
running = state != State.DISCONNECTED
|
||||
if not running:
|
||||
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:
|
||||
self._session.write_report(self._engine.report().to_text())
|
||||
self._session.stop()
|
||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||
self.port_combo.setEnabled(not running)
|
||||
self.all_ports.setEnabled(not running)
|
||||
self.refresh_btn.setEnabled(not running)
|
||||
# Offline analysis would mix with the live session; disconnect first.
|
||||
self.analyze_btn.setEnabled(not running)
|
||||
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
|
||||
self._update_status()
|
||||
|
||||
@@ -172,6 +245,9 @@ class SerialTab(QWidget):
|
||||
# ---- lines -------------------------------------------------------
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
self._session.write(line)
|
||||
self._engine.feed(line)
|
||||
self._runner.feed(line)
|
||||
self._diag_dirty = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
@@ -200,6 +276,52 @@ class SerialTab(QWidget):
|
||||
if at_bottom:
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
# ---- diagnostics -------------------------------------------------
|
||||
def _refresh_diag(self) -> None:
|
||||
if self._diag_dirty:
|
||||
self._diag_dirty = False
|
||||
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:
|
||||
QGuiApplication.clipboard().setText(self._engine.report().to_text())
|
||||
|
||||
def _analyze_file(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)")
|
||||
if not path:
|
||||
return
|
||||
lines = read_log_file(Path(path))
|
||||
self.view.clear()
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
for line in lines:
|
||||
self._engine.feed(line)
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
self._counts[line.level] += 1
|
||||
self._append(line)
|
||||
self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)"
|
||||
self._update_status()
|
||||
self._diag_dirty = True
|
||||
self._refresh_diag()
|
||||
|
||||
# ---- misc --------------------------------------------------------
|
||||
def _save_view(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
|
||||
@@ -225,8 +347,10 @@ class MainWindow(QMainWindow):
|
||||
guide = QTextBrowser(openExternalLinks=True)
|
||||
guide.setHtml(cheatsheet_html())
|
||||
self.serial_tab = SerialTab()
|
||||
self.flash_tab = FlashTab(self.serial_tab)
|
||||
tabs.addTab(guide, "Quick start")
|
||||
tabs.addTab(self.serial_tab, "Serial monitor")
|
||||
tabs.addTab(self.flash_tab, "Flash firmware")
|
||||
self.setCentralWidget(tabs)
|
||||
|
||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
[project]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = [
|
||||
"pyserial>=3.5",
|
||||
"PySide6-Essentials>=6.7",
|
||||
"esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
# Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.1"
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.4"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
rem Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.1"
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.4"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||
|
||||
where uv >nul 2>nul
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
W (19497) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (19820) SEN66: Sent start measurement command, waiting for sensor to be ready...
|
||||
W (20498) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (21499) BME688: RTC is uninitialized, using current time as timestamp
|
||||
E (21821) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0
|
||||
E (21823) esp-tls: Failed to open new connection
|
||||
E (21824) transport_base: Failed to open a new connection
|
||||
E (21835) HTTP_CLIENT: Connection failed, sock < 0
|
||||
I (21836) INFLUX: HTTP request performed, status code: 0
|
||||
E (21847) INFLUX: Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT
|
||||
E (21849) INFLUX_BUFFER: Failed to write chunk to Influx: ESP_FAIL
|
||||
W (22500) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (23501) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (23860) INFLUX: Writing to InfluxDB
|
||||
ESP-ROM:esp32s3-20210327
|
||||
Build:Mar 27 2021
|
||||
rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (SPI_FAST_FLASH_BOOT)
|
||||
Saved PC:0x4037f34a
|
||||
SPIWP:0xee
|
||||
mode:DIO, clock div:1
|
||||
load:0x3fce2820,len:0x14f0
|
||||
load:0x403c8700,len:0xd24
|
||||
load:0x403cb700,len:0x2f48
|
||||
entry 0x403c8924
|
||||
I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader
|
||||
I (24) boot: compile time Jun 29 2026 11:03:24
|
||||
I (25) boot: Multicore bootloader
|
||||
I (25) boot: chip revision: v0.2
|
||||
I (28) boot: efuse block revision: v1.4
|
||||
I (32) boot.esp32s3: Boot SPI Speed : 80MHz
|
||||
I (36) boot.esp32s3: SPI Mode : DIO
|
||||
I (39) boot.esp32s3: SPI Flash Size : 8MB
|
||||
I (43) boot: Enabling RNG early entropy source...
|
||||
I (48) boot: Partition Table:
|
||||
I (50) boot: ## Label Usage Type ST Offset Length
|
||||
I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000
|
||||
I (63) boot: 1 nvs WiFi data 01 02 00010000 00020000
|
||||
I (70) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000
|
||||
I (76) boot: 3 otadata OTA data 01 00 00031000 00002000
|
||||
I (83) boot: 4 phy_init RF data 01 01 00033000 00001000
|
||||
I (89) boot: 5 ota_0 OTA app 00 10 00040000 00300000
|
||||
I (96) boot: 6 ota_1 OTA app 00 11 00340000 00300000
|
||||
I (102) boot: 7 fctry WiFi data 01 02 00640000 00006000
|
||||
I (109) boot: End of partition table
|
||||
I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map
|
||||
I (191) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load
|
||||
I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load
|
||||
I (203) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map
|
||||
I (469) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load
|
||||
I (485) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load
|
||||
I (495) boot: Loaded app from partition at offset 0x40000
|
||||
I (495) boot: Disabling RNG early entropy source...
|
||||
I (506) octal_psram: vendor id : 0x0d (AP)
|
||||
I (506) octal_psram: dev id : 0x02 (generation 3)
|
||||
I (506) octal_psram: density : 0x03 (64 Mbit)
|
||||
I (508) octal_psram: good-die : 0x01 (Pass)
|
||||
I (512) octal_psram: Latency : 0x01 (Fixed)
|
||||
I (516) octal_psram: VCC : 0x01 (3V)
|
||||
I (520) octal_psram: SRF : 0x01 (Fast Refresh)
|
||||
I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap)
|
||||
I (530) octal_psram: BurstLen : 0x01 (32 Byte)
|
||||
I (535) octal_psram: Readlatency : 0x02 (10 cycles@Fixed)
|
||||
I (540) octal_psram: DriveStrength: 0x00 (1/1)
|
||||
I (544) esp_psram: Found 8MB PSRAM device
|
||||
I (548) esp_psram: Speed: 40MHz
|
||||
I (551) cpu_start: Multicore app
|
||||
I (1285) esp_psram: SPI SRAM memory test OK
|
||||
I (1293) cpu_start: GPIO 44 and 43 are used as console UART I/O pins
|
||||
I (1294) cpu_start: Pro cpu start user code
|
||||
I (1294) cpu_start: cpu freq: 160000000 Hz
|
||||
I (1296) app_init: Application information:
|
||||
I (1299) app_init: Project name: kota-sensorboard
|
||||
I (1304) app_init: App version: 1.0
|
||||
I (1308) app_init: Compile time: Jun 29 2026 11:03:32
|
||||
I (1313) app_init: ELF file SHA256: 45fa9d5b9...
|
||||
I (1317) app_init: ESP-IDF: v5.5.3-dirty
|
||||
I (1322) efuse_init: Min chip rev: v0.0
|
||||
I (1326) efuse_init: Max chip rev: v0.99
|
||||
I (1330) efuse_init: Chip rev: v0.2
|
||||
I (1334) heap_init: Initializing. RAM available for dynamic allocation:
|
||||
I (1340) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM
|
||||
I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM
|
||||
I (1351) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM
|
||||
I (1356) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM
|
||||
I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator
|
||||
I (1369) spi_flash: detected chip: generic
|
||||
I (1372) spi_flash: flash io: dio
|
||||
I (1377) sleep_gpio: Configure to isolate all GPIO pins in sleep state
|
||||
I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration
|
||||
I (1397) main_task: Started on CPU0
|
||||
I (1400) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations
|
||||
I (1408) main_task: Calling app_main()
|
||||
E (1411) ID: Device MAC address: A4:CB:8F:C9:46:F8
|
||||
I (1459) OTA_CLIENT: No OTA token found in NVS, need to register device
|
||||
I (1463) MAIN: Matter node created successfully
|
||||
W (1464) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1468) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1476) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1485) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1494) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1503) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1512) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1520) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1529) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1538) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1547) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1555) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1564) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1573) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1582) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1590) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1599) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1608) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
I (1619) pp: pp rom version: e7ae62f
|
||||
I (1620) net80211: net80211 rom version: e7ae62f
|
||||
I (1626) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0
|
||||
I (1635) wifi:wifi firmware version: 4df78f2
|
||||
I (1635) wifi:wifi certification version: v7.0
|
||||
I (1638) wifi:config NVS flash: enabled
|
||||
I (1642) wifi:config nano formatting: disabled
|
||||
I (1646) wifi:Init data frame dynamic rx buffer num: 32
|
||||
I (1651) wifi:Init static rx mgmt buffer num: 5
|
||||
I (1655) wifi:Init management short buffer num: 32
|
||||
I (1660) wifi:Init dynamic tx buffer num: 32
|
||||
I (1664) wifi:Init static tx FG buffer num: 2
|
||||
I (1668) wifi:Init static rx buffer size: 1600
|
||||
I (1672) wifi:Init static rx buffer num: 10
|
||||
I (1676) wifi:Init dynamic rx buffer num: 32
|
||||
I (1681) wifi_init: rx ba win: 6
|
||||
I (1683) wifi_init: accept mbox: 6
|
||||
I (1686) wifi_init: tcpip mbox: 32
|
||||
I (1689) wifi_init: udp mbox: 6
|
||||
I (1692) wifi_init: tcp mbox: 6
|
||||
I (1695) wifi_init: tcp tx win: 5760
|
||||
I (1698) wifi_init: tcp rx win: 5760
|
||||
I (1701) wifi_init: tcp mss: 1440
|
||||
I (1712) chip[DL]: NVS set: chip-counters/reboot-count = 11 (0xB)
|
||||
I (1715) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC)
|
||||
I (1718) BLE_INIT: BT controller compile version [1bb2f50]
|
||||
I (1722) BLE_INIT: Using main XTAL as clock source
|
||||
I (1727) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1
|
||||
I (1736) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa
|
||||
I (1741) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10
|
||||
I (1784) phy_init: Saving new calibration data due to checksum failure or outdated calibration data, mode(0)
|
||||
I (1801) NimBLE: GAP procedure initiated: stop advertising.
|
||||
|
||||
I (1802) CHIP[DL]: BLE host-controller synced
|
||||
I (2303) chip[DL]: Starting ESP WiFi layer
|
||||
I (2305) wifi:mode : sta (a4:cb:8f:c9:46:f8)
|
||||
I (2306) wifi:enable tsf
|
||||
W (2306) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2307) chip[DL]: I (2307) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43
|
||||
Done driving station state, nothing else to do...
|
||||
I (2316) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2
|
||||
W (2320) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2331) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (2337) chip[SVR]: Initializing subscription resumption storage...
|
||||
I (2344) chip[SVR]: Server initializing...
|
||||
I (2347) chip[TS]: Last Known Good Time: 2026-08-04T05:56:06
|
||||
I (2352) chip[DMG]: AccessControl: initializing
|
||||
I (2356) chip[DMG]: Examples::AccessControlDelegate::Init
|
||||
I (2361) chip[DMG]: AccessControl: setting
|
||||
I (2365) chip[DMG]: DefaultAclStorage: initializing
|
||||
I (2370) chip[DMG]: DefaultAclStorage: 0 entries loaded
|
||||
I (2375) chip[SVR]: WARNING: mTestEventTriggerDelegate is null
|
||||
I (2392) chip[ZCL]: Using ZAP configuration...
|
||||
I (2393) esp_matter_cluster: Cluster plugin init common callback
|
||||
I (2393) chip[DMG]: AccessControlCluster: initializing
|
||||
I (2396) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported
|
||||
I (2406) chip[DIS]: Updating services using commissioning mode 1
|
||||
I (2410) chip[DIS]: CHIP minimal mDNS started advertising.
|
||||
I (2415) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
|
||||
I (2425) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: F90440ED10E7BA33.
|
||||
I (2435) chip[DIS]: mDNS service published: _matterc._udp
|
||||
I (2440) chip[IN]: CASE Server enabling CASE session setups
|
||||
I (2446) chip[SVR]: Joining Multicast groups
|
||||
I (2449) chip[SVR]: Server Listening...
|
||||
I (2454) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 **********
|
||||
I (2464) data_model: Dynamic endpoint 0 added
|
||||
E (2467) data_model: Cluster cannot be NULL.
|
||||
I (2472) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 **********
|
||||
I (2482) data_model: Dynamic endpoint 1 added
|
||||
E (2486) data_model: Cluster cannot be NULL.
|
||||
I (2491) chip[DL]: WIFI_EVENT_STA_START
|
||||
I (2492) MAIN: ESP-Matter started successfully
|
||||
W (2494) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2503) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (2510) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable)
|
||||
I (2520) NimBLE: GAP procedure initiated: advertise;
|
||||
I (2521) NimBLE: disc_mode=2
|
||||
I (2521) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40
|
||||
I (2526) NimBLE:
|
||||
|
||||
I (2527) chip[DL]: CHIPoBLE advertising started
|
||||
I (2528) MAIN: Commissioning window opened
|
||||
>
|
||||
I (4074) I2C: Creating I2C mutex
|
||||
I (14075) SPI: Creating SPI mutex
|
||||
I (14117) INFLUX_BUFFER: SD card mounted successfully
|
||||
I (14118) INFLUX_BUFFER: /sdcard exists, SD card initialized successfully
|
||||
I (14121) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
W (14135) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
I (14159) BME688: Delaying for 10000 microseconds
|
||||
I (14777) Flicker: Flicker sensor initialized on pin 14
|
||||
I (14779) temperature_sensor: Range [20°C ~ 100°C], error < 2°C
|
||||
I (14781) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data
|
||||
I (14781) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data
|
||||
W (14786) wifi:I (14782) SEN66: Creating sensor mutex
|
||||
Haven't to connect to a suitable AP now!I (14797) SEN66: Waking up sensor by setting power GPIO to enabled level...
|
||||
|
||||
I (14788) INFLUX_BUFFER: Flushing chunk starting at line 10:
|
||||
I (14795) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms
|
||||
W (14783) MICROPHONE: Microphone is in shutdown state, skipping sample collection
|
||||
I (14823) Flicker: Starting to collect samples in gather task
|
||||
I (14811) DeviceStatus: Active tasks: 26
|
||||
I (14811) INFLUX: Writing to InfluxDB
|
||||
E (14835) SENSOR_GATHERER: Failed to get line protocol from sensor
|
||||
I (14841) DeviceStatus: Task list:
|
||||
gather_sensor_t X 1 1216 22
|
||||
main R 1 876 4
|
||||
IDLE1 R 0 700 6
|
||||
IDLE0 R 0 692 5
|
||||
gather_task B 1 1756 19
|
||||
gather_task B 1 14032 20
|
||||
alive_diode_tas B 5 352 8
|
||||
reset_task B 0 3432 15
|
||||
tiT B 18 2420 10
|
||||
gather_sensor_t B 23 1720 24
|
||||
ota_task B 1 7516 18
|
||||
CHIP B 1 3700 12
|
||||
Tmr Svc B 1 1364 7
|
||||
ds1307_update B 1 800 17
|
||||
sys_evt B 20 2684 9
|
||||
ipc0 S 24 372 1
|
||||
ipc1 S 24 524 2
|
||||
console B 5 648 16
|
||||
gather_sensor_t B 1 2332 23
|
||||
esp_timer S 22 3188 3
|
||||
gather_sensor_t B 1 2324 25
|
||||
wifi B 23 4324 11
|
||||
gather_sensor_t S 1 1344 26
|
||||
btController B 23 1960 13
|
||||
gather_sensor_t B 1 1496 21
|
||||
flicker_gather_ S 24 1948 27
|
||||
nimble_host B 21 1480 14
|
||||
|
||||
I (14853) SENSOR_GATHERER: Sensor should now be asleep
|
||||
W (14929) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (15861) Flicker: Finished collecting samples, notifying main task
|
||||
I (15863) Flicker: Collected 1024 samples for dominant frequency analysis
|
||||
W (15870) AS7341: Reading spectral data from AS7341
|
||||
I (15870) SENSOR_GATHERER: Sensor should now be asleep
|
||||
I (15880) BME688: Delaying for 199517 microseconds
|
||||
W (15941) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (16178) AS7341: Reading basic counts data from AS7341
|
||||
W (16194) AS7341: Reading flicker detection state from AS7341
|
||||
W (16412) as7341: FD Status Register: 0x20 (0b00100000)
|
||||
I (16418) AS7341: Updating Matter attributes with AS7341 data: 18108
|
||||
I (16419) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 **********
|
||||
I (16432) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 18108 **********
|
||||
E (16445) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
E (16447) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
W (16459) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
I (16460) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null **********
|
||||
I (16473) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1013 **********
|
||||
E (16486) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
E (16498) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
W (16499) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (16942) SENSOR_GATHERER: Sensor should now be asleep
|
||||
I (17283) SENSOR_GATHERER: Gathered line protocol:
|
||||
device_status,device=A4CB8FC946F8 battery=4.04,last_restart=13421,status="OK",temp=35.40,rssi=0,wifi_status="disconnected",free_heap=54927,psram_free=8305944,heap_blocks=253768,psram_blocks=79976,active_tasks=26 946684801000000000
|
||||
|
||||
W (17461) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (17511) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (18462) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (18512) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (19463) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (19513) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (19809) SEN66: Sent start measurement command, waiting for sensor to be ready...
|
||||
W (20464) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (20514) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (21465) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (21515) BME688: RTC is uninitialized, using current time as timestamp
|
||||
E (21843) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0
|
||||
E (21845) esp-tls: Failed to open new connection
|
||||
E (21846) transport_base: Failed to open a new connection
|
||||
E (21857) HTTP_CLIENT: Connection failed, sock < 0
|
||||
I (21858) INFLUX: HTTP request performed, status code: 0
|
||||
E (21869) INFLUX: Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT
|
||||
E (21871) INFLUX_BUFFER: Failed to write chunk to Influx: ESP_FAIL
|
||||
W (22466) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (22516) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (23467) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (23517) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (23882) INFLUX: Writing to InfluxDB
|
||||
W (24468) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (24518) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (24781) OTA_CLIENT: Device is not registered, attempting to register...
|
||||
I (24781) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data
|
||||
W (24785) wifi:W (24781) MICROPHONE: Microphone is in shutdown state, skipping sample collection
|
||||
Haven't to connect to a suitable AP now!
|
||||
E (24798) SENSOR_GATHERER: Failed to get line protocol from sensor
|
||||
I (24784) OTA_CLIENT: HTTP client handle: 0x3fcf1b34
|
||||
I (24799) DeviceStatus: Active tasks: 26
|
||||
I (24815) DeviceStatus: Task list:
|
||||
gather_sensor_t R 1 1496 21
|
||||
gather_sensor_t X 1 1136 22
|
||||
IDLE1 R 0 700 6
|
||||
IDLE0 R 0 692 5
|
||||
gather_task B 1 1756 19
|
||||
gather_task B 1 14032 20
|
||||
ds1307_update B 1 800 17
|
||||
alive_diode_tas B 5 352 8
|
||||
gather_sensor_t B 1 532 25
|
||||
gather_sensor_t B 1 748 23
|
||||
reset_task B 0 3432 15
|
||||
CHIP B 1 3700 12
|
||||
gather_sensor_t B 23 1720 24
|
||||
Tmr Svc B 1 1364 7
|
||||
tiT B 18 2420 10
|
||||
console B 5 648 16
|
||||
ipc0 S 1 372 1
|
||||
gather_sensor_t S 1 1344 26
|
||||
sys_evt B 20 2684 9
|
||||
main B 1 876 4
|
||||
esp_timer S 22 3188 3
|
||||
btController B 23 1960 13
|
||||
wifi B 23 4324 11
|
||||
ota_task B 1 5356 18
|
||||
nimble_host B 21 1480 14
|
||||
ipc1 S 24 524 2
|
||||
|
||||
W (24890) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Diagnostics engine: real captures (fixtures) + synthetic scenarios built
|
||||
from the firmware's exact log strings for failures not captured yet."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
from kota_tech.core.logline import LineKind, LogLine, parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine, Severity, load_rules
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def run(lines, engine=None):
|
||||
e = engine or DiagnosticsEngine()
|
||||
t = 0.0
|
||||
for item in lines:
|
||||
t += 0.01
|
||||
if isinstance(item, LogLine):
|
||||
e.feed(item)
|
||||
else:
|
||||
e.feed(parse_line(item, host_time=t))
|
||||
return e, e.report()
|
||||
|
||||
|
||||
def replay(name):
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(FIX / name):
|
||||
e.feed(ln)
|
||||
return e.report()
|
||||
|
||||
|
||||
def roots(report):
|
||||
return {rc.finding.rule_id: rc for rc in report.roots}
|
||||
|
||||
|
||||
BOOT = ["ESP-ROM:esp32s3-20210327"]
|
||||
APP = ["I (1299) app_init: Project name: kota-sensorboard",
|
||||
"I (1317) app_init: ELF file SHA256: 45fa9d5b9..."]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ rules
|
||||
|
||||
def test_rules_load_and_have_no_cycles():
|
||||
rules = load_rules()
|
||||
by_id = {r.id: r for r in rules}
|
||||
for r in rules:
|
||||
seen, todo = set(), list(r.explained_by)
|
||||
while todo:
|
||||
rid = todo.pop()
|
||||
assert rid != r.id, f"explained_by cycle through {r.id}"
|
||||
if rid not in seen:
|
||||
seen.add(rid)
|
||||
todo += by_id[rid].explained_by
|
||||
|
||||
|
||||
# --------------------------------------------------------- real captures
|
||||
|
||||
def test_live_bench_box_uncommissioned():
|
||||
r = replay("live_boot_A4CB8FC946F8_fw1.0.txt")
|
||||
rs = roots(r)
|
||||
assert r.firmware.known and "new_system.bin" in r.firmware.describe()
|
||||
assert r.firmware.supports_sd_wifi is False
|
||||
assert r.led == "PURPLE" # confirmed on the bench
|
||||
assert "Wi-Fi" in r.verdict and r.verdict.startswith("Problem")
|
||||
grouped = {c.rule_id for c in rs["matter.not_commissioned"].consequences}
|
||||
assert {"net.dns_fail", "influx.buffer_fail", "ota.registration_failed"} <= grouped
|
||||
assert "rtc.clock_not_running" in rs
|
||||
assert "mic.disabled" in rs
|
||||
reached = {m for m, _, t in r.milestones if t is not None}
|
||||
assert {"rom_boot", "app_started", "sd_mounted", "env_loaded", "sensors_ready"} <= reached
|
||||
assert "wifi_connected" not in reached
|
||||
|
||||
|
||||
def test_live_bench_box_led_still_green_at_30s():
|
||||
# Purple only starts when the first Influx flush fails (~60 s uptime).
|
||||
assert replay("live_reset_A4CB8FC946F8_fw1.0_restored.txt").led == "GREEN"
|
||||
|
||||
|
||||
def test_connected_box_is_healthy():
|
||||
r = replay("serial_log_20260917_162922.txt")
|
||||
assert r.verdict_severity is None
|
||||
assert "time_synced" in {m for m, _, t in r.milestones if t is not None}
|
||||
assert r.mid_run
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["serial_log_20260917_162714.txt", "kota_serial_log_20260917_163439.txt"])
|
||||
def test_offline_sample_boxes(name):
|
||||
rs = roots(replay(name))
|
||||
assert "net.dns_fail" in rs and "rtc.clock_not_running" in rs
|
||||
|
||||
|
||||
# ---------------------------------------------------- synthetic scenarios
|
||||
|
||||
def _rtc_fail_boot(reset="RTC_SW_CPU_RST"):
|
||||
return BOOT + [f"rst:0xc ({reset}),boot:0x8 (SPI_FAST_FLASH_BOOT)"] + APP + [
|
||||
"I (2493) MAIN: ESP-Matter started successfully",
|
||||
"E (3510) DS1307: Failed to initialize DS1307 control register: ESP_ERR_TIMEOUT",
|
||||
"E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT",
|
||||
"ESP_ERROR_CHECK failed: esp_err_t 0x107 (ESP_ERR_TIMEOUT) at 0x4200f7b2",
|
||||
'file: "./main/main.cpp" line 473',
|
||||
"abort() was called at PC 0x40379b1f on core 0",
|
||||
]
|
||||
|
||||
|
||||
def test_rtc_i2c_reboot_loop():
|
||||
_, r = run(_rtc_fail_boot("POWERON") + _rtc_fail_boot() + _rtc_fail_boot() + _rtc_fail_boot())
|
||||
rs = roots(r)
|
||||
root = rs["rtc.init_failed"]
|
||||
assert root.finding.severity == Severity.CRITICAL
|
||||
assert "I2C" in root.finding.meaning
|
||||
grouped = {c.rule_id for c in root.consequences}
|
||||
assert {"sys.crash", "reboot.loop"} <= grouped
|
||||
assert r.led == "BLUE"
|
||||
assert r.verdict.startswith("Problem")
|
||||
|
||||
|
||||
def test_sd_card_missing():
|
||||
_, r = run(BOOT + APP + ["E (1519) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT). "
|
||||
"Make sure SD card lines have pull-up resistors in place."])
|
||||
f = roots(r)["sd.card_fail"].finding
|
||||
assert "ESP_ERR_TIMEOUT" in f.title and f.severity == Severity.CRITICAL
|
||||
assert r.led == "WHITE"
|
||||
|
||||
|
||||
def test_influx_token_rejected():
|
||||
_, r = run(BOOT + APP + ["E (30000) INFLUX: Failed to perform HTTP request: 401, ESP_OK"])
|
||||
f = roots(r)["influx.auth"].finding
|
||||
assert "401" in f.title and f.severity == Severity.CRITICAL
|
||||
|
||||
|
||||
def test_recovery_resolves_network_findings():
|
||||
lines = BOOT + APP + [
|
||||
"E (21837) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0",
|
||||
"I (30222) SNTP: Time synchronized: Thu Sep 17 14:29:22 2026",
|
||||
"I (31000) INFLUX: HTTP request performed, status code: 204",
|
||||
]
|
||||
_, r = run(lines)
|
||||
assert "net.dns_fail" not in roots(r)
|
||||
assert any(f.rule_id == "net.dns_fail" for f in r.resolved)
|
||||
assert r.verdict.startswith("Healthy")
|
||||
|
||||
|
||||
def test_led_conditional_green_after_purple():
|
||||
e, r = run(BOOT + APP + ["E (60009) INFLUX_BUFFER: Failed to write chunk with 5 retries to Influx: ESP_FAIL"])
|
||||
assert r.led == "PURPLE"
|
||||
_, r = run(["I (61000) OTA_CLIENT: No update available"], e)
|
||||
assert r.led == "GREEN"
|
||||
_, r = run(["I (62000) OTA_CLIENT: New update available, starting OTA update..."], e)
|
||||
assert r.led == "YELLOW"
|
||||
|
||||
|
||||
def test_status_watchdog_restart_grouped_under_network_cause():
|
||||
lines = BOOT + APP + [
|
||||
"I (2542) MAIN: Commissioning window opened",
|
||||
"E (60009) INFLUX_BUFFER: Failed to write chunk with 5 retries to Influx: ESP_FAIL",
|
||||
"W (200000) BME688: x",
|
||||
] + BOOT + ["rst:0x3 (RTC_SW_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)"] + APP + [
|
||||
"I (2542) MAIN: Commissioning window opened",
|
||||
"E (60009) INFLUX_BUFFER: Failed to write chunk with 5 retries to Influx: ESP_FAIL",
|
||||
]
|
||||
_, r = run(lines)
|
||||
grouped = {c.rule_id for c in roots(r)["matter.not_commissioned"].consequences}
|
||||
assert "reboot.status_watchdog" in grouped
|
||||
assert "reboot.loop" not in grouped # 200 s boots are not a loop
|
||||
|
||||
|
||||
def test_tool_reset_is_not_a_finding():
|
||||
lines = BOOT + APP + [LogLine("--- reset issued by tool ---", LineKind.HOST, 0.5)]
|
||||
lines += BOOT + ["rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (SPI_FAST_FLASH_BOOT)"] + APP
|
||||
_, r = run(lines)
|
||||
assert not [rc for rc in r.roots if rc.finding.rule_id.startswith("reboot.")]
|
||||
assert len(r.boots) == 3 # mid-run record + 2 boots
|
||||
|
||||
|
||||
def test_first_seen_is_per_boot_and_own_severity_breaks_ties():
|
||||
rtc = "W ({}) BME688: RTC is uninitialized, using current time as timestamp"
|
||||
lines = [rtc.format(200_000 + i) for i in range(5)] # before the reset
|
||||
lines += BOOT + APP + [rtc.format(15_000 + i) for i in range(5)]
|
||||
lines += ["W (24756) MICROPHONE: Microphone is in shutdown state, skipping sample collection",
|
||||
"E (24773) SENSOR_GATHERER: Failed to get line protocol from sensor"] * 2
|
||||
_, r = run(lines)
|
||||
order = [rc.finding.rule_id for rc in r.roots]
|
||||
assert order.index("rtc.clock_not_running") < order.index("mic.disabled")
|
||||
assert roots(r)["rtc.clock_not_running"].finding.first_uptime_ms == 15_000
|
||||
|
||||
|
||||
def test_reboot_detected_without_banner():
|
||||
e, r = run(["I (90000) A: running", "I (95000) A: running", "I (1200) B: fresh boot"])
|
||||
assert len(r.boots) == 2 and not r.boots[1].explicit
|
||||
|
||||
|
||||
def test_split_by_sensor():
|
||||
_, r = run(BOOT + APP + [
|
||||
"E (14000) MAIN: Failed to initialize BME688: ESP_FAIL",
|
||||
"E (14001) MAIN: Failed to initialize SEN66: ESP_ERR_TIMEOUT",
|
||||
])
|
||||
titles = {rc.finding.title for rc in r.roots if rc.finding.rule_id == "sensor.init_failed"}
|
||||
assert titles == {"BME688 failed to start", "SEN66 failed to start"}
|
||||
assert r.led == "RED"
|
||||
|
||||
|
||||
def test_rtc_loses_time_after_sync():
|
||||
_, r = run(BOOT + APP + ["I (10222) SNTP: Time synchronized: Thu Sep 17 14:29:22 2026"]
|
||||
+ [f"W ({30000 + i}) BME688: RTC is uninitialized, using current time as timestamp" for i in range(3)])
|
||||
assert "rtc.lost_after_sync" in roots(r)
|
||||
|
||||
|
||||
def test_unknown_firmware_reports_sd_wifi_from_evidence():
|
||||
_, r = run(BOOT + ["I (1317) app_init: ELF file SHA256: 0123456789..."] + [
|
||||
"I (1523) WIFI_CREDENTIALS: SSID/PASSWORD not set in .env; Matter will be used"])
|
||||
assert r.firmware.known is None and r.firmware.supports_sd_wifi is True
|
||||
assert "OTA" in r.firmware.describe()
|
||||
|
||||
|
||||
def test_report_text_renders():
|
||||
text = replay("live_boot_A4CB8FC946F8_fw1.0.txt").to_text()
|
||||
assert text.startswith("VERDICT: Problem") and "consequence:" in text
|
||||
|
||||
|
||||
# ------------------------------------------------------------- log files
|
||||
|
||||
def test_session_log_format(tmp_path):
|
||||
p = tmp_path / "session.log"
|
||||
p.write_text("[14:00:00.000] --- reset issued by tool ---\n"
|
||||
"[14:00:00.400] ESP-ROM:esp32s3-20210327\n"
|
||||
"[14:00:00.401] rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (SPI_FAST_FLASH_BOOT)\n"
|
||||
"[14:00:01.500] I (1299) app_init: Project name: kota-sensorboard\n", encoding="utf-8")
|
||||
lines = read_log_file(p)
|
||||
assert [ln.kind for ln in lines] == [LineKind.HOST, LineKind.BOOT, LineKind.RESET, LineKind.LOG]
|
||||
e = DiagnosticsEngine()
|
||||
for ln in lines:
|
||||
e.feed(ln)
|
||||
assert e.boot.tool_reset
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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 a merged"),
|
||||
((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
|
||||
@@ -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
|
||||
+23
-14
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logline import LineFramer, LineKind, parse_line
|
||||
from kota_tech.core.logline import LineFramer, LineKind, parse_line, split_glued
|
||||
|
||||
FIXTURES = sorted((Path(__file__).parent / "fixtures").glob("*.txt"))
|
||||
|
||||
@@ -39,27 +39,35 @@ def test_framer_splits_and_idle_flushes():
|
||||
|
||||
|
||||
def test_framer_drops_stale_fragment_after_open():
|
||||
f = LineFramer(drop_first_partial=True, now=0.0)
|
||||
assert f.feed(b"current time as t", now=0.01) == []
|
||||
assert f.feed(b"imestamp\r\nW (15899) DeviceStatus: RTC\r\n", now=0.02) == ["W (15899) DeviceStatus: RTC\r"]
|
||||
# Real capture: stale FIFO tail released together with the next live line.
|
||||
f = LineFramer(drop_first_partial=True)
|
||||
got = f.feed(b"I (96) boot: 6 ota_1 OTA app 00 11 00340000"
|
||||
b"W (19497) BME688: RTC is uninitialized\r\nI (19820) SEN66: x\r\n")
|
||||
assert got == ["W (19497) BME688: RTC is uninitialized\r", "I (19820) SEN66: x\r"]
|
||||
|
||||
|
||||
def test_framer_keeps_first_line_from_idle_board():
|
||||
# Board was silent at open; the boot banner arriving later must survive.
|
||||
f = LineFramer(drop_first_partial=True, now=0.0)
|
||||
assert f.feed(b"", now=0.1) == []
|
||||
assert f.feed(b"ESP-ROM:esp32s3-20210327\r\n", now=5.0) == ["ESP-ROM:esp32s3-20210327\r"]
|
||||
# Board silent at open; the boot banner arriving later must survive.
|
||||
f = LineFramer(drop_first_partial=True)
|
||||
assert f.feed(b"ESP-ROM:esp32s3-20210327\r\n") == ["ESP-ROM:esp32s3-20210327\r"]
|
||||
|
||||
|
||||
def test_framer_cancel_drop():
|
||||
f = LineFramer(drop_first_partial=True, now=0.0)
|
||||
f.cancel_drop()
|
||||
assert f.feed(b"ESP-ROM:esp32s3\n", now=0.01) == ["ESP-ROM:esp32s3"]
|
||||
def test_framer_keeps_complete_first_line():
|
||||
f = LineFramer(drop_first_partial=True)
|
||||
assert f.feed(b"W (1) A: complete\r\n") == ["W (1) A: complete\r"]
|
||||
|
||||
|
||||
def test_framer_splits_interleaved_lines():
|
||||
f = LineFramer()
|
||||
assert f.feed(b"I (2302) chip[DL]: I (2302) chip[DL]: Posting event\n") == [
|
||||
"I (2302) chip[DL]: ", "I (2302) chip[DL]: Posting event"]
|
||||
assert f.feed(b"wifi:\x1b[0;32mI (5) X: y\n") == ["wifi:", "\x1b[0;32mI (5) X: y"]
|
||||
|
||||
|
||||
def test_framer_tolerates_invalid_utf8():
|
||||
f = LineFramer()
|
||||
assert f.feed(b"\xff\xfeI (1) X: y\n") == ["��I (1) X: y"]
|
||||
# Garbage in front of a log line is split off rather than corrupting it.
|
||||
assert f.feed(b"\xff\xfeI (1) X: y\n") == ["��", "I (1) X: y"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", FIXTURES, ids=lambda p: p.name)
|
||||
@@ -79,6 +87,7 @@ def test_replay_sample_log_chunked_equals_whole(path):
|
||||
@pytest.mark.parametrize("path", FIXTURES, ids=lambda p: p.name)
|
||||
def test_replay_sample_log_parses(path):
|
||||
lines = [parse_line(r) for r in LineFramer().feed(path.read_bytes())]
|
||||
raw_errors = sum(1 for r in path.read_text("utf-8", "replace").splitlines() if r.startswith("E ("))
|
||||
raw_parts = [p for r in path.read_text("utf-8", "replace").splitlines() for p in split_glued(r)]
|
||||
raw_errors = sum(1 for p in raw_parts if p.startswith("E ("))
|
||||
assert sum(1 for ln in lines if ln.level == "E") == raw_errors
|
||||
assert raw_errors > 0
|
||||
@@ -33,7 +33,7 @@ def test_loopback_lines_reset_and_disconnect(monkeypatch):
|
||||
m.connect(LOOP)
|
||||
assert _wait(lambda: m.state == State.CONNECTED)
|
||||
|
||||
m.write(b"stale tail\r\nESP-ROM:esp32s3-20210327\r\nE (5) INFLUX: token: s3cr3t\r\npartial")
|
||||
m.write(b"stale tailESP-ROM:esp32s3-20210327\r\nE (5) INFLUX: token: s3cr3t\r\npartial")
|
||||
assert _wait(lambda: any(ln.text == "partial" for ln in lines)) # idle flush
|
||||
assert not any("stale" in ln.text for ln in lines) # first fragment after open dropped
|
||||
m.reset_board()
|
||||
|
||||
@@ -1,6 +1,129 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
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 = "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]]
|
||||
name = "colorama"
|
||||
@@ -11,6 +134,79 @@ 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" },
|
||||
]
|
||||
|
||||
[[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 = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -20,11 +216,21 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "esptool" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyside6-essentials" },
|
||||
]
|
||||
@@ -36,6 +242,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "esptool", specifier = "~=4.8" },
|
||||
{ name = "pyserial", specifier = ">=3.5" },
|
||||
{ name = "pyside6-essentials", specifier = ">=6.7" },
|
||||
]
|
||||
@@ -61,6 +268,15 @@ 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" },
|
||||
]
|
||||
|
||||
[[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 = "pygments"
|
||||
version = "2.21.0"
|
||||
@@ -110,6 +326,52 @@ 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" },
|
||||
]
|
||||
|
||||
[[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 = "shiboken6"
|
||||
version = "6.11.2"
|
||||
@@ -121,3 +383,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/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" },
|
||||
]
|
||||
Reference in new issue
Block a user