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:
KrzyszurandClaude Opus 5.5 committed 2026-10-01 00:06:48 +02:00
1 parent 2b6d43eb45
commit 66cb804102
11 files changed
+676 -15

No files matched your search

+92
View File
@@ -242,3 +242,95 @@ def test_skip_wait_and_report_contains_technician_checks(rig):
assert runner.end.id == "update_slow"
text = engine.report().to_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"]
+142
View File
@@ -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
+2
View File
@@ -15,5 +15,7 @@ def test_main_window_builds_with_all_tabs():
assert [tabs.tabText(i) for i in range(tabs.count())] == \
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
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()
app.processEvents()