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

+40 -19
View File
@@ -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)
+13 -5
View File
@@ -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'
+8 -1
View File
@@ -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
+12 -8
View File
@@ -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"
+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"):