feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target
- New Z2MLightOutputTarget storage, processor, editor and routes for Zigbee2MQTT light entities (shares the HA-Light editor UI via the new light-target-editor module) - Replace global MQTTService/MQTTConfig with per-source MQTTManager + MQTTRuntime; thread mqtt_source_id through Z2M targets, DIY MQTT devices, and the automation engine - Migrate legacy single-broker YAML/env config to a "Default Broker" MQTTSource on startup (core/mqtt/legacy_migration.py) and drop the obsolete core/mqtt/mqtt_service.py - Refresh /api/v1/system integration status to surface every MQTT source - Extract shared light-target editor and refactor OutputTargetStore + output_targets routes around typed factories / auto-registry - Modal CSS polish, locale strings, and storage/bindable test coverage
This commit is contained in:
@@ -340,6 +340,89 @@ async def test_brightness_clamped_at_255():
|
||||
assert turn_on.service_data["brightness"] == 255
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_off_lights_when_idle_borrows_runtime_from_manager():
|
||||
"""Manual turn-off must work even when the processor isn't running."""
|
||||
runtime = _FakeHARuntime()
|
||||
ha_mgr = _FakeHAManager(runtime)
|
||||
|
||||
proc = HALightTargetProcessor(
|
||||
target_id="t_off_idle",
|
||||
ha_source_id="ha_1",
|
||||
source_kind="color_vs",
|
||||
color_value_source_id="vs_x",
|
||||
light_mappings=[_mapping("light.a"), _mapping("light.b"), _mapping("light.a")],
|
||||
ctx=_make_ctx(ha_manager=ha_mgr),
|
||||
)
|
||||
|
||||
count = await proc.turn_off_lights()
|
||||
|
||||
assert count == 2 # duplicates collapsed
|
||||
assert ha_mgr.acquired_for == ["ha_1"]
|
||||
assert ha_mgr.released_for == ["ha_1"]
|
||||
services = [(c.service, c.target["entity_id"]) for c in runtime.calls]
|
||||
assert ("turn_off", "light.a") in services
|
||||
assert ("turn_off", "light.b") in services
|
||||
assert all(c.service == "turn_off" for c in runtime.calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_off_lights_while_running_uses_existing_runtime():
|
||||
"""If running, turn_off must reuse self._ha_runtime — not borrow another."""
|
||||
runtime = _FakeHARuntime()
|
||||
ha_mgr = _FakeHAManager(runtime)
|
||||
color_stream = _FakeColorStream((10, 20, 30))
|
||||
|
||||
proc = HALightTargetProcessor(
|
||||
target_id="t_off_run",
|
||||
ha_source_id="ha_1",
|
||||
source_kind="color_vs",
|
||||
color_value_source_id="vs_x",
|
||||
light_mappings=[_mapping("light.a")],
|
||||
color_tolerance=0,
|
||||
ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(color_stream)),
|
||||
)
|
||||
|
||||
await proc.start()
|
||||
try:
|
||||
# One acquire from start(); turn_off_lights must NOT add another.
|
||||
before_acquires = list(ha_mgr.acquired_for)
|
||||
before_releases = list(ha_mgr.released_for)
|
||||
count = await proc.turn_off_lights()
|
||||
finally:
|
||||
await proc.stop()
|
||||
|
||||
assert count == 1
|
||||
# No extra acquire/release pairs while running.
|
||||
assert len(ha_mgr.acquired_for) == len(before_acquires)
|
||||
assert len(ha_mgr.released_for) == len(before_releases) + 1 # +1 from stop()
|
||||
assert any(
|
||||
c.service == "turn_off" and c.target["entity_id"] == "light.a" for c in runtime.calls
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_off_lights_no_mappings_returns_zero():
|
||||
runtime = _FakeHARuntime()
|
||||
ha_mgr = _FakeHAManager(runtime)
|
||||
|
||||
proc = HALightTargetProcessor(
|
||||
target_id="t_off_empty",
|
||||
ha_source_id="ha_1",
|
||||
source_kind="color_vs",
|
||||
color_value_source_id="vs_x",
|
||||
light_mappings=[],
|
||||
ctx=_make_ctx(ha_manager=ha_mgr),
|
||||
)
|
||||
|
||||
count = await proc.turn_off_lights()
|
||||
|
||||
assert count == 0
|
||||
# No need to acquire when there's nothing to do.
|
||||
assert ha_mgr.acquired_for == []
|
||||
assert runtime.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_state_reports_source_kind():
|
||||
runtime = _FakeHARuntime()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for BindableFloat / BindableColor update semantics.
|
||||
|
||||
The key behaviour pinned here: when ``apply_update`` receives a plain
|
||||
primitive (number for float, ``[R,G,B]`` list for color) it must clear
|
||||
``source_id`` -- that is how the client signals "unbind, use the static
|
||||
value now". Previously the implementation preserved ``source_id`` on
|
||||
plain-primitive updates, which silently dropped unbind requests.
|
||||
"""
|
||||
|
||||
from ledgrab.storage.bindable import BindableColor, BindableFloat
|
||||
|
||||
|
||||
class TestBindableFloatApplyUpdate:
|
||||
def test_plain_number_unbinds_when_previously_bound(self):
|
||||
bf = BindableFloat(value=0.5, source_id="vs_abc")
|
||||
updated = bf.apply_update(0.8)
|
||||
assert updated.value == 0.8
|
||||
assert updated.source_id == ""
|
||||
assert not updated.is_bound
|
||||
|
||||
def test_plain_number_leaves_unbound_unbound(self):
|
||||
bf = BindableFloat(value=0.5, source_id="")
|
||||
updated = bf.apply_update(0.8)
|
||||
assert updated.value == 0.8
|
||||
assert updated.source_id == ""
|
||||
|
||||
def test_dict_with_source_id_binds(self):
|
||||
bf = BindableFloat(value=0.5, source_id="")
|
||||
updated = bf.apply_update({"value": 0.8, "source_id": "vs_new"})
|
||||
assert updated.value == 0.8
|
||||
assert updated.source_id == "vs_new"
|
||||
assert updated.is_bound
|
||||
|
||||
def test_dict_with_empty_source_id_unbinds(self):
|
||||
bf = BindableFloat(value=0.5, source_id="vs_abc")
|
||||
updated = bf.apply_update({"value": 0.8, "source_id": ""})
|
||||
assert updated.value == 0.8
|
||||
assert updated.source_id == ""
|
||||
|
||||
def test_none_leaves_unchanged(self):
|
||||
bf = BindableFloat(value=0.5, source_id="vs_abc")
|
||||
updated = bf.apply_update(None)
|
||||
assert updated is bf
|
||||
|
||||
|
||||
class TestBindableColorApplyUpdate:
|
||||
def test_plain_list_unbinds_when_previously_bound(self):
|
||||
bc = BindableColor(color=[10, 20, 30], source_id="vs_abc")
|
||||
updated = bc.apply_update([100, 150, 200])
|
||||
assert updated.color == [100, 150, 200]
|
||||
assert updated.source_id == ""
|
||||
assert not updated.is_bound
|
||||
|
||||
def test_plain_list_leaves_unbound_unbound(self):
|
||||
bc = BindableColor(color=[10, 20, 30], source_id="")
|
||||
updated = bc.apply_update([100, 150, 200])
|
||||
assert updated.color == [100, 150, 200]
|
||||
assert updated.source_id == ""
|
||||
|
||||
def test_dict_with_source_id_binds(self):
|
||||
bc = BindableColor(color=[10, 20, 30], source_id="")
|
||||
updated = bc.apply_update({"color": [100, 150, 200], "source_id": "vs_new"})
|
||||
assert updated.color == [100, 150, 200]
|
||||
assert updated.source_id == "vs_new"
|
||||
assert updated.is_bound
|
||||
|
||||
def test_dict_with_empty_source_id_unbinds(self):
|
||||
bc = BindableColor(color=[10, 20, 30], source_id="vs_abc")
|
||||
updated = bc.apply_update({"color": [100, 150, 200], "source_id": ""})
|
||||
assert updated.color == [100, 150, 200]
|
||||
assert updated.source_id == ""
|
||||
|
||||
def test_none_leaves_unchanged(self):
|
||||
bc = BindableColor(color=[10, 20, 30], source_id="vs_abc")
|
||||
updated = bc.apply_update(None)
|
||||
assert updated is bc
|
||||
@@ -37,10 +37,6 @@ class TestDefaultConfig:
|
||||
reload(paths_mod)
|
||||
assert paths_mod.default_data_dir() == Path(str(tmp_path / "custom"))
|
||||
|
||||
def test_default_mqtt_disabled(self):
|
||||
config = Config()
|
||||
assert config.mqtt.enabled is False
|
||||
|
||||
def test_default_demo_off(self):
|
||||
config = Config()
|
||||
assert config.demo is False
|
||||
|
||||
Reference in New Issue
Block a user