Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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()