Files
healthcheck-container/hamqtt.py
T
Milan PandurovandClaude Fable 5.1 62b57a7e56 Add MQTT diagnostics, broker connection probe, and switch to Debian base
The MQTT publisher now tracks connection state, publish counters, discovery
and availability timestamps, and the last error, exposed at /api/mqtt and on
the dashboard as an MQTT card and a Home Assistant section. Connection
failures, disconnects, and dropped publishes are logged; paho's
on_connect_fail callback was not registered before, so failed attempts were
silent. MQTT connect, disconnect, and unreachable events go to the timeline.

When a connection attempt fails the service runs a probe from inside the
container: system resolver, each nameserver from resolv.conf queried directly,
/etc/hosts, and a TCP connect to every address found. The result is shown as
a summary and raw JSON on the dashboard and can be rerun via
POST /api/mqtt/probe.

The image base moves from Alpine to Debian slim. musl queries all
nameservers in parallel and accepts the first reply, so a fast public
NXDOMAIN beats a slower local server that knows the name. glibc asks the
nameservers in order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:08:48 +02:00

255 lines
11 KiB
Python

import json
import logging
import threading
import time
import paho.mqtt.client as mqtt
import netprobe
log = logging.getLogger(__name__)
PROBE_INTERVAL = 120
VERSION = "2.3.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, storage=None):
self.cfg = cfg
self.storage = storage
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._lock = threading.Lock()
self._diag = {
"status": "not_started",
"connected": False,
"last_error": None,
"started_at": None,
"connected_at": None,
"disconnected_at": None,
"last_attempt_at": None,
"connect_failures": 0,
"disconnects": 0,
"discovery_count": 0,
"discovery_at": None,
"availability_at": None,
"ha_status": None,
"ha_status_at": None,
"publish_count": 0,
"publish_dropped": 0,
"last_publish_at": None,
"last_publish_error": None,
"probe": None,
"probe_running": False,
}
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_connect_fail = self._on_connect_fail
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._set(status="connecting", started_at=time.time(), last_attempt_at=time.time())
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 diagnostics(self):
with self._lock:
diag = dict(self._diag)
diag.update({
"enabled": True,
"host": self.cfg.mqtt_host,
"port": self.cfg.mqtt_port,
"username": self.cfg.mqtt_username or None,
"client_id": self.node,
"state_topic": self.state_topic,
"availability_topic": self.availability_topic,
"discovery_prefix": self.cfg.mqtt_discovery_prefix,
"last_payload": self._last_payload,
})
return diag
def publish_state(self, snapshot):
payload = flatten(snapshot)
if payload == self._last_payload:
return
self._last_payload = payload
self._publish(self.state_topic, json.dumps(payload))
def run_probe(self):
with self._lock:
if self._diag["probe_running"]:
return self._diag["probe"]
self._diag["probe_running"] = True
try:
result = netprobe.probe(self.cfg.mqtt_host, self.cfg.mqtt_port)
except Exception as exc:
log.exception("MQTT: probe failed")
result = {"ts": time.time(), "error": str(exc), "summary": [f"probe crashed: {exc}"]}
finally:
self._set(probe_running=False)
self._set(probe=result)
for line in result["summary"]:
log.warning("MQTT probe: %s", line)
return result
def _probe_in_background(self):
with self._lock:
probe = self._diag["probe"]
if self._diag["probe_running"] or (probe and time.time() - probe["ts"] < PROBE_INTERVAL):
return
threading.Thread(target=self.run_probe, name="mqtt-probe", daemon=True).start()
def _set(self, *counters, **fields):
with self._lock:
for key in counters:
self._diag[key] += 1
self._diag.update(fields)
def _event(self, kind, message):
if self.storage is not None:
self.storage.add_event(kind, message)
def _publish(self, topic, payload):
info = self.client.publish(topic, payload, retain=True)
if info.rc == mqtt.MQTT_ERR_SUCCESS:
self._set("publish_count", last_publish_at=time.time(), last_publish_error=None)
else:
error = mqtt.error_string(info.rc)
self._set("publish_dropped", last_publish_error=error)
log.warning("MQTT: publish to %s failed: %s", topic, error)
return info
def _on_connect(self, client, userdata, flags, reason_code, properties):
if reason_code.is_failure:
log.warning("MQTT: connection refused: %s", reason_code)
self._set("connect_failures", status="refused", connected=False,
last_error=f"connection refused: {reason_code}")
return
log.info("MQTT: connected")
with self._lock:
was_connected = self._diag["connected"]
self._diag.update(status="connected", connected=True, connected_at=time.time(), last_error=None)
if not was_connected:
self._event("mqtt_connected", f"MQTT connected to {self.cfg.mqtt_host}:{self.cfg.mqtt_port}")
client.subscribe(self.status_topic)
self._announce()
if self._last_payload is not None:
self._publish(self.state_topic, json.dumps(self._last_payload))
def _on_connect_fail(self, client, userdata):
with self._lock:
first = self._diag["connect_failures"] == 0 and not self._diag["connected"]
self._diag.update(status="failed", connected=False, last_attempt_at=time.time(),
last_error="connection attempt failed (unreachable, refused or unresolvable host)")
self._diag["connect_failures"] += 1
log.warning("MQTT: connection to %s:%s failed, retrying", self.cfg.mqtt_host, self.cfg.mqtt_port)
if first:
self._event("mqtt_connect_failed", f"MQTT cannot reach {self.cfg.mqtt_host}:{self.cfg.mqtt_port}")
self._probe_in_background()
def _on_disconnect(self, client, userdata, flags, reason_code, properties):
log.warning("MQTT: disconnected: %s", reason_code)
self._set("disconnects", status="disconnected", connected=False, disconnected_at=time.time(),
last_error=f"disconnected: {reason_code}")
self._event("mqtt_disconnected", f"MQTT disconnected: {reason_code}")
def _on_message(self, client, userdata, message):
if message.topic == self.status_topic:
status = message.payload.decode(errors="ignore")
self._set(ha_status=status, ha_status_at=time.time())
if status == "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._publish(topic, json.dumps(payload))
self._set("discovery_count", discovery_at=time.time())
info = self._publish(self.availability_topic, "online")
if info.rc == mqtt.MQTT_ERR_SUCCESS:
self._set(availability_at=time.time())