mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
16 KiB
Python
354 lines
16 KiB
Python
"""The Home Assistant MQTT bridge, without a broker or a matrix.
|
|
|
|
The bridge is a translation layer: one MQTT payload in, one api_v3 call out.
|
|
Everything worth pinning is on that path -- attaching the plugin_id a mode
|
|
needs, rejecting values HA can produce but the API cannot take, and the
|
|
discovery configs HA reads once and caches -- so it is all reachable with a
|
|
fake API client and a pure function.
|
|
"""
|
|
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
BRIDGE_PATH = (Path(__file__).resolve().parent.parent
|
|
/ "integrations" / "mqtt_bridge" / "ledmatrix_mqtt_bridge.py")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def bridge_module():
|
|
if not BRIDGE_PATH.exists():
|
|
pytest.skip("mqtt bridge is not present")
|
|
try:
|
|
spec = importlib.util.spec_from_file_location("ledmatrix_mqtt_bridge", BRIDGE_PATH)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
except ImportError as e:
|
|
pytest.skip(f"mqtt bridge dependencies are not installed here: {e}")
|
|
finally:
|
|
sys.modules.pop("ledmatrix_mqtt_bridge", None)
|
|
|
|
|
|
MODES = [
|
|
{"mode": "clock-simple", "plugin_id": "clock-simple",
|
|
"plugin_name": "Simple Clock", "name": "Simple Clock", "enabled": True},
|
|
{"mode": "nfl_live", "plugin_id": "football-scoreboard",
|
|
"plugin_name": "Football Scoreboard", "name": "nfl_live", "enabled": True},
|
|
{"mode": "aquarium", "plugin_id": "starlark-apps",
|
|
"plugin_name": "Starlark Apps", "name": "aquarium", "enabled": True},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""A stand-in for LEDMatrixClient that records what it was asked to do."""
|
|
fake = MagicMock()
|
|
fake.list_modes.return_value = list(MODES)
|
|
fake.start_on_demand.return_value = {"request_id": "r1"}
|
|
fake.stop_on_demand.return_value = {}
|
|
fake.set_power.return_value = {}
|
|
fake.set_brightness.return_value = {}
|
|
fake.get_brightness.return_value = 90
|
|
fake.display_status.return_value = {
|
|
"service": {"active": True},
|
|
"state": {"active": True, "mode": "nfl_live"},
|
|
}
|
|
return fake
|
|
|
|
|
|
@pytest.fixture
|
|
def handler(bridge_module, client):
|
|
h = bridge_module.CommandHandler(client)
|
|
h.refresh_modes()
|
|
return h
|
|
|
|
|
|
class TestDisplayCommands:
|
|
def test_a_mode_is_forced_on_demand(self, handler, client):
|
|
result = handler.handle({"action": "display", "mode": "nfl_live"})
|
|
assert result["status"] == "success"
|
|
assert client.start_on_demand.call_args.kwargs["mode"] == "nfl_live"
|
|
|
|
def test_the_owning_plugin_is_sent_with_the_mode(self, handler, client):
|
|
"""on-demand/start's find_plugin_for_mode fallback cannot resolve a
|
|
generated mode, so an omitted plugin_id 404s for every Starlark app."""
|
|
handler.handle({"action": "display", "mode": "aquarium"})
|
|
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
|
|
|
|
def test_a_home_assistant_label_resolves_to_its_mode(self, handler, client):
|
|
"""The select entity shows labels, so that is what comes back."""
|
|
handler.handle({"action": "display", "mode": "Simple Clock"})
|
|
assert client.start_on_demand.call_args.kwargs["mode"] == "clock-simple"
|
|
|
|
def test_an_unknown_mode_refreshes_before_giving_up(self, handler, client):
|
|
"""A plugin installed since the last refresh is the usual reason."""
|
|
client.list_modes.return_value = MODES + [
|
|
{"mode": "new_app", "plugin_id": "starlark-apps", "name": "new_app"}]
|
|
handler.handle({"action": "display", "mode": "new_app"})
|
|
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
|
|
|
|
def test_an_explicit_plugin_id_is_respected(self, handler, client):
|
|
handler.handle({"action": "display", "mode": "x", "plugin_id": "custom"})
|
|
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "custom"
|
|
|
|
def test_duration_and_pinned_are_passed_through(self, handler, client):
|
|
handler.handle({"action": "display", "mode": "aquarium",
|
|
"duration": 300, "pinned": True})
|
|
kwargs = client.start_on_demand.call_args.kwargs
|
|
assert kwargs["duration"] == 300 and kwargs["pinned"] is True
|
|
|
|
def test_display_with_nothing_to_show_is_rejected(self, handler, client):
|
|
result = handler.handle({"action": "display"})
|
|
assert result["status"] == "error"
|
|
client.start_on_demand.assert_not_called()
|
|
|
|
def test_stop_returns_to_normal_rotation(self, handler, client):
|
|
assert handler.handle({"action": "stop_display"})["status"] == "success"
|
|
client.stop_on_demand.assert_called_once()
|
|
|
|
|
|
class TestPowerAndBrightness:
|
|
@pytest.mark.parametrize("state,expected", [("on", True), ("OFF", False), (" On ", True)])
|
|
def test_power_states_are_accepted(self, handler, client, state, expected):
|
|
assert handler.handle({"action": "power", "state": state})["status"] == "success"
|
|
client.set_power.assert_called_with(expected)
|
|
|
|
def test_an_unknown_power_state_is_rejected(self, handler, client):
|
|
assert handler.handle({"action": "power", "state": "maybe"})["status"] == "error"
|
|
client.set_power.assert_not_called()
|
|
|
|
def test_brightness_is_applied(self, handler, client):
|
|
assert handler.handle({"action": "brightness", "value": 75})["status"] == "success"
|
|
client.set_brightness.assert_called_with(75)
|
|
|
|
def test_a_float_from_home_assistant_is_accepted(self, handler, client):
|
|
"""The number entity publishes "75.0"; int() would raise on that."""
|
|
handler.handle({"action": "brightness", "value": "75.0"})
|
|
client.set_brightness.assert_called_with(75)
|
|
|
|
@pytest.mark.parametrize("value", [-1, 101, "bright", None])
|
|
def test_out_of_range_brightness_never_reaches_the_api(self, handler, client, value):
|
|
assert handler.handle({"action": "brightness", "value": value})["status"] == "error"
|
|
client.set_brightness.assert_not_called()
|
|
|
|
|
|
class TestFailuresAreReportedNotRaised:
|
|
"""A failed command must publish an error, not kill the MQTT loop."""
|
|
|
|
def test_an_api_error_becomes_an_error_result(self, handler, client):
|
|
client.start_on_demand.side_effect = RuntimeError("Display service is not running")
|
|
result = handler.handle({"action": "display", "mode": "nfl_live"})
|
|
assert result["status"] == "error"
|
|
assert "not running" in result["message"]
|
|
|
|
def test_an_unknown_action_lists_the_known_ones(self, handler):
|
|
result = handler.handle({"action": "explode"})
|
|
assert result["status"] == "error"
|
|
assert "brightness" in result["message"]
|
|
|
|
def test_a_missing_action_is_an_error(self, handler):
|
|
assert handler.handle({})["status"] == "error"
|
|
|
|
|
|
class TestDiscoveryPayloads:
|
|
"""HA reads these once and caches them; a wrong shape is a dead entity."""
|
|
|
|
@pytest.fixture
|
|
def messages(self, bridge_module):
|
|
return bridge_module.discovery_messages(
|
|
"ledmatrix/command", "ledmatrix/command/state",
|
|
"ledmatrix/command/availability", ["Simple Clock", "nfl_live"])
|
|
|
|
def test_every_entity_is_published(self, messages):
|
|
components = {m["topic"].split("/")[1] for m in messages}
|
|
assert components == {"select", "button", "switch", "number"}
|
|
|
|
def test_each_entity_has_a_stable_unique_id(self, messages):
|
|
"""Without one HA cannot let a user rename or reassign the entity."""
|
|
ids = [m["payload"]["unique_id"] for m in messages]
|
|
assert len(ids) == len(set(ids)) and all(ids)
|
|
|
|
def test_the_select_offers_the_modes_it_was_given(self, messages):
|
|
select = next(m for m in messages if "/select/" in m["topic"])
|
|
assert select["payload"]["options"] == ["Simple Clock", "nfl_live"]
|
|
|
|
def test_every_entity_shares_the_availability_topic(self, messages):
|
|
"""It is also the bridge's last will, so HA greys the controls out
|
|
when the bridge dies rather than leaving them silently inert."""
|
|
assert all(m["payload"]["availability_topic"] == "ledmatrix/command/availability"
|
|
for m in messages)
|
|
|
|
def test_every_entity_belongs_to_one_device(self, messages):
|
|
assert all(m["payload"]["device"]["identifiers"] == ["ledmatrix"] for m in messages)
|
|
|
|
def test_the_command_templates_are_valid_json_the_handler_accepts(self, messages, handler):
|
|
"""A template that renders malformed JSON fails only at runtime, in HA."""
|
|
select = next(m for m in messages if "/select/" in m["topic"])
|
|
rendered = select["payload"]["command_template"].replace("{{ value }}", "nfl_live")
|
|
assert handler.handle(json.loads(rendered))["status"] == "success"
|
|
|
|
number = next(m for m in messages if "/number/" in m["topic"])
|
|
rendered = number["payload"]["command_template"].replace("{{ value }}", "40")
|
|
assert handler.handle(json.loads(rendered))["status"] == "success"
|
|
|
|
def test_the_switch_payloads_are_valid_json_the_handler_accepts(self, messages, handler):
|
|
switch = next(m for m in messages if "/switch/" in m["topic"])
|
|
for key in ("payload_on", "payload_off"):
|
|
assert handler.handle(json.loads(switch["payload"][key]))["status"] == "success"
|
|
|
|
def test_the_button_payload_is_valid_json_the_handler_accepts(self, messages, handler):
|
|
button = next(m for m in messages if "/button/" in m["topic"])
|
|
assert handler.handle(json.loads(button["payload"]["payload_press"]))["status"] == "success"
|
|
|
|
|
|
class TestStateReporting:
|
|
def test_state_reflects_the_running_display(self, bridge_module, client):
|
|
state = bridge_module.read_state(client)
|
|
assert state == {"power": True, "mode": "nfl_live", "brightness": 90}
|
|
|
|
def test_normal_rotation_reports_no_forced_mode(self, bridge_module, client):
|
|
client.display_status.return_value = {
|
|
"service": {"active": True}, "state": {"active": False}}
|
|
assert bridge_module.read_state(client)["mode"] is None
|
|
|
|
def test_an_unreachable_field_does_not_blank_the_others(self, bridge_module, client):
|
|
"""A stopped display service still has a brightness worth showing."""
|
|
client.display_status.side_effect = RuntimeError("connection refused")
|
|
state = bridge_module.read_state(client)
|
|
assert state["brightness"] == 90 and state["power"] is False
|
|
|
|
|
|
class TestConfigLoading:
|
|
def test_defaults_apply_when_no_file_exists(self, bridge_module, tmp_path):
|
|
config = bridge_module.load_config(str(tmp_path / "absent.json"))
|
|
assert config["mqtt_topic"] == "ledmatrix/command"
|
|
assert config["mqtt_port"] == 1883
|
|
|
|
def test_the_file_overrides_defaults(self, bridge_module, tmp_path):
|
|
path = tmp_path / "bridge_config.json"
|
|
path.write_text(json.dumps({"mqtt_host": "broker.local", "mqtt_port": "8883"}))
|
|
config = bridge_module.load_config(str(path))
|
|
assert config["mqtt_host"] == "broker.local"
|
|
assert config["mqtt_port"] == 8883, "a port read from JSON must still be an int"
|
|
|
|
def test_the_environment_overrides_the_file(self, bridge_module, tmp_path, monkeypatch):
|
|
"""So a password need not sit in a file the service user can read."""
|
|
path = tmp_path / "bridge_config.json"
|
|
path.write_text(json.dumps({"mqtt_password": "from-file"}))
|
|
monkeypatch.setenv("LEDMATRIX_MQTT_MQTT_PASSWORD", "from-env")
|
|
assert bridge_module.load_config(str(path))["mqtt_password"] == "from-env"
|
|
|
|
def test_the_example_placeholder_password_is_refused(self, bridge_module, tmp_path):
|
|
"""Copying the example unedited must fail loudly, not silently fail to
|
|
authenticate against the broker."""
|
|
path = tmp_path / "bridge_config.json"
|
|
path.write_text(json.dumps(
|
|
{"mqtt_password": "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD"}))
|
|
with pytest.raises(bridge_module.ConfigError):
|
|
bridge_module.load_config(str(path))
|
|
|
|
def test_malformed_json_is_a_clear_error(self, bridge_module, tmp_path):
|
|
path = tmp_path / "bridge_config.json"
|
|
path.write_text("{not json")
|
|
with pytest.raises(bridge_module.ConfigError):
|
|
bridge_module.load_config(str(path))
|
|
|
|
def test_a_non_numeric_port_is_a_clear_error(self, bridge_module, tmp_path):
|
|
path = tmp_path / "bridge_config.json"
|
|
path.write_text(json.dumps({"mqtt_port": "not-a-port"}))
|
|
with pytest.raises(bridge_module.ConfigError):
|
|
bridge_module.load_config(str(path))
|
|
|
|
|
|
class TestTheExampleConfigIsSecureByDefault:
|
|
"""The installer copies bridge_config.example.json verbatim on first run.
|
|
|
|
Without TLS the broker password and every display command cross the network
|
|
in cleartext, so the shipped default has to be the safe one -- a plaintext
|
|
broker is a deliberate edit, not something you get by not reading.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def example(self):
|
|
path = (Path(__file__).resolve().parent.parent
|
|
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
|
|
if not path.exists():
|
|
pytest.skip("mqtt bridge example config is not present")
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
def test_tls_is_on(self, example):
|
|
assert example["mqtt_tls"] is True
|
|
|
|
def test_the_port_is_the_tls_one(self, example):
|
|
"""1883 with mqtt_tls on would just fail to connect."""
|
|
assert example["mqtt_port"] == 8883
|
|
|
|
def test_certificate_verification_is_not_disabled(self, example):
|
|
assert example.get("mqtt_tls_insecure", False) is False
|
|
|
|
def test_no_password_ships_in_the_example(self, example):
|
|
assert example["mqtt_password"] is None
|
|
|
|
def test_the_example_loads(self, bridge_module, tmp_path):
|
|
"""It is copied verbatim, so it must survive load_config."""
|
|
path = (Path(__file__).resolve().parent.parent
|
|
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
|
|
config = bridge_module.load_config(str(path))
|
|
assert config["mqtt_tls"] is True and config["mqtt_port"] == 8883
|
|
|
|
|
|
class TestCleartextIsCalledOut:
|
|
"""Turning TLS off is allowed -- the Mosquitto add-on is plaintext on 1883 --
|
|
but it should not be silent when a password is going over it."""
|
|
|
|
def test_a_password_without_tls_warns(self, bridge_module, caplog):
|
|
with caplog.at_level(logging.WARNING):
|
|
warned = bridge_module.warn_if_cleartext(
|
|
{"mqtt_tls": False, "mqtt_password": "hunter2"})
|
|
assert warned is True
|
|
assert "unencrypted" in caplog.text
|
|
|
|
def test_the_warning_does_not_repeat_the_password(self, bridge_module, caplog):
|
|
with caplog.at_level(logging.WARNING):
|
|
bridge_module.warn_if_cleartext({"mqtt_tls": False, "mqtt_password": "hunter2"})
|
|
assert "hunter2" not in caplog.text
|
|
|
|
def test_no_password_means_nothing_to_lose(self, bridge_module):
|
|
assert bridge_module.warn_if_cleartext(
|
|
{"mqtt_tls": False, "mqtt_password": None}) is False
|
|
|
|
def test_tls_on_does_not_warn(self, bridge_module):
|
|
assert bridge_module.warn_if_cleartext(
|
|
{"mqtt_tls": True, "mqtt_password": "hunter2"}) is False
|
|
|
|
|
|
class TestTheApiClientReadsStartingAsTaken:
|
|
"""A cold start answers 202 with ``status: "starting"``: the request is
|
|
taken and the web process delivers it once the display listens. The
|
|
bridge must report that as success, not as a failure."""
|
|
|
|
def _client(self, bridge_module, status_code, body):
|
|
response = MagicMock(status_code=status_code)
|
|
response.json.return_value = body
|
|
session = MagicMock()
|
|
session.request.return_value = response
|
|
return bridge_module.LEDMatrixClient("http://pi:5000", session=session)
|
|
|
|
def test_202_starting_is_returned_not_raised(self, bridge_module):
|
|
api = self._client(bridge_module, 202, {
|
|
"status": "starting", "message": "starting",
|
|
"data": {"request_id": "r1", "pending": True}})
|
|
assert api.start_on_demand(mode="clock") == {"request_id": "r1", "pending": True}
|
|
|
|
def test_an_error_is_still_raised(self, bridge_module):
|
|
api = self._client(bridge_module, 503, {"status": "error", "message": "no display"})
|
|
with pytest.raises(RuntimeError, match="no display"):
|
|
api.start_on_demand(mode="clock")
|