- Test box: restart + start-up capture + online InfluxDB check (data newer than the restart), with a status strip; checklist and findings are the main view, live log / guided troubleshooting / box details are tabs, rarely used actions in a More menu - light palette on all systems (macOS dark mode made the colours unreadable) - new rule influx.invalid_value: -inf from a silent microphone makes InfluxDB reject every upload (HTTP 400); guide end names it - exclusive serial port on macOS/Linux: two tool windows no longer split the log between them - InfluxDB check: `since` filter, neutral wording when nothing new arrives Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
5.8 KiB
Python
155 lines
5.8 KiB
Python
"""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 "Nothing new" 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
|
|
|
|
|
|
def test_since_ignores_data_from_before_the_test():
|
|
# healthy a few minutes ago, but nothing since the box was restarted by the test
|
|
latest = {"device_status": _t(5), "microphone": _t(5), "as7341_adc": _t(5), "bme688": _t(5), "sen66": _t(5)}
|
|
assert influx.classify(latest, NOW).outcome == influx.OK
|
|
c = influx.classify(latest, NOW, since=_t(2))
|
|
assert c.outcome == influx.SILENT and "since the test started" in c.summary
|
|
latest.update(device_status=_t(0), microphone=_t(0))
|
|
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.SENSORS_MISSING
|
|
latest.update(as7341_adc=_t(0), bme688=_t(0), sen66=_t(2.5)) # within the clock slack
|
|
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.OK
|