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>
This commit is contained in:
Milan PandurovandClaude Fable 5.1 committed 2026-09-29 12:08:48 +02:00
1 parent d80c40065e
commit 62b57a7e56
8 files changed
+737 -14

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
import time
import unittest
from types import SimpleNamespace
from unittest import mock
import paho.mqtt.client as mqtt
import hamqtt
def make_cfg(**overrides):
values = dict(device_id="pi", device_name="Pi", mqtt_host="broker", mqtt_port=1883,
mqtt_username="", mqtt_password="", mqtt_discovery_prefix="homeassistant",
wifi_interface="")
values.update(overrides)
return SimpleNamespace(**values)
class FakeStorage:
def __init__(self):
self.events = []
def add_event(self, kind, message):
self.events.append((kind, message))
def ok_publish(*args, **kwargs):
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS)
def no_conn_publish(*args, **kwargs):
return SimpleNamespace(rc=mqtt.MQTT_ERR_NO_CONN)
class PublisherDiagnosticsTest(unittest.TestCase):
def setUp(self):
patcher = mock.patch("hamqtt.mqtt.Client")
self.client_cls = patcher.start()
self.addCleanup(patcher.stop)
self.client = self.client_cls.return_value
self.client.publish.side_effect = ok_publish
self.storage = FakeStorage()
self.publisher = hamqtt.HomeAssistantPublisher(make_cfg(), self.storage)
def test_initial_state(self):
diag = self.publisher.diagnostics()
self.assertTrue(diag["enabled"])
self.assertEqual(diag["status"], "not_started")
self.assertFalse(diag["connected"])
self.assertEqual(diag["host"], "broker")
self.assertEqual(diag["client_id"], "healthcheck_pi")
self.assertEqual(diag["state_topic"], "healthcheck/pi/state")
self.assertIsNone(diag["username"])
def test_start_marks_connecting(self):
self.publisher.start()
self.assertEqual(self.publisher.diagnostics()["status"], "connecting")
self.assertEqual(self.client.on_connect_fail, self.publisher._on_connect_fail)
def test_connect_announces_and_records(self):
self.publisher.publish_state({"internet_up": True})
self.publisher._on_connect(self.client, None, {}, SimpleNamespace(is_failure=False), None)
diag = self.publisher.diagnostics()
self.assertEqual(diag["status"], "connected")
self.assertTrue(diag["connected"])
self.assertIsNotNone(diag["connected_at"])
self.assertEqual(diag["discovery_count"], 1)
self.assertIsNotNone(diag["availability_at"])
self.assertEqual(diag["publish_dropped"], 0)
self.assertGreater(diag["publish_count"], len(hamqtt.SENSORS))
self.assertEqual(diag["last_payload"]["internet_up"], True)
self.assertEqual(self.storage.events[0][0], "mqtt_connected")
def test_connect_fail_records_first_event_only(self):
self.publisher._on_connect_fail(self.client, None)
self.publisher._on_connect_fail(self.client, None)
diag = self.publisher.diagnostics()
self.assertEqual(diag["status"], "failed")
self.assertEqual(diag["connect_failures"], 2)
self.assertIn("failed", diag["last_error"])
self.assertEqual([k for k, _ in self.storage.events], ["mqtt_connect_failed"])
def test_refused(self):
self.publisher._on_connect(self.client, None, {}, SimpleNamespace(is_failure=True), None)
diag = self.publisher.diagnostics()
self.assertEqual(diag["status"], "refused")
self.assertEqual(diag["connect_failures"], 1)
self.assertFalse(diag["connected"])
def test_disconnect(self):
self.publisher._on_connect(self.client, None, {}, SimpleNamespace(is_failure=False), None)
self.publisher._on_disconnect(self.client, None, {}, "Keep alive timeout", None)
diag = self.publisher.diagnostics()
self.assertEqual(diag["status"], "disconnected")
self.assertEqual(diag["disconnects"], 1)
self.assertIn("Keep alive timeout", diag["last_error"])
self.assertEqual(self.storage.events[-1][0], "mqtt_disconnected")
def test_dropped_publish_is_counted(self):
self.client.publish.side_effect = no_conn_publish
self.publisher.publish_state({"internet_up": False})
diag = self.publisher.diagnostics()
self.assertEqual(diag["publish_count"], 0)
self.assertEqual(diag["publish_dropped"], 1)
self.assertIsNotNone(diag["last_publish_error"])
self.assertIsNone(diag["last_publish_at"])
def test_ha_status_message(self):
message = SimpleNamespace(topic="homeassistant/status", payload=b"online")
self.publisher._on_message(self.client, None, message)
diag = self.publisher.diagnostics()
self.assertEqual(diag["ha_status"], "online")
self.assertEqual(diag["discovery_count"], 1)
def test_connect_fail_runs_probe_once_per_interval(self):
probe = {"ts": time.time(), "summary": ["System resolver failed"]}
with mock.patch("hamqtt.netprobe.probe", return_value=probe) as probe_fn, \
mock.patch("hamqtt.threading.Thread") as thread_cls:
thread_cls.return_value.start.side_effect = lambda: self.publisher.run_probe()
self.publisher._on_connect_fail(self.client, None)
self.publisher._on_connect_fail(self.client, None)
probe_fn.assert_called_once_with("broker", 1883)
diag = self.publisher.diagnostics()
self.assertEqual(diag["probe"], probe)
self.assertFalse(diag["probe_running"])
def test_run_probe_survives_crash(self):
with mock.patch("hamqtt.netprobe.probe", side_effect=RuntimeError("boom")):
result = self.publisher.run_probe()
self.assertIn("boom", result["error"])
self.assertFalse(self.publisher.diagnostics()["probe_running"])
def test_publish_state_dedupes(self):
self.publisher.publish_state({"internet_up": True})
self.publisher.publish_state({"internet_up": True})
self.assertEqual(self.publisher.diagnostics()["publish_count"], 1)
if __name__ == "__main__":
unittest.main()