Files
kota-technician-tool/kota_tech/service/report.py
T
kkallas f5b32e1554 Technician tool v0.5
Imported from the Google Drive folder 004-technician_tool/v0.5.
2026-09-26 18:30:51 +02:00

102 lines
4.4 KiB
Python

"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script).
The tool opens the box's service page in the technician's browser:
<base><MAC>#kota-report=<base64url(JSON)>
The service page (v1.1.0+) reads the fragment and pre-fills status and note;
the technician checks it, adds the location and saves - so the report is
authored by their own Google login and the tool holds no credentials.
The fragment is never sent to any server (redirect host or Apps Script).
"""
from __future__ import annotations
import base64
import json
import re
from .. import __version__
from ..diag.engine import Report, Severity
DEFAULT_BASE = "https://service.kota-app.com/"
NOTE_LIMIT = 4000 # the service form accepts 5000; leave room for the technician
# Status names exactly as in the service system (Code.gs STATUSES).
OK = "Działa poprawnie"
CHECK = "Do sprawdzenia"
IN_SERVICE = "W trakcie serwisu"
HARDWARE = "Błąd hardware"
SOFTWARE = "Błąd software"
POWER = "Problem z zasilaniem"
CONNECTIVITY = "Problem z łącznością"
# Root-finding rule id prefix -> service status (first match wins).
_STATUS_BY_RULE = [
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE),
("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE),
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
("rtc.clock_not_running", CHECK), ("reboot.", CHECK),
]
_MAC = re.compile(r"^[0-9A-F]{12}$")
def normalize_mac(mac: str) -> str:
return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper()
def service_status(report: Report) -> str:
"""Suggested status; the technician can change it in the form."""
problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING]
if not problems:
return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK
rid = problems[0].finding.rule_id
for prefix, status in _STATUS_BY_RULE:
if rid.startswith(prefix):
return status
return CHECK
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}",
f"Firmware: {report.firmware.describe()}",
f"Expected LED: {report.led.capitalize()}"]
reached = [label for _, label, t in report.milestones if t is not None]
missing = [label for _, label, t in report.milestones if t is None]
if reached:
lines.append("OK: " + ", ".join(reached))
if missing:
lines.append("Not reached: " + ", ".join(missing))
for rc in report.roots:
f = rc.finding
lines.append(f"- [{f.severity.label}] {f.title}")
for c in rc.consequences:
lines.append(f" -> {c.title}")
if report.technician:
lines.append("Technician checks:")
lines += [f" * {t}" for t in report.technician[-12:]]
text = "\n".join(lines)
return text if len(text) <= limit else text[:limit - 20].rstrip() + "\n...(shortened)"
def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str:
mac = normalize_mac(mac)
if not _MAC.match(mac):
raise ValueError(f"not a box MAC address: {mac!r}")
payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report),
"tool": f"v{__version__}"}
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
base = base if base.endswith("/") else base + "/"
return f"{base}{mac}#kota-report={frag}"
def decode_fragment(url: str) -> dict:
"""Inverse of report_url (used by tests and for troubleshooting)."""
frag = url.split("#kota-report=", 1)[1]
return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8"))