Checklist: verify that sensor data reaches InfluxDB
New guided check "Sensor data in the database (InfluxDB)", also the last step of the Quick check on the green path. It queries InfluxDB (read token) for the newest point per measurement of the box and tells apart: sensor data arrives / status arrives without sensor data / offline now / never seen / check not possible. Polls every 30 s for up to 4 minutes. With a stopped RTC in the log it concludes that sensor readings are lost (stamped 1 Jan 2000): box A4:CB:8F:C9:47:3C sent only device_status and microphone. - service/influx.py: query, CSV parsing, classification (no new deps) - guides: new step type "influx" (host answers via influx_result) - Settings > InfluxDB access (or KOTA_INFLUX_TOKEN); token never logged - service status mapping for tech.influx.* findings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b6d43eb45
commit
66cb804102
11 files changed
+676
-15
No files matched your search
@@ -50,6 +50,13 @@ Stare środowiska `venv-0.x` z poprzednich wersji można usunąć.
|
|||||||
|
|
||||||
Sesje, kopie zapasowe flash i ustawienia są w `~/KotaTechnicianTool` (Windows: `C:\Users\<użytkownik>\KotaTechnicianTool`).
|
Sesje, kopie zapasowe flash i ustawienia są w `~/KotaTechnicianTool` (Windows: `C:\Users\<użytkownik>\KotaTechnicianTool`).
|
||||||
|
|
||||||
|
## Dane w InfluxDB
|
||||||
|
|
||||||
|
Kontrola **Sensor data in the database (InfluxDB)** (Guided troubleshooting, także ostatni krok Quick check)
|
||||||
|
sprawdza online, czy do InfluxDB trafiają dane z czujników boxa, a nie tylko jego status. Wymaga internetu
|
||||||
|
na laptopie i jednorazowego ustawienia tokenu tylko do odczytu (od zespołu firmware):
|
||||||
|
**Settings > InfluxDB access...**. Tokenu nie zapisujemy w repozytorium.
|
||||||
|
|
||||||
## Port sensorboxa
|
## Port sensorboxa
|
||||||
|
|
||||||
- Windows: `COMx - Sensorbox (native USB) [MAC]`
|
- Windows: `COMx - Sensorbox (native USB) [MAC]`
|
||||||
|
|||||||
@@ -12,3 +12,15 @@
|
|||||||
the firmware team should bump the version for the next build.
|
the firmware team should bump the version for the next build.
|
||||||
- error_ids.csv updated from the firmware (E-CON-01).
|
- error_ids.csv updated from the firmware (E-CON-01).
|
||||||
- Test that builds the main window (catches start-up errors).
|
- Test that builds the main window (catches start-up errors).
|
||||||
|
- New check **Sensor data in the database (InfluxDB)** (Guided troubleshooting; also the last step of the
|
||||||
|
Quick check on the green path): queries InfluxDB online (read-only) for the box's newest points and tells
|
||||||
|
apart "sensor data arrives", "status arrives but sensor data does not", "offline now" and "never seen".
|
||||||
|
Polls every 30 s for up to 4 minutes. With a stopped RTC in the log it concludes that the sensor readings
|
||||||
|
are lost (they are stamped 1 Jan 2000) - found on box A4:CB:8F:C9:47:3C, which reported only
|
||||||
|
device_status and microphone. The result and the per-measurement times go into the report and the
|
||||||
|
service note.
|
||||||
|
- Settings > **InfluxDB access...**: address, organisation, bucket and a read token (or the
|
||||||
|
`KOTA_INFLUX_TOKEN` environment variable). Defaults: influx.kota-app.com, org `kota`, bucket `sensorbox`.
|
||||||
|
The token is never written to the session log or the service report.
|
||||||
|
- macOS: Connect no longer restarts the box (pyserial dropped DTR before RTS, passing through the
|
||||||
|
USB-Serial-JTAG reset state). Windows unchanged.
|
||||||
@@ -4,7 +4,8 @@ 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
|
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
|
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
|
records every answer and conclusion in the engine so they appear in the
|
||||||
report and the session log.
|
report and the session log. "influx" steps ask the host to query InfluxDB
|
||||||
|
(on_action("influx")); the host answers with influx_result().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,7 +25,9 @@ GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
|||||||
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "CYAN", "OFF", "CYCLING"]
|
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "CYAN", "OFF", "CYCLING"]
|
||||||
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
|
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
|
||||||
"CYCLING": "Keeps changing / goes dark every few seconds"}
|
"CYCLING": "Keeps changing / goes dark every few seconds"}
|
||||||
STEP_TYPES = {"led", "question", "info", "wait", "route"}
|
STEP_TYPES = {"led", "question", "info", "wait", "route", "influx"}
|
||||||
|
# Results of an InfluxDB check (service.influx.OUTCOMES).
|
||||||
|
INFLUX_OUTCOMES = ("ok", "sensors_missing", "silent", "never", "unavailable")
|
||||||
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
||||||
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
||||||
|
|
||||||
@@ -58,9 +61,12 @@ class Step:
|
|||||||
skip_unless_feature: str | None = None
|
skip_unless_feature: str | None = None
|
||||||
skip_to: str | None = None
|
skip_to: str | None = None
|
||||||
show_qr: bool = False # show the Matter pairing QR next to the step text
|
show_qr: bool = False # show the Matter pairing QR next to the step text
|
||||||
|
outcomes: dict[str, str] = field(default_factory=dict) # influx result -> target
|
||||||
|
poll_s: float = 30.0 # influx: query again after this many seconds
|
||||||
|
|
||||||
def targets(self) -> list[str]:
|
def targets(self) -> list[str]:
|
||||||
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
||||||
|
t += list(self.outcomes.values())
|
||||||
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x]
|
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
|
return t
|
||||||
|
|
||||||
@@ -103,6 +109,7 @@ def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
|||||||
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
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_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
||||||
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
|
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
|
||||||
|
outcomes=dict(s.get("outcomes", {})), poll_s=float(s.get("poll_s", 30)),
|
||||||
)
|
)
|
||||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
||||||
for e in g.get("end", [])}
|
for e in g.get("end", [])}
|
||||||
@@ -123,6 +130,10 @@ def _validate(guides: dict[str, Guide]) -> None:
|
|||||||
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {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):
|
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")
|
raise ValueError(f"{g.id}.{s.id}: wait step needs until/until_milestone/pass_on_timeout")
|
||||||
|
if s.type == "influx" and set(INFLUX_OUTCOMES) - set(s.outcomes):
|
||||||
|
raise ValueError(f"{g.id}.{s.id}: influx step needs outcomes {set(INFLUX_OUTCOMES) - set(s.outcomes)}")
|
||||||
|
if set(s.outcomes) - set(INFLUX_OUTCOMES):
|
||||||
|
raise ValueError(f"{g.id}.{s.id}: unknown influx outcomes {set(s.outcomes) - set(INFLUX_OUTCOMES)}")
|
||||||
if s.tool_action not in (None, "reset"):
|
if s.tool_action not in (None, "reset"):
|
||||||
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
|
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
|
||||||
for t in s.targets():
|
for t in s.targets():
|
||||||
@@ -191,6 +202,10 @@ class GuideRunner:
|
|||||||
self._wait_started = 0.0
|
self._wait_started = 0.0
|
||||||
self._wait_boot = 0
|
self._wait_boot = 0
|
||||||
self._wait_milestones: set[str] = set()
|
self._wait_milestones: set[str] = set()
|
||||||
|
self._influx_pending = False
|
||||||
|
self._influx_next = 0.0
|
||||||
|
self._influx_last: str | None = None
|
||||||
|
self._influx_noted = ""
|
||||||
|
|
||||||
def start(self, guide_id: str) -> None:
|
def start(self, guide_id: str) -> None:
|
||||||
self.cancel()
|
self.cancel()
|
||||||
@@ -227,6 +242,28 @@ class GuideRunner:
|
|||||||
elif s.type == "wait":
|
elif s.type == "wait":
|
||||||
self._record(s, "technician skipped the wait")
|
self._record(s, "technician skipped the wait")
|
||||||
self._finish_wait("timeout")
|
self._finish_wait("timeout")
|
||||||
|
elif s.type == "influx":
|
||||||
|
self._record(s, "technician skipped the InfluxDB check")
|
||||||
|
self._finish_influx(self._influx_last or "unavailable")
|
||||||
|
|
||||||
|
def influx_result(self, outcome: str, summary: str, details: list[str] | tuple[str, ...] = ()) -> None:
|
||||||
|
"""Result of the host's InfluxDB query for the current influx step."""
|
||||||
|
s = self.step
|
||||||
|
if s is None or s.type != "influx" or not self._influx_pending:
|
||||||
|
return # stale answer: the technician moved on
|
||||||
|
self._influx_pending = False
|
||||||
|
self._influx_last = outcome
|
||||||
|
note = "InfluxDB: " + "; ".join([summary, *details])
|
||||||
|
if note != self._influx_noted: # polls repeat the same answer
|
||||||
|
self._influx_noted = note
|
||||||
|
self.engine.note_technician(note)
|
||||||
|
self._on_log(f"InfluxDB check: {summary}")
|
||||||
|
waited = self._clock() - self._wait_started
|
||||||
|
if outcome in ("ok", "unavailable") or waited >= s.timeout_s:
|
||||||
|
self._finish_influx(outcome)
|
||||||
|
else:
|
||||||
|
self._influx_next = self._clock() + s.poll_s
|
||||||
|
self.note = f"{summary}. Checking again in {s.poll_s:.0f} s..."
|
||||||
|
|
||||||
def back(self) -> None:
|
def back(self) -> None:
|
||||||
# Never step back into a wait: re-entering it could restart the box.
|
# Never step back into a wait: re-entering it could restart the box.
|
||||||
@@ -256,6 +293,11 @@ class GuideRunner:
|
|||||||
s = self.step
|
s = self.step
|
||||||
if s is not None and s.type == "wait" and self._clock() - self._wait_started >= s.timeout_s:
|
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")
|
self._finish_wait("ok" if s.pass_on_timeout else "timeout")
|
||||||
|
elif s is not None and s.type == "influx" and not self._influx_pending:
|
||||||
|
if self._clock() - self._wait_started >= s.timeout_s:
|
||||||
|
self._finish_influx(self._influx_last or "unavailable")
|
||||||
|
elif self._clock() >= self._influx_next:
|
||||||
|
self._request_influx()
|
||||||
|
|
||||||
# --------------------------------------------------------------- view
|
# --------------------------------------------------------------- view
|
||||||
def view(self) -> View:
|
def view(self) -> View:
|
||||||
@@ -268,7 +310,7 @@ class GuideRunner:
|
|||||||
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
|
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
|
||||||
elif s.type == "question":
|
elif s.type == "question":
|
||||||
v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
|
v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
|
||||||
elif s.type == "wait":
|
elif s.type in ("wait", "influx"):
|
||||||
v.waiting = True
|
v.waiting = True
|
||||||
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
|
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
|
||||||
return v
|
return v
|
||||||
@@ -316,6 +358,27 @@ class GuideRunner:
|
|||||||
if s.until_milestone and not s.tool_action and s.until_milestone in self.engine.milestones:
|
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._record(s, f"already reached: {s.until_milestone}")
|
||||||
self._finish_wait("ok")
|
self._finish_wait("ok")
|
||||||
|
if s.type == "influx":
|
||||||
|
self._wait_started = self._clock()
|
||||||
|
self._influx_last = None
|
||||||
|
self._request_influx()
|
||||||
|
|
||||||
|
def _request_influx(self) -> None:
|
||||||
|
self._influx_pending = True
|
||||||
|
if not self._on_action("influx"):
|
||||||
|
self._influx_pending = False
|
||||||
|
self._influx_last = "unavailable"
|
||||||
|
self._record(self.step, "InfluxDB check not possible from this computer")
|
||||||
|
self._finish_influx("unavailable")
|
||||||
|
|
||||||
|
def _finish_influx(self, outcome: str) -> None:
|
||||||
|
s = self.step
|
||||||
|
self._influx_pending = False
|
||||||
|
target = s.outcomes.get(outcome)
|
||||||
|
if target is None:
|
||||||
|
return
|
||||||
|
self._push()
|
||||||
|
self._go(target)
|
||||||
|
|
||||||
def _auto(self, s: Step, why: str, target: str | None) -> None:
|
def _auto(self, s: Step, why: str, target: str | None) -> None:
|
||||||
self._on_log(f"technician guide: {s.id}: {why}")
|
self._on_log(f"technician guide: {s.id}: {why}")
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
# counts as ok (used for "watch for N seconds").
|
# counts as ok (used for "watch for N seconds").
|
||||||
# route automatic: first current root finding whose rule id is in
|
# route automatic: first current root finding whose rule id is in
|
||||||
# `routes` decides; otherwise `default`.
|
# `routes` decides; otherwise `default`.
|
||||||
|
# influx the tool asks InfluxDB (online) whether this box's data arrives,
|
||||||
|
# again every `poll_s` until "ok" or `timeout_s`; `outcomes` maps
|
||||||
|
# ok / sensors_missing / silent / never / unavailable -> target.
|
||||||
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
|
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
|
||||||
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
|
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
|
||||||
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
|
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
|
||||||
@@ -48,14 +51,7 @@ options = [
|
|||||||
id = "green_route"
|
id = "green_route"
|
||||||
type = "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" }
|
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"
|
default = "guide:influx"
|
||||||
|
|
||||||
[[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]]
|
[[guide.end]]
|
||||||
id = "hung"
|
id = "hung"
|
||||||
@@ -169,6 +165,7 @@ text = "Pairing, Internet time and the first upload all worked."
|
|||||||
actions = [
|
actions = [
|
||||||
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
|
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
|
||||||
"It should end green.",
|
"It should end green.",
|
||||||
|
"Then run the check 'Sensor data in the database' - the first upload may carry only the box status.",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[guide.end]]
|
[[guide.end]]
|
||||||
@@ -738,3 +735,83 @@ severity = "error"
|
|||||||
title = "Microphone records nothing"
|
title = "Microphone records nothing"
|
||||||
text = "Even loud noise next to the box gives no signal, although the switch is on."
|
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."]
|
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
||||||
|
|
||||||
|
# ================================================================== influx
|
||||||
|
[[guide]]
|
||||||
|
id = "influx"
|
||||||
|
title = "Sensor data in the database (InfluxDB)"
|
||||||
|
summary = "Checks online that the sensor readings of this box reach InfluxDB, not only its status."
|
||||||
|
recommend = ["always"]
|
||||||
|
start = "check"
|
||||||
|
|
||||||
|
[[guide.step]]
|
||||||
|
id = "check"
|
||||||
|
type = "influx"
|
||||||
|
text = "Checking in InfluxDB that this box's data arrives: device status, microphone and the sensors (AS7341, BME688, SEN66). Keep the box switched on and online. The tool checks again every 30 s for up to 4 minutes (the air sensor needs about 2 minutes after a restart). The laptop needs Internet for this check."
|
||||||
|
timeout_s = 240
|
||||||
|
poll_s = 30
|
||||||
|
outcomes = { ok = "end:data_ok", sensors_missing = "why", silent = "end:silent", never = "end:never", unavailable = "end:unavailable" }
|
||||||
|
|
||||||
|
[[guide.step]]
|
||||||
|
id = "why"
|
||||||
|
type = "route"
|
||||||
|
routes = { "kota.rtc_stopped" = "end:sensors_clock", "rtc.clock_not_running" = "end:sensors_clock", "kota.rtc_not_ticking" = "end:sensors_clock", "kota.time_invalid" = "end:sensors_clock", "kota.rtc_read_failed" = "guide:rtc_i2c", "rtc.init_failed" = "guide:rtc_i2c", "sensor.init_failed" = "guide:sensors", "kota.sensor_missing" = "guide:sensors" }
|
||||||
|
default = "end:sensors_missing"
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "data_ok"
|
||||||
|
severity = "ok"
|
||||||
|
title = "Box is healthy and its sensor data reaches InfluxDB"
|
||||||
|
text = "Status, microphone and all sensor readings of this box arrived in InfluxDB in the last 15 minutes."
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "sensors_clock"
|
||||||
|
severity = "error"
|
||||||
|
title = "Sensor data is lost: the box clock (RTC) is not running"
|
||||||
|
text = "The box sends its status and microphone level with the Internet time, but the sensor readings are timestamped by the clock module. With the clock stopped they are stamped 1 Jan 2000 and do not show up in InfluxDB."
|
||||||
|
actions = [
|
||||||
|
"Replace the RTC coin cell (inside the case), switch the box off and on, then run this check again.",
|
||||||
|
"If the Diagnosis tab still shows the clock not running with a new cell: replace the RTC module.",
|
||||||
|
"Save the session log for the firmware team.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "sensors_missing"
|
||||||
|
severity = "error"
|
||||||
|
title = "Status arrives, sensor data does not"
|
||||||
|
text = "The box is online and InfluxDB receives its status, but readings of some sensors are missing (the list is in the technician notes of the report)."
|
||||||
|
actions = [
|
||||||
|
"Check the Diagnosis tab for sensor errors; re-seat the cable of the missing sensor (inside the case).",
|
||||||
|
"If the log shows no sensor error: save the session log and send it to the firmware team.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "silent"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Box is not uploading right now"
|
||||||
|
text = "InfluxDB has older data from this box, but nothing from the last 15 minutes."
|
||||||
|
actions = [
|
||||||
|
"Check that the box has Wi-Fi and Internet (Purple LED / no Internet check), then run this check again.",
|
||||||
|
"The technician notes of the report show when each kind of data arrived last - if the sensor data stopped long before the status, treat it like 'Status arrives, sensor data does not'.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "never"
|
||||||
|
severity = "warning"
|
||||||
|
title = "No data from this box in InfluxDB"
|
||||||
|
text = "In the last 30 days InfluxDB received nothing from this box: it was never online, or its SD card has wrong database settings."
|
||||||
|
actions = [
|
||||||
|
"Run the Purple LED / no Internet check.",
|
||||||
|
"If the box is online: re-provision the SD card with the current ENV.TXT (SD card tab).",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "unavailable"
|
||||||
|
severity = "info"
|
||||||
|
title = "Could not check InfluxDB"
|
||||||
|
text = "The tool could not query InfluxDB from this computer (no Internet on the laptop, InfluxDB access not set up, or the box MAC not known yet)."
|
||||||
|
actions = [
|
||||||
|
"Settings > InfluxDB access: enter the read token from the firmware team.",
|
||||||
|
"Or check the box later in the service system (Stan w InfluxDB).",
|
||||||
|
]
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Does the box's data reach InfluxDB? (read-only query, InfluxDB 2.x)
|
||||||
|
|
||||||
|
The box writes to the "sensorbox" bucket with the tag device=<MAC without
|
||||||
|
separators>. device_status and microphone use the system time, the sensor
|
||||||
|
measurements (AS7341, BME688, SEN66) are timestamped from the box's RTC -
|
||||||
|
so a box can report its status while none of its sensor data arrives
|
||||||
|
(e.g. a stopped RTC stamps readings with 1 Jan 2000).
|
||||||
|
|
||||||
|
The token must be a read token for the bucket. It comes from the
|
||||||
|
KOTA_INFLUX_TOKEN environment variable or the tool settings and is never
|
||||||
|
written to the session log or the service report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import datetime as dt
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
DEFAULT_URL = "https://influx.kota-app.com"
|
||||||
|
DEFAULT_ORG = "kota"
|
||||||
|
DEFAULT_BUCKET = "sensorbox"
|
||||||
|
LOOKBACK = "-30d"
|
||||||
|
FRESH_MIN = 15 # like SEEN_OK_MIN in the service system
|
||||||
|
FUTURE_TOLERANCE_MIN = 10 # newer than now + this: the box clock is ahead
|
||||||
|
TIMEOUT_S = 20
|
||||||
|
|
||||||
|
# Sensor groups that every box must upload: name -> measurement prefixes.
|
||||||
|
SENSOR_GROUPS = {
|
||||||
|
"AS7341 (light)": ("as7341_",),
|
||||||
|
"BME688 (air)": ("bme688",),
|
||||||
|
"SEN66 (air quality)": ("sen66",),
|
||||||
|
}
|
||||||
|
STATUS = "device_status"
|
||||||
|
MIC = "microphone"
|
||||||
|
|
||||||
|
OK = "ok" # all sensor groups arrive
|
||||||
|
SENSORS_MISSING = "sensors_missing" # status/microphone arrive, (some) sensor data does not
|
||||||
|
SILENT = "silent" # data exists, but nothing recent
|
||||||
|
NEVER = "never" # no data for this box in the lookback window
|
||||||
|
UNAVAILABLE = "unavailable" # no token / no Internet / query failed
|
||||||
|
OUTCOMES = (OK, SENSORS_MISSING, SILENT, NEVER, UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InfluxConfig:
|
||||||
|
token: str
|
||||||
|
url: str = DEFAULT_URL
|
||||||
|
org: str = DEFAULT_ORG
|
||||||
|
bucket: str = DEFAULT_BUCKET
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InfluxCheck:
|
||||||
|
outcome: str
|
||||||
|
summary: str
|
||||||
|
latest: dict[str, dt.datetime] = field(default_factory=dict) # measurement -> newest point
|
||||||
|
missing: list[str] = field(default_factory=list) # sensor groups without fresh data
|
||||||
|
details: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def build_query(bucket: str, mac: str) -> str:
|
||||||
|
"""Newest point per measurement of one box (future times included)."""
|
||||||
|
return "\n".join([
|
||||||
|
f"from(bucket: {json.dumps(bucket)})",
|
||||||
|
f" |> range(start: {LOOKBACK}, stop: 2100-01-01T00:00:00Z)",
|
||||||
|
f" |> filter(fn: (r) => r.device == {json.dumps(mac)})",
|
||||||
|
" |> last()",
|
||||||
|
# Drop _value first: string and float fields cannot share a table.
|
||||||
|
' |> keep(columns: ["_measurement", "_time"])',
|
||||||
|
' |> group(columns: ["_measurement"])',
|
||||||
|
' |> max(column: "_time")',
|
||||||
|
" |> group()",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(value: str) -> dt.datetime:
|
||||||
|
"""RFC 3339 from InfluxDB; nanoseconds are cut to microseconds."""
|
||||||
|
value = value.strip().replace("Z", "+00:00")
|
||||||
|
if "." in value:
|
||||||
|
head, rest = value.split(".", 1)
|
||||||
|
frac, sign, tz = rest.partition("+") if "+" in rest else rest.partition("-")
|
||||||
|
value = f"{head}.{frac[:6]}{sign}{tz}"
|
||||||
|
return dt.datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(text: str) -> dict[str, dt.datetime]:
|
||||||
|
"""Annotated/plain CSV from /api/v2/query -> {measurement: newest time}."""
|
||||||
|
latest: dict[str, dt.datetime] = {}
|
||||||
|
header: list[str] | None = None
|
||||||
|
for row in csv.reader(io.StringIO(text)):
|
||||||
|
if not row or not any(row):
|
||||||
|
header = None # tables are separated by an empty line
|
||||||
|
continue
|
||||||
|
if row[0].startswith("#"):
|
||||||
|
continue
|
||||||
|
if header is None:
|
||||||
|
header = row
|
||||||
|
continue
|
||||||
|
rec = dict(zip(header, row))
|
||||||
|
m, t = rec.get("_measurement"), rec.get("_time")
|
||||||
|
if m and t:
|
||||||
|
ts = parse_time(t)
|
||||||
|
if m not in latest or ts > latest[m]:
|
||||||
|
latest[m] = ts
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(now: dt.datetime, t: dt.datetime) -> str:
|
||||||
|
minutes = (now - t).total_seconds() / 60
|
||||||
|
if minutes < -1:
|
||||||
|
return f"{-minutes:.0f} min in the FUTURE"
|
||||||
|
if minutes < 90:
|
||||||
|
return f"{max(minutes, 0):.0f} min ago"
|
||||||
|
if minutes < 48 * 60:
|
||||||
|
return f"{minutes / 60:.0f} h ago"
|
||||||
|
return f"{minutes / 1440:.0f} days ago"
|
||||||
|
|
||||||
|
|
||||||
|
def classify(latest: dict[str, dt.datetime], now: dt.datetime, fresh_min: int = FRESH_MIN) -> InfluxCheck:
|
||||||
|
def newest(prefixes: tuple[str, ...]) -> dt.datetime | None:
|
||||||
|
times = [t for m, t in latest.items() if m.startswith(prefixes)]
|
||||||
|
return max(times) if times else None
|
||||||
|
|
||||||
|
def fresh(t: dt.datetime | None) -> bool:
|
||||||
|
return t is not None and (now - t).total_seconds() <= fresh_min * 60
|
||||||
|
|
||||||
|
details = []
|
||||||
|
for label, prefixes in (("Device status", (STATUS,)), ("Microphone", (MIC,)),
|
||||||
|
*SENSOR_GROUPS.items()):
|
||||||
|
t = newest(prefixes)
|
||||||
|
details.append(f"{label}: " + (f"{t:%Y-%m-%d %H:%M} UTC ({_ago(now, t)})" if t else "no data"))
|
||||||
|
if t is not None and (t - now).total_seconds() > FUTURE_TOLERANCE_MIN * 60:
|
||||||
|
details.append(f"{label}: timestamp is in the future - the box clock is wrong")
|
||||||
|
missing = [g for g, p in SENSOR_GROUPS.items() if not fresh(newest(p))]
|
||||||
|
status_t = max(filter(None, (newest((STATUS,)), newest((MIC,)))), default=None)
|
||||||
|
sensors_t = [newest(p) for p in SENSOR_GROUPS.values()]
|
||||||
|
lagging = status_t is not None and any(t is None or (status_t - t).total_seconds() > fresh_min * 60
|
||||||
|
for t in sensors_t)
|
||||||
|
if lagging:
|
||||||
|
details.append("Sensor data stopped (or never started) while the status still arrived")
|
||||||
|
|
||||||
|
if not latest:
|
||||||
|
return InfluxCheck(NEVER, f"No data from this box in InfluxDB in the last {LOOKBACK[1:]}",
|
||||||
|
latest, missing, details)
|
||||||
|
if not missing:
|
||||||
|
return InfluxCheck(OK, "Sensor data arrives in InfluxDB", latest, missing, details)
|
||||||
|
if fresh(status_t):
|
||||||
|
return InfluxCheck(SENSORS_MISSING, "Status arrives in InfluxDB, sensor data does not: "
|
||||||
|
+ ", ".join(missing), latest, missing, details)
|
||||||
|
if lagging:
|
||||||
|
# Offline now, but the history already shows the fault.
|
||||||
|
return InfluxCheck(SENSORS_MISSING, f"Box is offline now (last status {_ago(now, status_t)}); "
|
||||||
|
"before that its status arrived without sensor data: " + ", ".join(missing),
|
||||||
|
latest, missing, details)
|
||||||
|
return InfluxCheck(SILENT, f"No recent data in InfluxDB (nothing in the last {fresh_min} min)",
|
||||||
|
latest, missing, details)
|
||||||
|
|
||||||
|
|
||||||
|
def query_latest(cfg: InfluxConfig, mac: str) -> dict[str, dt.datetime]:
|
||||||
|
url = cfg.url.rstrip("/") + "/api/v2/query?" + urllib.parse.urlencode({"org": cfg.org})
|
||||||
|
req = urllib.request.Request(url, data=build_query(cfg.bucket, mac).encode(), method="POST", headers={
|
||||||
|
"Authorization": f"Token {cfg.token}",
|
||||||
|
"Content-Type": "application/vnd.flux",
|
||||||
|
"Accept": "application/csv",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
|
||||||
|
return parse_csv(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_device(cfg: InfluxConfig | None, mac: str, now: dt.datetime | None = None) -> InfluxCheck:
|
||||||
|
"""Blocking; run it off the UI thread. Never raises."""
|
||||||
|
mac = "".join(c for c in (mac or "") if c.isalnum()).upper()
|
||||||
|
if cfg is None or not cfg.token:
|
||||||
|
return InfluxCheck(UNAVAILABLE, "InfluxDB access is not set up (Settings > InfluxDB access)")
|
||||||
|
if len(mac) != 12:
|
||||||
|
return InfluxCheck(UNAVAILABLE, "The box MAC address is not known yet")
|
||||||
|
try:
|
||||||
|
latest = query_latest(cfg, mac)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
why = {401: "the token was rejected", 403: "the token has no read access",
|
||||||
|
404: "organisation or bucket not found"}.get(exc.code, f"HTTP {exc.code}")
|
||||||
|
return InfluxCheck(UNAVAILABLE, f"InfluxDB query failed: {why}")
|
||||||
|
except (urllib.error.URLError, OSError) as exc:
|
||||||
|
reason = getattr(exc, "reason", exc)
|
||||||
|
return InfluxCheck(UNAVAILABLE, f"InfluxDB not reachable from this computer ({reason})")
|
||||||
|
except (ValueError, csv.Error) as exc:
|
||||||
|
return InfluxCheck(UNAVAILABLE, f"Unexpected InfluxDB answer ({exc})")
|
||||||
|
return classify(latest, now or dt.datetime.now(dt.timezone.utc))
|
||||||
@@ -40,6 +40,8 @@ _STATUS_BY_RULE = [
|
|||||||
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
|
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
|
||||||
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
|
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
|
||||||
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
|
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
|
||||||
|
("tech.influx.sensors_clock", HARDWARE), ("tech.influx.silent", CONNECTIVITY),
|
||||||
|
("tech.influx.never", CONNECTIVITY), ("tech.influx.", CHECK),
|
||||||
# firmware 1.0.16+ (KOTA: lines, status, error IDs)
|
# firmware 1.0.16+ (KOTA: lines, status, error IDs)
|
||||||
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
|
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
|
||||||
("kota.low_heap", SOFTWARE),
|
("kota.low_heap", SOFTWARE),
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ class GuidePanel(QWidget):
|
|||||||
self.wait_bar.setMaximum(int(total))
|
self.wait_bar.setMaximum(int(total))
|
||||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||||
m, s = divmod(int(v.remaining_s), 60)
|
m, s = divmod(int(v.remaining_s), 60)
|
||||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
doing = "Checking InfluxDB" if v.step.type == "influx" else "Watching the live log"
|
||||||
|
self.wait_label.setText(f"{doing}... {m}:{s:02d} left")
|
||||||
self.note.setText(v.note)
|
self.note.setText(v.note)
|
||||||
self.note.setVisible(bool(v.note))
|
self.note.setVisible(bool(v.note))
|
||||||
self.back_btn.setEnabled(v.can_back)
|
self.back_btn.setEnabled(v.can_back)
|
||||||
@@ -212,9 +213,10 @@ class GuidePanel(QWidget):
|
|||||||
self.options.addWidget(b)
|
self.options.addWidget(b)
|
||||||
step_type = v.step.type if v.step else None
|
step_type = v.step.type if v.step else None
|
||||||
self.continue_btn.setVisible(step_type == "info")
|
self.continue_btn.setVisible(step_type == "info")
|
||||||
self.skip_btn.setVisible(step_type == "wait")
|
waits = step_type in ("wait", "influx")
|
||||||
self.wait_bar.setVisible(step_type == "wait")
|
self.skip_btn.setVisible(waits)
|
||||||
self.wait_label.setVisible(step_type == "wait")
|
self.wait_bar.setVisible(waits)
|
||||||
|
self.wait_label.setVisible(waits)
|
||||||
self.end_box.setVisible(is_end)
|
self.end_box.setVisible(is_end)
|
||||||
if is_end:
|
if is_end:
|
||||||
e = v.end
|
e = v.end
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
|
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
|
||||||
@@ -7,10 +9,14 @@ from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTex
|
|||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPlainTextEdit,
|
QPlainTextEdit,
|
||||||
@@ -32,6 +38,7 @@ from ..serialio.commands import loggable
|
|||||||
from ..serialio.manager import SerialManager, State
|
from ..serialio.manager import SerialManager, State
|
||||||
from ..serialio.ports import PortInfo, list_serial_ports
|
from ..serialio.ports import PortInfo, list_serial_ports
|
||||||
from ..matter.payload import SetupPayload, payload_from_log
|
from ..matter.payload import SetupPayload, payload_from_log
|
||||||
|
from ..service import influx
|
||||||
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||||
from .cheatsheet import cheatsheet_html
|
from .cheatsheet import cheatsheet_html
|
||||||
from .diag_panel import DiagnosticsPanel
|
from .diag_panel import DiagnosticsPanel
|
||||||
@@ -63,6 +70,7 @@ class _Bridge(QObject):
|
|||||||
|
|
||||||
line = Signal(object)
|
line = Signal(object)
|
||||||
state = Signal(object, str)
|
state = Signal(object, str)
|
||||||
|
influx = Signal(object) # InfluxCheck from the query thread
|
||||||
|
|
||||||
|
|
||||||
class SerialTab(QWidget):
|
class SerialTab(QWidget):
|
||||||
@@ -71,6 +79,7 @@ class SerialTab(QWidget):
|
|||||||
self._bridge = _Bridge()
|
self._bridge = _Bridge()
|
||||||
self._bridge.line.connect(self._on_line)
|
self._bridge.line.connect(self._on_line)
|
||||||
self._bridge.state.connect(self._on_state)
|
self._bridge.state.connect(self._on_state)
|
||||||
|
self._bridge.influx.connect(self._on_influx)
|
||||||
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
||||||
self._session = SessionLog()
|
self._session = SessionLog()
|
||||||
self._ports: list[PortInfo] = []
|
self._ports: list[PortInfo] = []
|
||||||
@@ -377,8 +386,33 @@ class SerialTab(QWidget):
|
|||||||
if action == "reset" and self._manager.state == State.CONNECTED:
|
if action == "reset" and self._manager.state == State.CONNECTED:
|
||||||
self._manager.reset_board()
|
self._manager.reset_board()
|
||||||
return True
|
return True
|
||||||
|
if action == "influx":
|
||||||
|
cfg = self.influx_config()
|
||||||
|
mac = self._engine.mac or (self._active_port.mac if self._active_port else "") or ""
|
||||||
|
if cfg is None:
|
||||||
|
self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access."
|
||||||
|
return False
|
||||||
|
threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac)),
|
||||||
|
name="influx-check", daemon=True).start()
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def influx_config(self) -> influx.InfluxConfig | None:
|
||||||
|
"""Read token: KOTA_INFLUX_TOKEN, else the tool settings. None when not set up."""
|
||||||
|
token = os.environ.get("KOTA_INFLUX_TOKEN") or str(self._settings.value("influx/token", ""))
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return influx.InfluxConfig(
|
||||||
|
token=token,
|
||||||
|
url=str(self._settings.value("influx/url", influx.DEFAULT_URL)),
|
||||||
|
org=str(self._settings.value("influx/org", influx.DEFAULT_ORG)),
|
||||||
|
bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET)))
|
||||||
|
|
||||||
|
def _on_influx(self, check: influx.InfluxCheck) -> None:
|
||||||
|
self._runner.influx_result(check.outcome, check.summary, check.details)
|
||||||
|
self._diag_dirty = True
|
||||||
|
self.guide.refresh()
|
||||||
|
|
||||||
def _guide_log(self, text: str) -> None:
|
def _guide_log(self, text: str) -> None:
|
||||||
# Technician answers go into the session log and the log view.
|
# Technician answers go into the session log and the log view.
|
||||||
line = host_event(text)
|
line = host_event(text)
|
||||||
@@ -447,6 +481,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.setCentralWidget(tabs)
|
self.setCentralWidget(tabs)
|
||||||
settings_menu = self.menuBar().addMenu("Settings")
|
settings_menu = self.menuBar().addMenu("Settings")
|
||||||
settings_menu.addAction("Service system address...", self._edit_service_base)
|
settings_menu.addAction("Service system address...", self._edit_service_base)
|
||||||
|
settings_menu.addAction("InfluxDB access...", self._edit_influx)
|
||||||
|
|
||||||
def _edit_service_base(self) -> None:
|
def _edit_service_base(self) -> None:
|
||||||
st = self.serial_tab
|
st = self.serial_tab
|
||||||
@@ -459,6 +494,39 @@ class MainWindow(QMainWindow):
|
|||||||
elif ok:
|
elif ok:
|
||||||
QMessageBox.warning(self, "Service system address", "The address must start with https://")
|
QMessageBox.warning(self, "Service system address", "The address must start with https://")
|
||||||
|
|
||||||
|
def _edit_influx(self) -> None:
|
||||||
|
st = self.serial_tab._settings
|
||||||
|
dlg = QDialog(self)
|
||||||
|
dlg.setWindowTitle("InfluxDB access")
|
||||||
|
form = QFormLayout(dlg)
|
||||||
|
form.addRow(QLabel("Used by the check 'Sensor data in the database'. Ask the firmware team for a "
|
||||||
|
"<b>read-only</b> token for the bucket.", wordWrap=True))
|
||||||
|
url = QLineEdit(str(st.value("influx/url", influx.DEFAULT_URL)))
|
||||||
|
org = QLineEdit(str(st.value("influx/org", influx.DEFAULT_ORG)))
|
||||||
|
bucket = QLineEdit(str(st.value("influx/bucket", influx.DEFAULT_BUCKET)))
|
||||||
|
token = QLineEdit(echoMode=QLineEdit.EchoMode.Password)
|
||||||
|
token.setPlaceholderText("stored - leave empty to keep" if st.value("influx/token", "")
|
||||||
|
else "paste the read token")
|
||||||
|
clear = QCheckBox("Remove the stored token")
|
||||||
|
for label, w in (("Address", url), ("Organisation", org), ("Bucket", bucket), ("Token", token), ("", clear)):
|
||||||
|
form.addRow(label, w)
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
buttons.accepted.connect(dlg.accept)
|
||||||
|
buttons.rejected.connect(dlg.reject)
|
||||||
|
form.addRow(buttons)
|
||||||
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||||
|
return
|
||||||
|
if not url.text().strip().startswith("https://"):
|
||||||
|
QMessageBox.warning(self, "InfluxDB access", "The address must start with https://")
|
||||||
|
return
|
||||||
|
st.setValue("influx/url", url.text().strip())
|
||||||
|
st.setValue("influx/org", org.text().strip() or influx.DEFAULT_ORG)
|
||||||
|
st.setValue("influx/bucket", bucket.text().strip() or influx.DEFAULT_BUCKET)
|
||||||
|
if clear.isChecked():
|
||||||
|
st.remove("influx/token")
|
||||||
|
elif token.text().strip():
|
||||||
|
st.setValue("influx/token", token.text().strip())
|
||||||
|
|
||||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||||
self.serial_tab.shutdown()
|
self.serial_tab.shutdown()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
@@ -242,3 +242,95 @@ def test_skip_wait_and_report_contains_technician_checks(rig):
|
|||||||
assert runner.end.id == "update_slow"
|
assert runner.end.id == "update_slow"
|
||||||
text = engine.report().to_text()
|
text = engine.report().to_text()
|
||||||
assert "Technician checks:" in text and "Update is taking very long" in text
|
assert "Technician checks:" in text and "Update is taking very long" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ InfluxDB check
|
||||||
|
RTC_STOPPED = [*BOOT, "I (2945) KOTA: rtc running=0 time=2000-01-01T00:00:00"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def influx_rig(rig):
|
||||||
|
engine, runner, clock, actions, board, log = rig
|
||||||
|
return engine, runner, clock, actions, board
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_ok_ends_healthy(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
assert actions == ["influx"] and runner.view().waiting
|
||||||
|
runner.influx_result("ok", "Sensor data arrives in InfluxDB", ["Device status: 1 min ago"])
|
||||||
|
assert runner.end.id == "data_ok"
|
||||||
|
assert any("InfluxDB: Sensor data arrives" in t for t in engine.technician)
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_polls_until_sensors_arrive(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
runner.influx_result("sensors_missing", "Status arrives, sensor data does not: SEN66")
|
||||||
|
assert runner.end is None and "Checking again" in runner.view().note
|
||||||
|
clock.t += 10
|
||||||
|
runner.tick()
|
||||||
|
assert actions == ["influx"] # not yet
|
||||||
|
clock.t += 25
|
||||||
|
runner.tick()
|
||||||
|
assert actions == ["influx", "influx"] # polled again after poll_s
|
||||||
|
runner.tick()
|
||||||
|
assert actions == ["influx", "influx"] # no second query while one is pending
|
||||||
|
runner.influx_result("ok", "Sensor data arrives in InfluxDB")
|
||||||
|
assert runner.end.id == "data_ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_repeated_answer_is_noted_once(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
for _ in range(3):
|
||||||
|
runner.influx_result("silent", "No recent data in InfluxDB")
|
||||||
|
clock.t += 31
|
||||||
|
runner.tick()
|
||||||
|
assert sum("InfluxDB:" in t for t in engine.technician) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_sensors_missing_with_stopped_clock(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
board(*RTC_STOPPED)
|
||||||
|
runner.start("influx")
|
||||||
|
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
|
||||||
|
clock.t += 300 # past timeout_s
|
||||||
|
runner.tick()
|
||||||
|
assert runner.end.id == "sensors_clock"
|
||||||
|
f = engine.findings["tech.influx.sensors_clock"]
|
||||||
|
assert f.severity == Severity.ERROR and f.active
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_sensors_missing_without_log_cause(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
clock.t += 250
|
||||||
|
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
|
||||||
|
assert runner.end.id == "sensors_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_unavailable_and_stale_results(rig):
|
||||||
|
engine, runner, clock, actions, board, log = rig
|
||||||
|
runner._on_action = lambda a: False # no token / host cannot query
|
||||||
|
runner.start("influx")
|
||||||
|
assert runner.end.id == "unavailable"
|
||||||
|
runner.influx_result("ok", "late answer") # ignored: step already left
|
||||||
|
assert runner.end.id == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_skip_uses_last_result(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
runner.influx_result("silent", "No recent data in InfluxDB")
|
||||||
|
runner.proceed()
|
||||||
|
assert runner.end.id == "silent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_check_green_ends_with_influx_check(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
board(*BOOT)
|
||||||
|
runner.start("quick")
|
||||||
|
runner.answer("GREEN")
|
||||||
|
runner.answer("0") # heartbeat blinking
|
||||||
|
assert runner.guide.id == "influx" and actions == ["influx"]
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""InfluxDB check: query text, CSV parsing, classification, failures (no network)."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
from kota_tech.diag.guides import INFLUX_OUTCOMES
|
||||||
|
from kota_tech.service import influx
|
||||||
|
|
||||||
|
NOW = dt.datetime(2026, 9, 30, 12, 0, tzinfo=dt.timezone.utc)
|
||||||
|
UTC = dt.timezone.utc
|
||||||
|
|
||||||
|
# Real answer shape of /api/v2/query (Accept: application/csv).
|
||||||
|
CSV = (
|
||||||
|
",result,table,_measurement,_time\r\n"
|
||||||
|
",_result,0,as7341_adc,2026-09-30T11:55:01Z\r\n"
|
||||||
|
",_result,0,bme688,2026-09-30T11:55:02.123456789Z\r\n"
|
||||||
|
",_result,0,device_status,2026-09-30T11:58:00Z\r\n"
|
||||||
|
",_result,0,microphone,2026-09-30T11:58:10Z\r\n"
|
||||||
|
",_result,0,sen66,2026-09-30T11:56:00Z\r\n"
|
||||||
|
"\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _t(minutes_ago):
|
||||||
|
return NOW - dt.timedelta(minutes=minutes_ago)
|
||||||
|
|
||||||
|
|
||||||
|
def test_outcomes_match_guide_runner():
|
||||||
|
assert influx.OUTCOMES == INFLUX_OUTCOMES
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_is_for_one_box_and_keeps_only_time():
|
||||||
|
q = influx.build_query("sensorbox", "A4CB8FC9473C")
|
||||||
|
assert 'from(bucket: "sensorbox")' in q and 'r.device == "A4CB8FC9473C"' in q
|
||||||
|
# _value must be dropped before grouping (string and float fields clash -> HTTP 400)
|
||||||
|
assert q.index("keep(") < q.index("group(columns")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_csv_newest_per_measurement():
|
||||||
|
latest = influx.parse_csv(CSV)
|
||||||
|
assert latest["bme688"] == dt.datetime(2026, 9, 30, 11, 55, 2, 123456, tzinfo=UTC)
|
||||||
|
assert set(latest) == {"as7341_adc", "bme688", "device_status", "microphone", "sen66"}
|
||||||
|
assert influx.parse_csv("") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_sensors_fresh_is_ok():
|
||||||
|
c = influx.classify(influx.parse_csv(CSV), NOW)
|
||||||
|
assert c.outcome == influx.OK and not c.missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_without_sensors_is_sensors_missing():
|
||||||
|
# box A4CB8FC9473C: RTC stopped, only status and microphone ever arrived
|
||||||
|
c = influx.classify({"device_status": _t(1), "microphone": _t(1)}, NOW)
|
||||||
|
assert c.outcome == influx.SENSORS_MISSING
|
||||||
|
assert c.missing == list(influx.SENSOR_GROUPS)
|
||||||
|
assert any("never started" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_sensor_missing():
|
||||||
|
latest = {"device_status": _t(1), "as7341_adc": _t(2), "bme688": _t(2), "sen66": _t(300)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert c.outcome == influx.SENSORS_MISSING and c.missing == ["SEN66 (air quality)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_box_whose_sensors_stopped_before_status():
|
||||||
|
# box A4CB8FC946F8: status 29 h ago, sensors 21 days ago
|
||||||
|
latest = {"device_status": _t(29 * 60), "microphone": _t(29 * 60),
|
||||||
|
"as7341_adc": _t(21 * 1440), "bme688": _t(21 * 1440), "sen66": _t(21 * 1440)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert c.outcome == influx.SENSORS_MISSING and "offline now" in c.summary
|
||||||
|
assert any("stopped" in d for d in c.details)
|
||||||
|
assert any("21 days ago" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_box_with_complete_history_is_silent():
|
||||||
|
# box A4CB8FC739EC: everything stopped together 11 days ago
|
||||||
|
latest = {"device_status": _t(11 * 1440), "microphone": _t(11 * 1440),
|
||||||
|
"as7341_adc": _t(11 * 1440 + 3), "bme688": _t(11 * 1440 + 3), "sen66": _t(11 * 1440 + 2)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert c.outcome == influx.SILENT
|
||||||
|
assert not any("stopped" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_is_never():
|
||||||
|
assert influx.classify({}, NOW).outcome == influx.NEVER
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_timestamps_are_flagged():
|
||||||
|
latest = {"device_status": _t(1), "microphone": _t(1), "as7341_adc": _t(-120), "bme688": _t(-120),
|
||||||
|
"sen66": _t(-120)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert any("future" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_device_without_token_or_mac():
|
||||||
|
assert influx.check_device(None, "A4CB8FC9473C").outcome == influx.UNAVAILABLE
|
||||||
|
cfg = influx.InfluxConfig("t")
|
||||||
|
assert influx.check_device(cfg, "").outcome == influx.UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_device_errors_never_raise(monkeypatch):
|
||||||
|
cfg = influx.InfluxConfig("secret-token")
|
||||||
|
|
||||||
|
def rejected(*_a, **_k):
|
||||||
|
raise urllib.error.HTTPError("u", 401, "Unauthorized", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(influx.urllib.request, "urlopen", rejected)
|
||||||
|
c = influx.check_device(cfg, "a4:cb:8f:c9:47:3c")
|
||||||
|
assert c.outcome == influx.UNAVAILABLE and "rejected" in c.summary
|
||||||
|
assert "secret-token" not in c.summary
|
||||||
|
|
||||||
|
def offline(*_a, **_k):
|
||||||
|
raise urllib.error.URLError("nodename nor servname provided")
|
||||||
|
|
||||||
|
monkeypatch.setattr(influx.urllib.request, "urlopen", offline)
|
||||||
|
assert "not reachable" in influx.check_device(cfg, "A4CB8FC9473C").summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_device_sends_token_header_and_normalized_mac(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return CSV.encode()
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout):
|
||||||
|
seen["req"] = req
|
||||||
|
return Resp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(influx.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
c = influx.check_device(influx.InfluxConfig("tok"), "a4:cb:8f:c9:47:3c", now=NOW)
|
||||||
|
req = seen["req"]
|
||||||
|
assert c.outcome == influx.OK
|
||||||
|
assert req.get_header("Authorization") == "Token tok"
|
||||||
|
assert "tok" not in req.full_url and req.full_url.endswith("/api/v2/query?org=kota")
|
||||||
|
assert b'"A4CB8FC9473C"' in req.data
|
||||||
@@ -15,5 +15,7 @@ def test_main_window_builds_with_all_tabs():
|
|||||||
assert [tabs.tabText(i) for i in range(tabs.count())] == \
|
assert [tabs.tabText(i) for i in range(tabs.count())] == \
|
||||||
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
|
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
|
||||||
assert not w.wifi_tab.write_btn.isEnabled() # nothing connected, nothing typed
|
assert not w.wifi_tab.write_btn.isEnabled() # nothing connected, nothing typed
|
||||||
|
settings = [a.text() for m in w.menuBar().actions() for a in m.menu().actions()]
|
||||||
|
assert "InfluxDB access..." in settings
|
||||||
w.serial_tab.shutdown()
|
w.serial_tab.shutdown()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
Reference in new issue
Block a user