Technician tool v0.7
Imported from the Google Drive folder 004-technician_tool/v0.7.
This commit is contained in:
1 parent
22bd853ecf
commit
dd915b5a74
29 files changed
+8429
-3981
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+22
@@ -0,0 +1,22 @@
|
||||
ESP-ROM:esp32s3-20210327
|
||||
rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)
|
||||
I (1299) app_init: Project name: kota-sensorboard
|
||||
I (1303) app_init: App version: 1.0.16
|
||||
I (1307) app_init: Compile time: Sep 28 2026 15:07:57
|
||||
I (1317) app_init: ELF file SHA256: 4f10989e2...
|
||||
E (1402) ID: [E-MAIN-04] Device MAC address: A4:CB:8F:C9:46:F8
|
||||
I (1480) KOTA: led previous_boot status=CONNECTION_ERROR code=1
|
||||
I (1481) KOTA: led color=purple pattern=blink period=1000ms
|
||||
I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688
|
||||
I (1795) KOTA: rtc running=1 time=2026-09-28T15:20:01
|
||||
I (1850) SD_CARD: SD card mounted successfully
|
||||
I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0
|
||||
I (2300) MAIN: ESP-Matter started successfully
|
||||
I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET mode=matter commissioned=1 last_restart=none
|
||||
I (2315) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332
|
||||
I (2400) KOTA: mic enabled=1 (GPIO3=0, expected 0)
|
||||
I (4481) KOTA: led color=green pattern=solid
|
||||
I (5200) WIFI_SERVICE: Got IP address
|
||||
I (6100) SNTP: Time synchronized: Mon Sep 28 15:20:06 2026
|
||||
I (8100) KOTA: rtc verify running=1 delta=2s ok=1
|
||||
I (60010) KOTA: health up=60s status=OK wifi=connected rssi=-58 time_valid=1 sd=ok buffer=0 heap=143220 psram=7.6M last_upload=14s ota=registered
|
||||
+20
-6
@@ -43,16 +43,20 @@ APP = ["I (1299) app_init: Project name: kota-sensorboard",
|
||||
# ------------------------------------------------------------------ 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)
|
||||
from kota_tech.diag import kota
|
||||
graph = {r.id: r.explained_by for r in load_rules()}
|
||||
for st in ("ERROR", "OUT_OF_RESOURCES", "CONNECTION_ERROR", "RTC_ERROR", "SD_ERROR", "RESETING", "HTTP_SERVER"):
|
||||
for code in range(7):
|
||||
if sf := kota.status_finding(st, code):
|
||||
graph[sf[0]] = sf[5]
|
||||
for start, explained_by in graph.items():
|
||||
seen, todo = set(), list(explained_by)
|
||||
while todo:
|
||||
rid = todo.pop()
|
||||
assert rid != r.id, f"explained_by cycle through {r.id}"
|
||||
assert rid != start, f"explained_by cycle through {start}"
|
||||
if rid not in seen:
|
||||
seen.add(rid)
|
||||
todo += by_id[rid].explained_by
|
||||
todo += graph.get(rid, ())
|
||||
|
||||
|
||||
# --------------------------------------------------------- real captures
|
||||
@@ -233,3 +237,13 @@ def test_session_log_format(tmp_path):
|
||||
for ln in lines:
|
||||
e.feed(ln)
|
||||
assert e.boot.tool_reset
|
||||
|
||||
|
||||
def test_live_brownout_loop_is_a_power_problem():
|
||||
# Real capture (2026-09-28): "E BOD: Brownout detector was triggered" has no
|
||||
# timestamp and the ROM reports RTC_SW_SYS_RST, not a brown-out reset.
|
||||
r = replay("live_brownout_loop_A4CB8FC946F8_fw1.0.txt")
|
||||
top = r.roots[0]
|
||||
assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5
|
||||
assert "reboot.loop" in {c.rule_id for c in top.consequences}
|
||||
assert r.verdict.startswith("Problem: Power supply")
|
||||
+104
-131
@@ -1,5 +1,6 @@
|
||||
"""Flashing: image validation on the real official image, plan building,
|
||||
and the runner against a fake esptool (no hardware)."""
|
||||
"""Flashing: image validation on the real image in firmware/, the folder
|
||||
catalogue (compile-date order), plan building, and the runner against a fake
|
||||
esptool (no hardware)."""
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
@@ -8,31 +9,39 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.flash.image import ImageError, inspect_image, official_images
|
||||
from kota_tech.flash.image import ImageError, app_image_length, folder_images, inspect_image
|
||||
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"
|
||||
FW_DIR = Path(__file__).resolve().parents[1] / "firmware"
|
||||
FW = sorted(FW_DIR.glob("*.bin"))[0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def image():
|
||||
imgs = official_images()
|
||||
assert len(imgs) == 1
|
||||
imgs, problems = folder_images()
|
||||
assert imgs and problems == []
|
||||
return imgs[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ image
|
||||
|
||||
def test_official_image_is_the_deployed_v1_build(image):
|
||||
def test_folder_image_is_a_kota_build(image):
|
||||
assert image.app.project == "kota-sensorboard"
|
||||
assert image.app.elf_sha.startswith("45fa9d5b9") # same as the live board
|
||||
assert image.kind == "merged"
|
||||
assert image.app.built_at is not None # compile date is readable
|
||||
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_label_starts_with_compile_date(image):
|
||||
label = image.label()
|
||||
assert label.startswith(f"Compiled {image.app.built_at:%Y-%m-%d %H:%M}")
|
||||
assert image.app.version in label and image.name in label
|
||||
|
||||
|
||||
def test_app_bytes_are_a_complete_app_image(image):
|
||||
app = image.app_bytes()
|
||||
assert app[0] == 0xE9
|
||||
@@ -40,17 +49,6 @@ def test_app_bytes_are_a_complete_app_image(image):
|
||||
assert len(image.blank_otadata()) == 0x2000 and set(image.blank_otadata()) == {0xFF}
|
||||
|
||||
|
||||
def test_modified_image_is_refused(tmp_path):
|
||||
data = bytearray(FW.read_bytes())
|
||||
data[-1] ^= 0xFF
|
||||
bad = tmp_path / "new_system.bin"
|
||||
bad.write_bytes(data)
|
||||
manifest = tmp_path / "firmware.toml"
|
||||
manifest.write_text(f'[[image]]\nfile = "new_system.bin"\nsha256 = "{hashlib.sha256(FW.read_bytes()).hexdigest()}"\n')
|
||||
with pytest.raises(ImageError, match="SHA-256"):
|
||||
official_images(manifest)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patch,msg", [
|
||||
((0, 0x00), "not an ESP32 firmware"),
|
||||
((12, 0x05), "not ESP32-S3"),
|
||||
@@ -64,6 +62,93 @@ def test_wrong_images_are_rejected(tmp_path, patch, msg):
|
||||
inspect_image(p)
|
||||
|
||||
|
||||
def test_integrity_check_on_folder_image(image):
|
||||
d = image.data
|
||||
assert app_image_length(d, 0) > 0 # bootloader
|
||||
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage,msg", [
|
||||
(lambda b: b[:len(b) // 2], "truncated"),
|
||||
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
|
||||
])
|
||||
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
|
||||
p = tmp_path / "broken.bin"
|
||||
p.write_bytes(damage(FW.read_bytes()))
|
||||
with pytest.raises(ImageError, match=msg):
|
||||
inspect_image(p)
|
||||
|
||||
|
||||
# ------------------------------------------------------- firmware folder
|
||||
|
||||
def _with_compile_date(data: bytes, date: str, time: str, off: int = 0x40000) -> bytes:
|
||||
"""Copy of a merged image whose app claims another compile date, with the
|
||||
segment checksum and appended SHA-256 recomputed (a valid, different build)."""
|
||||
d = bytearray(data)
|
||||
desc = off + 0x20
|
||||
old = bytes(d[desc + 80:desc + 112])
|
||||
new = time.encode().ljust(16, b"\0") + date.encode().ljust(16, b"\0")
|
||||
d[desc + 80:desc + 112] = new
|
||||
end = off + app_image_length(data, off) - 32 # SHA-256 follows the checksum byte
|
||||
xor = 0
|
||||
for a, b in zip(old, new):
|
||||
xor ^= a ^ b
|
||||
d[end - 1] ^= xor
|
||||
d[end:end + 32] = hashlib.sha256(bytes(d[off:end])).digest()
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def test_folder_lists_builds_newest_compile_date_first(tmp_path):
|
||||
data = FW.read_bytes()
|
||||
(tmp_path / "a_old.bin").write_bytes(_with_compile_date(data, "Jan 5 2026", "08:00:00"))
|
||||
(tmp_path / "z_new.bin").write_bytes(_with_compile_date(data, "Dec 24 2026", "18:30:00"))
|
||||
(tmp_path / "m_mid.bin").write_bytes(_with_compile_date(data, "Jun 15 2026", "12:00:00"))
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert problems == []
|
||||
assert [i.name for i in imgs] == ["z_new.bin", "m_mid.bin", "a_old.bin"]
|
||||
assert imgs[0].label().startswith("Compiled 2026-12-24 18:30")
|
||||
assert imgs[2].label().startswith("Compiled 2026-01-05 08:00")
|
||||
|
||||
|
||||
def test_folder_reports_bad_files_and_duplicates(tmp_path):
|
||||
data = FW.read_bytes()
|
||||
(tmp_path / "good.bin").write_bytes(data)
|
||||
(tmp_path / "copy.bin").write_bytes(data)
|
||||
(tmp_path / "notes.bin").write_bytes(b"hello firmware team")
|
||||
(tmp_path / "readme.txt").write_text("ignored: not a .bin")
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert len(imgs) == 1
|
||||
assert any("notes.bin" in p and "not an ESP32 firmware" in p for p in problems)
|
||||
assert any("same file as" in p for p in problems)
|
||||
assert not any("readme" in p for p in problems)
|
||||
|
||||
|
||||
def test_app_only_file_uses_the_merged_layout(tmp_path, image):
|
||||
(tmp_path / "merged.bin").write_bytes(image.data)
|
||||
(tmp_path / "kota-sensorboard.bin").write_bytes(
|
||||
_with_compile_date(image.data, "Dec 1 2026", "10:00:00")[0x40000:])
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert problems == []
|
||||
app = next(i for i in imgs if i.kind == "app")
|
||||
assert imgs[0] is app # newer compile date first
|
||||
assert app.partition("ota_0").offset == 0x40000 and "app only" in app.label()
|
||||
assert [s.kind for s in build_plan(app, Mode.APP_ONLY, "COM5", tmp_path / "w").steps] == ["read_pt", "write"]
|
||||
with pytest.raises(ValueError, match="app only"):
|
||||
build_plan(app, Mode.FULL, "COM5", tmp_path / "w")
|
||||
|
||||
|
||||
def test_app_only_file_without_merged_image_is_not_offered(tmp_path, image):
|
||||
(tmp_path / "kota-sensorboard.bin").write_bytes(image.app_bytes())
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert imgs == [] and "no complete (merged) image" in problems[0]
|
||||
|
||||
|
||||
def test_empty_or_missing_folder(tmp_path):
|
||||
assert folder_images(tmp_path) == ([], [])
|
||||
imgs, problems = folder_images(tmp_path / "nope")
|
||||
assert imgs == [] and "not found" in problems[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- plan
|
||||
|
||||
def test_app_only_plan_keeps_nvs(image, tmp_path):
|
||||
@@ -160,115 +245,3 @@ def test_runner_reports_connect_failure_with_hint(image, tmp_path):
|
||||
res, *_ = _run(plan, cmd)
|
||||
assert not res.ok and res.failed_step == 0
|
||||
assert "BOOT" in res.hint
|
||||
|
||||
|
||||
# ------------------------------------------------- developer builds (v0.5)
|
||||
|
||||
from kota_tech.flash.image import FirmwareLibrary, app_image_length # noqa: E402
|
||||
|
||||
|
||||
def _app_only_file(image, tmp_path, name="kota-sensorboard.bin"):
|
||||
p = tmp_path / name
|
||||
p.write_bytes(image.app_bytes()) # what a developer build/ folder contains
|
||||
return p
|
||||
|
||||
|
||||
def test_integrity_check_on_factory_image(image):
|
||||
d = image.data
|
||||
assert app_image_length(d, 0) > 0 # bootloader
|
||||
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage,msg", [
|
||||
(lambda b: b[:len(b) // 2], "truncated"),
|
||||
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
|
||||
])
|
||||
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
|
||||
p = tmp_path / "broken.bin"
|
||||
p.write_bytes(damage(FW.read_bytes()))
|
||||
with pytest.raises(ImageError, match=msg):
|
||||
inspect_image(p, origin="developer")
|
||||
|
||||
|
||||
def test_import_app_only_developer_build(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
src = _app_only_file(image, tmp_path)
|
||||
img = lib.import_file(src, reference=image)
|
||||
assert img.is_developer and img.kind == "app"
|
||||
assert img.partition("ota_0").offset == 0x40000 # layout from the factory image
|
||||
assert img.app_bytes() == image.app_bytes()
|
||||
listed, problems = lib.images(image)
|
||||
assert [i.sha256 for i in listed] == [img.sha256] and problems == []
|
||||
assert lib.import_file(src, reference=image).sha256 == img.sha256 # re-import: no duplicate
|
||||
assert len(lib.images(image)[0]) == 1
|
||||
|
||||
|
||||
def test_app_only_build_cannot_be_flashed_full(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
plan = build_plan(img, Mode.APP_ONLY, "COM5", tmp_path / "w")
|
||||
assert [s.kind for s in plan.steps] == ["read_pt", "write"]
|
||||
with pytest.raises(ValueError, match="app only"):
|
||||
build_plan(img, Mode.FULL, "COM5", tmp_path / "w")
|
||||
|
||||
|
||||
def test_import_merged_developer_build(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
src = tmp_path / "dev_full.bin"
|
||||
src.write_bytes(image.data)
|
||||
img = lib.import_file(src, reference=image)
|
||||
assert img.kind == "merged" and img.is_developer
|
||||
assert [s.kind for s in build_plan(img, Mode.FULL, "COM5", tmp_path / "w").steps] == ["write"]
|
||||
|
||||
|
||||
def test_library_detects_file_changed_after_import(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
stored = tmp_path / "lib" / img.path.name
|
||||
data = bytearray(stored.read_bytes())
|
||||
data[-40] ^= 1
|
||||
stored.write_bytes(data)
|
||||
listed, problems = lib.images(image)
|
||||
assert listed == [] and problems
|
||||
|
||||
|
||||
def test_library_remove(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
lib.remove(img.sha256)
|
||||
assert lib.images(image) == ([], []) and not img.path.exists()
|
||||
|
||||
|
||||
def test_non_firmware_file_refused(tmp_path, image):
|
||||
p = tmp_path / "notes.bin"
|
||||
p.write_bytes(b"hello firmware team")
|
||||
with pytest.raises(ImageError, match="not an ESP32 firmware"):
|
||||
FirmwareLibrary(tmp_path / "lib").import_file(p, reference=image)
|
||||
|
||||
|
||||
# ------------------------------------------- bundled developer build (v0.6)
|
||||
|
||||
from kota_tech.flash.image import bundled_images # noqa: E402
|
||||
|
||||
|
||||
def test_bundled_developer_build_is_labelled_and_not_default():
|
||||
imgs = bundled_images()
|
||||
assert [i.origin for i in imgs] == ["factory", "developer"] # factory first
|
||||
dev = imgs[1]
|
||||
assert dev.built_by == "Krzysztof Kallas" and "built by Krzysztof Kallas" in dev.label()
|
||||
assert dev.kind == "merged" and dev.app.elf_sha.startswith("c94ba0e9b")
|
||||
assert dev.partition_table == imgs[0].partition_table # App-only safe on v1.0 boxes
|
||||
assert [i.origin for i in factory_images_only()] == ["factory"]
|
||||
|
||||
|
||||
def factory_images_only():
|
||||
from kota_tech.flash.image import factory_images
|
||||
return factory_images()
|
||||
|
||||
|
||||
def test_import_records_who_built_it(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image, built_by=" Anna Nowak ")
|
||||
assert img.built_by == "Anna Nowak" and "built by Anna Nowak" in img.label()
|
||||
again, _ = lib.images(image)
|
||||
assert again[0].built_by == "Anna Nowak"
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Firmware 1.0.16+ structured "KOTA:" lines and error IDs.
|
||||
|
||||
The lines below are built from the exact format strings in the firmware
|
||||
source (boot_info.cpp, status.cpp, i2c.cpp, ds1307.cpp, sd_card.cpp,
|
||||
main.cpp, microphone.cpp, health.cpp); no hardware capture exists yet.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
from kota_tech.core.logline import LineKind, parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine, Severity
|
||||
from kota_tech.diag.firmware import same_build
|
||||
from kota_tech.matter.payload import payload_from_log
|
||||
from kota_tech.service.report import CONNECTIVITY, HARDWARE, service_status
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
BANNER = ["ESP-ROM:esp32s3-20210327", "rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)"]
|
||||
I2C_OK = "I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688"
|
||||
SD_OK = ("I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
|
||||
"env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0")
|
||||
BOOT = ("I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET "
|
||||
"mode=matter commissioned=1 last_restart={last}")
|
||||
|
||||
|
||||
def run(lines):
|
||||
e = DiagnosticsEngine()
|
||||
for i, text in enumerate(lines):
|
||||
e.feed(parse_line(text, host_time=i * 0.01))
|
||||
return e, e.report()
|
||||
|
||||
|
||||
def roots(report):
|
||||
return {rc.finding.rule_id: rc for rc in report.roots}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- parsing
|
||||
|
||||
def test_kota_lines_are_parsed_into_fields():
|
||||
ln = parse_line(BOOT.format(last="status_watchdog"))
|
||||
assert ln.kind == LineKind.KOTA and ln.record == "boot" and ln.level == "I"
|
||||
assert ln.fields["fw"] == "1.0.16" and ln.fields["last_restart"] == "status_watchdog"
|
||||
st = parse_line("I (5) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected")
|
||||
assert st.words == ("OK", "->", "CONNECTION_ERROR") and st.fields == {"code": "1", "reason": "influx_rejected"}
|
||||
mic = parse_line("I (6) KOTA: mic enabled=0 (GPIO3=1, expected 0)")
|
||||
assert mic.fields["enabled"] == "0" and mic.fields["GPIO3"] == "1"
|
||||
i2c = parse_line("I (7) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307(missing) 0x50=?")
|
||||
assert i2c.fields["0x68"] == "DS1307(missing)" and i2c.fields["0x50"] == "?"
|
||||
verify = parse_line("I (8) KOTA: rtc verify running=1 delta=2s ok=1")
|
||||
assert verify.record == "rtc" and "verify" in verify.words and verify.fields["ok"] == "1"
|
||||
|
||||
|
||||
def test_error_id_is_split_off_but_kept_in_text():
|
||||
ln = parse_line("E (1200) SD_CARD: [E-SD-03] Failed to initialize the card (ESP_ERR_TIMEOUT).")
|
||||
assert ln.kind == LineKind.LOG and ln.error_id == "E-SD-03"
|
||||
assert ln.message.startswith("Failed to initialize") and "[E-SD-03]" in ln.text
|
||||
assert ln.match_text == "E (1200) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT)."
|
||||
|
||||
|
||||
def test_sd_line_with_secret_key_names_is_not_redacted():
|
||||
ln = parse_line("I (9) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
|
||||
"env_keys=INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD buffer_lines=0")
|
||||
assert ln.fields["env_keys"] == "INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD"
|
||||
|
||||
|
||||
# -------------------------------------------------------------- engine
|
||||
|
||||
def test_healthy_box_from_kota_lines():
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(FIX / "synthetic_fw1.0.16_healthy.txt"):
|
||||
e.feed(ln)
|
||||
r = e.report()
|
||||
assert r.verdict.startswith("Healthy")
|
||||
assert (r.led, r.led_pattern, r.led_exact, r.led_meaning) == ("GREEN", "solid", True, "OK")
|
||||
assert "v1.0.16" in r.firmware.describe() and "4f10989e" in r.firmware.describe()
|
||||
assert e.mac == "A4:CB:8F:C9:46:F8"
|
||||
reached = {m for m, _, t in r.milestones if t is not None}
|
||||
assert {"i2c_ok", "sd_mounted", "env_loaded", "matter_commissioned", "wifi_connected",
|
||||
"time_synced", "clock_verified", "data_uploaded", "ota_registered"} <= reached
|
||||
topics = {t: st for t, _, st in r.box}
|
||||
assert topics["I2C"] == topics["SD card"] == topics["Health"] == "ok"
|
||||
# The purple state before the tool's reset is shown, but only as information now.
|
||||
prev = [f for f in r.earlier + [rc.finding for rc in r.roots] if f.rule_id == "boot.previous_status"]
|
||||
assert not prev or prev[0].severity == Severity.INFO
|
||||
assert "E-MAIN-04" not in r.to_text() # MAC line is a benign ESP_LOGE
|
||||
|
||||
|
||||
def test_i2c_bus_stuck_is_root_of_blue_led():
|
||||
_, r = run(BANNER + [
|
||||
"I (1480) KOTA: led color=blue pattern=pulses n=2",
|
||||
"I (1790) KOTA: i2c SDA=0 SCL=1 bus=stuck",
|
||||
"I (1791) KOTA: status OK -> RTC_ERROR code=2 reason=i2c_bus_stuck",
|
||||
])
|
||||
rs = roots(r)
|
||||
assert "kota.i2c_stuck" in rs and rs["kota.i2c_stuck"].finding.severity == Severity.CRITICAL
|
||||
assert "status.rtc.2" in {c.rule_id for c in rs["kota.i2c_stuck"].consequences}
|
||||
assert (r.led, r.led_pattern) == ("BLUE", "2 pulses") and "I2C bus stuck" in r.led_meaning
|
||||
assert service_status(r) == HARDWARE
|
||||
|
||||
|
||||
def test_missing_sensor_named_by_i2c_scan_and_red_pulses():
|
||||
_, r = run(BANNER + [
|
||||
"I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66(missing) 0x76=BME688",
|
||||
SD_OK,
|
||||
"E (2100) MAIN: [E-MAIN-16] Failed to initialize SEN66: ESP_ERR_TIMEOUT",
|
||||
"I (2101) KOTA: status OK -> ERROR code=2 reason=sen66_init_failed",
|
||||
"I (2102) KOTA: led color=red pattern=pulses n=2",
|
||||
])
|
||||
rs = roots(r)
|
||||
root = rs["kota.sensor_missing"]
|
||||
assert root.finding.title == "SEN66 does not answer on the I2C bus"
|
||||
grouped = {c.rule_id for c in root.consequences}
|
||||
assert {"status.error.2", "sensor.init_failed"} <= grouped
|
||||
init = next(c for c in root.consequences if c.rule_id == "sensor.init_failed")
|
||||
assert "E-MAIN-16" in init.error_ids and "SEN66" in init.error_ids["E-MAIN-16"]
|
||||
assert r.led_meaning == "SEN66 failed"
|
||||
assert "missing: SEN66" in dict((t, x) for t, x, _ in r.box)["I2C"]
|
||||
|
||||
|
||||
def test_server_rejected_box_is_purple_blinking():
|
||||
_, r = run(BANNER + [I2C_OK, SD_OK, BOOT.format(last="none"),
|
||||
"I (70000) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected",
|
||||
"I (70001) KOTA: led color=purple pattern=blink period=1000ms"])
|
||||
assert r.roots[0].finding.rule_id == "status.conn.1"
|
||||
assert r.roots[0].finding.severity == Severity.CRITICAL
|
||||
assert (r.led, r.led_pattern) == ("PURPLE", "blinking (1000 ms)")
|
||||
assert "server rejected" in r.led_meaning
|
||||
assert "influx_rejected" in r.led_reason
|
||||
assert service_status(r) == CONNECTIVITY
|
||||
|
||||
|
||||
def test_status_back_to_ok_resolves_the_status_finding():
|
||||
_, r = run(BANNER + [I2C_OK, "I (5000) KOTA: status OK -> CONNECTION_ERROR reason=influx_failed",
|
||||
"I (9000) KOTA: status CONNECTION_ERROR -> OK reason=influx_ok"])
|
||||
assert "status.conn" not in roots(r)
|
||||
assert any(f.rule_id == "status.conn" and f.resolved_by == "status -> OK" for f in r.resolved)
|
||||
|
||||
|
||||
def test_restart_reason_from_the_next_boot_line():
|
||||
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"),
|
||||
"I (5000) KOTA: status OK -> ERROR code=3 reason=as7341_failed",
|
||||
"W (155000) KOTA: restart reason=status_watchdog",
|
||||
"I (155001) KOTA: led restart_flash color=red",
|
||||
"ESP-ROM:esp32s3-20210327", "rst:0xc (RTC_SW_CPU_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)",
|
||||
"I (1480) KOTA: led previous_boot status=ERROR code=3",
|
||||
I2C_OK, BOOT.format(last="status_watchdog")])
|
||||
ids = {f.rule_id: f for rc in r.roots for f in [rc.finding, *rc.consequences]}
|
||||
f = ids["reboot.status_watchdog"]
|
||||
assert "150 s" in f.title and "state ERROR code=3" in f.meaning
|
||||
|
||||
|
||||
def test_brownout_restart_recorded_by_firmware():
|
||||
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"), "I (90000) KOTA: health up=90s status=OK "
|
||||
"wifi=connected rssi=-60 time_valid=1 sd=ok buffer=0 heap=150000 psram=7.6M "
|
||||
"last_upload=10s ota=registered",
|
||||
"ESP-ROM:esp32s3-20210327", "rst:0xf (RTCWDT_BROWN_OUT_RESET),boot:0x8",
|
||||
I2C_OK, BOOT.format(last="brownout")])
|
||||
assert r.roots[0].finding.rule_id == "reboot.brownout"
|
||||
|
||||
|
||||
def test_uncaptured_error_id_uses_the_csv_action():
|
||||
_, r = run(BANNER + [I2C_OK, "E (3000) INFLUX_BUFFER: [E-INF-13] Failed to open buffer file for appending"])
|
||||
f = next(f for rc in r.roots for f in [rc.finding, *rc.consequences] if "E-INF-13" in f.error_ids)
|
||||
# A rule already knows this message, so the rule finding carries the code.
|
||||
assert f.rule_id == "sd.write_fail"
|
||||
_, r = run(BANNER + [I2C_OK, "E (3000) SPI: [E-SPI-01] Failed to take SPI mutex"])
|
||||
f = roots(r)["eid.SPI"].finding
|
||||
assert f.title == "SPI bus: Failed to take SPI mutex"
|
||||
assert "firmware team" in f.actions[0] and f.error_ids == {"E-SPI-01": f.actions[0]}
|
||||
|
||||
|
||||
def test_legacy_rules_still_match_lines_with_error_ids():
|
||||
_, r = run(BANNER + ["E (1200) MAIN: [E-MAIN-13] Failed to initialize RTC: ESP_ERR_TIMEOUT"])
|
||||
assert "rtc.init_failed" in roots(r)
|
||||
|
||||
|
||||
def test_health_line_gives_status_when_connected_mid_run():
|
||||
e, r = run(["I (600000) KOTA: health up=600s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
|
||||
"time_valid=1 sd=ok buffer=120 heap=150000 psram=7.6M last_upload=never ota=registered",
|
||||
"I (660000) KOTA: health up=660s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
|
||||
"time_valid=1 sd=ok buffer=130 heap=150000 psram=7.6M last_upload=never ota=registered"])
|
||||
assert (r.led, r.led_pattern) == ("PURPLE", "solid") and not r.led_exact
|
||||
rs = roots(r)
|
||||
assert "kota.wifi_down" in rs
|
||||
assert "status.conn" in {c.rule_id for c in rs["kota.wifi_down"].consequences}
|
||||
assert r.mid_run
|
||||
|
||||
|
||||
def test_sd_and_rtc_self_report():
|
||||
_, r = run(BANNER + [
|
||||
"I (1795) KOTA: rtc running=0 time=2000-01-01T00:00:00",
|
||||
"I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL buffer_lines=0",
|
||||
"I (8100) KOTA: rtc verify running=0 delta=0s ok=0",
|
||||
])
|
||||
ids = {f.key for rc in r.roots for f in [rc.finding, *rc.consequences]}
|
||||
assert {"influx.env_missing:INFLUX_TOKEN", "kota.rtc_stopped", "kota.rtc_not_ticking"} <= ids
|
||||
_, r = run(BANNER + ["I (1990) KOTA: sd mounted=0 err=ESP_ERR_TIMEOUT",
|
||||
"I (1991) KOTA: status OK -> SD_ERROR reason=sd_mount_failed"])
|
||||
rs = roots(r)
|
||||
assert "kota.sd_unmounted" in rs and "ESP_ERR_TIMEOUT" in rs["kota.sd_unmounted"].finding.title
|
||||
assert "status.sd" in {c.rule_id for c in rs["kota.sd_unmounted"].consequences}
|
||||
|
||||
|
||||
def test_matter_and_mic_lines():
|
||||
e, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
|
||||
"I (2400) KOTA: mic enabled=0 (GPIO3=1, expected 0)",
|
||||
"I (4481) KOTA: led color=cyan pattern=breathe period=3000ms"])
|
||||
rs = roots(r)
|
||||
assert "matter.not_commissioned" in rs and "mic.disabled" in rs
|
||||
assert (r.led, r.led_meaning) == ("CYAN", "waiting to be paired in Matter")
|
||||
p = payload_from_log([], e.matter_qr, e.matter_manual)
|
||||
assert p.qr_text() == "MT:Y.K9042C00KA0648G00" and p.manual_code_pretty() == "3497-011-2332"
|
||||
_, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
|
||||
"I (90000) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332"])
|
||||
assert "matter.not_commissioned" not in roots(r)
|
||||
|
||||
|
||||
def test_build_prefixes_of_different_length_match():
|
||||
full = "4f10989e2b40c1d29fb3e8f8faaa50e5555997909c6d6b1db9d4e1e4d04555dc"
|
||||
assert same_build("4f10989e", full) and same_build(full, "4f10989e2")
|
||||
assert not same_build("4f10989f", full) and not same_build(None, full)
|
||||
|
||||
|
||||
def test_live_fw1016_uncommissioned_box():
|
||||
# Real capture (2026-09-28) after flashing 1.0.16 App-only: not paired in Matter.
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
|
||||
e.feed(ln)
|
||||
r = e.report()
|
||||
assert r.firmware.version == "1.0.16" and same_build(r.firmware.elf_sha, "4f10989e")
|
||||
assert (r.led, r.led_pattern, r.led_exact) == ("CYAN", "breathing", True)
|
||||
rs = roots(r)
|
||||
assert r.roots[0].finding.rule_id == "matter.not_commissioned"
|
||||
grouped = {c.rule_id for c in rs["matter.not_commissioned"].consequences}
|
||||
assert {"kota.wifi_down", "net.dns_fail", "status.conn", "kota.buffer_backlog"} <= grouped
|
||||
assert "rtc.clock_not_running" in {c.rule_id for c in rs["kota.rtc_stopped"].consequences}
|
||||
assert e.matter_qr == "MT:Y.K9042C00KA0648G00"
|
||||
assert not any(f.rule_id.startswith("other.") for f in e.findings.values())
|
||||
+17
-1
@@ -33,7 +33,7 @@ def test_bench_box_is_a_connectivity_problem():
|
||||
assert service_status(e.report()) == CONNECTIVITY
|
||||
note = service_note(e.report())
|
||||
assert note.startswith("[KOTA Technician Tool")
|
||||
assert "not commissioned in Matter" in note and "-> No Internet" in note
|
||||
assert "not commissioned in Matter" in note and "↳ [Error] No Internet" in note
|
||||
assert "Firmware: v1.0" in note
|
||||
|
||||
|
||||
@@ -89,3 +89,19 @@ def test_invalid_mac_rejected(bad):
|
||||
|
||||
def test_normalize_mac():
|
||||
assert normalize_mac("a4:cb:8f:c9:46:f8") == "A4CB8FC946F8"
|
||||
|
||||
|
||||
def test_note_mirrors_the_diagnosis_panel():
|
||||
from pathlib import Path
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(Path(__file__).parent / "fixtures" / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
|
||||
e.feed(ln)
|
||||
note = service_note(e.report())
|
||||
assert len(note) <= 5000
|
||||
for part in ("Status LED should be: Cyan, breathing", "Box self-report (KOTA lines):",
|
||||
"✔ I2C: found: AS7341, DS1307, SEN66, BME688", "Start-up progress (current boot):",
|
||||
"✔ SD card mounted (1.7 s)", "○ Connected to Wi-Fi", "What to do:",
|
||||
"Seen 12x, first at 21.6 s after start, last at 126.8 s",
|
||||
"↳ [Error] No Internet: server names cannot be resolved"):
|
||||
assert part in note, part
|
||||
Reference in new issue
Block a user