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)