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
@@ -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
|
||||
Reference in new issue
Block a user