Files
zorruno 593f86e6dd 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.
2026-10-03 15:31:08 +13:00

274 lines
10 KiB
Python

#!/usr/bin/env python3
"""Zorruno MeshCore Phone App Mode v1.1 — 2026-10-03.
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.
"""
import argparse
import configparser
import json
import logging
import queue
import shutil
import signal
import subprocess
import threading
import time
from datetime import datetime, timezone
from dataclasses import dataclass
CONTAINER = "meshcore-webui"
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"
if status in {"running", "restarting"}:
return "OFF"
raise RuntimeError("Unexpected WebUI container state: " + status)
class DockerControl:
def __init__(self):
self.executable = shutil.which("docker")
if not self.executable:
raise RuntimeError("Docker CLI is not installed")
def run(self, *args, timeout=15):
result = subprocess.run(
[self.executable, *args], capture_output=True, text=True,
timeout=timeout, check=False,
)
if result.returncode:
raise RuntimeError(result.stderr.strip() or "Docker command failed")
return result.stdout.strip()
def status(self):
return self.run("inspect", "--format", "{{.State.Status}}", CONTAINER)
def set_mode(self, requested):
if requested not in {"ON", "OFF"}:
raise ValueError("Only ON or OFF is allowed")
before = self.status()
if phone_mode(before) == requested:
return before
if requested == "ON":
self.run("stop", "--time", "10", CONTAINER, timeout=35)
else:
self.run("start", CONTAINER, timeout=35)
after = self.status()
if phone_mode(after) != requested:
raise RuntimeError("WebUI did not reach the requested state: " + after)
return after
class Controller:
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()
self.refresh = threading.Event()
self.commands = queue.Queue(maxsize=10)
self.subscription_mid = None
client.on_connect = self.on_connect
client.on_subscribe = self.on_subscribe
client.on_disconnect = self.on_disconnect
client.on_message = self.on_message
def on_connect(self, client, userdata, flags, reason_code, properties):
if reason_code != 0:
LOG.error("MQTT connection rejected: %s", reason_code)
return
self.connected.clear()
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()
return
LOG.info("MQTT connected; waiting for command subscription")
def on_subscribe(self, client, userdata, mid, reason_codes, properties):
if mid != self.subscription_mid:
return
if not reason_codes or any(code >= 128 for code in reason_codes):
LOG.error("MQTT command subscription denied; check broker permissions")
self.connected.clear()
return
# Never advertise online before subscription and Docker are checked.
self.refresh.set()
self.connected.set()
LOG.info("MQTT subscribed; checking current WebUI state")
def on_disconnect(self, client, userdata, flags, reason_code, properties):
self.connected.clear()
self.clear_commands()
if not self.stopping.is_set():
LOG.warning("MQTT disconnected; will reconnect automatically")
def clear_commands(self):
while True:
try:
self.commands.get_nowait()
except queue.Empty:
return
def on_message(self, client, userdata, message):
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")
return
if len(message.payload) > 16:
LOG.warning("Ignored invalid command")
return
try:
requested = message.payload.decode("utf-8").strip()
except UnicodeDecodeError:
LOG.warning("Ignored non-text command")
return
if requested not in {"ON", "OFF"}:
LOG.warning("Ignored invalid command; only ON/OFF is accepted")
return
try:
self.commands.put_nowait(requested)
except queue.Full:
LOG.warning("Command queue full; ignored command")
def publish_confirmed(self, topic, payload):
info = self.client.publish(topic, payload, qos=1, retain=True)
if info.rc != 0:
raise RuntimeError("MQTT publish failed")
info.wait_for_publish(timeout=5)
if not info.is_published():
raise RuntimeError("MQTT publication was not acknowledged")
def report(self, status=None):
try:
status = status if status is not None else self.docker.status()
state = {
"phone_app_mode": phone_mode(status),
"container": CONTAINER,
"container_status": status,
"observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
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(self.topics.availability, "offline")
except Exception:
pass
return False
def process_command(self, requested):
if not self.connected.is_set() or self.stopping.is_set():
return
try:
LOG.info("Phone App Mode requested: %s", requested)
status = self.docker.set_mode(requested)
self.report(status)
except Exception as exc:
LOG.error("Container control failed: %s", exc)
# Report what actually happened, even if the operation failed.
self.report()
def run(self):
next_report = 0
self.client.loop_start()
try:
while not self.stopping.is_set():
if not self.connected.is_set():
self.stopping.wait(0.5)
continue
if self.refresh.is_set() or time.monotonic() >= next_report:
self.refresh.clear()
self.report()
next_report = time.monotonic() + self.poll_seconds
try:
requested = self.commands.get(timeout=0.5)
except queue.Empty:
continue
self.process_command(requested)
finally:
if self.connected.is_set():
try:
self.publish_confirmed(self.topics.availability, "offline")
except Exception:
pass
self.client.disconnect()
self.client.loop_stop()
def load_config(path):
config = configparser.ConfigParser(interpolation=None)
if not config.read(path):
raise RuntimeError("Cannot read config: " + path)
host = config.get("mqtt", "host", fallback="127.0.0.1").strip()
port = config.getint("mqtt", "port", fallback=1883)
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")
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():
parser = argparse.ArgumentParser(description=__doc__)
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, topics = load_config(args.config)
import paho.mqtt.client as mqtt
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=config.get("mqtt", "client_id", fallback="meshcore-phone-mode").strip(),
clean_session=True,
protocol=mqtt.MQTTv311,
)
username = config.get("mqtt", "username", fallback="").strip()
password = config.get("mqtt", "password", fallback="")
if username:
client.username_pw_set(username, password)
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(), topics, poll)
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda *_: controller.stopping.set())
client.connect_async(host, port, keepalive=30)
controller.run()
if __name__ == "__main__":
main()