Add network monitoring, dashboard, and Home Assistant MQTT publishing

Keep /alive unchanged and serve a dashboard at / with status cards,
latency graphs, an event timeline, and a Wi-Fi section.

Background monitors on configurable intervals: TCP reachability,
DNS resolution, public IP change detection, host reboot detection,
Wi-Fi link quality, and Wi-Fi scan of networks in range via iw.
The Wi-Fi interface is selected with WIFI_INTERFACE; the page lists
visible interfaces and reports when none is available. Scan results
stay in memory, other metrics go to SQLite on a mounted volume.

Optional MQTT publishing with Home Assistant discovery groups all
sensors under one device per host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdvJYoY8kc8bmBck89U2i6
This commit is contained in:
Milan PandurovandClaude Fable 5.1 committed 2026-09-17 15:42:09 +02:00
1 parent aace768660
commit 7206e0c4db
13 files changed
+1509 -6

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
data/
+5 -2
View File
@@ -1,6 +1,9 @@
FROM python:alpine3.17
FROM python:3.12-alpine
RUN apk add --no-cache iw
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
RUN pip install -r requirements.txt
EXPOSE 9999
VOLUME /data
ENTRYPOINT ["python", "app.py"]
+113
View File
@@ -0,0 +1,113 @@
# healthcheck-container
Small Flask service for remote devices (Raspberry Pi in Docker) that answers
`/alive` for container monitoring and, on top of that, watches the network the
device sits on: internet reachability, DNS, public IP changes, Wi-Fi link
quality, Wi-Fi networks in range, and host reboots. Metrics go to SQLite and
are shown on a dashboard at `/`. Optionally the same values are published to an
MQTT broker with Home Assistant discovery, so each device shows up in Home
Assistant as a device with sensors and no manual configuration.
## Endpoints
| Path | Purpose |
| --- | --- |
| `/alive` | Unchanged health check, returns `{"alive": true}` |
| `/` | Dashboard: status, Wi-Fi, graphs, timeline |
| `/api/status` | Latest state of every monitor as JSON |
| `/api/wifi` | Interface list, link details, last scan results |
| `/api/events?limit=200` | Timeline events, newest first |
| `/api/samples?prefix=reach.&range=3600` | Bucketed samples for graphs |
## Running
```sh
docker compose up -d --build
```
Then open `http://<device>:9999/`.
Wi-Fi needs two things from Docker:
- `network_mode: host`, otherwise the container has no wireless interface at all.
- `cap_add: NET_ADMIN`, otherwise link and interface info work but scanning fails
with a permission error that is shown on the dashboard.
Without `WIFI_INTERFACE` the dashboard lists the interfaces it can see and
nothing is scanned. Set the variable to one of the listed wireless interfaces
and restart the container.
## Storage
Samples and events are stored in SQLite at `DB_PATH` (default
`/data/healthcheck.db`). The compose file mounts a named volume at `/data`, so
the database survives `docker compose down`, image rebuilds and `up -d`. Only
`docker compose down -v` or `docker volume rm` deletes it. A bind mount works
as well, for example `- /opt/healthcheck:/data`.
Samples older than `SAMPLE_RETENTION_DAYS` (default 7) are deleted hourly.
Events are kept. Wi-Fi scan results are held in memory only and never written.
## Configuration
All settings are environment variables. Intervals are in seconds.
| Variable | Default | Meaning |
| --- | --- | --- |
| `PORT` | `9999` | HTTP port |
| `DB_PATH` | `/data/healthcheck.db` | SQLite file |
| `SAMPLE_RETENTION_DAYS` | `7` | How long graph samples are kept |
| `DEVICE_NAME` | hostname | Shown on the dashboard and in Home Assistant |
| `DEVICE_ID` | slug of `DEVICE_NAME` | Used in MQTT topics and unique ids |
| `REACH_TARGETS` | `1.1.1.1:443,8.8.8.8:443,9.9.9.9:443` | TCP connect targets, `host:port` |
| `REACH_INTERVAL` | `30` | |
| `REACH_TIMEOUT` | `3` | Connect timeout per target |
| `DNS_NAME` | `cloudflare.com` | Name resolved through the system resolver |
| `DNS_INTERVAL` | `60` | |
| `PUBLIC_IP_URLS` | ipify, ifconfig.me, icanhazip | Tried in order, first answer wins |
| `PUBLIC_IP_INTERVAL` | `300` | |
| `PUBLIC_IP_TIMEOUT` | `5` | |
| `UPTIME_INTERVAL` | `60` | Reboot detection via `/proc/uptime` |
| `WIFI_INTERFACE` | empty | Wireless interface to use, empty disables Wi-Fi |
| `WIFI_LINK_INTERVAL` | `30` | Signal, bitrate, retries of the current link |
| `WIFI_SCAN_INTERVAL` | `60` | Scan for networks in range |
| `MQTT_HOST` | empty | Broker host, empty disables MQTT |
| `MQTT_PORT` | `1883` | |
| `MQTT_USERNAME` | empty | |
| `MQTT_PASSWORD` | empty | |
| `MQTT_DISCOVERY_PREFIX` | `homeassistant` | Must match the MQTT integration setting |
| `LOG_LEVEL` | `INFO` | |
## Events on the timeline
- Internet unreachable / reachable again (all targets failed, then any succeeded)
- DNS resolution failed / working again
- Public IP is X, Public IP changed from X to Y
- Wi-Fi connected, disconnected, roamed to another BSSID
- Host rebooted
- Health check service started
## Home Assistant
Set `MQTT_HOST` (and credentials if the broker needs them). On connect the
service publishes retained discovery messages under
`homeassistant/<component>/healthcheck_<device_id>/<key>/config`, all pointing
to one device, then publishes state to `healthcheck/<device_id>/state` and
availability to `healthcheck/<device_id>/availability`. When Home Assistant
restarts it announces itself on `homeassistant/status` and the service
re-publishes discovery.
Entities per device:
- Internet, DNS, Wi-Fi (binary sensors, `connectivity` class)
- Internet latency, DNS latency (ms)
- Public IP
- Host uptime
- Wi-Fi SSID, BSSID, channel, signal (dBm), TX bitrate (Mbit/s), networks in range
## Development
```sh
python3 -m unittest discover -s tests
DB_PATH=./data/hc.db python3 app.py
```
+73 -4
View File
@@ -1,7 +1,25 @@
from flask import Flask, request, jsonify
import logging
import time
from flask import Flask, jsonify, render_template, request
from config import Config
from monitors import State, build_monitors
from storage import Storage
cfg = Config()
logging.basicConfig(level=cfg.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("app")
storage = Storage(cfg.db_path)
state = State()
app = Flask(__name__)
WIFI_KEYS = ("wifi_interface", "wifi_interfaces", "wifi_status", "wifi_error",
"wifi_link", "wifi_link_checked_at", "wifi_link_error",
"wifi_scan", "wifi_scan_error")
@app.route('/alive', methods=['GET'])
def get_healthcheck():
response = {
@@ -9,9 +27,60 @@ def get_healthcheck():
}
return jsonify(response)
@app.route('/')
def hello_world():
return 'Health check application.'
def index():
return render_template("index.html")
@app.route('/api/status')
def api_status():
snapshot = state.snapshot()
snapshot.pop("wifi_scan", None)
snapshot["now"] = time.time()
snapshot["config"] = cfg.public()
return jsonify(snapshot)
@app.route('/api/wifi')
def api_wifi():
snapshot = state.snapshot()
result = {key: snapshot.get(key) for key in WIFI_KEYS}
result["now"] = time.time()
result["scan_interval"] = cfg.wifi_scan_interval
return jsonify(result)
@app.route('/api/events')
def api_events():
limit = min(max(request.args.get("limit", 200, type=int), 1), 1000)
return jsonify(storage.events(limit))
@app.route('/api/samples')
def api_samples():
prefix = request.args.get("prefix", "")
span = min(max(request.args.get("range", 3600, type=int), 60), cfg.sample_retention_days * 86400)
bucket = max(10, span // 400)
since = time.time() - span
return jsonify({"since": since, "bucket": bucket, "series": storage.samples_by_prefix(prefix, since, bucket)})
def start_background():
storage.add_event("service_started", "Health check service started")
storage.prune_samples(cfg.sample_retention_days)
if cfg.mqtt_host:
from hamqtt import HomeAssistantPublisher
publisher = HomeAssistantPublisher(cfg)
state.on_change(publisher.publish_state)
publisher.start()
for monitor in build_monitors(cfg, state, storage):
monitor.start()
log.info("started monitor %s every %ss", monitor.name, monitor.interval)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=9999)
start_background()
app.run(host="0.0.0.0", port=cfg.port)
+88
View File
@@ -0,0 +1,88 @@
import os
import re
import socket
def _int(name, default):
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
try:
return int(raw)
except ValueError:
return default
def _float(name, default):
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
try:
return float(raw)
except ValueError:
return default
def _list(name, default):
raw = os.environ.get(name, default)
return [item.strip() for item in raw.split(",") if item.strip()]
def _slug(value):
return re.sub(r"[^a-z0-9_-]+", "_", value.lower()).strip("_") or "device"
class Config:
def __init__(self):
env = os.environ
hostname = socket.gethostname()
self.port = _int("PORT", 9999)
self.log_level = env.get("LOG_LEVEL", "INFO")
self.db_path = env.get("DB_PATH", "/data/healthcheck.db")
self.sample_retention_days = _int("SAMPLE_RETENTION_DAYS", 7)
self.device_name = env.get("DEVICE_NAME", hostname)
self.device_id = _slug(env.get("DEVICE_ID", self.device_name))
self.reach_targets = _list("REACH_TARGETS", "1.1.1.1:443,8.8.8.8:443,9.9.9.9:443")
self.reach_interval = _int("REACH_INTERVAL", 30)
self.reach_timeout = _float("REACH_TIMEOUT", 3.0)
self.dns_name = env.get("DNS_NAME", "cloudflare.com")
self.dns_interval = _int("DNS_INTERVAL", 60)
self.public_ip_urls = _list(
"PUBLIC_IP_URLS",
"https://api.ipify.org,https://ifconfig.me/ip,https://icanhazip.com",
)
self.public_ip_interval = _int("PUBLIC_IP_INTERVAL", 300)
self.public_ip_timeout = _float("PUBLIC_IP_TIMEOUT", 5.0)
self.uptime_interval = _int("UPTIME_INTERVAL", 60)
self.wifi_interface = env.get("WIFI_INTERFACE", "").strip()
self.wifi_link_interval = _int("WIFI_LINK_INTERVAL", 30)
self.wifi_scan_interval = _int("WIFI_SCAN_INTERVAL", 60)
self.mqtt_host = env.get("MQTT_HOST", "").strip()
self.mqtt_port = _int("MQTT_PORT", 1883)
self.mqtt_username = env.get("MQTT_USERNAME", "")
self.mqtt_password = env.get("MQTT_PASSWORD", "")
self.mqtt_discovery_prefix = env.get("MQTT_DISCOVERY_PREFIX", "homeassistant")
def public(self):
return {
"device_name": self.device_name,
"device_id": self.device_id,
"reach_targets": self.reach_targets,
"reach_interval": self.reach_interval,
"dns_name": self.dns_name,
"dns_interval": self.dns_interval,
"public_ip_interval": self.public_ip_interval,
"wifi_interface": self.wifi_interface,
"wifi_link_interval": self.wifi_link_interval,
"wifi_scan_interval": self.wifi_scan_interval,
"sample_retention_days": self.sample_retention_days,
"mqtt_enabled": bool(self.mqtt_host),
}
+23
View File
@@ -0,0 +1,23 @@
services:
healthcheck:
build: .
image: healthcheck-container
container_name: healthcheck
restart: unless-stopped
# Host networking exposes the Wi-Fi interface to the container. The app
# listens on port 9999 on the host directly, so no `ports:` mapping is used.
network_mode: host
cap_add:
- NET_ADMIN
volumes:
- healthcheck-data:/data
environment:
DEVICE_NAME: pi-livingroom
WIFI_INTERFACE: wlan0
WIFI_SCAN_INTERVAL: "60"
# MQTT_HOST: homeassistant.local
# MQTT_USERNAME: mqtt
# MQTT_PASSWORD: secret
volumes:
healthcheck-data:
+135
View File
@@ -0,0 +1,135 @@
import json
import logging
import paho.mqtt.client as mqtt
log = logging.getLogger(__name__)
VERSION = "2.0.0"
CONNECTIVITY = {"device_class": "connectivity"}
SENSORS = [
("internet_up", "binary_sensor", "Internet", CONNECTIVITY),
("internet_latency_ms", "sensor", "Internet latency",
{"unit_of_measurement": "ms", "state_class": "measurement", "icon": "mdi:timer-outline"}),
("dns_up", "binary_sensor", "DNS", CONNECTIVITY),
("dns_latency_ms", "sensor", "DNS latency",
{"unit_of_measurement": "ms", "state_class": "measurement", "icon": "mdi:dns"}),
("public_ip", "sensor", "Public IP", {"icon": "mdi:ip-network-outline"}),
("uptime_s", "sensor", "Host uptime",
{"device_class": "duration", "unit_of_measurement": "s", "entity_category": "diagnostic"}),
]
WIFI_SENSORS = [
("wifi_connected", "binary_sensor", "Wi-Fi", CONNECTIVITY),
("wifi_ssid", "sensor", "Wi-Fi SSID", {"icon": "mdi:wifi"}),
("wifi_signal_dbm", "sensor", "Wi-Fi signal",
{"device_class": "signal_strength", "unit_of_measurement": "dBm", "state_class": "measurement"}),
("wifi_tx_bitrate_mbps", "sensor", "Wi-Fi TX bitrate",
{"device_class": "data_rate", "unit_of_measurement": "Mbit/s", "state_class": "measurement"}),
("wifi_bssid", "sensor", "Wi-Fi BSSID", {"icon": "mdi:access-point", "entity_category": "diagnostic"}),
("wifi_channel", "sensor", "Wi-Fi channel", {"icon": "mdi:radio-tower", "entity_category": "diagnostic"}),
("wifi_ap_count", "sensor", "Wi-Fi networks in range",
{"icon": "mdi:access-point-network", "state_class": "measurement"}),
]
def flatten(snapshot):
link = snapshot.get("wifi_link") or {}
scan = snapshot.get("wifi_scan") or {}
return {
"internet_up": snapshot.get("internet_up"),
"internet_latency_ms": snapshot.get("internet_latency_ms"),
"dns_up": snapshot.get("dns_up"),
"dns_latency_ms": snapshot.get("dns_latency_ms"),
"public_ip": snapshot.get("public_ip"),
"uptime_s": snapshot.get("uptime_s"),
"wifi_connected": link.get("connected"),
"wifi_ssid": link.get("ssid"),
"wifi_bssid": link.get("bssid"),
"wifi_signal_dbm": link.get("signal_dbm"),
"wifi_tx_bitrate_mbps": link.get("tx_bitrate_mbps"),
"wifi_channel": link.get("channel"),
"wifi_ap_count": scan.get("count"),
}
class HomeAssistantPublisher:
def __init__(self, cfg):
self.cfg = cfg
self.node = f"healthcheck_{cfg.device_id}"
base = f"healthcheck/{cfg.device_id}"
self.state_topic = f"{base}/state"
self.availability_topic = f"{base}/availability"
self.status_topic = f"{cfg.mqtt_discovery_prefix}/status"
self._last_payload = None
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.node)
if cfg.mqtt_username:
self.client.username_pw_set(cfg.mqtt_username, cfg.mqtt_password)
self.client.will_set(self.availability_topic, "offline", retain=True)
self.client.on_connect = self._on_connect
self.client.on_message = self._on_message
self.client.on_disconnect = self._on_disconnect
def start(self):
self.client.reconnect_delay_set(min_delay=2, max_delay=60)
self.client.connect_async(self.cfg.mqtt_host, self.cfg.mqtt_port, keepalive=60)
self.client.loop_start()
log.info("MQTT: connecting to %s:%s as %s", self.cfg.mqtt_host, self.cfg.mqtt_port, self.node)
def publish_state(self, snapshot):
payload = flatten(snapshot)
if payload == self._last_payload:
return
self._last_payload = payload
self.client.publish(self.state_topic, json.dumps(payload), retain=True)
def _on_connect(self, client, userdata, flags, reason_code, properties):
if reason_code.is_failure:
log.warning("MQTT: connection refused: %s", reason_code)
return
log.info("MQTT: connected")
client.subscribe(self.status_topic)
self._announce()
if self._last_payload is not None:
client.publish(self.state_topic, json.dumps(self._last_payload), retain=True)
def _on_disconnect(self, client, userdata, flags, reason_code, properties):
log.warning("MQTT: disconnected: %s", reason_code)
def _on_message(self, client, userdata, message):
if message.topic == self.status_topic and message.payload.decode(errors="ignore") == "online":
log.info("MQTT: Home Assistant came online, re-announcing")
self._announce()
def _announce(self):
device = {
"identifiers": [self.node],
"name": self.cfg.device_name,
"model": "healthcheck-container",
"manufacturer": "healthcheck",
"sw_version": VERSION,
}
sensors = SENSORS + (WIFI_SENSORS if self.cfg.wifi_interface else [])
for key, component, name, extra in sensors:
if component == "binary_sensor":
template = (
f"{{{{ 'None' if value_json.{key} is none "
f"else ('ON' if value_json.{key} else 'OFF') }}}}"
)
else:
template = f"{{{{ value_json.{key} }}}}"
payload = {
"name": name,
"unique_id": f"{self.node}_{key}",
"state_topic": self.state_topic,
"value_template": template,
"availability_topic": self.availability_topic,
"device": device,
**extra,
}
topic = f"{self.cfg.mqtt_discovery_prefix}/{component}/{self.node}/{key}/config"
self.client.publish(topic, json.dumps(payload), retain=True)
self.client.publish(self.availability_topic, "online", retain=True)
+328
View File
@@ -0,0 +1,328 @@
import ipaddress
import logging
import socket
import threading
import time
import urllib.request
import wifi
log = logging.getLogger(__name__)
class State:
def __init__(self):
self._lock = threading.Lock()
self._data = {}
self._listeners = []
def on_change(self, listener):
self._listeners.append(listener)
def update(self, **fields):
with self._lock:
self._data.update(fields)
snapshot = dict(self._data)
for listener in self._listeners:
try:
listener(snapshot)
except Exception:
log.exception("state listener failed")
def snapshot(self):
with self._lock:
return dict(self._data)
class Monitor(threading.Thread):
def __init__(self, name, interval, state, storage):
super().__init__(name=name, daemon=True)
self.interval = interval
self.state = state
self.storage = storage
def run(self):
while True:
started = time.monotonic()
try:
self.tick()
except Exception as exc:
log.warning("%s: %s", self.name, exc)
self.state.update(**{f"{self.name}_error": str(exc)})
elapsed = time.monotonic() - started
time.sleep(max(1.0, self.interval - elapsed))
def tick(self):
raise NotImplementedError
def tcp_latency_ms(target, timeout):
if ":" in target:
host, port = target.rsplit(":", 1)
else:
host, port = target, "443"
started = time.monotonic()
try:
with socket.create_connection((host, int(port)), timeout=timeout):
pass
except (OSError, ValueError):
return None
return round((time.monotonic() - started) * 1000, 1)
class Reachability(Monitor):
def __init__(self, cfg, state, storage):
super().__init__("internet", cfg.reach_interval, state, storage)
self.targets = cfg.reach_targets
self.timeout = cfg.reach_timeout
self.up = None
def tick(self):
ts = time.time()
results = {target: tcp_latency_ms(target, self.timeout) for target in self.targets}
reachable = [ms for ms in results.values() if ms is not None]
up = bool(reachable)
samples = [(f"reach.{target}", ms) for target, ms in results.items()]
samples.append(("internet.up", 1 if up else 0))
self.storage.add_samples(samples, ts)
if up != self.up:
if not up:
self.storage.add_event("internet_down", "Internet unreachable: all targets failed")
elif self.up is False:
self.storage.add_event("internet_up", "Internet reachable again")
self.up = up
self.state.update(
internet_up=up,
internet_latency_ms=min(reachable) if reachable else None,
reach=results,
internet_checked_at=ts,
internet_error=None,
)
class Dns(Monitor):
def __init__(self, cfg, state, storage):
super().__init__("dns", cfg.dns_interval, state, storage)
self.name_to_resolve = cfg.dns_name
self.up = None
def tick(self):
ts = time.time()
started = time.monotonic()
try:
socket.getaddrinfo(self.name_to_resolve, 443, proto=socket.IPPROTO_TCP)
ms = round((time.monotonic() - started) * 1000, 1)
except socket.gaierror:
ms = None
up = ms is not None
self.storage.add_sample("dns.latency_ms", ms, ts)
if up != self.up:
if not up:
self.storage.add_event("dns_down", f"DNS resolution of {self.name_to_resolve} failed")
elif self.up is False:
self.storage.add_event("dns_up", "DNS resolution working again")
self.up = up
self.state.update(dns_up=up, dns_latency_ms=ms, dns_checked_at=ts, dns_error=None)
class PublicIp(Monitor):
def __init__(self, cfg, state, storage):
super().__init__("public_ip", cfg.public_ip_interval, state, storage)
self.urls = cfg.public_ip_urls
self.timeout = cfg.public_ip_timeout
previous = storage.get("public_ip")
since = storage.get("public_ip_since")
if previous:
state.update(public_ip=previous, public_ip_since=float(since) if since else None)
def tick(self):
ts = time.time()
ip, source, error = None, None, "no lookup services configured"
for url in self.urls:
try:
request = urllib.request.Request(url, headers={"User-Agent": "healthcheck-container"})
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read(200).decode("utf-8", errors="replace").strip()
ip = str(ipaddress.ip_address(body))
source = url
break
except (OSError, ValueError) as exc:
error = f"{url}: {exc}"
if ip is None:
self.state.update(public_ip_checked_at=ts, public_ip_error=error)
return
previous = self.storage.get("public_ip")
if previous != ip:
if previous:
self.storage.add_event("public_ip_changed", f"Public IP changed from {previous} to {ip}")
else:
self.storage.add_event("public_ip", f"Public IP is {ip}")
self.storage.set("public_ip", ip)
self.storage.set("public_ip_since", str(ts))
since = self.storage.get("public_ip_since")
self.state.update(
public_ip=ip,
public_ip_source=source,
public_ip_since=float(since) if since else ts,
public_ip_checked_at=ts,
public_ip_error=None,
)
class Uptime(Monitor):
def __init__(self, cfg, state, storage):
super().__init__("host", cfg.uptime_interval, state, storage)
def tick(self):
with open("/proc/uptime") as handle:
uptime = float(handle.read().split()[0])
boot_time = time.time() - uptime
previous = self.storage.get("boot_time")
if previous is None or abs(float(previous) - boot_time) > 120:
if previous is not None:
self.storage.add_event("reboot", "Host rebooted")
self.storage.set("boot_time", str(boot_time))
self.state.update(uptime_s=int(uptime), boot_time=boot_time, host_error=None)
class Housekeeping(Monitor):
def __init__(self, cfg, state, storage):
super().__init__("housekeeping", 3600, state, storage)
self.retention_days = cfg.sample_retention_days
def tick(self):
self.storage.prune_samples(self.retention_days)
def resolve_wifi_interface(cfg, state):
found = wifi.interfaces()
wireless = [item["name"] for item in found if item["wireless"]]
fields = {"wifi_interfaces": found, "wifi_interface": cfg.wifi_interface}
if not cfg.wifi_interface:
if wireless:
fields["wifi_status"] = "not_configured"
fields["wifi_error"] = (
"No interface selected. Set WIFI_INTERFACE to one of: " + ", ".join(wireless)
)
else:
fields["wifi_status"] = "unavailable"
fields["wifi_error"] = (
"No wireless interfaces are visible from this container. "
"Run it with network_mode: host on a device that has Wi-Fi."
)
state.update(**fields)
return None
if cfg.wifi_interface not in wireless:
available = ", ".join(wireless) if wireless else "none"
fields["wifi_status"] = "missing"
fields["wifi_error"] = (
f"Interface '{cfg.wifi_interface}' is not a visible wireless interface. "
f"Wireless interfaces visible: {available}."
)
state.update(**fields)
return None
fields["wifi_status"] = "ok"
fields["wifi_error"] = None
state.update(**fields)
return cfg.wifi_interface
class WifiLink(Monitor):
def __init__(self, cfg, state, storage):
super().__init__("wifi_link", cfg.wifi_link_interval, state, storage)
self.cfg = cfg
self.connected = None
self.bssid = None
self.counters = None
def tick(self):
iface = resolve_wifi_interface(self.cfg, self.state)
if iface is None:
return
ts = time.time()
info = wifi.link(iface)
if info["connected"]:
info.update(wifi.station(iface))
self._record_events(info)
self._record_samples(info, ts)
self.state.update(wifi_link=info, wifi_link_checked_at=ts, wifi_link_error=None)
def _record_events(self, info):
connected = info["connected"]
bssid = info.get("bssid")
where = f"{info.get('ssid', '?')} ({bssid}, ch {info.get('channel', '?')}, {info.get('band', '?')})"
if connected != self.connected:
if connected:
self.storage.add_event("wifi_connected", f"Wi-Fi connected to {where}")
else:
self.storage.add_event("wifi_disconnected", "Wi-Fi disconnected")
self.connected = connected
self.counters = None
elif connected and bssid != self.bssid:
self.storage.add_event("wifi_roamed", f"Wi-Fi roamed to {where}")
self.counters = None
self.bssid = bssid
def _record_samples(self, info, ts):
if not info["connected"]:
self.storage.add_sample("wifi.signal_dbm", None, ts)
self.counters = None
return
samples = [
("wifi.signal_dbm", info.get("signal_dbm")),
("wifi.tx_bitrate_mbps", info.get("tx_bitrate_mbps")),
("wifi.rx_bitrate_mbps", info.get("rx_bitrate_mbps")),
]
counters = (info.get("tx_retries"), info.get("tx_failed"), info.get("beacon_loss"))
if None not in counters:
if self.counters and all(now >= before for now, before in zip(counters, self.counters)):
deltas = [now - before for now, before in zip(counters, self.counters)]
samples += [
("wifi.tx_retries", deltas[0]),
("wifi.tx_failed", deltas[1]),
("wifi.beacon_loss", deltas[2]),
]
self.counters = counters
self.storage.add_samples(samples, ts)
class WifiScan(Monitor):
def __init__(self, cfg, state, storage):
super().__init__("wifi_scan", cfg.wifi_scan_interval, state, storage)
self.cfg = cfg
def tick(self):
iface = resolve_wifi_interface(self.cfg, self.state)
if iface is None:
self.state.update(wifi_scan=None, wifi_scan_error=None)
return
aps = wifi.scan(iface)
self.state.update(
wifi_scan={"aps": aps, "count": len(aps), "ts": time.time()},
wifi_scan_error=None,
)
def build_monitors(cfg, state, storage):
return [
Reachability(cfg, state, storage),
Dns(cfg, state, storage),
PublicIp(cfg, state, storage),
Uptime(cfg, state, storage),
WifiLink(cfg, state, storage),
WifiScan(cfg, state, storage),
Housekeeping(cfg, state, storage),
]
+1
View File
@@ -1 +1,2 @@
flask
paho-mqtt>=2,<3
+106
View File
@@ -0,0 +1,106 @@
import os
import sqlite3
import time
from contextlib import closing
SCHEMA = """
CREATE TABLE IF NOT EXISTS samples (
ts REAL NOT NULL,
metric TEXT NOT NULL,
value REAL
);
CREATE INDEX IF NOT EXISTS samples_metric_ts ON samples(metric, ts);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
kind TEXT NOT NULL,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT
);
"""
def _like_prefix(prefix):
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return escaped + "%"
class Storage:
def __init__(self, path):
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
self.path = path
with closing(self._connect()) as conn:
conn.executescript(SCHEMA)
def _connect(self):
conn = sqlite3.connect(self.path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def add_samples(self, pairs, ts=None):
ts = ts or time.time()
with closing(self._connect()) as conn, conn:
conn.executemany(
"INSERT INTO samples (ts, metric, value) VALUES (?, ?, ?)",
[(ts, metric, value) for metric, value in pairs],
)
def add_sample(self, metric, value, ts=None):
self.add_samples([(metric, value)], ts)
def add_event(self, kind, message, ts=None):
with closing(self._connect()) as conn, conn:
conn.execute(
"INSERT INTO events (ts, kind, message) VALUES (?, ?, ?)",
(ts or time.time(), kind, message),
)
def events(self, limit=200):
with closing(self._connect()) as conn:
rows = conn.execute(
"SELECT ts, kind, message FROM events ORDER BY ts DESC, id DESC LIMIT ?",
(limit,),
).fetchall()
return [{"ts": ts, "kind": kind, "message": message} for ts, kind, message in rows]
def samples_by_prefix(self, prefix, since, bucket):
bucket = max(1, int(bucket))
with closing(self._connect()) as conn:
rows = conn.execute(
"""
SELECT metric, CAST(ts / ? AS INTEGER) * ? AS bucket, AVG(value)
FROM samples
WHERE metric LIKE ? ESCAPE '\\' AND ts >= ?
GROUP BY metric, bucket
ORDER BY metric, bucket
""",
(bucket, bucket, _like_prefix(prefix), since),
).fetchall()
series = {}
for metric, ts, value in rows:
series.setdefault(metric, []).append([ts, value])
return series
def get(self, key):
with closing(self._connect()) as conn:
row = conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone()
return row[0] if row else None
def set(self, key, value):
with closing(self._connect()) as conn, conn:
conn.execute(
"INSERT INTO kv (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
def prune_samples(self, days):
cutoff = time.time() - days * 86400
with closing(self._connect()) as conn, conn:
conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))
+279
View File
@@ -0,0 +1,279 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Health check</title>
<style>
:root {
--bg: #0f1419; --panel: #171d26; --border: #262f3b; --text: #e6e9ef; --muted: #8b97a8;
--ok: #3fb950; --bad: #f85149; --warn: #d29922; --accent: #58a6ff;
--c1: #58a6ff; --c2: #3fb950; --c3: #d29922; --c4: #bc8cff; --c5: #f85149;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 40px; }
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px 20px; margin-bottom: 20px; }
header h1 { font-size: 20px; margin: 0; }
header .meta { color: var(--muted); }
h2 { font-size: 15px; margin: 28px 0 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.card .value { font-size: 22px; font-weight: 600; margin: 4px 0 2px; word-break: break-all; }
.card .sub { color: var(--muted); font-size: 12px; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; vertical-align: middle; background: var(--muted); }
.dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); } .dot.warn { background: var(--warn); }
.error { color: var(--bad); font-size: 12px; margin-top: 6px; }
.notice { background: rgba(210,153,34,.12); border: 1px solid rgba(210,153,34,.4); color: #e3b341; border-radius: 6px; padding: 10px 12px; margin: 10px 0; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
th { color: var(--muted); font-weight: 500; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.table-wrap { overflow-x: auto; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; background: var(--border); color: var(--text); margin-right: 6px; }
.badge.wireless { background: rgba(88,166,255,.2); color: var(--accent); }
.badge.selected { outline: 1px solid var(--ok); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: 13px; }
.kv dt { color: var(--muted); } .kv dd { margin: 0; }
.range { display: flex; gap: 6px; margin-bottom: 12px; }
.range button { background: var(--panel); border: 1px solid var(--border); color: var(--text); padding: 4px 12px; border-radius: 6px; cursor: pointer; }
.range button.active { border-color: var(--accent); color: var(--accent); }
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 12px; }
.chart h3 { font-size: 13px; font-weight: 500; margin: 0 0 8px; color: var(--muted); }
.chart canvas { width: 100%; height: 180px; display: block; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--muted); margin-top: 6px; }
.legend i { display: inline-block; width: 10px; height: 3px; margin-right: 6px; vertical-align: middle; }
.empty { color: var(--muted); font-size: 13px; padding: 12px 0; }
ul.timeline { list-style: none; margin: 0; padding: 0; }
ul.timeline li { display: grid; grid-template-columns: 170px 1fr; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
ul.timeline time { color: var(--muted); font-variant-numeric: tabular-nums; }
ul.timeline .kind { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; background: var(--muted); }
.kind.up, .kind.wifi_connected, .kind.public_ip { background: var(--ok); }
.kind.down, .kind.wifi_disconnected, .kind.reboot { background: var(--bad); }
.kind.public_ip_changed, .kind.wifi_roamed, .kind.service_started { background: var(--warn); }
@media (max-width: 600px) { ul.timeline li { grid-template-columns: 1fr; gap: 2px; } }
</style>
</head>
<body>
<main>
<header>
<h1 id="device">Health check</h1>
<span class="meta" id="uptime"></span>
<span class="meta" id="updated"></span>
</header>
<section class="cards">
<div class="card" id="card-internet"><div class="label">Internet</div><div class="value">…</div><div class="sub"></div></div>
<div class="card" id="card-dns"><div class="label">DNS</div><div class="value">…</div><div class="sub"></div></div>
<div class="card" id="card-ip"><div class="label">Public IP</div><div class="value">…</div><div class="sub"></div></div>
<div class="card" id="card-wifi"><div class="label">Wi-Fi</div><div class="value">…</div><div class="sub"></div></div>
</section>
<h2>History</h2>
<div class="range" id="range">
<button data-range="3600">1h</button>
<button data-range="21600" class="active">6h</button>
<button data-range="86400">24h</button>
<button data-range="604800">7d</button>
</div>
<div class="charts">
<div class="panel chart"><h3>Reachability latency (ms)</h3><canvas id="ch-reach"></canvas><div class="legend" id="lg-reach"></div></div>
<div class="panel chart"><h3>DNS latency (ms)</h3><canvas id="ch-dns"></canvas><div class="legend" id="lg-dns"></div></div>
</div>
<h2>Timeline</h2>
<div class="panel"><ul class="timeline" id="timeline"></ul></div>
<h2>Wi-Fi</h2>
<div class="panel">
<div id="wifi-interfaces"></div>
<div id="wifi-notice"></div>
<div id="wifi-link" style="margin-top:12px"></div>
</div>
<div class="panel" style="margin-top:12px">
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px">
<strong>Networks in range</strong>
<span class="meta" id="scan-meta" style="color:var(--muted);font-size:12px"></span>
</div>
<div id="scan-error"></div>
<div class="table-wrap" id="scan-table"></div>
</div>
<div class="charts" style="margin-top:12px">
<div class="panel chart"><h3>Wi-Fi signal (dBm)</h3><canvas id="ch-signal"></canvas><div class="legend" id="lg-signal"></div></div>
<div class="panel chart"><h3>Wi-Fi bitrate (Mbit/s)</h3><canvas id="ch-bitrate"></canvas><div class="legend" id="lg-bitrate"></div></div>
<div class="panel chart"><h3>Wi-Fi retries / failed / beacon loss per interval</h3><canvas id="ch-errors"></canvas><div class="legend" id="lg-errors"></div></div>
</div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const COLORS = ['#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#f85149', '#79c0ff'];
let range = 21600;
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function ago(ts, now) {
if (!ts) return '';
const s = Math.max(0, Math.round((now ?? Date.now() / 1000) - ts));
if (s < 90) return s + 's ago';
if (s < 5400) return Math.round(s / 60) + 'm ago';
if (s < 172800) return Math.round(s / 3600) + 'h ago';
return Math.round(s / 86400) + 'd ago';
}
function duration(s) {
if (s == null) return '';
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
return (d ? d + 'd ' : '') + (h || d ? h + 'h ' : '') + m + 'm';
}
function fmtTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour12: false });
}
function card(id, cls, value, sub, error) {
const el = $(id);
el.querySelector('.value').innerHTML = `<span class="dot ${cls}"></span>${value}`;
el.querySelector('.sub').innerHTML = sub + (error ? `<div class="error">${esc(error)}</div>` : '');
}
async function loadStatus() {
const s = await (await fetch('/api/status')).json();
$('device').textContent = s.config.device_name;
$('uptime').textContent = s.uptime_s != null ? 'host up ' + duration(s.uptime_s) : (s.host_error ? 'uptime unavailable' : '');
$('updated').textContent = 'refreshed ' + new Date().toLocaleTimeString(undefined, { hour12: false });
if (s.internet_up == null) card('card-internet', '', 'checking', '', s.internet_error);
else card('card-internet', s.internet_up ? 'ok' : 'bad', s.internet_up ? 'reachable' : 'unreachable',
(s.internet_latency_ms != null ? `best ${s.internet_latency_ms} ms · ` : '') +
Object.entries(s.reach || {}).map(([t, ms]) => `${esc(t)}: ${ms == null ? 'fail' : ms + ' ms'}`).join(' · '), s.internet_error);
if (s.dns_up == null) card('card-dns', '', 'checking', '', s.dns_error);
else card('card-dns', s.dns_up ? 'ok' : 'bad', s.dns_up ? 'resolving' : 'failing',
`${esc(s.config.dns_name)}${s.dns_latency_ms != null ? ' in ' + s.dns_latency_ms + ' ms' : ''}`, s.dns_error);
card('card-ip', s.public_ip ? (s.public_ip_error ? 'warn' : 'ok') : '', s.public_ip ? esc(s.public_ip) : 'unknown',
(s.public_ip_since ? 'since ' + fmtTime(s.public_ip_since) : '') + (s.public_ip_checked_at ? ' · checked ' + ago(s.public_ip_checked_at, s.now) : ''),
s.public_ip_error);
const link = s.wifi_link;
if (s.wifi_status !== 'ok') card('card-wifi', s.wifi_status === 'not_configured' ? 'warn' : 'bad',
s.wifi_status === 'not_configured' ? 'not configured' : 'unavailable', esc(s.wifi_interface || ''), s.wifi_error || s.wifi_link_error);
else if (!link) card('card-wifi', '', 'checking', esc(s.wifi_interface), s.wifi_link_error);
else if (!link.connected) card('card-wifi', 'bad', 'disconnected', esc(s.wifi_interface), s.wifi_link_error);
else card('card-wifi', link.signal_dbm > -67 ? 'ok' : 'warn', esc(link.ssid || '(hidden)'),
`${link.signal_dbm ?? '?'} dBm · ch ${link.channel ?? '?'} (${link.band ?? '?'}) · ${link.tx_bitrate_mbps ?? '?'} Mbit/s`, s.wifi_link_error);
}
async function loadWifi() {
const w = await (await fetch('/api/wifi')).json();
const ifaces = w.wifi_interfaces || [];
$('wifi-interfaces').innerHTML = '<span style="color:var(--muted)">Interfaces visible: </span>' +
(ifaces.length ? ifaces.map(i => `<span class="badge ${i.wireless ? 'wireless' : ''} ${i.name === w.wifi_interface ? 'selected' : ''}">${esc(i.name)}${i.wireless ? ' · wireless' : ''}</span>`).join('') : '<em>none</em>') +
`<span style="color:var(--muted);margin-left:8px">selected: <code>${esc(w.wifi_interface || 'none')}</code></span>`;
$('wifi-notice').innerHTML = w.wifi_error ? `<div class="notice">${esc(w.wifi_error)}</div>` : '';
const link = w.wifi_link;
if (w.wifi_status === 'ok' && link) {
const rows = link.connected ? [
['SSID', link.ssid || '(hidden)'], ['BSSID', link.bssid], ['Channel', `${link.channel ?? '?'} (${link.freq_mhz ?? '?'} MHz, ${link.band ?? '?'})`],
['Signal', `${link.signal_dbm ?? '?'} dBm` + (link.signal_avg_dbm != null ? ` (avg ${link.signal_avg_dbm})` : '')],
['TX / RX bitrate', `${link.tx_bitrate_mbps ?? '?'} / ${link.rx_bitrate_mbps ?? '?'} Mbit/s`],
['TX retries / failed', `${link.tx_retries ?? '?'} / ${link.tx_failed ?? '?'}`], ['Beacon loss', link.beacon_loss ?? '?'],
['Connected for', duration(link.connected_s)],
] : [['Link', 'not connected']];
$('wifi-link').innerHTML = '<dl class="kv">' + rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('') + '</dl>' +
(w.wifi_link_error ? `<div class="error">${esc(w.wifi_link_error)}</div>` : '');
} else {
$('wifi-link').innerHTML = w.wifi_link_error ? `<div class="error">${esc(w.wifi_link_error)}</div>` : '';
}
const scan = w.wifi_scan;
$('scan-error').innerHTML = w.wifi_scan_error ? `<div class="error">${esc(w.wifi_scan_error)}</div>` : '';
if (w.wifi_status !== 'ok') { $('scan-meta').textContent = ''; $('scan-table').innerHTML = '<div class="empty">Scanning is disabled until a wireless interface is selected.</div>'; return; }
if (!scan) { $('scan-meta').textContent = `scanning every ${w.scan_interval}s`; $('scan-table').innerHTML = '<div class="empty">No scan results yet.</div>'; return; }
$('scan-meta').textContent = `${scan.count} networks · scanned ${ago(scan.ts, w.now)} · every ${w.scan_interval}s · not stored`;
$('scan-table').innerHTML = `<table><thead><tr><th>SSID</th><th>BSSID</th><th>Band</th><th class="num">Ch</th><th class="num">Signal</th><th>Security</th></tr></thead><tbody>` +
scan.aps.map(ap => `<tr${ap.associated ? ' style="color:var(--ok)"' : ''}><td>${esc(ap.ssid || '(hidden)')}${ap.associated ? ' ●' : ''}</td><td><code>${esc(ap.bssid)}</code></td><td>${esc(ap.band ?? '?')}</td><td class="num">${ap.channel ?? '?'}</td><td class="num">${ap.signal_dbm ?? '?'} dBm</td><td>${esc(ap.security)}</td></tr>`).join('') +
'</tbody></table>';
}
async function loadEvents() {
const events = await (await fetch('/api/events?limit=300')).json();
$('timeline').innerHTML = events.length ? events.map(e =>
`<li><time>${fmtTime(e.ts)}</time><span><span class="kind ${esc(e.kind.replace(/^(internet|dns)_/, ''))}"></span>${esc(e.message)}</span></li>`).join('')
: '<li class="empty">No events yet.</li>';
}
function drawChart(canvas, legendEl, series, opts = {}) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const pad = { l: 44, r: 8, t: 8, b: 22 };
const now = Date.now() / 1000, x0 = now - range;
let ys = series.flatMap(s => s.points.map(p => p[1]).filter(v => v != null));
legendEl.innerHTML = series.map((s, i) => `<span><i style="background:${COLORS[i % COLORS.length]}"></i>${esc(s.name)}</span>`).join('');
if (!ys.length) { ctx.fillStyle = '#8b97a8'; ctx.font = '12px sans-serif'; ctx.fillText('no data in range', pad.l, h / 2); return; }
let ymin = Math.min(...ys), ymax = Math.max(...ys);
if (opts.min != null) ymin = Math.min(ymin, opts.min);
if (opts.zero) ymin = Math.min(ymin, 0);
if (ymax === ymin) ymax = ymin + 1;
const span = ymax - ymin; ymin -= span * 0.05; ymax += span * 0.1;
const X = t => pad.l + (t - x0) / range * (w - pad.l - pad.r);
const Y = v => pad.t + (ymax - v) / (ymax - ymin) * (h - pad.t - pad.b);
ctx.strokeStyle = '#262f3b'; ctx.fillStyle = '#8b97a8'; ctx.font = '11px sans-serif'; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const v = ymin + (ymax - ymin) * i / 4, y = Y(v);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
ctx.textAlign = 'right'; ctx.fillText(Math.abs(v) >= 100 ? Math.round(v) : v.toFixed(1), pad.l - 6, y + 4);
}
ctx.textAlign = 'center';
for (let i = 0; i <= 4; i++) {
const t = x0 + range * i / 4, d = new Date(t * 1000);
const label = range > 86400 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
ctx.fillText(label, X(t), h - 6);
}
series.forEach((s, i) => {
ctx.strokeStyle = COLORS[i % COLORS.length]; ctx.lineWidth = 1.5; ctx.beginPath();
let pen = false;
for (const [t, v] of s.points) {
if (v == null) { pen = false; continue; }
const x = X(t), y = Y(v);
if (pen) ctx.lineTo(x, y); else ctx.moveTo(x, y);
pen = true;
}
ctx.stroke();
});
}
async function loadCharts() {
const fetchSeries = async (prefix) => (await (await fetch(`/api/samples?prefix=${encodeURIComponent(prefix)}&range=${range}`)).json()).series;
const [reach, dns, wifi] = await Promise.all([fetchSeries('reach.'), fetchSeries('dns.'), fetchSeries('wifi.')]);
const toSeries = (obj, strip) => Object.entries(obj).map(([m, points]) => ({ name: m.replace(strip, ''), points }));
drawChart($('ch-reach'), $('lg-reach'), toSeries(reach, 'reach.'), { zero: true });
drawChart($('ch-dns'), $('lg-dns'), toSeries(dns, 'dns.').map(s => ({ ...s, name: 'latency' })), { zero: true });
const pick = (...keys) => keys.filter(k => wifi[k]).map(k => ({ name: k.replace('wifi.', ''), points: wifi[k] }));
drawChart($('ch-signal'), $('lg-signal'), pick('wifi.signal_dbm'), { min: -90 });
drawChart($('ch-bitrate'), $('lg-bitrate'), pick('wifi.tx_bitrate_mbps', 'wifi.rx_bitrate_mbps'), { zero: true });
drawChart($('ch-errors'), $('lg-errors'), pick('wifi.tx_retries', 'wifi.tx_failed', 'wifi.beacon_loss'), { zero: true });
}
$('range').addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
range = +b.dataset.range;
document.querySelectorAll('#range button').forEach(x => x.classList.toggle('active', x === b));
loadCharts();
});
window.addEventListener('resize', () => loadCharts());
async function refreshFast() { try { await Promise.all([loadStatus(), loadWifi(), loadEvents()]); } catch (e) { console.error(e); } }
refreshFast(); loadCharts();
setInterval(refreshFast, 10000);
setInterval(loadCharts, 60000);
</script>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
import unittest
import wifi
LINK = """Connected to 9c:3d:cf:12:34:56 (on wlan0)
\tSSID: Home:Net
\tfreq: 5180
\tRX: 123456 bytes (789 packets)
\tTX: 6543 bytes (98 packets)
\tsignal: -52 dBm
\trx bitrate: 6.0 MBit/s
\ttx bitrate: 866.7 MBit/s VHT-MCS 9 80MHz short GI VHT-NSS 2
\tbss flags:\tshort-slot-time
\tdtim period:\t1
\tbeacon int:\t100
"""
STATION = """Station 9c:3d:cf:12:34:56 (on wlan0)
\tinactive time:\t20 ms
\trx bytes:\t123456
\trx packets:\t789
\ttx bytes:\t6543
\ttx packets:\t98
\ttx retries:\t42
\ttx failed:\t3
\tbeacon loss:\t1
\tbeacon rx:\t1000
\trx drop misc:\t5
\tsignal: \t-52 [-54, -58] dBm
\tsignal avg:\t-53 [-55, -57] dBm
\ttx bitrate:\t866.7 MBit/s VHT-MCS 9 80MHz short GI VHT-NSS 2
\trx bitrate:\t6.0 MBit/s
\tauthorized:\tyes
\tconnected time:\t12345 seconds
"""
SCAN = """BSS 9c:3d:cf:12:34:56(on wlan0) -- associated
\tlast seen: 1234.567s [boottime]
\tTSF: 1 usec (0d, 00:00:00)
\tfreq: 5180.0
\tbeacon interval: 100 TUs
\tcapability: ESS Privacy ShortSlotTime (0x0411)
\tsignal: -52.00 dBm
\tlast seen: 0 ms ago
\tInformation elements from Probe Response frame:
\tSSID: Home:Net
\tSupported rates: 6.0* 9.0 12.0* 18.0 24.0* 36.0 48.0 54.0
\tRSN:\t * Version: 1
\t\t * Group cipher: CCMP
\t\t * Pairwise ciphers: CCMP
\t\t * Authentication suites: PSK SAE
\t\t * Capabilities: 1-PTKSA-RC 1-GTKSA-RC MFP-capable (0x0080)
BSS 00:11:22:33:44:55(on wlan0)
\tfreq: 2437
\tcapability: ESS (0x0401)
\tsignal: -71.00 dBm
\tSSID: Cafe
\tDS Parameter set: channel 6
BSS 66:77:88:99:aa:bb(on wlan0)
\tfreq: 2412
\tcapability: ESS Privacy (0x0411)
\tsignal: -80.00 dBm
\tSSID:
\tWPA:\t * Version: 1
\t\t * Group cipher: TKIP
\t\t * Pairwise ciphers: TKIP
\t\t * Authentication suites: PSK
BSS cc:dd:ee:ff:00:11(on wlan0)
\tfreq: 2462
\tcapability: ESS Privacy (0x0411)
\tsignal: -85.00 dBm
\tSSID: Legacy
"""
class ParseLinkTest(unittest.TestCase):
def test_connected(self):
info = wifi.parse_link(LINK)
self.assertTrue(info["connected"])
self.assertEqual(info["bssid"], "9c:3d:cf:12:34:56")
self.assertEqual(info["ssid"], "Home:Net")
self.assertEqual(info["freq_mhz"], 5180)
self.assertEqual(info["channel"], 36)
self.assertEqual(info["band"], "5 GHz")
self.assertEqual(info["signal_dbm"], -52)
self.assertEqual(info["tx_bitrate_mbps"], 866.7)
self.assertEqual(info["rx_bitrate_mbps"], 6.0)
def test_not_connected(self):
self.assertEqual(wifi.parse_link("Not connected.\n"), {"connected": False})
self.assertEqual(wifi.parse_link(""), {"connected": False})
class ParseStationTest(unittest.TestCase):
def test_counters(self):
info = wifi.parse_station_dump(STATION)
self.assertEqual(info["tx_retries"], 42)
self.assertEqual(info["tx_failed"], 3)
self.assertEqual(info["beacon_loss"], 1)
self.assertEqual(info["connected_s"], 12345)
self.assertEqual(info["signal_avg_dbm"], -53)
def test_empty(self):
self.assertEqual(wifi.parse_station_dump(""), {})
class ParseScanTest(unittest.TestCase):
def test_networks_sorted_by_signal(self):
aps = wifi.parse_scan(SCAN)
self.assertEqual([ap["bssid"] for ap in aps], [
"9c:3d:cf:12:34:56", "00:11:22:33:44:55", "66:77:88:99:aa:bb", "cc:dd:ee:ff:00:11",
])
def test_fields(self):
home, cafe, hidden, legacy = wifi.parse_scan(SCAN)
self.assertTrue(home["associated"])
self.assertEqual(home["ssid"], "Home:Net")
self.assertEqual(home["freq_mhz"], 5180)
self.assertEqual(home["channel"], 36)
self.assertEqual(home["signal_dbm"], -52.0)
self.assertEqual(home["security"], "WPA3/WPA2")
self.assertFalse(cafe["associated"])
self.assertEqual(cafe["channel"], 6)
self.assertEqual(cafe["band"], "2.4 GHz")
self.assertEqual(cafe["security"], "Open")
self.assertEqual(hidden["ssid"], "")
self.assertEqual(hidden["channel"], 1)
self.assertEqual(hidden["security"], "WPA")
self.assertEqual(legacy["channel"], 11)
self.assertEqual(legacy["security"], "WEP")
def test_no_internal_keys_leak(self):
for ap in wifi.parse_scan(SCAN):
self.assertFalse([key for key in ap if key.startswith("_")])
def test_empty(self):
self.assertEqual(wifi.parse_scan(""), [])
class ChannelTest(unittest.TestCase):
def test_mapping(self):
self.assertEqual(wifi.freq_to_channel(2412), 1)
self.assertEqual(wifi.freq_to_channel(2484), 14)
self.assertEqual(wifi.freq_to_channel(5745), 149)
self.assertEqual(wifi.freq_to_channel(5955), 1)
self.assertIsNone(wifi.freq_to_channel(None))
self.assertIsNone(wifi.freq_to_channel(1000))
if __name__ == "__main__":
unittest.main()
+200
View File
@@ -0,0 +1,200 @@
import os
import re
import subprocess
SYS_NET = "/sys/class/net"
_KV = re.compile(r"^\s*([^:]+?):\s*(.*)$")
_BSS = re.compile(r"^BSS ([0-9a-f]{2}(?::[0-9a-f]{2}){5})", re.IGNORECASE)
class WifiError(Exception):
pass
def interfaces():
try:
names = sorted(os.listdir(SYS_NET))
except OSError:
return []
result = []
for name in names:
path = os.path.join(SYS_NET, name)
wireless = os.path.isdir(os.path.join(path, "wireless"))
physical = os.path.exists(os.path.join(path, "device"))
if wireless or physical:
result.append({"name": name, "wireless": wireless})
return result
def freq_to_channel(freq):
if freq is None:
return None
if freq == 2484:
return 14
if 2412 <= freq <= 2472:
return (freq - 2407) // 5
if 5160 <= freq <= 5885:
return (freq - 5000) // 5
if 5955 <= freq <= 7115:
return (freq - 5950) // 5
return None
def freq_to_band(freq):
if freq is None:
return None
if freq < 3000:
return "2.4 GHz"
if freq < 5925:
return "5 GHz"
return "6 GHz"
def _run(args, timeout):
try:
proc = subprocess.run(["iw", *args], capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
raise WifiError("the 'iw' tool is not installed in the container image")
except subprocess.TimeoutExpired:
raise WifiError(f"'iw {' '.join(args)}' timed out after {timeout}s")
if proc.returncode != 0:
raise WifiError(_explain(proc.stderr.strip() or proc.stdout.strip(), args))
return proc.stdout
def _explain(stderr, args):
if "Operation not permitted" in stderr:
return "permission denied: scanning needs the NET_ADMIN capability (cap_add: NET_ADMIN)"
if "Device or resource busy" in stderr:
return "device busy: another scan is in progress, will retry on the next interval"
if "No such device" in stderr:
return "interface disappeared"
if "Network is down" in stderr:
return "interface is down"
return f"iw {' '.join(args)} failed: {stderr}"
def link(iface):
return parse_link(_run(["dev", iface, "link"], timeout=10))
def station(iface):
return parse_station_dump(_run(["dev", iface, "station", "dump"], timeout=10))
def scan(iface):
return parse_scan(_run(["dev", iface, "scan"], timeout=40))
def _first_number(value):
return float(value.split()[0])
def parse_link(text):
lines = text.strip().splitlines()
if not lines or not lines[0].startswith("Connected to"):
return {"connected": False}
info = {"connected": True, "bssid": lines[0].split()[2].lower()}
for line in lines[1:]:
match = _KV.match(line)
if not match:
continue
key, value = match.group(1).strip(), match.group(2).strip()
if key == "SSID":
info["ssid"] = value
elif key == "freq":
info["freq_mhz"] = int(_first_number(value))
elif key == "signal":
info["signal_dbm"] = int(_first_number(value))
elif key == "tx bitrate":
info["tx_bitrate_mbps"] = _first_number(value)
elif key == "rx bitrate":
info["rx_bitrate_mbps"] = _first_number(value)
freq = info.get("freq_mhz")
info["channel"] = freq_to_channel(freq)
info["band"] = freq_to_band(freq)
return info
def parse_station_dump(text):
info = {}
for line in text.splitlines():
if line.startswith("Station") and info:
break
match = _KV.match(line)
if not match:
continue
key, value = match.group(1).strip(), match.group(2).strip()
if key == "tx retries":
info["tx_retries"] = int(value)
elif key == "tx failed":
info["tx_failed"] = int(value)
elif key == "beacon loss":
info["beacon_loss"] = int(value)
elif key == "connected time":
info["connected_s"] = int(_first_number(value))
elif key == "signal avg":
info["signal_avg_dbm"] = int(_first_number(value))
return info
def parse_scan(text):
aps = []
current = None
for line in text.splitlines():
match = _BSS.match(line)
if match:
current = {
"bssid": match.group(1).lower(),
"ssid": "",
"associated": "associated" in line,
"_rsn": False,
"_wpa": False,
"_sae": False,
"_privacy": False,
}
aps.append(current)
continue
if current is None:
continue
stripped = line.strip()
if stripped.startswith("freq:"):
current["freq_mhz"] = int(_first_number(stripped[5:]))
elif stripped.startswith("signal:"):
current["signal_dbm"] = round(_first_number(stripped[7:]), 1)
elif stripped.startswith("SSID:"):
current["ssid"] = stripped[5:].strip()
elif stripped.startswith("RSN:"):
current["_rsn"] = True
elif stripped.startswith("WPA:"):
current["_wpa"] = True
elif stripped.startswith("capability:") and "Privacy" in stripped:
current["_privacy"] = True
elif "Authentication suites:" in stripped and "SAE" in stripped:
current["_sae"] = True
for ap in aps:
freq = ap.get("freq_mhz")
ap["channel"] = freq_to_channel(freq)
ap["band"] = freq_to_band(freq)
ap["security"] = _security_label(ap)
for key in ("_rsn", "_wpa", "_sae", "_privacy"):
del ap[key]
aps.sort(key=lambda ap: ap.get("signal_dbm", -1000), reverse=True)
return aps
def _security_label(ap):
labels = []
if ap["_sae"]:
labels.append("WPA3")
if ap["_rsn"]:
labels.append("WPA2")
if ap["_wpa"]:
labels.append("WPA")
if labels:
return "/".join(labels)
if ap["_privacy"]:
return "WEP"
return "Open"