296 lines
8.5 KiB
Python
296 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""BLE sensor status / log / names / settings (collector UI bridge)."""
|
|
import json
|
|
import subprocess
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from auth import login_required
|
|
from config_store import load_config, save_config
|
|
from paths import install_root, run_dir, scripts_dir
|
|
|
|
sensors_bp = Blueprint("sensors", __name__)
|
|
|
|
STATUS_NAME = "sensors-status.json"
|
|
LOG_NAME = "sensors-ui.log"
|
|
CMD_NAME = "sensors-cmd"
|
|
NAMES_NAME = "sensors-names.json"
|
|
SETTINGS_NAME = "sensors-settings.json"
|
|
LOG_LIMIT = 200
|
|
DEFAULT_INTERVAL_MIN = 30
|
|
|
|
|
|
def _config_dir():
|
|
p = install_root() / "config"
|
|
try:
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
return p
|
|
|
|
|
|
def _normalize_addr(addr: str) -> str:
|
|
return (addr or "").strip().upper()
|
|
|
|
|
|
def _read_status():
|
|
path = run_dir() / STATUS_NAME
|
|
if not path.exists():
|
|
return {
|
|
"n": 4,
|
|
"pool_full": False,
|
|
"period_s": DEFAULT_INTERVAL_MIN * 60,
|
|
"report_interval_min": DEFAULT_INTERVAL_MIN,
|
|
"slots": [],
|
|
"ts": 0,
|
|
"available": False,
|
|
}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data["available"] = True
|
|
if "report_interval_min" not in data and "period_s" in data:
|
|
try:
|
|
data["report_interval_min"] = max(1, int(data["period_s"]) // 60)
|
|
except (TypeError, ValueError):
|
|
data["report_interval_min"] = DEFAULT_INTERVAL_MIN
|
|
return data
|
|
except (OSError, ValueError):
|
|
return {"available": False, "slots": [], "error": "bad status file"}
|
|
|
|
|
|
def _read_log(limit=LOG_LIMIT):
|
|
path = run_dir() / LOG_NAME
|
|
if not path.exists():
|
|
return []
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except OSError:
|
|
return []
|
|
entries = []
|
|
for line in lines[-limit:]:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entries.append(json.loads(line))
|
|
except ValueError:
|
|
continue
|
|
entries.reverse()
|
|
return entries
|
|
|
|
|
|
def _write_cmd(payload: dict) -> bool:
|
|
path = run_dir() / CMD_NAME
|
|
try:
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _load_names():
|
|
path = _config_dir() / NAMES_NAME
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
out = {}
|
|
for k, v in raw.items():
|
|
addr = _normalize_addr(str(k))
|
|
name = str(v).strip()
|
|
if addr and name:
|
|
out[addr] = name
|
|
return out
|
|
|
|
|
|
def _save_names(names: dict) -> dict:
|
|
cleaned = {}
|
|
for k, v in (names or {}).items():
|
|
addr = _normalize_addr(str(k))
|
|
name = str(v).strip()
|
|
if addr and name:
|
|
cleaned[addr] = name
|
|
path = _config_dir() / NAMES_NAME
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(cleaned, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
tmp.replace(path)
|
|
return cleaned
|
|
|
|
|
|
def _clean_calibration(raw) -> dict:
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
out = {}
|
|
for key, entry in raw.items():
|
|
addr = _normalize_addr(str(key))
|
|
if not addr or not isinstance(entry, dict):
|
|
continue
|
|
try:
|
|
mn = int(entry.get("min"))
|
|
mx = int(entry.get("max"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
out[addr] = {"min": mn, "max": mx}
|
|
return out
|
|
|
|
|
|
def _read_settings_raw() -> dict:
|
|
path = _config_dir() / SETTINGS_NAME
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
def _load_settings():
|
|
raw = _read_settings_raw()
|
|
try:
|
|
v = int(raw.get("report_interval_min", DEFAULT_INTERVAL_MIN))
|
|
except (TypeError, ValueError):
|
|
v = DEFAULT_INTERVAL_MIN
|
|
return {
|
|
"report_interval_min": max(1, min(65535, v)),
|
|
"calibration": _clean_calibration(raw.get("calibration")),
|
|
}
|
|
|
|
|
|
def _write_settings_raw(data: dict) -> None:
|
|
path = _config_dir() / SETTINGS_NAME
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def _save_settings(minutes: int) -> dict:
|
|
v = max(1, min(65535, int(minutes)))
|
|
data = _read_settings_raw()
|
|
data["report_interval_min"] = v
|
|
data["calibration"] = _clean_calibration(data.get("calibration"))
|
|
_write_settings_raw(data)
|
|
_write_cmd({"op": "reload_settings", "report_interval_min": v})
|
|
return {"report_interval_min": v, "calibration": data["calibration"]}
|
|
|
|
|
|
def collector_enabled() -> bool:
|
|
cfg = load_config()
|
|
return bool(cfg.get("settings", {}).get("collectorEnabled", True))
|
|
|
|
|
|
def set_collector_enabled(enabled: bool) -> bool:
|
|
"""Persist the flag, then start or stop the collector unit.
|
|
|
|
On systemctl failure the previous flag is restored.
|
|
"""
|
|
enabled = bool(enabled)
|
|
cfg = load_config()
|
|
settings = cfg.setdefault("settings", {})
|
|
prev = bool(settings.get("collectorEnabled", True))
|
|
settings["collectorEnabled"] = enabled
|
|
save_config(cfg)
|
|
script = scripts_dir() / "bt-service"
|
|
action = "collector-on" if enabled else "collector-off"
|
|
proc = subprocess.run(
|
|
["sudo", "-n", str(script), action],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
cfg = load_config()
|
|
cfg.setdefault("settings", {})["collectorEnabled"] = prev
|
|
save_config(cfg)
|
|
err = (proc.stderr or proc.stdout or "bt-service failed").strip()
|
|
raise RuntimeError(err or "bt-service failed")
|
|
return enabled
|
|
|
|
|
|
@sensors_bp.route("/api/sensors", methods=["GET"])
|
|
@login_required
|
|
def api_sensors():
|
|
return jsonify(_read_status())
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/module", methods=["GET"])
|
|
@login_required
|
|
def api_sensors_module_get():
|
|
return jsonify({"enabled": collector_enabled()})
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/module", methods=["POST"])
|
|
@login_required
|
|
def api_sensors_module_set():
|
|
body = request.get_json(silent=True) or {}
|
|
if "enabled" not in body:
|
|
return jsonify({"ok": False, "error": "enabled is required"}), 400
|
|
try:
|
|
enabled = set_collector_enabled(bool(body.get("enabled")))
|
|
except (OSError, RuntimeError) as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 500
|
|
return jsonify({"ok": True, "enabled": enabled})
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/log", methods=["GET"])
|
|
@login_required
|
|
def api_sensors_log():
|
|
return jsonify({"entries": _read_log()})
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/advertise", methods=["POST"])
|
|
@login_required
|
|
def api_sensors_advertise():
|
|
if not _write_cmd({"op": "advertise"}):
|
|
return jsonify({"ok": False, "error": "cannot write command"}), 500
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/names", methods=["GET"])
|
|
@login_required
|
|
def api_sensors_names_get():
|
|
return jsonify({"names": _load_names()})
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/names", methods=["PUT"])
|
|
@login_required
|
|
def api_sensors_names_put():
|
|
body = request.get_json(silent=True) or {}
|
|
names = body.get("names", body)
|
|
if not isinstance(names, dict):
|
|
return jsonify({"ok": False, "error": "names must be an object"}), 400
|
|
try:
|
|
cleaned = _save_names(names)
|
|
except OSError as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 500
|
|
_write_cmd({"op": "reload_settings"})
|
|
return jsonify({"ok": True, "names": cleaned})
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/settings", methods=["GET"])
|
|
@login_required
|
|
def api_sensors_settings_get():
|
|
return jsonify(_load_settings())
|
|
|
|
|
|
@sensors_bp.route("/api/sensors/settings", methods=["PUT"])
|
|
@login_required
|
|
def api_sensors_settings_put():
|
|
body = request.get_json(silent=True) or {}
|
|
try:
|
|
minutes = int(body.get("report_interval_min", DEFAULT_INTERVAL_MIN))
|
|
except (TypeError, ValueError):
|
|
return jsonify({"ok": False, "error": "report_interval_min must be int"}), 400
|
|
if minutes < 1 or minutes > 65535:
|
|
return jsonify({"ok": False, "error": "report_interval_min out of range"}), 400
|
|
try:
|
|
data = _save_settings(minutes)
|
|
except OSError as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 500
|
|
return jsonify({"ok": True, **data})
|