From 593f86e6dd87398f1fc2a90c479f1300b87a506e Mon Sep 17 00:00:00 2001 From: zorruno <1+zorruno@noreply.home.fox.co.nz> Date: Sat, 3 Oct 2026 15:31:08 +1300 Subject: [PATCH] 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. --- controller.py | 59 +++++++++++------ install.sh | 18 ++++-- meshcore-phone-mode.ini.example | 9 ++- meshcore_phone_app_mode.yaml | 20 +++--- test_controller.py | 108 +++++++++++++++++++++++++++----- 5 files changed, 167 insertions(+), 47 deletions(-) diff --git a/controller.py b/controller.py index e6e38ab..f81cccb 100644 --- a/controller.py +++ b/controller.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Zorruno MeshCore Phone App Mode v1.0 — 2026-10-03. +"""Zorruno MeshCore Phone App Mode v1.1 — 2026-10-03. -Runs on beaver independently of meshcore-webui. Only ON/OFF commands on the -fixed MQTT topic are accepted. Container names and executable arguments never +Runs independently of meshcore-webui. Only ON/OFF commands on the configured +MQTT topic are accepted. Container names and executable arguments never come from MQTT. No container action occurs at startup or broker reconnect. """ @@ -17,14 +17,29 @@ import subprocess import threading import time from datetime import datetime, timezone +from dataclasses import dataclass CONTAINER = "meshcore-webui" -COMMAND_TOPIC = "viewroad-commands/meshcore/phone_app_mode" -STATE_TOPIC = "viewroad-status/meshcore/phone_app_mode" -AVAILABILITY_TOPIC = "viewroad-status/meshcore/controller/availability" LOG = logging.getLogger("meshcore-phone-mode") +@dataclass(frozen=True) +class Topics: + command: str + state: str + availability: str + + def __post_init__(self): + values = (self.command, self.state, self.availability) + for name, value in zip(("command_topic", "state_topic", "availability_topic"), values): + if not value or any(char in value for char in ("+", "#", "\0")): + raise ValueError(f"Invalid [mqtt] {name}: use a non-empty exact topic without wildcards") + if len(value.encode("utf-8")) > 65535: + raise ValueError(f"Invalid [mqtt] {name}: topic exceeds MQTT length limit") + if len(set(values)) != 3: + raise ValueError("MQTT command, state and availability topics must be different") + + def phone_mode(status): if status in {"exited", "created"}: return "ON" @@ -68,9 +83,10 @@ class DockerControl: class Controller: - def __init__(self, client, docker, poll_seconds=15): + def __init__(self, client, docker, topics, poll_seconds=15): self.client = client self.docker = docker + self.topics = topics self.poll_seconds = poll_seconds self.connected = threading.Event() self.stopping = threading.Event() @@ -87,8 +103,8 @@ class Controller: LOG.error("MQTT connection rejected: %s", reason_code) return self.connected.clear() - client.publish(AVAILABILITY_TOPIC, "offline", qos=1, retain=True) - result, self.subscription_mid = client.subscribe(COMMAND_TOPIC, qos=1) + client.publish(self.topics.availability, "offline", qos=1, retain=True) + result, self.subscription_mid = client.subscribe(self.topics.command, qos=1) if result != 0: LOG.error("MQTT subscribe failed") client.disconnect() @@ -121,7 +137,7 @@ class Controller: return def on_message(self, client, userdata, message): - if message.topic != COMMAND_TOPIC or not self.connected.is_set(): + if message.topic != self.topics.command or not self.connected.is_set(): return if message.retain: LOG.warning("Ignored retained command; publish commands without retain") @@ -159,13 +175,13 @@ class Controller: "container_status": status, "observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } - self.publish_confirmed(STATE_TOPIC, json.dumps(state)) - self.publish_confirmed(AVAILABILITY_TOPIC, "online") + self.publish_confirmed(self.topics.state, json.dumps(state)) + self.publish_confirmed(self.topics.availability, "online") return True except Exception as exc: LOG.error("State check failed: %s", exc) try: - self.publish_confirmed(AVAILABILITY_TOPIC, "offline") + self.publish_confirmed(self.topics.availability, "offline") except Exception: pass return False @@ -202,7 +218,7 @@ class Controller: finally: if self.connected.is_set(): try: - self.publish_confirmed(AVAILABILITY_TOPIC, "offline") + self.publish_confirmed(self.topics.availability, "offline") except Exception: pass self.client.disconnect() @@ -218,7 +234,12 @@ def load_config(path): poll = config.getfloat("controller", "poll_seconds", fallback=15) if not host or not 1 <= port <= 65535 or not 5 <= poll <= 300: raise ValueError("Invalid host/port/poll interval in config") - return host, port, poll, config + names = ("command_topic", "state_topic", "availability_topic") + missing = [name for name in names if not config.has_option("mqtt", name)] + if missing: + raise ValueError("Add full topics to [mqtt] in the private INI file: " + ", ".join(missing)) + topics = Topics(*(config.get("mqtt", name).strip() for name in names)) + return host, port, poll, config, topics def main(): @@ -226,11 +247,11 @@ def main(): parser.add_argument("--config", default="/etc/meshcore-phone-mode.ini") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") - host, port, poll, config = load_config(args.config) + host, port, poll, config, topics = load_config(args.config) import paho.mqtt.client as mqtt client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, - client_id="beaver-meshcore-phone-mode", + client_id=config.get("mqtt", "client_id", fallback="meshcore-phone-mode").strip(), clean_session=True, protocol=mqtt.MQTTv311, ) @@ -238,10 +259,10 @@ def main(): password = config.get("mqtt", "password", fallback="") if username: client.username_pw_set(username, password) - client.will_set(AVAILABILITY_TOPIC, "offline", qos=1, retain=True) + client.will_set(topics.availability, "offline", qos=1, retain=True) client.reconnect_delay_set(min_delay=1, max_delay=30) client.connect_timeout = 10 - controller = Controller(client, DockerControl(), poll) + controller = Controller(client, DockerControl(), topics, poll) for sig in (signal.SIGTERM, signal.SIGINT): signal.signal(sig, lambda *_: controller.stopping.set()) client.connect_async(host, port, keepalive=30) diff --git a/install.sh b/install.sh index e7a972d..1d062d8 100644 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Zorruno — MeshCore Phone App Mode v1.0 — 2026-10-03 -# Install on beaver; preserves an existing credentials file and leaves the +# Zorruno — MeshCore Phone App Mode v1.1 — 2026-10-03 +# Install on the Docker host; preserves an existing credentials file and leaves the # service stopped until you have entered credentials and explicitly started it. set -euo pipefail if [ "$(id -u)" -ne 0 ]; then @@ -15,15 +15,23 @@ apt-get install -y python3-venv install -d -m 0755 /opt/meshcore-phone-mode python3 -m venv /opt/meshcore-phone-mode/venv /opt/meshcore-phone-mode/venv/bin/python -m pip install -r "$source_dir/requirements.txt" +if [ ! -e /etc/meshcore-phone-mode.ini ]; then + install -m 0600 "$source_dir/meshcore-phone-mode.ini.example" /etc/meshcore-phone-mode.ini +fi +# Validate with the new code before stopping or replacing an existing controller. +python3 - "$source_dir/controller.py" <<'PY' +import runpy +import sys +runpy.run_path(sys.argv[1])["load_config"]("/etc/meshcore-phone-mode.ini") +PY if systemctl is-active --quiet meshcore-phone-mode.service; then systemctl stop meshcore-phone-mode.service fi install -m 0644 "$source_dir/controller.py" /opt/meshcore-phone-mode/controller.py install -m 0644 "$source_dir/requirements.txt" /opt/meshcore-phone-mode/requirements.txt -if [ ! -e /etc/meshcore-phone-mode.ini ]; then - install -m 0600 "$source_dir/meshcore-phone-mode.ini.example" /etc/meshcore-phone-mode.ini -fi install -m 0644 "$source_dir/meshcore-phone-mode.service" /etc/systemd/system/meshcore-phone-mode.service systemctl daemon-reload +echo 'Upgrading from 1.0? Add command_topic, state_topic and availability_topic under [mqtt].' +echo 'Use your existing full topics to preserve the current Home Assistant switch.' echo 'Installed. Edit /etc/meshcore-phone-mode.ini locally, then run:' echo 'sudo systemctl enable --now meshcore-phone-mode.service' diff --git a/meshcore-phone-mode.ini.example b/meshcore-phone-mode.ini.example index 7ced8c8..27fe166 100644 --- a/meshcore-phone-mode.ini.example +++ b/meshcore-phone-mode.ini.example @@ -1,4 +1,4 @@ -# Zorruno — MeshCore Phone App Mode v1.0 — 2026-10-03 +# Zorruno — MeshCore Phone App Mode v1.1 — 2026-10-03 # Copy to /etc/meshcore-phone-mode.ini; installer creates it with mode 0600. # Enter the MQTT credentials locally. Values are literal (no surrounding quotes). # Keep username/password blank only if your broker permits anonymous access. @@ -7,6 +7,13 @@ host = 127.0.0.1 port = 1883 username = password = +client_id = meshcore-phone-mode + +# Full topic names; choose your own and use the same values in Home Assistant. +# All three are required and must be different, without + or # wildcards. +command_topic = meshcore/phone_app_mode/set +state_topic = meshcore/phone_app_mode/state +availability_topic = meshcore/phone_app_mode/availability [controller] poll_seconds = 15 diff --git a/meshcore_phone_app_mode.yaml b/meshcore_phone_app_mode.yaml index 806a372..878788c 100644 --- a/meshcore_phone_app_mode.yaml +++ b/meshcore_phone_app_mode.yaml @@ -1,16 +1,20 @@ ############################################################################### -# MeshCore Phone App Mode — Zorruno v1.0 +# MeshCore Phone App Mode — Zorruno v1.1 # Date: 2026-10-03 # Home Assistant package: /config/packages/meshcore_phone_app_mode.yaml # # Purpose: -# Hand over the Wi-Fi companion between meshcore-webui on beaver and the -# native MeshCore phone app. The independent MQTT controller on beaver manages +# Hand over the Wi-Fi companion between meshcore-webui on the Docker host and the +# native MeshCore phone app. The independent MQTT controller on the Docker host manages # ONLY the meshcore-webui container. No SSH or Docker API access from HA. # # ON = WebUI stopped; disconnect other direct clients, then connect phone app. # OFF = WebUI running; disconnect phone app BEFORE turning this switch OFF. # +# Set the four topic fields below to match [mqtt] in your private INI. +# json_attributes_topic uses the same value as state_topic. +# Keep custom topic values in your local HA configuration, outside this repo. +# # MQTT commands: plain ON/OFF, QoS 1, NOT retained. # MQTT state: retained JSON, derived from Docker inspection every 15 seconds. # Availability: retained online/offline with an MQTT Last Will. @@ -26,13 +30,13 @@ mqtt: - switch: name: "MeshCore Phone App Mode" - unique_id: meshcore_phone_app_mode_beaver + unique_id: meshcore_phone_app_mode icon: mdi:cellphone-wireless - command_topic: "viewroad-commands/meshcore/phone_app_mode" - state_topic: "viewroad-status/meshcore/phone_app_mode" + command_topic: "meshcore/phone_app_mode/set" + state_topic: "meshcore/phone_app_mode/state" value_template: "{{ value_json.phone_app_mode }}" - json_attributes_topic: "viewroad-status/meshcore/phone_app_mode" - availability_topic: "viewroad-status/meshcore/controller/availability" + json_attributes_topic: "meshcore/phone_app_mode/state" + availability_topic: "meshcore/phone_app_mode/availability" payload_on: "ON" payload_off: "OFF" payload_available: "online" diff --git a/test_controller.py b/test_controller.py index e26a617..3532beb 100644 --- a/test_controller.py +++ b/test_controller.py @@ -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"):