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"):