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
@@ -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"]
|
||||
@@ -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())] == \
|
||||
["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()
|
||||
Reference in new issue
Block a user