🚧 WIP
This commit is contained in:
+29
-18
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user