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:
1 parent
25cacd4894
commit
593f86e6dd
5 files changed
+167
-47
No files matched your search
+40
-19
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user