feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations

Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
This commit is contained in:
2026-06-22 23:21:24 +03:00
parent 126d8f2449
commit 6745e25b20
91 changed files with 4390 additions and 540 deletions
@@ -7,6 +7,7 @@ shape, and self-referential exclusion.
from __future__ import annotations
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -95,7 +96,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
assert len(persisted) >= 1, "Expected at least one auth record"
for entry in persisted:
@@ -121,7 +122,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, None)
asyncio.run(verify_api_key(req, None))
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
@@ -152,7 +153,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
@@ -284,7 +285,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
for entry in persisted:
for v in entry.metadata.values():
@@ -367,7 +368,7 @@ class TestBestEffortResilience:
# Should raise the HTTP 401, not the recorder RuntimeError
with pytest.raises(Exception) as exc_info:
verify_api_key(req, None) # missing creds
asyncio.run(verify_api_key(req, None)) # missing creds
# Must be an HTTPException (401), NOT the RuntimeError from the recorder
assert "RuntimeError" not in type(exc_info.value).__name__
@@ -734,7 +735,7 @@ class TestNoDuplicateRecords:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in persisted if e.action == "auth.rejected"]
assert len(rejected) == 1, f"Expected exactly 1 auth.rejected record, got {len(rejected)}"
@@ -768,7 +769,7 @@ class TestMetadataShape:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in persisted if e.action == "auth.rejected"]
assert len(rejected) >= 1
@@ -800,7 +801,7 @@ class TestMetadataShape:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in persisted if e.action == "auth.rejected"]
assert rejected[0].metadata["client"] == _EXPECTED_IP
@@ -1041,7 +1042,7 @@ class TestCategorySeverityContract:
cfg.auth.api_keys = {"dev": "good"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
self._check_all_entries(persisted)
auth_records = [e for e in persisted if e.category == "auth"]
@@ -1384,7 +1385,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "correct-key"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
return persisted_ref
@@ -1414,7 +1415,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "real-key"}
mock_cfg.return_value = cfg
try:
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
except Exception:
exceptions_raised += 1
@@ -1444,7 +1445,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "right"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
assert len(rejected) == len(
@@ -1477,7 +1478,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "correct"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
assert (