🚧 Add handling of static metadata

This commit is contained in:
Jonas Bengtsson
2026-09-04 22:56:02 +02:00
parent 46639a837c
commit c1b9006f78
9 changed files with 762 additions and 87 deletions
+25 -10
View File
@@ -3,8 +3,10 @@
The network thread writes rows; writes happen only on that thread so a plain
(no pool) blocking connection is used, with reconnection on failure.
The IMEI -> device mapping lives in the imei_map table and is polled by a
separate daemon thread (ImeiMapReader) with its own connection.
Device settings live in the imei_map table (one JSONB document per IMEI;
the "device" key holds the device name). The ImeiMapReader daemon thread
polls the table with its own connection and derives the IMEI -> device
name mapping used by the ingest path from the documents.
"""
from __future__ import annotations
@@ -84,12 +86,15 @@ class Inserter:
class ImeiMapReader:
"""Thread that polls the imei_map table for the IMEI -> device mapping.
"""Thread that polls the imei_map table for device settings documents.
Runs its OWN persistent autocommit connection (never shared with
Inserter, whose connection is funneled through the MQTT network thread).
Reconnects on failure; a failed poll keeps the previously read mapping,
mirroring the old broken-file-keeps-last-good-map semantics.
Derives the {imei: device name} mapping used by the ingest path from
the rows' meta documents (meta->>'device'; IMEIs without a usable
device value fall back to the IMEI itself, matching the unmapped-IMEI
behaviour). Runs its OWN persistent autocommit connection (never shared
with Inserter, whose connection is funneled through the MQTT network
thread). Reconnects on failure; a failed poll keeps the previously read
mapping, mirroring the old broken-file-keeps-last-good-map semantics.
"""
POLL_INTERVAL_SECONDS = 5.0
@@ -111,14 +116,24 @@ class ImeiMapReader:
return self._conn
def read(self) -> dict[str, str]:
"""Read the mapping from the database (single SELECT).
"""Read the {imei: device name} mapping from the database (single
SELECT), deriving each device name from the row's settings
document.
Raises psycopg.Error on connection/query failure; the caller keeps
the last good mapping."""
conn = self._connection()
with conn.cursor() as cur:
cur.execute("SELECT imei, device FROM imei_map")
return dict(cur.fetchall())
cur.execute("SELECT imei, meta FROM imei_map")
mapping: dict[str, str] = {}
for imei, meta in cur.fetchall():
device = None
if isinstance(meta, dict):
value = meta.get("device")
if isinstance(value, str) and value:
device = value
mapping[imei] = device if device is not None else imei
return mapping
def _run(self) -> None:
"""Poll loop; runs in its own daemon thread."""
+46 -11
View File
@@ -6,6 +6,7 @@ import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
# An IMEI is a 15 digit decimal number. The topic segment must be exactly
# that: 15 digits, no separators, no letters, no other characters.
@@ -27,24 +28,58 @@ def normalize_imei(value: str) -> str | None:
return value
def validate_imei_map(data: object, source: str) -> dict[str, str]:
"""Validate an IMEI -> device name mapping.
def validate_imei_doc(doc: object, source: str) -> dict[str, Any]:
"""Validate a single device settings document.
Must be a JSON object with 15-digit IMEI keys and non-empty string
values. Raises ValueError with a descriptive message otherwise. Shared
by the listener (file load) and the API (config write endpoint) so both
enforce identical rules.
Must be a JSON object whose "device" key is a non-empty string; every
other key may hold an arbitrary JSON value (merged into message
payloads by the API at read time). The "imei" key is reserved (the
IMEI is the resource key / comes from the topic) and rejected.
Raises ValueError with a descriptive message otherwise. Shared by the
API's per-device and bulk config endpoints so all writers enforce
identical rules.
"""
if not isinstance(doc, dict):
raise ValueError(
f"{source}: expected a JSON object with a \"device\" key "
"and optional extra settings"
)
if "imei" in doc:
raise ValueError(f"{source}: device documents must not contain an \"imei\" key")
device = doc.get("device")
if not isinstance(device, str) or not device:
raise ValueError(f"{source}: \"device\" must be a non-empty string")
return dict(doc)
def validate_imei_map(data: object, source: str) -> dict[str, dict[str, Any]]:
"""Validate an IMEI -> device settings mapping.
Must be a JSON object with 15-digit IMEI keys. Values are either a
settings document (see validate_imei_doc) or — for backward
compatibility with the flat legacy config — a plain device-name
string, which is promoted to {"device": name}. Raises ValueError
with a descriptive message otherwise. Shared by the API (config
write endpoints) and the migration tool so all writers enforce
identical rules.
"""
if not isinstance(data, dict):
raise ValueError(f"{source}: expected a JSON object mapping IMEI to device name")
result: dict[str, str] = {}
raise ValueError(
f"{source}: expected a JSON object mapping IMEI to a device settings document"
)
result: dict[str, dict[str, Any]] = {}
for key, value in data.items():
if not isinstance(value, str) or not value:
raise ValueError(f"{source}: values must be non-empty strings (key {key!r})")
normalized = key.strip()
if normalize_imei(normalized) is None:
raise ValueError(f"{source}: key {key!r} is not a 15-digit IMEI")
result[normalized] = value
if isinstance(value, str):
if not value:
raise ValueError(
f"{source}: device name for {normalized!r} must be a non-empty string"
)
result[normalized] = {"device": value}
else:
result[normalized] = validate_imei_doc(value, source)
return result