"""The unifi plugin's MCP tools and its get_overview findings (#5839). Registered through register_mcp_tools() in __init__.py, so they exist only while the plugin is enabled. Everything here is read from what the poller stored; nothing reaches the UniFi controller. """ from __future__ import annotations import json from datetime import timedelta from sqlalchemy import or_, select from steward.mcp._context import mcp_app from steward.mcp.tools._shape import ( age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, rnd, summarize, ) from steward.mcp.tools.overview import finding from steward.models.users import UserRole from .models import ( UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiEvent, UnifiFwRule, UnifiKnownClient, UnifiNetwork, UnifiPortForward, UnifiSpeedtestResult, UnifiWanStat, UnifiWlanStat, ) from .routes import device_state, devices_in_order, history # The poller's readings are trusted while newer than this many poll intervals: # an older "WAN down" is a poller that stopped, not a WAN that went down. FRESH_POLLS = 5 DEVICE_STATES = ("down", "degraded", "pending", "up") def _poll_seconds() -> int: cfg = mcp_app().config.get("PLUGINS", {}).get("unifi", {}) return int(cfg.get("poll_interval_seconds", 60) or 60) def _device_row(d, now) -> dict: status, label = device_state(d.state) return {"name": d.name or d.mac, "mac": d.mac, "model": d.model, "type": d.device_type, "ip": d.ip, "status": status, "state": label, "version": d.version, "uptime_s": d.uptime_seconds, "last_seen_at": iso(d.last_seen), "last_seen_age_s": age_s(d.last_seen, now)} async def get_network(range: str = "24h") -> dict: """The UniFi network at a glance: the WAN, clients, Wi-Fi networks, the last speed test, and devices by status. range: for the WAN latency and client-count series: "1h", "6h", "24h" (default), "7d" or "30d". `wan`: up or down, its IP, latency, rx/tx bytes per second and uptime, from the newest poll (`polled_at`); `stale: true` when that poll is old, so the figures are not current. `clients`: connected now (wireless, wired, guest) and the series. `wlans`: per SSID and radio (ng 2.4 GHz, na 5 GHz, 6e 6 GHz) the clients, channel and UniFi's satisfaction score (0-100). `speedtests`: the newest few. `devices`: counts by status (list_devices names them). """ since, key = lookback(range) now = now_utc() async with db_session() as db: wan_history, client_history = await history(db, since, max(60, int((now - since).total_seconds() // 60))) wan = (await db.execute(select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1))).scalar_one_or_none() clients = (await db.execute(select(UnifiClientSnapshot) .order_by(UnifiClientSnapshot.scraped_at.desc()).limit(1))).scalar_one_or_none() wlans = (await db.execute(select(UnifiWlanStat).distinct(UnifiWlanStat.ssid, UnifiWlanStat.radio) .order_by(UnifiWlanStat.ssid, UnifiWlanStat.radio, UnifiWlanStat.scraped_at.desc()))).scalars().all() tests = (await db.execute(select(UnifiSpeedtestResult) .order_by(UnifiSpeedtestResult.run_at.desc()).limit(5))).scalars().all() devices = (await db.execute(select(UnifiDevice))).scalars().all() fresh = timedelta(seconds=FRESH_POLLS * _poll_seconds()) counts: dict[str, int] = {} for d in devices: counts[device_state(d.state)[0]] = counts.get(device_state(d.state)[0], 0) + 1 return { "range": key, "wan": ({"up": wan.is_up, "ip": wan.wan_ip, "latency_ms": rnd(wan.latency_ms, 1), "rx_bytes_per_s": rnd(wan.rx_bytes_rate, 0), "tx_bytes_per_s": rnd(wan.tx_bytes_rate, 0), "uptime_s": wan.uptime_seconds, "polled_at": iso(wan.scraped_at), "stale": now - wan.scraped_at > fresh, "latency_ms_series": summarize([(r.bucket, float(r.latency_ms)) for r in wan_history if r.latency_ms is not None])} if wan else None), "clients": ({"total": clients.total_clients, "wireless": clients.wireless_clients, "wired": clients.wired_clients, "guest": clients.guest_clients, "polled_at": iso(clients.scraped_at), "series": summarize([(r.bucket, float(r.total_clients)) for r in client_history])} if clients else None), "wlans": [{"ssid": w.ssid, "radio": w.radio, "clients": w.num_sta, "channel": w.channel, "satisfaction": w.satisfaction, "polled_at": iso(w.scraped_at)} for w in wlans], "speedtests": [{"run_at": iso(t.run_at), "download_mbps": rnd(t.download_mbps, 1), "upload_mbps": rnd(t.upload_mbps, 1), "latency_ms": rnd(t.latency_ms, 1), "server": t.server_name} for t in tests], "devices": counts, } async def list_devices(status: str = "") -> dict: """UniFi's network devices (gateways, switches, access points): offline and failing first, then those in a transition, then working ones. status: "down" (offline, inform error, adoption failed), "degraded" (firmware mismatch, heartbeat missed, isolated), "pending" (adopting, upgrading, provisioning), "up"; empty for all. """ if status and status not in DEVICE_STATES: raise ValueError(f"status must be one of {', '.join(DEVICE_STATES)}, or empty for all.") now = now_utc() async with db_session() as db: devices = devices_in_order((await db.execute(select(UnifiDevice))).scalars().all()) rows = [_device_row(d, now) for d in devices] return {"devices": [r for r in rows if not status or r["status"] == status], "total": len(rows)} async def list_clients(search: str = "", active: bool = False, limit: int = 50) -> dict: """Network clients UniFi has seen, most recently seen first. search: part of a hostname, alias, IP, MAC or vendor; empty for all. active: true for the top talkers of the newest poll instead (with their current tx/rx rates, signal and SSID). limit: default 50, max 500. A known client carries `first_seen_at` / `last_seen_at`, its vendor (from the MAC), `blocked`, and the alias and note set in UniFi. """ limit = clamp_limit(limit, 50, 500) now = now_utc() async with db_session() as db: if active: snap = (await db.execute(select(UnifiClientSnapshot) .order_by(UnifiClientSnapshot.scraped_at.desc()).limit(1))).scalar_one_or_none() top = json.loads(snap.top_clients_json) if snap else [] if search: needle = search.lower() top = [c for c in top if any(needle in str(c.get(k) or "").lower() for k in ("hostname", "ip", "mac", "essid"))] out = bounded(top, limit, key="clients") out["polled_at"] = iso(snap.scraped_at) if snap else None return out q = select(UnifiKnownClient) if search: like = f"%{search.strip()}%" q = q.where(or_(*(col.ilike(like) for col in ( UnifiKnownClient.hostname, UnifiKnownClient.name, UnifiKnownClient.ip, UnifiKnownClient.mac, UnifiKnownClient.mac_vendor)))) known = (await db.execute(q.order_by(UnifiKnownClient.last_seen.desc().nullslast()) .limit(limit + 1))).scalars().all() rows = [{"mac": c.mac, "hostname": c.hostname, "alias": c.name, "ip": c.ip, "vendor": c.mac_vendor, "blocked": c.is_blocked, "note": c.note, "first_seen_at": iso(c.first_seen), "last_seen_at": iso(c.last_seen), "last_seen_age_s": age_s(c.last_seen, now)} for c in known] out = {"clients": rows[:limit], "truncated": len(rows) > limit} if out["truncated"]: out["more"] = f"More than {limit}. Pass a larger `limit`, or a `search`." return out async def list_network_events(range: str = "24h", category: str = "", search: str = "", limit: int = 100) -> dict: """UniFi's site events, newest first: clients joining and leaving, devices restarting or losing contact, WAN changes, IDS hits. range: "1h", "6h", "24h" (default), "7d" or "30d". category: UniFi's own, e.g. "wlan", "wired", "ap", "wan", "ids", "system"; empty for all. search: part of the message or the event key (e.g. "EVT_AP_Lost_Contact"). limit: default 100, max 500. """ since, key = lookback(range) limit = clamp_limit(limit, 100, 500) q = select(UnifiEvent).where(UnifiEvent.event_time >= since) if category: q = q.where(UnifiEvent.category == category) if search: like = f"%{search.strip()}%" q = q.where(or_(UnifiEvent.message.ilike(like), UnifiEvent.event_key.ilike(like))) async with db_session() as db: rows = (await db.execute(q.order_by(UnifiEvent.event_time.desc()).limit(limit + 1))).scalars().all() events = [{"at": iso(e.event_time), "key": e.event_key, "category": e.category, "message": e.message, "source_mac": e.source_mac, "source_ip": e.source_ip} for e in rows] out = {"range": key, "events": events[:limit], "truncated": len(events) > limit} if out["truncated"]: out["more"] = f"More than {limit}. Pass a `category`, a `search`, a shorter `range` or a larger `limit`." return out async def list_alarms(include_archived: bool = False) -> dict: """UniFi's alarms, newest first: those still active, and with include_archived the last 20 that were archived (dealt with in UniFi).""" async with db_session() as db: active = (await db.execute(select(UnifiAlarm).where(UnifiAlarm.archived.is_(False)) .order_by(UnifiAlarm.alarm_time.desc()))).scalars().all() archived = ((await db.execute(select(UnifiAlarm).where(UnifiAlarm.archived.is_(True)) .order_by(UnifiAlarm.alarm_time.desc()).limit(20))).scalars().all() if include_archived else []) def row(a): return {"at": iso(a.alarm_time), "key": a.alarm_key, "message": a.message} out = {"active": [row(a) for a in active]} if include_archived: out["archived"] = [row(a) for a in archived] return out async def get_network_config() -> dict: """How the UniFi network is set up, as the controller last reported it: networks and VLANs (subnet, DHCP, guest), port forwards (what is open from the internet, to which internal address), and firewall rules by ruleset in order. Read-only: Steward never changes UniFi.""" async with db_session() as db: networks = (await db.execute(select(UnifiNetwork).order_by(UnifiNetwork.name))).scalars().all() forwards = (await db.execute(select(UnifiPortForward).order_by(UnifiPortForward.name))).scalars().all() rules = (await db.execute(select(UnifiFwRule) .order_by(UnifiFwRule.ruleset, UnifiFwRule.rule_index))).scalars().all() return { "networks": [{"name": n.name, "purpose": n.purpose, "vlan": n.vlan, "subnet": n.ip_subnet, "dhcp": n.dhcp_enabled, "guest": n.is_guest} for n in networks], "port_forwards": [{"name": f.name, "enabled": f.enabled, "proto": f.proto, "port": f.dst_port, "to": f"{f.fwd_ip}:{f.fwd_port}", "from": f.src_filter or "any"} for f in forwards], "firewall_rules": [{"ruleset": r.ruleset, "index": r.rule_index, "name": r.name, "action": r.action, "protocol": r.protocol, "enabled": r.enabled, "src": r.src_address, "dst": r.dst_address} for r in rules], } # ── get_overview ───────────────────────────────────────────────────────────── def network_findings(wan, devices, alarms, now, fresh: timedelta) -> list[dict]: """The WAN down (critical, while the poll is current), devices not working (offline critical, degraded warning; a transition such as an upgrade is not a problem; a device the controller no longer reports is left out), and active alarms (one warning naming the newest).""" out = [] if wan is not None and not wan.is_up and now - wan.scraped_at <= fresh: out.append(finding("critical", "unifi_wan_down", "WAN", "UniFi reports the WAN down", since=wan.scraped_at, next="get_network()")) for d in devices: status, label = device_state(d.state) if status not in ("down", "degraded") or now - d.scraped_at > fresh: continue name = d.name or d.mac out.append(finding("critical" if status == "down" else "warning", "unifi_device_" + status, name, f"UniFi {d.device_type or 'device'} {name} ({d.model or 'unknown model'}): {label}", since=d.last_seen, ids={"mac": d.mac}, next='list_devices(status="%s")' % status)) if alarms: newest = alarms[0] more = f" and {len(alarms) - 1} more" if len(alarms) > 1 else "" out.append(finding("warning", "unifi_alarms", "UniFi alarms", f"UniFi alarm: {newest.message}{more}", since=newest.alarm_time, next="list_alarms()")) return out async def mcp_overview(db) -> list[dict]: now = now_utc() wan = (await db.execute(select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1))).scalar_one_or_none() devices = (await db.execute(select(UnifiDevice))).scalars().all() alarms = (await db.execute(select(UnifiAlarm).where(UnifiAlarm.archived.is_(False)) .order_by(UnifiAlarm.alarm_time.desc()))).scalars().all() return network_findings(wan, devices, alarms, now, timedelta(seconds=FRESH_POLLS * _poll_seconds())) def register_tools(register) -> None: for fn in (get_network, list_devices, list_clients, list_network_events, list_alarms, get_network_config): register(fn, role=UserRole.viewer, read_only=True)