feat(value-sources): optional normalization for magnitude sources

Add an opt-out `normalize` flag to the four magnitude value sources
(ha_entity, http, system_metrics, game_event). get_value() stays in
[0,1] for every source (the normalized scalar-bus invariant), so all
existing consumers — brightness sinks, gradient_map, template `name`
bindings, and color-strip bindable floats — are unaffected.

- normalize=False is a clamp-passthrough: skip the min/max rescale and
  clamp the raw reading into [0,1] (for sources already reporting a
  0..1 fraction). The un-normalized magnitude stays available via
  get_raw_value() / template raw[name] / automations.
- Add get_raw_value() + a raw channel to GameEventValueStream (it had
  none). game_event's flag is model/stream-level only (no CRUD schema).
- Finite-safe clamp01() util; harden the composite-layer brightness
  multiply (latent negative-wrap / >=1 skip) with it.
- Preview WebSocket: tolerate non-numeric raw, generalize raw-range.
- Frontend: settings-toggle slider per HA/system_metrics/http editor
  with min/max grey-out; toggle hidden for fixed-mapping percent
  metrics. en/ru/zh locale keys.
- Additive optional field (default True) — JSON round-trip, no migration.

Tests: store create/update round-trip, clamp-passthrough, live
normalize flip, game_event raw channel + build_stream forwarding,
and finite-safe clamp01.
This commit is contained in:
2026-06-02 02:24:40 +03:00
parent 6de61b965e
commit 669ae20824
20 changed files with 753 additions and 30 deletions
@@ -7,7 +7,10 @@ from ledgrab.storage.value_source import (
AnimatedValueSource,
AudioValueSource,
DaylightValueSource,
HAEntityValueSource,
HTTPValueSource,
StaticValueSource,
SystemMetricsValueSource,
ValueSource,
)
from ledgrab.storage.value_source_store import ValueSourceStore
@@ -238,6 +241,50 @@ class TestValueSourceStoreCRUD:
assert updated.speed == 30.0
# ---------------------------------------------------------------------------
# normalize flag — full store create/update path (factory builder + applier)
# ---------------------------------------------------------------------------
class TestValueSourceNormalizeStoreRoundTrip:
"""The ``normalize`` flag must survive the real write path —
create_source -> CREATE_BUILDERS and update_source -> UPDATE_APPLIERS —
not just dataclass from_dict/to_dict. A dropped builder/applier line would
silently revert/ignore the flag while every dataclass-level test stayed
green, so these exercise the factory layer end-to-end (game_event is
intentionally excluded — it has no store/CRUD path).
"""
_CASES = [
("ha_entity", {"ha_source_id": "ha1", "entity_id": "sensor.temp"}, HAEntityValueSource),
("http", {"http_endpoint_id": "ep1"}, HTTPValueSource),
("system_metrics", {"metric": "cpu_load"}, SystemMetricsValueSource),
]
@pytest.mark.parametrize("source_type,kwargs,cls", _CASES)
def test_create_normalize_false_persists(self, store, source_type, kwargs, cls):
s = store.create_source(
name=f"{source_type}_n", source_type=source_type, normalize=False, **kwargs
)
assert isinstance(s, cls)
assert s.normalize is False
# Re-read exercises the SQLite blob persistence round-trip as well.
assert store.get_source(s.id).normalize is False
@pytest.mark.parametrize("source_type,kwargs,cls", _CASES)
def test_create_normalize_defaults_true(self, store, source_type, kwargs, cls):
s = store.create_source(name=f"{source_type}_d", source_type=source_type, **kwargs)
assert s.normalize is True
@pytest.mark.parametrize("source_type,kwargs,cls", _CASES)
def test_update_normalize_flip(self, store, source_type, kwargs, cls):
s = store.create_source(
name=f"{source_type}_u", source_type=source_type, normalize=False, **kwargs
)
assert store.update_source(s.id, normalize=True).normalize is True
assert store.update_source(s.id, normalize=False).normalize is False
# ---------------------------------------------------------------------------
# Name uniqueness
# ---------------------------------------------------------------------------