@@ -0,0 +1,446 @@
|
||||
"""Журнал событий периферии (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()
|
||||
Reference in New Issue
Block a user