Files
Button/webconfig/sensors.py
T
2026-11-02 16:46:20 +03:00

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})