163 lines
6.0 KiB
Python
163 lines
6.0 KiB
Python
import logging
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from .config import load_config
|
|
from .database import SessionLocal
|
|
from .meshcentral import _extract_devices, _parse_json_output, _run_meshctrl
|
|
from .models import Asset
|
|
from .presence_state import derive_presence_state, extract_last_address, extract_last_seen
|
|
|
|
logger = logging.getLogger("assetmanager.presence")
|
|
|
|
|
|
def _normalized_node_id(value: Any) -> str:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return ""
|
|
if text.startswith("node//"):
|
|
return text[6:]
|
|
if text.startswith("node/"):
|
|
return text.split("/")[-1]
|
|
return text
|
|
|
|
|
|
def _device_node_id(device: dict[str, Any]) -> str:
|
|
node = device.get("node") if isinstance(device.get("node"), dict) else {}
|
|
return str(
|
|
device.get("_id")
|
|
or device.get("nodeid")
|
|
or device.get("nodeId")
|
|
or device.get("id")
|
|
or node.get("_id")
|
|
or node.get("nodeid")
|
|
or ""
|
|
).strip()
|
|
|
|
|
|
def _device_online(device: dict[str, Any]) -> bool | None:
|
|
for key in ("online", "connected", "isOnline"):
|
|
if key not in device:
|
|
continue
|
|
value = device.get(key)
|
|
if isinstance(value, bool):
|
|
return value
|
|
text = str(value or "").strip().casefold()
|
|
if text in {"1", "true", "yes", "online", "connected"}:
|
|
return True
|
|
if text in {"0", "false", "no", "offline", "disconnected"}:
|
|
return False
|
|
|
|
# MeshCentral exposes a connection bit mask as `conn`.
|
|
if "conn" in device:
|
|
try:
|
|
return int(device.get("conn") or 0) > 0
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _presence_config() -> dict[str, Any] | None:
|
|
config = load_config().get("meshcentral", {})
|
|
if not config.get("enabled") or not config.get("presence_enabled", True):
|
|
return None
|
|
if not config.get("url") or not config.get("username") or not config.get("password"):
|
|
return None
|
|
return config
|
|
|
|
|
|
def refresh_mesh_presence() -> dict[str, int]:
|
|
config = _presence_config()
|
|
if not config:
|
|
return {"checked": 0, "online": 0, "offline": 0, "unknown": 0}
|
|
|
|
# Die Präsenzprüfung läuft standardmäßig alle 30 Sekunden. Ihre erfolgreiche
|
|
# ListDevices-Ausgabe wird nach dem Einlesen nicht als Zeitstempeldatei behalten.
|
|
# Bei einem Parsefehler speichern wir nur eine einzige überschreibbare Fehlerdatei.
|
|
result = _run_meshctrl(
|
|
config,
|
|
"ListDevices",
|
|
include_details=False,
|
|
retain_stdout=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError((result.stderr or result.stdout or "MeshCentral presence query failed").strip())
|
|
|
|
diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics"))
|
|
presence_error_path = diagnostic_dir / "meshctrl-presence-last-error.stdout.json"
|
|
try:
|
|
devices = _extract_devices(_parse_json_output(result.stdout))
|
|
except Exception:
|
|
diagnostic_dir.mkdir(parents=True, exist_ok=True)
|
|
presence_error_path.write_text(result.stdout or "", encoding="utf-8", errors="replace")
|
|
raise
|
|
else:
|
|
presence_error_path.unlink(missing_ok=True)
|
|
by_exact: dict[str, dict[str, Any]] = {}
|
|
by_normalized: dict[str, dict[str, Any]] = {}
|
|
for device in devices:
|
|
node_id = _device_node_id(device)
|
|
if not node_id:
|
|
continue
|
|
by_exact[node_id] = device
|
|
by_normalized[_normalized_node_id(node_id)] = device
|
|
|
|
now = datetime.utcnow()
|
|
counters = {"checked": 0, "online": 0, "offline": 0, "unknown": 0}
|
|
db = SessionLocal()
|
|
try:
|
|
assets = db.query(Asset).filter(Asset.mesh_node_id.isnot(None), Asset.mesh_node_id != "").all()
|
|
for asset in assets:
|
|
counters["checked"] += 1
|
|
node_id = str(asset.mesh_node_id or "").strip()
|
|
device = by_exact.get(node_id) or by_normalized.get(_normalized_node_id(node_id))
|
|
state_value = _device_online(device) if device else None
|
|
reported_last_seen = extract_last_seen(device)
|
|
if reported_last_seen and (asset.mesh_last_seen is None or reported_last_seen > asset.mesh_last_seen):
|
|
asset.mesh_last_seen = reported_last_seen
|
|
|
|
# The live query is authoritative for "online". An offline label is
|
|
# only meaningful once MeshCentral has supplied a historical
|
|
# connection time. Devices without such history remain "unknown".
|
|
effective_last_seen = asset.mesh_last_seen
|
|
derived_state = derive_presence_state(state_value, effective_last_seen)
|
|
asset.mesh_online_state = derived_state
|
|
asset.mesh_online = True if derived_state == "online" else (False if derived_state == "offline" else None)
|
|
if derived_state == "online":
|
|
asset.mesh_last_online_at = now
|
|
counters[derived_state] += 1
|
|
|
|
asset.mesh_presence_updated_at = now
|
|
asset.mesh_presence_source = "meshcentral"
|
|
logger.debug(
|
|
"MeshCentral presence asset=%s node_id=%s live=%s last_seen=%s last_address=%s derived_state=%s",
|
|
asset.name, node_id, state_value, effective_last_seen, extract_last_address(device), derived_state,
|
|
)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
logger.info(
|
|
"MeshCentral presence refreshed: checked=%s online=%s offline=%s unknown=%s",
|
|
counters["checked"],
|
|
counters["online"],
|
|
counters["offline"],
|
|
counters["unknown"],
|
|
)
|
|
return counters
|
|
|
|
|
|
def mesh_presence_loop(stop_event: threading.Event) -> None:
|
|
while not stop_event.is_set():
|
|
config = load_config().get("meshcentral", {})
|
|
interval = max(10, min(int(config.get("presence_interval_seconds", 30) or 30), 600))
|
|
try:
|
|
refresh_mesh_presence()
|
|
except Exception:
|
|
logger.exception("MeshCentral presence refresh failed")
|
|
if stop_event.wait(interval):
|
|
return
|