Files
healthcheck-container/tests/test_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

141 lines
5.9 KiB
Python

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()