Files
kota-technician-tool/kota_tech/service/influx.py
T
KrzyszurandClaude Opus 5.5 bb933ff748 Serial monitor redesign: Test box button, InfluxDB row in the checklist
- 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>
2026-10-01 00:06:48 +02:00

203 lines
8.8 KiB
Python

"""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
SINCE_SLACK_S = 60 # clock difference box / laptop when counting "since the test started"
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,
since: dt.datetime | None = None) -> InfluxCheck:
"""`since`: only data newer than this counts (the box was restarted then)."""
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:
if t is None or (now - t).total_seconds() > fresh_min * 60:
return False
return since is None or (since - t).total_seconds() <= SINCE_SLACK_S
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"Nothing new in InfluxDB (last status {_ago(now, status_t)}); "
"before that the status arrived without sensor data: " + ", ".join(missing),
latest, missing, details)
if since is not None:
return InfluxCheck(SILENT, "No new data in InfluxDB since the test started", 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,
since: 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), since=since)