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:
2026-05-12 18:06:09 +03:00
parent 6e4c1b6642
commit 530316c2c3
60 changed files with 5187 additions and 1025 deletions
@@ -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()