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