commit 19446a7ebe8b657d04ff105c4f7fced4d6572677 Author: zorruno <1+zorruno@noreply.home.fox.co.nz> Date: Sat Oct 3 15:20:31 2026 +1300 Add MQTT handover controller and systemd installer diff --git a/controller.py b/controller.py new file mode 100644 index 0000000..e6e38ab --- /dev/null +++ b/controller.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Zorruno MeshCore Phone App Mode v1.0 — 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 +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 + +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") + + +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, poll_seconds=15): + self.client = client + self.docker = docker + 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(AVAILABILITY_TOPIC, "offline", qos=1, retain=True) + result, self.subscription_mid = client.subscribe(COMMAND_TOPIC, 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 != COMMAND_TOPIC 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(STATE_TOPIC, json.dumps(state)) + self.publish_confirmed(AVAILABILITY_TOPIC, "online") + return True + except Exception as exc: + LOG.error("State check failed: %s", exc) + try: + self.publish_confirmed(AVAILABILITY_TOPIC, "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(AVAILABILITY_TOPIC, "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") + return host, port, poll, config + + +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 = 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", + 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(AVAILABILITY_TOPIC, "offline", qos=1, retain=True) + client.reconnect_delay_set(min_delay=1, max_delay=30) + client.connect_timeout = 10 + controller = Controller(client, DockerControl(), 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() diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e7a972d --- /dev/null +++ b/install.sh @@ -0,0 +1,29 @@ +#!/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 +# service stopped until you have entered credentials and explicitly started it. +set -euo pipefail +if [ "$(id -u)" -ne 0 ]; then + echo 'Run with sudo: sudo bash install.sh' >&2 + exit 1 +fi +source_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +command -v docker >/dev/null +docker inspect meshcore-webui >/dev/null +apt-get update +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 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 '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 new file mode 100644 index 0000000..7ced8c8 --- /dev/null +++ b/meshcore-phone-mode.ini.example @@ -0,0 +1,12 @@ +# Zorruno — MeshCore Phone App Mode v1.0 — 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. +[mqtt] +host = 127.0.0.1 +port = 1883 +username = +password = + +[controller] +poll_seconds = 15 diff --git a/meshcore-phone-mode.service b/meshcore-phone-mode.service new file mode 100644 index 0000000..bd8d3c8 --- /dev/null +++ b/meshcore-phone-mode.service @@ -0,0 +1,28 @@ +# Zorruno — MeshCore Phone App Mode v1.0 — 2026-10-03 +[Unit] +Description=MeshCore WebUI phone-app handover via MQTT +After=network-online.target docker.service +Wants=network-online.target docker.service + +[Service] +Type=simple +User=root +ExecStart=/opt/meshcore-phone-mode/venv/bin/python /opt/meshcore-phone-mode/controller.py --config /etc/meshcore-phone-mode.ini +Restart=on-failure +RestartSec=10 +TimeoutStopSec=50 +Environment=PYTHONUNBUFFERED=1 +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..96a2716 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +paho-mqtt==2.1.0