207 lines
5.9 KiB
Python
207 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""BLE sensor status / log / names / settings (collector UI bridge)."""
|
|
import json
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from auth import login_required
|
|
from paths import install_root, run_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 _load_settings():
|
|
path = _config_dir() / SETTINGS_NAME
|
|
if not path.exists():
|
|
return {"report_interval_min": DEFAULT_INTERVAL_MIN}
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
v = int(raw.get("report_interval_min", DEFAULT_INTERVAL_MIN))
|
|
except (OSError, ValueError, TypeError):
|
|
return {"report_interval_min": DEFAULT_INTERVAL_MIN}
|
|
return {"report_interval_min": max(1, min(65535, v))}
|
|
|
|
|
|
def _save_settings(minutes: int) -> dict:
|
|
v = max(1, min(65535, int(minutes)))
|
|
data = {"report_interval_min": v}
|
|
path = _config_dir() / SETTINGS_NAME
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
tmp.replace(path)
|
|
_write_cmd({"op": "reload_settings", "report_interval_min": v})
|
|
return data
|
|
|
|
|
|
@sensors_bp.route("/api/sensors", methods=["GET"])
|
|
@login_required
|
|
def api_sensors():
|
|
return jsonify(_read_status())
|
|
|
|
|
|
@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})
|