🚧 Add handling of static metadata
This commit is contained in:
+25
-10
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user