This commit is contained in:
Jonas Bengtsson
2026-08-31 22:39:20 +02:00
parent 26a027d1e8
commit ac52671b10
8 changed files with 367 additions and 60 deletions
+29 -18
View File
@@ -20,7 +20,7 @@ import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from listener.db import Inserter
from listener.process import DEFAULT_TIME_KEYS, normalize_imei, process_message
from listener.process import DEFAULT_TIME_KEYS, normalize_imei, process_message, validate_imei_map
logger = logging.getLogger("listener")
@@ -28,23 +28,12 @@ _stop_event = threading.Event()
_shutdown_complete = threading.Event()
def load_imei_map(path: str) -> dict[str, str]:
"""Load the IMEI -> device name map from a JSON file.
Keys must be 15 digit IMEIs; values must be non-empty strings.
"""
def load_imei_map(path: str, log: bool = True) -> dict[str, str]:
"""Load and validate the IMEI -> device name map from a JSON file."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"{path}: expected a JSON object mapping IMEI to device name")
result: dict[str, str] = {}
for key, value in data.items():
if not isinstance(value, str) or not value:
raise ValueError(f"{path}: values must be non-empty strings (key {key!r})")
normalized = key.strip()
if normalize_imei(normalized) is None:
raise ValueError(f"{path}: key {key!r} is not a 15-digit IMEI")
result[normalized] = value
logger.info("loaded %d IMEI mappings from %s", len(result), path)
result = validate_imei_map(data, path)
if log:
logger.info("loaded %d IMEI mappings from %s", len(result), path)
return result
@@ -74,6 +63,7 @@ def main() -> None:
map_file = _env("IMEI_MAP_FILE")
imei_map = load_imei_map(map_file)
imei_lock = threading.Lock() # guards imei_map against the reload thread
imei_key = _env("INJECT_IMEI_KEY", "imei")
device_key = _env("INJECT_DEVICE_KEY", "device")
time_keys = tuple(
@@ -88,6 +78,25 @@ def main() -> None:
Path("/tmp/listener.pid").write_text(str(os.getpid()), encoding="utf-8")
logger.info("listener starting, pid %d", os.getpid())
def reload_imei_map() -> None:
"""Periodically reload the IMEI map so edits via the API take effect
without a container restart. A broken file is logged and skipped; the
previously loaded mapping stays in effect."""
while not _stop_event.wait(timeout=5.0):
try:
fresh = load_imei_map(map_file, log=False)
except (OSError, ValueError, json.JSONDecodeError):
logger.exception("IMEI map reload failed; keeping previous mapping")
continue
nonlocal imei_map
with imei_lock:
if fresh != imei_map:
imei_map = fresh
logger.info("IMEI map reloaded: %d mappings", len(fresh))
reload_thread = threading.Thread(target=reload_imei_map, name="imei-map-reload", daemon=True)
reload_thread.start()
def on_connect(client, userdata, flags, reason_code, properties): # noqa: ANN001
if reason_code.is_failure:
logger.error("broker refused connection: %s", reason_code)
@@ -98,10 +107,12 @@ def main() -> None:
def on_message(client, userdata, message): # noqa: ANN001
try:
topic = message.topic
with imei_lock:
current_map = imei_map
result = process_message(
topic,
bytes(message.payload),
imei_map,
current_map,
imei_key=imei_key,
device_key=device_key,
time_keys=time_keys,
+21
View File
@@ -27,6 +27,27 @@ 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.
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.
"""
if not isinstance(data, dict):
raise ValueError(f"{source}: expected a JSON object mapping IMEI to device name")
result: dict[str, str] = {}
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
return result
def _as_utc(value: datetime) -> datetime:
"""Interpret naive timestamps as UTC."""
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)