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:
1 parent
d80c40065e
commit
62b57a7e56
8 files changed
+737
-14
No files matched your search
@@ -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()
|
||||
@@ -0,0 +1,125 @@
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
import netprobe
|
||||
|
||||
|
||||
def answer(ident, name, rcode=0, addresses=(), cname=None):
|
||||
header = struct.pack(">HHHHHH", ident, 0x8180 | rcode, 1, len(addresses) + (1 if cname else 0), 0, 0)
|
||||
question = netprobe.build_query(name, ident)[12:]
|
||||
records = b""
|
||||
if cname:
|
||||
target = b"".join(bytes([len(l)]) + l.encode() for l in cname.split(".")) + b"\x00"
|
||||
records += b"\xc0\x0c" + struct.pack(">HHIH", 5, 1, 60, len(target)) + target
|
||||
for address in addresses:
|
||||
records += b"\xc0\x0c" + struct.pack(">HHIH", 1, 1, 60, 4) + socket.inet_aton(address)
|
||||
return header + question + records
|
||||
|
||||
|
||||
class ParseResponseTest(unittest.TestCase):
|
||||
def test_addresses(self):
|
||||
data = answer(7, "broker.lan", addresses=["192.168.0.10", "192.168.0.11"])
|
||||
parsed = netprobe.parse_response(data, 7)
|
||||
self.assertEqual(parsed["rcode"], "NOERROR")
|
||||
self.assertEqual(parsed["addresses"], ["192.168.0.10", "192.168.0.11"])
|
||||
|
||||
def test_nxdomain(self):
|
||||
parsed = netprobe.parse_response(answer(1, "nope.invalid", rcode=3), 1)
|
||||
self.assertEqual(parsed["rcode"], "NXDOMAIN")
|
||||
self.assertEqual(parsed["addresses"], [])
|
||||
|
||||
def test_cname(self):
|
||||
parsed = netprobe.parse_response(answer(2, "ha.example", cname="real.example", addresses=["10.0.0.1"]), 2)
|
||||
self.assertEqual(parsed["cnames"], ["real.example"])
|
||||
self.assertEqual(parsed["addresses"], ["10.0.0.1"])
|
||||
|
||||
def test_id_mismatch(self):
|
||||
with self.assertRaises(ValueError):
|
||||
netprobe.parse_response(answer(3, "x.y"), 4)
|
||||
|
||||
|
||||
class DnsQueryTest(unittest.TestCase):
|
||||
def test_against_fake_server(self):
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
port = server.getsockname()[1]
|
||||
|
||||
def serve():
|
||||
data, addr = server.recvfrom(512)
|
||||
ident = struct.unpack(">H", data[:2])[0]
|
||||
server.sendto(answer(ident, "broker.lan", addresses=["192.168.0.10"]), addr)
|
||||
server.close()
|
||||
|
||||
threading.Thread(target=serve, daemon=True).start()
|
||||
result = netprobe.dns_query("127.0.0.1", "broker.lan", timeout=2, port=port)
|
||||
self.assertEqual(result["addresses"], ["192.168.0.10"])
|
||||
self.assertEqual(result["rcode"], "NOERROR")
|
||||
|
||||
|
||||
class ResolvConfTest(unittest.TestCase):
|
||||
def test_parse(self):
|
||||
with tempfile.NamedTemporaryFile("w", delete=False) as fh:
|
||||
fh.write("# comment\nnameserver 192.168.0.1\nnameserver 8.8.8.8 # public\nsearch lan home\noptions ndots:1\n")
|
||||
try:
|
||||
parsed = netprobe.read_resolv_conf(fh.name)
|
||||
finally:
|
||||
os.unlink(fh.name)
|
||||
self.assertEqual(parsed["nameservers"], ["192.168.0.1", "8.8.8.8"])
|
||||
self.assertEqual(parsed["search"], ["lan", "home"])
|
||||
self.assertEqual(parsed["options"], ["ndots:1"])
|
||||
|
||||
def test_missing(self):
|
||||
parsed = netprobe.read_resolv_conf("/nonexistent/resolv.conf")
|
||||
self.assertEqual(parsed["nameservers"], [])
|
||||
self.assertIn("error", parsed)
|
||||
|
||||
def test_hosts(self):
|
||||
with tempfile.NamedTemporaryFile("w", delete=False) as fh:
|
||||
fh.write("127.0.0.1 localhost\n192.168.0.5 ha.local homeassistant\n")
|
||||
try:
|
||||
self.assertEqual(netprobe.hosts_entries("homeassistant", fh.name), ["192.168.0.5"])
|
||||
self.assertEqual(netprobe.hosts_entries("other", fh.name), [])
|
||||
finally:
|
||||
os.unlink(fh.name)
|
||||
|
||||
|
||||
class TcpTest(unittest.TestCase):
|
||||
def test_connect_ok_and_refused(self):
|
||||
listener = socket.socket()
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
port = listener.getsockname()[1]
|
||||
try:
|
||||
ok = netprobe.tcp_connect("127.0.0.1", port, 2)
|
||||
finally:
|
||||
listener.close()
|
||||
self.assertTrue(ok["ok"])
|
||||
refused = netprobe.tcp_connect("127.0.0.1", port, 2)
|
||||
self.assertFalse(refused["ok"])
|
||||
self.assertIn("error", refused)
|
||||
|
||||
|
||||
class SummaryTest(unittest.TestCase):
|
||||
def test_disagreement_flagged(self):
|
||||
result = {
|
||||
"host": "ha.milans.cloud", "port": 1883, "hosts_file": [],
|
||||
"resolv_conf": {"nameservers": ["192.168.0.1", "8.8.8.8"]},
|
||||
"getaddrinfo": {"addresses": [], "error": "Name does not resolve"},
|
||||
"nameservers": [
|
||||
{"server": "192.168.0.1", "rcode": "NOERROR", "addresses": ["192.168.0.20"]},
|
||||
{"server": "8.8.8.8", "rcode": "NXDOMAIN", "addresses": []},
|
||||
],
|
||||
"tcp": [{"address": "192.168.0.20", "ok": True, "ms": 2.0}],
|
||||
}
|
||||
lines = netprobe.summarize(result)
|
||||
self.assertTrue(any("disagree" in line for line in lines))
|
||||
self.assertTrue(any("System resolver failed" in line for line in lines))
|
||||
self.assertTrue(any("connected in" in line for line in lines))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user