Configure full MQTT topics in the private INI

Use configured topics for commands, state, availability and Last Will. Replace shared installation prefixes with generic examples. Validate configuration before stopping an existing service; add tests for custom and invalid topics.
This commit is contained in:
zorruno committed 2026-10-03 15:31:08 +13:00
1 parent 25cacd4894
commit 593f86e6dd
5 files changed
+167 -47

No files matched your search

+94 -14
View File
@@ -1,16 +1,21 @@
"""Behaviour tests with mocked MQTT and Docker; no live services are changed."""
import json
import queue
import configparser
import tempfile
from pathlib import Path
import sys
import unittest
from types import SimpleNamespace
from types import SimpleNamespace, ModuleType
from unittest.mock import Mock, patch
from controller import (
AVAILABILITY_TOPIC, COMMAND_TOPIC, STATE_TOPIC, CONTAINER,
Controller, DockerControl, phone_mode,
CONTAINER, Topics, Controller, DockerControl, phone_mode, load_config, main,
)
TEST_TOPICS = Topics("custom/command", "custom/state", "custom/availability")
class ControllerTests(unittest.TestCase):
def setUp(self):
self.client = Mock()
@@ -20,7 +25,7 @@ class ControllerTests(unittest.TestCase):
self.client.publish.return_value = publication
self.docker = Mock()
self.docker.status.return_value = "running"
self.control = Controller(self.client, self.docker)
self.control = Controller(self.client, self.docker, TEST_TOPICS)
def ready(self):
self.control.on_connect(self.client, None, None, 0, None)
@@ -33,6 +38,16 @@ class ControllerTests(unittest.TestCase):
self.ready()
self.control.report()
self.docker.set_mode.assert_not_called()
self.client.subscribe.assert_called_with(TEST_TOPICS.command, qos=1)
def test_shutdown_publishes_to_configured_availability_topic(self):
self.ready()
self.control.stopping.set()
self.client.publish.reset_mock()
self.control.run()
self.client.publish.assert_called_once_with(
TEST_TOPICS.availability, "offline", qos=1, retain=True,
)
def test_subscription_denial_keeps_controller_unavailable(self):
self.control.on_connect(self.client, None, None, 0, None)
@@ -47,10 +62,10 @@ class ControllerTests(unittest.TestCase):
def test_retained_and_invalid_commands_do_not_run_docker(self):
self.ready()
for topic, payload, retained in [
(COMMAND_TOPIC, b"ON", True),
(COMMAND_TOPIC, b"ON; docker rm mqtt5", False),
(COMMAND_TOPIC, b"off", False),
(COMMAND_TOPIC, b"\xff", False),
(TEST_TOPICS.command, b"ON", True),
(TEST_TOPICS.command, b"ON; docker rm mqtt5", False),
(TEST_TOPICS.command, b"off", False),
(TEST_TOPICS.command, b"\xff", False),
("another/topic", b"ON", False),
]:
self.control.on_message(self.client, None, SimpleNamespace(
@@ -62,12 +77,12 @@ class ControllerTests(unittest.TestCase):
def test_live_command_waits_for_confirmed_docker_state(self):
self.ready()
self.control.on_message(self.client, None, SimpleNamespace(
topic=COMMAND_TOPIC, payload=b"ON", retain=False,
topic=TEST_TOPICS.command, payload=b"ON", retain=False,
))
self.docker.set_mode.return_value = "exited"
self.control.process_command(self.control.commands.get_nowait())
self.docker.set_mode.assert_called_once_with("ON")
states = [json.loads(c.args[1]) for c in self.client.publish.call_args_list if c.args[0] == STATE_TOPIC]
states = [json.loads(c.args[1]) for c in self.client.publish.call_args_list if c.args[0] == TEST_TOPICS.state]
self.assertEqual(states[-1]["phone_app_mode"], "ON")
self.assertEqual(states[-1]["container_status"], "exited")
@@ -75,14 +90,14 @@ class ControllerTests(unittest.TestCase):
self.ready()
self.docker.set_mode.side_effect = RuntimeError("stop failed")
self.control.process_command("ON")
states = [json.loads(c.args[1]) for c in self.client.publish.call_args_list if c.args[0] == STATE_TOPIC]
states = [json.loads(c.args[1]) for c in self.client.publish.call_args_list if c.args[0] == TEST_TOPICS.state]
self.assertEqual(states[-1]["phone_app_mode"], "OFF")
def test_missing_container_reports_offline(self):
self.ready()
self.docker.status.side_effect = RuntimeError("missing container")
self.assertFalse(self.control.report())
self.client.publish.assert_called_with(AVAILABILITY_TOPIC, "offline", qos=1, retain=True)
self.client.publish.assert_called_with(TEST_TOPICS.availability, "offline", qos=1, retain=True)
def test_unacknowledged_state_does_not_report_online(self):
self.ready()
@@ -103,10 +118,75 @@ class ControllerTests(unittest.TestCase):
self.ready()
self.control.report("running")
self.control.report("exited")
states = [json.loads(c.args[1])["phone_app_mode"] for c in self.client.publish.call_args_list if c.args[0] == STATE_TOPIC]
states = [json.loads(c.args[1])["phone_app_mode"] for c in self.client.publish.call_args_list if c.args[0] == TEST_TOPICS.state]
self.assertEqual(states, ["OFF", "ON"])
class ConfigTests(unittest.TestCase):
def config(self, values):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "controller.ini"
path.write_text("[mqtt]\n" + values)
return load_config(path)
def test_full_custom_topics_are_loaded_without_prefix_or_interpolation(self):
_, _, _, _, topics = self.config(
"command_topic = house%20/radio/set\n"
"state_topic = another/tree/state\n"
"availability_topic = separate/health\n"
)
self.assertEqual(topics, Topics("house%20/radio/set", "another/tree/state", "separate/health"))
def test_missing_topics_do_not_fall_back_to_another_installation(self):
with self.assertRaisesRegex(ValueError, "Add full topics"):
self.config("host = 127.0.0.1\n")
def test_invalid_topics_are_rejected_for_each_setting(self):
for index in range(3):
for invalid in ("", "radio/#", "radio/+/state", "radio\0state", "x" * 65536, "é" * 32768):
with self.subTest(index=index, invalid=invalid[:20]):
values = ["radio/set", "radio/state", "radio/availability"]
values[index] = invalid
with self.assertRaises(ValueError):
Topics(*values)
def test_duplicate_topics_are_rejected(self):
for values in (("same", "same", "other"), ("same", "other", "same"), ("other", "same", "same")):
with self.assertRaisesRegex(ValueError, "must be different"):
Topics(*values)
def test_blank_topic_in_ini_is_rejected(self):
with self.assertRaises(ValueError):
self.config("command_topic = \nstate_topic = radio/state\navailability_topic = radio/health\n")
def test_shared_examples_use_matching_generic_topics(self):
root = Path(__file__).parent
_, _, _, _, topics = load_config(root / "meshcore-phone-mode.ini.example")
yaml = (root / "meshcore_phone_app_mode.yaml").read_text()
for key, topic in (("command_topic", topics.command), ("state_topic", topics.state),
("json_attributes_topic", topics.state), ("availability_topic", topics.availability)):
self.assertIn(f'{key}: "{topic}"', yaml)
def test_main_sets_last_will_and_controller_from_same_private_config(self):
mqtt = Mock()
paho_package = ModuleType("paho")
mqtt_package = ModuleType("paho.mqtt")
paho_package.mqtt = mqtt_package
mqtt_package.client = mqtt
modules = {"paho": paho_package, "paho.mqtt": mqtt_package, "paho.mqtt.client": mqtt}
with patch.dict(sys.modules, modules), patch("controller.load_config", return_value=(
"127.0.0.1", 1883, 15, configparser.ConfigParser(), TEST_TOPICS,
)), patch("controller.DockerControl") as docker, patch("controller.Controller") as control, \
patch("controller.signal.signal"), patch("controller.logging.basicConfig"), \
patch.object(sys, "argv", ["controller.py"]):
main()
mqtt.Client.return_value.will_set.assert_called_once_with(
TEST_TOPICS.availability, "offline", qos=1, retain=True,
)
control.assert_called_once_with(mqtt.Client.return_value, docker.return_value, TEST_TOPICS, 15)
control.return_value.run.assert_called_once_with()
class DockerTests(unittest.TestCase):
def control(self):
with patch("controller.shutil.which", return_value="/usr/bin/docker"):