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:
1 parent
aace768660
commit
7206e0c4db
13 files changed
+1509
-6
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
+5
-2
@@ -1,6 +1,9 @@
|
|||||||
FROM python:alpine3.17
|
FROM python:3.12-alpine
|
||||||
|
RUN apk add --no-cache iw
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . /app
|
COPY . /app
|
||||||
RUN pip install -r requirements.txt
|
|
||||||
EXPOSE 9999
|
EXPOSE 9999
|
||||||
|
VOLUME /data
|
||||||
ENTRYPOINT ["python", "app.py"]
|
ENTRYPOINT ["python", "app.py"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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__)
|
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'])
|
@app.route('/alive', methods=['GET'])
|
||||||
def get_healthcheck():
|
def get_healthcheck():
|
||||||
response = {
|
response = {
|
||||||
@@ -9,9 +27,60 @@ def get_healthcheck():
|
|||||||
}
|
}
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def hello_world():
|
def index():
|
||||||
return 'Health check application.'
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=9999)
|
start_background()
|
||||||
|
app.run(host="0.0.0.0", port=cfg.port)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
@@ -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 +1,2 @@
|
|||||||
flask
|
flask
|
||||||
|
paho-mqtt>=2,<3
|
||||||
+106
@@ -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,))
|
||||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
Reference in new issue
Block a user