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
|
#!/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
|
Runs independently of meshcore-webui. Only ON/OFF commands on the configured
|
||||||
fixed MQTT topic are accepted. Container names and executable arguments never
|
MQTT topic are accepted. Container names and executable arguments never
|
||||||
come from MQTT. No container action occurs at startup or broker reconnect.
|
come from MQTT. No container action occurs at startup or broker reconnect.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -17,14 +17,29 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
CONTAINER = "meshcore-webui"
|
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")
|
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):
|
def phone_mode(status):
|
||||||
if status in {"exited", "created"}:
|
if status in {"exited", "created"}:
|
||||||
return "ON"
|
return "ON"
|
||||||
@@ -68,9 +83,10 @@ class DockerControl:
|
|||||||
|
|
||||||
|
|
||||||
class Controller:
|
class Controller:
|
||||||
def __init__(self, client, docker, poll_seconds=15):
|
def __init__(self, client, docker, topics, poll_seconds=15):
|
||||||
self.client = client
|
self.client = client
|
||||||
self.docker = docker
|
self.docker = docker
|
||||||
|
self.topics = topics
|
||||||
self.poll_seconds = poll_seconds
|
self.poll_seconds = poll_seconds
|
||||||
self.connected = threading.Event()
|
self.connected = threading.Event()
|
||||||
self.stopping = threading.Event()
|
self.stopping = threading.Event()
|
||||||
@@ -87,8 +103,8 @@ class Controller:
|
|||||||
LOG.error("MQTT connection rejected: %s", reason_code)
|
LOG.error("MQTT connection rejected: %s", reason_code)
|
||||||
return
|
return
|
||||||
self.connected.clear()
|
self.connected.clear()
|
||||||
client.publish(AVAILABILITY_TOPIC, "offline", qos=1, retain=True)
|
client.publish(self.topics.availability, "offline", qos=1, retain=True)
|
||||||
result, self.subscription_mid = client.subscribe(COMMAND_TOPIC, qos=1)
|
result, self.subscription_mid = client.subscribe(self.topics.command, qos=1)
|
||||||
if result != 0:
|
if result != 0:
|
||||||
LOG.error("MQTT subscribe failed")
|
LOG.error("MQTT subscribe failed")
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
@@ -121,7 +137,7 @@ class Controller:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def on_message(self, client, userdata, message):
|
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
|
return
|
||||||
if message.retain:
|
if message.retain:
|
||||||
LOG.warning("Ignored retained command; publish commands without retain")
|
LOG.warning("Ignored retained command; publish commands without retain")
|
||||||
@@ -159,13 +175,13 @@ class Controller:
|
|||||||
"container_status": status,
|
"container_status": status,
|
||||||
"observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
"observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
self.publish_confirmed(STATE_TOPIC, json.dumps(state))
|
self.publish_confirmed(self.topics.state, json.dumps(state))
|
||||||
self.publish_confirmed(AVAILABILITY_TOPIC, "online")
|
self.publish_confirmed(self.topics.availability, "online")
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
LOG.error("State check failed: %s", exc)
|
LOG.error("State check failed: %s", exc)
|
||||||
try:
|
try:
|
||||||
self.publish_confirmed(AVAILABILITY_TOPIC, "offline")
|
self.publish_confirmed(self.topics.availability, "offline")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
@@ -202,7 +218,7 @@ class Controller:
|
|||||||
finally:
|
finally:
|
||||||
if self.connected.is_set():
|
if self.connected.is_set():
|
||||||
try:
|
try:
|
||||||
self.publish_confirmed(AVAILABILITY_TOPIC, "offline")
|
self.publish_confirmed(self.topics.availability, "offline")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.client.disconnect()
|
self.client.disconnect()
|
||||||
@@ -218,7 +234,12 @@ def load_config(path):
|
|||||||
poll = config.getfloat("controller", "poll_seconds", fallback=15)
|
poll = config.getfloat("controller", "poll_seconds", fallback=15)
|
||||||
if not host or not 1 <= port <= 65535 or not 5 <= poll <= 300:
|
if not host or not 1 <= port <= 65535 or not 5 <= poll <= 300:
|
||||||
raise ValueError("Invalid host/port/poll interval in config")
|
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():
|
def main():
|
||||||
@@ -226,11 +247,11 @@ def main():
|
|||||||
parser.add_argument("--config", default="/etc/meshcore-phone-mode.ini")
|
parser.add_argument("--config", default="/etc/meshcore-phone-mode.ini")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
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
|
import paho.mqtt.client as mqtt
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
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,
|
clean_session=True,
|
||||||
protocol=mqtt.MQTTv311,
|
protocol=mqtt.MQTTv311,
|
||||||
)
|
)
|
||||||
@@ -238,10 +259,10 @@ def main():
|
|||||||
password = config.get("mqtt", "password", fallback="")
|
password = config.get("mqtt", "password", fallback="")
|
||||||
if username:
|
if username:
|
||||||
client.username_pw_set(username, password)
|
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.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||||
client.connect_timeout = 10
|
client.connect_timeout = 10
|
||||||
controller = Controller(client, DockerControl(), poll)
|
controller = Controller(client, DockerControl(), topics, poll)
|
||||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
signal.signal(sig, lambda *_: controller.stopping.set())
|
signal.signal(sig, lambda *_: controller.stopping.set())
|
||||||
client.connect_async(host, port, keepalive=30)
|
client.connect_async(host, port, keepalive=30)
|
||||||
|
|||||||
+13
-5
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Zorruno — MeshCore Phone App Mode v1.0 — 2026-10-03
|
# Zorruno — MeshCore Phone App Mode v1.1 — 2026-10-03
|
||||||
# Install on beaver; preserves an existing credentials file and leaves the
|
# Install on the Docker host; preserves an existing credentials file and leaves the
|
||||||
# service stopped until you have entered credentials and explicitly started it.
|
# service stopped until you have entered credentials and explicitly started it.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
@@ -15,15 +15,23 @@ apt-get install -y python3-venv
|
|||||||
install -d -m 0755 /opt/meshcore-phone-mode
|
install -d -m 0755 /opt/meshcore-phone-mode
|
||||||
python3 -m venv /opt/meshcore-phone-mode/venv
|
python3 -m venv /opt/meshcore-phone-mode/venv
|
||||||
/opt/meshcore-phone-mode/venv/bin/python -m pip install -r "$source_dir/requirements.txt"
|
/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
|
if systemctl is-active --quiet meshcore-phone-mode.service; then
|
||||||
systemctl stop meshcore-phone-mode.service
|
systemctl stop meshcore-phone-mode.service
|
||||||
fi
|
fi
|
||||||
install -m 0644 "$source_dir/controller.py" /opt/meshcore-phone-mode/controller.py
|
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
|
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
|
install -m 0644 "$source_dir/meshcore-phone-mode.service" /etc/systemd/system/meshcore-phone-mode.service
|
||||||
systemctl daemon-reload
|
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 'Installed. Edit /etc/meshcore-phone-mode.ini locally, then run:'
|
||||||
echo 'sudo systemctl enable --now meshcore-phone-mode.service'
|
echo 'sudo systemctl enable --now meshcore-phone-mode.service'
|
||||||
@@ -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.
|
# Copy to /etc/meshcore-phone-mode.ini; installer creates it with mode 0600.
|
||||||
# Enter the MQTT credentials locally. Values are literal (no surrounding quotes).
|
# Enter the MQTT credentials locally. Values are literal (no surrounding quotes).
|
||||||
# Keep username/password blank only if your broker permits anonymous access.
|
# Keep username/password blank only if your broker permits anonymous access.
|
||||||
@@ -7,6 +7,13 @@ host = 127.0.0.1
|
|||||||
port = 1883
|
port = 1883
|
||||||
username =
|
username =
|
||||||
password =
|
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]
|
[controller]
|
||||||
poll_seconds = 15
|
poll_seconds = 15
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# MeshCore Phone App Mode — Zorruno v1.0
|
# MeshCore Phone App Mode — Zorruno v1.1
|
||||||
# Date: 2026-10-03
|
# Date: 2026-10-03
|
||||||
# Home Assistant package: /config/packages/meshcore_phone_app_mode.yaml
|
# Home Assistant package: /config/packages/meshcore_phone_app_mode.yaml
|
||||||
#
|
#
|
||||||
# Purpose:
|
# Purpose:
|
||||||
# Hand over the Wi-Fi companion between meshcore-webui on beaver and the
|
# Hand over the Wi-Fi companion between meshcore-webui on the Docker host and the
|
||||||
# native MeshCore phone app. The independent MQTT controller on beaver manages
|
# 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.
|
# ONLY the meshcore-webui container. No SSH or Docker API access from HA.
|
||||||
#
|
#
|
||||||
# ON = WebUI stopped; disconnect other direct clients, then connect phone app.
|
# ON = WebUI stopped; disconnect other direct clients, then connect phone app.
|
||||||
# OFF = WebUI running; disconnect phone app BEFORE turning this switch OFF.
|
# 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 commands: plain ON/OFF, QoS 1, NOT retained.
|
||||||
# MQTT state: retained JSON, derived from Docker inspection every 15 seconds.
|
# MQTT state: retained JSON, derived from Docker inspection every 15 seconds.
|
||||||
# Availability: retained online/offline with an MQTT Last Will.
|
# Availability: retained online/offline with an MQTT Last Will.
|
||||||
@@ -26,13 +30,13 @@
|
|||||||
mqtt:
|
mqtt:
|
||||||
- switch:
|
- switch:
|
||||||
name: "MeshCore Phone App Mode"
|
name: "MeshCore Phone App Mode"
|
||||||
unique_id: meshcore_phone_app_mode_beaver
|
unique_id: meshcore_phone_app_mode
|
||||||
icon: mdi:cellphone-wireless
|
icon: mdi:cellphone-wireless
|
||||||
command_topic: "viewroad-commands/meshcore/phone_app_mode"
|
command_topic: "meshcore/phone_app_mode/set"
|
||||||
state_topic: "viewroad-status/meshcore/phone_app_mode"
|
state_topic: "meshcore/phone_app_mode/state"
|
||||||
value_template: "{{ value_json.phone_app_mode }}"
|
value_template: "{{ value_json.phone_app_mode }}"
|
||||||
json_attributes_topic: "viewroad-status/meshcore/phone_app_mode"
|
json_attributes_topic: "meshcore/phone_app_mode/state"
|
||||||
availability_topic: "viewroad-status/meshcore/controller/availability"
|
availability_topic: "meshcore/phone_app_mode/availability"
|
||||||
payload_on: "ON"
|
payload_on: "ON"
|
||||||
payload_off: "OFF"
|
payload_off: "OFF"
|
||||||
payload_available: "online"
|
payload_available: "online"
|
||||||
|
|||||||
+94
-14
@@ -1,16 +1,21 @@
|
|||||||
"""Behaviour tests with mocked MQTT and Docker; no live services are changed."""
|
"""Behaviour tests with mocked MQTT and Docker; no live services are changed."""
|
||||||
import json
|
import json
|
||||||
import queue
|
import configparser
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace, ModuleType
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from controller import (
|
from controller import (
|
||||||
AVAILABILITY_TOPIC, COMMAND_TOPIC, STATE_TOPIC, CONTAINER,
|
CONTAINER, Topics, Controller, DockerControl, phone_mode, load_config, main,
|
||||||
Controller, DockerControl, phone_mode,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_TOPICS = Topics("custom/command", "custom/state", "custom/availability")
|
||||||
|
|
||||||
|
|
||||||
class ControllerTests(unittest.TestCase):
|
class ControllerTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = Mock()
|
self.client = Mock()
|
||||||
@@ -20,7 +25,7 @@ class ControllerTests(unittest.TestCase):
|
|||||||
self.client.publish.return_value = publication
|
self.client.publish.return_value = publication
|
||||||
self.docker = Mock()
|
self.docker = Mock()
|
||||||
self.docker.status.return_value = "running"
|
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):
|
def ready(self):
|
||||||
self.control.on_connect(self.client, None, None, 0, None)
|
self.control.on_connect(self.client, None, None, 0, None)
|
||||||
@@ -33,6 +38,16 @@ class ControllerTests(unittest.TestCase):
|
|||||||
self.ready()
|
self.ready()
|
||||||
self.control.report()
|
self.control.report()
|
||||||
self.docker.set_mode.assert_not_called()
|
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):
|
def test_subscription_denial_keeps_controller_unavailable(self):
|
||||||
self.control.on_connect(self.client, None, None, 0, None)
|
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):
|
def test_retained_and_invalid_commands_do_not_run_docker(self):
|
||||||
self.ready()
|
self.ready()
|
||||||
for topic, payload, retained in [
|
for topic, payload, retained in [
|
||||||
(COMMAND_TOPIC, b"ON", True),
|
(TEST_TOPICS.command, b"ON", True),
|
||||||
(COMMAND_TOPIC, b"ON; docker rm mqtt5", False),
|
(TEST_TOPICS.command, b"ON; docker rm mqtt5", False),
|
||||||
(COMMAND_TOPIC, b"off", False),
|
(TEST_TOPICS.command, b"off", False),
|
||||||
(COMMAND_TOPIC, b"\xff", False),
|
(TEST_TOPICS.command, b"\xff", False),
|
||||||
("another/topic", b"ON", False),
|
("another/topic", b"ON", False),
|
||||||
]:
|
]:
|
||||||
self.control.on_message(self.client, None, SimpleNamespace(
|
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):
|
def test_live_command_waits_for_confirmed_docker_state(self):
|
||||||
self.ready()
|
self.ready()
|
||||||
self.control.on_message(self.client, None, SimpleNamespace(
|
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.docker.set_mode.return_value = "exited"
|
||||||
self.control.process_command(self.control.commands.get_nowait())
|
self.control.process_command(self.control.commands.get_nowait())
|
||||||
self.docker.set_mode.assert_called_once_with("ON")
|
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]["phone_app_mode"], "ON")
|
||||||
self.assertEqual(states[-1]["container_status"], "exited")
|
self.assertEqual(states[-1]["container_status"], "exited")
|
||||||
|
|
||||||
@@ -75,14 +90,14 @@ class ControllerTests(unittest.TestCase):
|
|||||||
self.ready()
|
self.ready()
|
||||||
self.docker.set_mode.side_effect = RuntimeError("stop failed")
|
self.docker.set_mode.side_effect = RuntimeError("stop failed")
|
||||||
self.control.process_command("ON")
|
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")
|
self.assertEqual(states[-1]["phone_app_mode"], "OFF")
|
||||||
|
|
||||||
def test_missing_container_reports_offline(self):
|
def test_missing_container_reports_offline(self):
|
||||||
self.ready()
|
self.ready()
|
||||||
self.docker.status.side_effect = RuntimeError("missing container")
|
self.docker.status.side_effect = RuntimeError("missing container")
|
||||||
self.assertFalse(self.control.report())
|
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):
|
def test_unacknowledged_state_does_not_report_online(self):
|
||||||
self.ready()
|
self.ready()
|
||||||
@@ -103,10 +118,75 @@ class ControllerTests(unittest.TestCase):
|
|||||||
self.ready()
|
self.ready()
|
||||||
self.control.report("running")
|
self.control.report("running")
|
||||||
self.control.report("exited")
|
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"])
|
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):
|
class DockerTests(unittest.TestCase):
|
||||||
def control(self):
|
def control(self):
|
||||||
with patch("controller.shutil.which", return_value="/usr/bin/docker"):
|
with patch("controller.shutil.which", return_value="/usr/bin/docker"):
|
||||||
|
|||||||
Reference in new issue
Block a user