Files
site/WESP_REL/app/services/admin_peripheral_monitor.py
T
2026-07-17 12:57:18 +03:00

447 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Журнал событий периферии (HX711, GPIO) и снимок hardware-status для админки."""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
_LOG = logging.getLogger(__name__)
_LOCK = threading.Lock()
# (component, code) -> monotonic time of last append
_DEBOUNCE_LAST: Dict[Tuple[str, str], float] = {}
_HOST_ALERT_LAST: Dict[str, float] = {}
def _peripheral_log_path(app) -> str:
return (app.config.get("WESP_ADMIN_PERIPHERAL_LOG_PATH") or "").strip()
def _resolve_path(app, path_str: str) -> Path:
path = Path(path_str)
if not path.is_absolute():
path = Path(app.config.get("BASE_DIR", ".")) / path
return path
def _line_unix_ts(obj: Dict[str, Any]) -> Optional[float]:
u = obj.get("u")
if isinstance(u, (int, float)):
return float(u)
return None
def _debounce_sec(app) -> float:
return float(app.config.get("WESP_PERIPHERAL_EVENT_DEBOUNCE_SEC", 30) or 30)
def append_peripheral_event(
app,
*,
component: str,
code: str,
level: str = "warn",
message: str,
details: Optional[Dict[str, Any]] = None,
force: bool = False,
) -> None:
"""Дописывает событие в JSONL; одинаковые (component, code) не чаще debounce."""
try:
_append_peripheral_event_impl(
app,
component=component,
code=code,
level=level,
message=message,
details=details,
force=force,
)
except Exception:
_LOG.exception("Сбой журнала периферии (WESP_ADMIN_PERIPHERAL_LOG_PATH).")
def _append_peripheral_event_impl(
app,
*,
component: str,
code: str,
level: str,
message: str,
details: Optional[Dict[str, Any]],
force: bool,
) -> None:
path_str = _peripheral_log_path(app)
if not path_str:
return
comp = (component or "unknown")[:64]
cod = (code or "event")[:64]
lvl = level if level in ("ok", "warn", "err", "info") else "warn"
key = (comp, cod)
now = time.monotonic()
debounce = _debounce_sec(app)
with _LOCK:
prev = _DEBOUNCE_LAST.get(key)
if (
not force
and isinstance(prev, (int, float))
and (now - float(prev)) < debounce
):
return
_DEBOUNCE_LAST[key] = now
path = _resolve_path(app, path_str)
row: Dict[str, Any] = {
"u": int(time.time()),
"component": comp,
"code": cod,
"level": lvl,
"message": (message or "")[:2000],
}
if details:
row["details"] = details
line = json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n"
days = int(app.config.get("WESP_ADMIN_PERIPHERAL_LOG_RETENTION_DAYS", 7) or 7)
days = max(1, min(days, 30))
cutoff = time.time() - float(days * 86400)
with _LOCK:
path.parent.mkdir(parents=True, exist_ok=True)
_rewrite_prune_and_append(path, cutoff, line)
def _rewrite_prune_and_append(path: Path, cutoff_ts: float, new_line: str) -> None:
lines_kept: List[str] = []
if path.is_file():
try:
with path.open("r", encoding="utf-8") as f:
for ln in f:
raw = ln.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except json.JSONDecodeError:
continue
ts = _line_unix_ts(obj)
if ts is not None and ts >= cutoff_ts:
lines_kept.append(raw + "\n")
except OSError:
lines_kept = []
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
try:
with tmp.open("w", encoding="utf-8") as out:
for ln in lines_kept:
out.write(ln)
out.write(new_line)
os.replace(str(tmp), str(path))
except OSError:
try:
if tmp.is_file():
tmp.unlink(missing_ok=True)
except OSError:
pass
def read_peripheral_events(
app,
limit: int = 80,
component: Optional[str] = None,
exclude_component: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], str]:
path_str = _peripheral_log_path(app)
if not path_str:
return [], ""
path = _resolve_path(app, path_str)
if not path.is_file():
return [], str(path)
days = int(app.config.get("WESP_ADMIN_PERIPHERAL_LOG_RETENTION_DAYS", 7) or 7)
days = max(1, min(days, 30))
cutoff = time.time() - float(days * 86400)
comp_filter = (component or "").strip().lower() or None
comp_exclude = (exclude_component or "").strip().lower() or None
cap = max(1, min(int(limit), 500))
out: List[Dict[str, Any]] = []
try:
with path.open("r", encoding="utf-8") as f:
for ln in f:
raw = ln.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except json.JSONDecodeError:
continue
if not isinstance(obj, dict):
continue
ts = _line_unix_ts(obj)
if ts is None or ts < cutoff:
continue
comp = str(obj.get("component") or "").lower()
if comp_filter and comp != comp_filter:
continue
if comp_exclude and comp == comp_exclude:
continue
out.append(obj)
except OSError:
return [], str(path)
out.sort(key=lambda x: int(x.get("u") or 0))
return out[-cap:], str(path)
def clear_peripheral_events(
app,
*,
component: Optional[str] = None,
exclude_component: Optional[str] = None,
) -> Tuple[bool, str]:
"""Очищает JSONL-журнал: целиком, по component или всё кроме exclude_component."""
path_str = _peripheral_log_path(app)
if not path_str:
return False, ""
path = _resolve_path(app, path_str)
comp_filter = (component or "").strip().lower() or None
comp_exclude = (exclude_component or "").strip().lower() or None
with _LOCK:
_DEBOUNCE_LAST.clear()
if not comp_filter and not comp_exclude:
_HOST_ALERT_LAST.clear()
try:
path.parent.mkdir(parents=True, exist_ok=True)
if not comp_filter and not comp_exclude:
path.write_text("", encoding="utf-8")
elif not path.is_file():
path.write_text("", encoding="utf-8")
else:
kept: List[str] = []
with path.open("r", encoding="utf-8") as f:
for ln in f:
raw = ln.strip()
if not raw:
continue
line = ln if ln.endswith("\n") else ln + "\n"
try:
obj = json.loads(raw)
except json.JSONDecodeError:
if comp_exclude:
kept.append(line)
continue
if not isinstance(obj, dict):
if comp_exclude:
kept.append(line)
continue
comp = str(obj.get("component") or "").lower()
if comp_filter:
if comp != comp_filter:
kept.append(line)
elif comp_exclude:
if comp == comp_exclude:
kept.append(line)
else:
kept.append(line)
path.write_text("".join(kept), encoding="utf-8")
except OSError as exc:
raise OSError(f"Не удалось очистить журнал: {exc}") from exc
return True, str(path)
def _collect_host_alerts(app, metrics: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Пороги CPU/RAM/диск; при срабатывании пишет в журнал (с debounce)."""
if not metrics.get("available"):
return []
alerts: List[Dict[str, Any]] = []
cpu_thr = float(app.config.get("WESP_HOST_ALERT_CPU_PCT", 90) or 90)
ram_thr = float(app.config.get("WESP_HOST_ALERT_RAM_PCT", 90) or 90)
disk_thr = float(app.config.get("WESP_HOST_ALERT_DISK_PCT", 90) or 90)
cpu = metrics.get("cpu") if isinstance(metrics.get("cpu"), dict) else {}
mem = metrics.get("memory") if isinstance(metrics.get("memory"), dict) else {}
disk = metrics.get("disk") if isinstance(metrics.get("disk"), dict) else {}
checks = [
("host_cpu_high", "cpu", float(cpu.get("percent") or 0), cpu_thr, "CPU"),
("host_ram_high", "memory", float(mem.get("percent") or 0), ram_thr, "RAM"),
("host_disk_high", "disk", float(disk.get("percent") or 0), disk_thr, "Диск /"),
]
now = time.monotonic()
debounce = _debounce_sec(app)
for code, component, pct, thr, label in checks:
ok = pct < thr
alerts.append(
{
"code": code,
"component": "host",
"ok": ok,
"percent": round(pct, 1),
"threshold": thr,
"label": label,
}
)
if ok:
continue
with _LOCK:
prev = _HOST_ALERT_LAST.get(code)
if isinstance(prev, (int, float)) and (now - float(prev)) < debounce:
continue
_HOST_ALERT_LAST[code] = now
append_peripheral_event(
app,
component="host",
code=code,
level="warn",
message=f"{label}: загрузка {pct:.1f}% (порог {thr:.0f}%)",
details={"percent": pct, "threshold": thr},
force=True,
)
return alerts
def _scales_snapshot_from_reader(reader) -> Dict[str, Any]:
health = reader.get_scale_health()
debug = reader.get_scale_debug_snapshot()
sim = reader.get_simulation_state()
payload = {
"weight_kg": reader.get_current_weight(),
**health,
**debug,
**sim,
}
return payload
def _effective_simulation_mode(app) -> bool:
try:
from app.routes.scales import _get_reader
return bool(_get_reader().get_simulation_state().get("simulation_mode"))
except Exception:
return bool(app.config.get("SIMULATION_MODE", False))
def diagnostics_hardware_block(app) -> Dict[str, Any]:
"""Текущее состояние драйверов GPIO/HX711 (без опроса весов)."""
from app.services.hardware import GPIOController, HX711Wrapper
from app.services.hardware_settings_service import is_scale_hardware_platform
cfg = app.config
simulation = _effective_simulation_mode(app)
scale_hw = is_scale_hardware_platform()
pin = int(cfg.get("GPIO_LED_PIN", 18))
dout = int(cfg.get("HX711_DOUT_PIN", 2))
pd_sck = int(cfg.get("HX711_PD_SCK_PIN", 3))
ctrl = GPIOController.get_instance(pin=pin, simulation_mode=simulation or not scale_hw)
wrapper = HX711Wrapper(simulation_mode=simulation or not scale_hw, dout_pin=dout, pd_sck_pin=pd_sck)
rpi = bool(ctrl.rpi_gpio_available)
hx = bool(getattr(wrapper, "_device", None) is not None)
drivers_absent = (not simulation) and scale_hw and (not rpi) and (not hx)
return {
"simulation_mode": simulation,
"scale_hardware_platform": scale_hw,
"gpio_led_pin": pin,
"hx711_dout_pin": dout,
"hx711_pd_sck_pin": pd_sck,
"rpi_gpio_available": rpi,
"hx711_driver_ready": hx,
"drivers_absent": drivers_absent,
}
def _peripherals_block(app, scales_error: Optional[str] = None) -> Dict[str, Any]:
block = diagnostics_hardware_block(app)
if scales_error:
block["scales_reader_error"] = scales_error
block["last_hx711_error"] = None
try:
from app.routes.scales import _get_reader
reader = _get_reader()
block["last_hx711_error"] = reader.get_last_hx711_error()
except Exception as exc:
block["scales_reader_error"] = str(exc)
return block
def build_hardware_status(app) -> Dict[str, Any]:
from app.services.admin_system_metrics import (
collect_host_hardware_overview,
collect_system_metrics,
)
metrics = collect_system_metrics()
host_alerts = _collect_host_alerts(app, metrics)
scales: Dict[str, Any] = {
"available": False,
"error": None,
}
try:
from app.routes.scales import _get_reader
reader = _get_reader()
scales = {"available": True, **_scales_snapshot_from_reader(reader)}
except Exception as exc:
scales = {"available": False, "error": str(exc)}
peripherals = _peripherals_block(app, scales.get("error"))
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"machine": collect_host_hardware_overview(),
"metrics_available": bool(metrics.get("available")),
"host_alerts": host_alerts,
"peripherals": peripherals,
"scales": scales,
"config": {
"simulation_mode": _effective_simulation_mode(app),
"read_interval": float(app.config.get("READ_INTERVAL", 0.05)),
"samples_per_read": int(app.config.get("SAMPLES_PER_READ", 3)),
"hx711_dout_pin": int(app.config.get("HX711_DOUT_PIN", 2)),
"hx711_pd_sck_pin": int(app.config.get("HX711_PD_SCK_PIN", 3)),
"gpio_led_pin": int(app.config.get("GPIO_LED_PIN", 18)),
},
}
def notify_hx711_error(app, message: str) -> None:
append_peripheral_event(
app,
component="hx711",
code="hx711_read_error",
level="err",
message=message,
)
def notify_hx711_recovered(app) -> None:
append_peripheral_event(
app,
component="hx711",
code="hx711_recovered",
level="ok",
message="Чтение HX711 восстановлено",
force=True,
)
def reset_debounce_state_for_tests() -> None:
with _LOCK:
_DEBOUNCE_LAST.clear()
_HOST_ALERT_LAST.clear()