diff --git a/docs/API.md b/docs/API.md index a2e900e..ebf69ef 100644 --- a/docs/API.md +++ b/docs/API.md @@ -461,17 +461,22 @@ Reusable audio filter chains. ## Value sources -Dynamic data inputs (brightness and other parameters): static, animated, audio, adaptive, color, sensor, HTTP, and Home Assistant. +Dynamic data inputs (brightness and other parameters): static, animated, audio, adaptive, color, sensor, HTTP, Home Assistant, and `template` — a sandboxed-Jinja **combinator** that evaluates an expression over the live values of other value sources. | Method | Path | Description | | ------ | ---- | ----------- | | GET | `/api/v1/value-sources` | List all value sources (optional `source_type`). | | POST | `/api/v1/value-sources` | Create a value source (discriminated by `source_type`). | +| POST | `/api/v1/value-sources/validate-template` | Validate a template expression + inputs (advisory; always `200` with `{valid, error, errors, warnings, variables}`). | | GET | `/api/v1/value-sources/{source_id}` | Get a value source by ID. | | PUT | `/api/v1/value-sources/{source_id}` | Update a value source; hot-reloads running streams. | -| DELETE | `/api/v1/value-sources/{source_id}` | Delete a value source (`409` if referenced). | +| DELETE | `/api/v1/value-sources/{source_id}` | Delete a value source (`400` if referenced by a target or another value source). | | WS | `/api/v1/value-sources/{source_id}/test/ws` | Real-time value output stream (~20 Hz). | +### Template value source (`source_type: "template"`) + +A `float` combinator. Fields: `template` (a Jinja *expression*), `inputs` (`[{name, value_source_id}]` bindings to other value sources), `default_value` (fallback in `[0,1]` on any error), and `eval_interval` (optional re-eval throttle in seconds; `0`/null = every poll). At runtime each input is exposed by its `name` (the source's normalized `0..1` value) plus `raw[name]` (its un-normalized value, where available). Globals: `min`, `max`, `abs`, `round`, `clamp(x, lo=0, hi=1)`. The expression runs in a hardened `ImmutableSandboxedEnvironment` (no statements/blocks, filters, attribute access, `**`, or string repetition); results are coerced, NaN/inf-rejected, and clamped to `[0,1]`. Reference cycles and over-deep nesting are rejected at save time. For time-of-day logic, bind an `adaptive_time` or `daylight` source as an input. + ## Weather sources Weather data providers feeding weather-driven value sources. diff --git a/server/src/ledgrab/api/routes/value_sources.py b/server/src/ledgrab/api/routes/value_sources.py index 01b6f4e..bd6125c 100644 --- a/server/src/ledgrab/api/routes/value_sources.py +++ b/server/src/ledgrab/api/routes/value_sources.py @@ -4,6 +4,7 @@ import asyncio from typing import Annotated from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field from ledgrab.api.auth import AuthRequired from ledgrab.api.dependencies import ( @@ -27,6 +28,8 @@ from ledgrab.api.schemas.value_sources import ( StaticColorValueSourceResponse, StaticValueSourceResponse, SystemMetricsValueSourceResponse, + TemplateInput, + TemplateValueSourceResponse, ValueSourceCreate, ValueSourceListResponse, ValueSourceResponse, @@ -46,6 +49,7 @@ from ledgrab.storage.value_source import ( StaticColorValueSource, StaticValueSource, SystemMetricsValueSource, + TemplateValueSource, ValueSource, ) from ledgrab.storage.value_source_store import ValueSourceStore @@ -231,6 +235,22 @@ _RESPONSE_MAP = { max_value=s.max_value, smoothing=s.smoothing, ), + TemplateValueSource: lambda s: TemplateValueSourceResponse( + id=s.id, + name=s.name, + description=s.description, + tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", + created_at=s.created_at, + updated_at=s.updated_at, + template=s.template, + inputs=[ + TemplateInput(name=i["name"], value_source_id=i["value_source_id"]) for i in s.inputs + ], + default_value=s.default_value, + eval_interval=s.eval_interval, + ), } @@ -395,6 +415,13 @@ async def delete_value_source( if getattr(target, "brightness_value_source_id", "") == source_id: raise ValueError(f"Cannot delete: referenced by target '{target.name}'") + # Check if any other value source (template / gradient_map) references it. + referencing = store.find_referencing_sources(source_id) + if referencing: + raise ValueError( + "Cannot delete: referenced by value source(s) " + ", ".join(referencing) + ) + store.delete_source(source_id) fire_entity_event("value_source", "deleted", source_id) except EntityNotFoundError as e: @@ -404,6 +431,100 @@ async def delete_value_source( raise HTTPException(status_code=400, detail=str(e)) +class ValidateTemplateRequest(BaseModel): + """Request body for the advisory template-validation endpoint.""" + + template: str = Field(description="Jinja2 expression to validate", max_length=2000) + inputs: list[TemplateInput] = Field(default_factory=list, description="Named input bindings") + id: str | None = Field(None, description="Source id when editing (enables cycle detection)") + + +@router.post("/api/v1/value-sources/validate-template", tags=["Value Sources"]) +async def validate_template_value_source( + payload: ValidateTemplateRequest, + _auth: AuthRequired, + store: ValueSourceStore = Depends(get_value_source_store), +): + """Validate a template expression + inputs without persisting anything. + + Advisory: always returns HTTP 200 with ``{valid, error, errors, warnings, + variables}``. Powers the live editor validator (which must run before a + source exists), reusing the exact factory/store validation so the client and + server can never disagree. ``errors`` are blocking (save disabled); + ``warnings`` are non-blocking (e.g. unknown/unbound inputs — create is + lenient about those). + """ + from ledgrab.utils.template_expr import ( + TemplateValidationError, + extract_variables, + validate_input_name, + validate_template_expression, + ) + + errors: list[str] = [] + warnings: list[str] = [] + + # 1) Expression compiles and is safe (cost-guarded). + try: + validate_template_expression(payload.template) + except TemplateValidationError as e: + errors.append(str(e)) + + # 2) Input names valid / unique / non-reserved (blocking). + seen: set[str] = set() + for inp in payload.inputs: + try: + validate_input_name(inp.name) + except TemplateValidationError as e: + errors.append(str(e)) + continue + if inp.name in seen: + errors.append(f"duplicate input name: {inp.name}") + seen.add(inp.name) + + # 3) Referenced sources exist (non-blocking warning — create is lenient). + missing = [ + inp.value_source_id + for inp in payload.inputs + if inp.value_source_id and not _source_exists(store, inp.value_source_id) + ] + if missing: + warnings.append("unknown value source(s): " + ", ".join(sorted(set(missing)))) + + # 4) Variables referenced in the expression but not bound to an input + # (blocking): at runtime they raise UndefinedError, so the template would + # silently always return default_value. This is almost always a typo, so + # flag it as an error rather than letting "valid" mislead the user. + used = set(extract_variables(payload.template)) + undeclared = used - seen + if undeclared: + errors.append("unbound variable(s): " + ", ".join(sorted(undeclared))) + + # 5) Cycle check when editing an existing source (blocking). + if payload.id: + child_ids = [i.value_source_id for i in payload.inputs if i.value_source_id] + try: + store.validate_nesting(payload.id, child_ids) + except ValueError as e: + errors.append(str(e)) + + return { + "valid": not errors, + "error": errors[0] if errors else None, + "errors": errors, + "warnings": warnings, + "variables": extract_variables(payload.template), + } + + +def _source_exists(store: ValueSourceStore, source_id: str) -> bool: + try: + store.get_source(source_id) + return True + except Exception: + return False + + # ===== REAL-TIME VALUE SOURCE TEST WEBSOCKET ===== diff --git a/server/src/ledgrab/api/schemas/value_sources.py b/server/src/ledgrab/api/schemas/value_sources.py index 94e02a7..8d9841e 100644 --- a/server/src/ledgrab/api/schemas/value_sources.py +++ b/server/src/ledgrab/api/schemas/value_sources.py @@ -10,6 +10,17 @@ from pydantic import BaseModel, Discriminator, Field, Tag # ===================================================================== +class TemplateInput(BaseModel): + """A single ``{name -> value_source_id}`` binding for a template source.""" + + name: str = Field( + description="Variable name used in the expression (valid identifier)", + min_length=1, + max_length=64, + ) + value_source_id: str = Field("", description="Bound value source ID (empty = unbound)") + + class _ValueSourceResponseBase(BaseModel): """Shared fields for all value source responses.""" @@ -162,6 +173,19 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase): smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)") +class TemplateValueSourceResponse(_ValueSourceResponseBase): + source_type: Literal["template"] = "template" + return_type: Literal["float"] = "float" + template: str = Field(description="Jinja2 expression") + inputs: List[TemplateInput] = Field( + default_factory=list, description="Named value-source bindings" + ) + default_value: float = Field(description="Fallback when the expression errors (0.0-1.0)") + eval_interval: float | None = Field( + None, description="Re-eval throttle in seconds (None/0 = every poll)" + ) + + ValueSourceResponse = Annotated[ Annotated[StaticValueSourceResponse, Tag("static")] | Annotated[AnimatedValueSourceResponse, Tag("animated")] @@ -176,7 +200,8 @@ ValueSourceResponse = Annotated[ | Annotated[GradientMapValueSourceResponse, Tag("gradient_map")] | Annotated[CSSExtractValueSourceResponse, Tag("css_extract")] | Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")] - | Annotated[HTTPValueSourceResponse, Tag("http")], + | Annotated[HTTPValueSourceResponse, Tag("http")] + | Annotated[TemplateValueSourceResponse, Tag("template")], Discriminator("source_type"), ] @@ -330,6 +355,27 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase): smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0) +class TemplateValueSourceCreate(_ValueSourceCreateBase): + source_type: Literal["template"] = "template" + template: str = Field( + description=( + "Jinja2 expression (no statements/blocks). Inputs are exposed by name and via " + "raw[name]; globals: min, max, abs, round, clamp(x, lo=0, hi=1)." + ), + min_length=1, + max_length=2000, + ) + inputs: List[TemplateInput] = Field( + default_factory=list, description="Named value-source bindings" + ) + default_value: float = Field( + 0.0, description="Fallback when the expression errors (0.0-1.0)", ge=0.0, le=1.0 + ) + eval_interval: float | None = Field( + None, description="Re-eval throttle in seconds (None/0 = every poll)", ge=0.0 + ) + + ValueSourceCreate = Annotated[ Annotated[StaticValueSourceCreate, Tag("static")] | Annotated[AnimatedValueSourceCreate, Tag("animated")] @@ -344,7 +390,8 @@ ValueSourceCreate = Annotated[ | Annotated[GradientMapValueSourceCreate, Tag("gradient_map")] | Annotated[CSSExtractValueSourceCreate, Tag("css_extract")] | Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")] - | Annotated[HTTPValueSourceCreate, Tag("http")], + | Annotated[HTTPValueSourceCreate, Tag("http")] + | Annotated[TemplateValueSourceCreate, Tag("template")], Discriminator("source_type"), ] @@ -490,6 +537,20 @@ class HTTPValueSourceUpdate(_ValueSourceUpdateBase): smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0) +class TemplateValueSourceUpdate(_ValueSourceUpdateBase): + source_type: Literal["template"] = "template" + template: str | None = Field( + None, description="Jinja2 expression", min_length=1, max_length=2000 + ) + inputs: List[TemplateInput] | None = Field(None, description="Named value-source bindings") + default_value: float | None = Field( + None, description="Fallback when the expression errors (0.0-1.0)", ge=0.0, le=1.0 + ) + eval_interval: float | None = Field( + None, description="Re-eval throttle in seconds (0 = every poll)", ge=0.0 + ) + + ValueSourceUpdate = Annotated[ Annotated[StaticValueSourceUpdate, Tag("static")] | Annotated[AnimatedValueSourceUpdate, Tag("animated")] @@ -504,7 +565,8 @@ ValueSourceUpdate = Annotated[ | Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")] | Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")] | Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")] - | Annotated[HTTPValueSourceUpdate, Tag("http")], + | Annotated[HTTPValueSourceUpdate, Tag("http")] + | Annotated[TemplateValueSourceUpdate, Tag("template")], Discriminator("source_type"), ] diff --git a/server/src/ledgrab/core/demo_seed.py b/server/src/ledgrab/core/demo_seed.py index 768f92f..343f8f3 100644 --- a/server/src/ledgrab/core/demo_seed.py +++ b/server/src/ledgrab/core/demo_seed.py @@ -40,6 +40,11 @@ _AS_IDS = { "system": "as_demo0001", } +_VS_IDS = { + "level": "vs_demo0001", + "boost": "vs_demo0002", +} + _TPL_ID = "tpl_demo0001" _SCENE_ID = "scene_demo0001" @@ -86,6 +91,7 @@ def seed_demo_data(db: Database) -> None: _insert_entities(db, "picture_sources", _build_picture_sources()) _insert_entities(db, "color_strip_sources", _build_color_strip_sources()) _insert_entities(db, "audio_sources", _build_audio_sources()) + _insert_entities(db, "value_sources", _build_value_sources()) _insert_entities(db, "scene_presets", _build_scene_presets()) logger.info("Demo seed data complete") @@ -334,6 +340,40 @@ def _build_audio_sources() -> dict: } +# ── Value Sources ────────────────────────────────────────────────── + + +def _build_value_sources() -> dict: + """A static float source plus a template combinator that references it, + so demo mode showcases the Jinja template value source out of the box.""" + return { + _VS_IDS["level"]: { + "id": _VS_IDS["level"], + "name": "Base Level", + "source_type": "static", + "description": "A constant brightness level (demo input for the template below)", + "tags": ["demo"], + "value": 0.5, + "created_at": _NOW, + "updated_at": _NOW, + }, + _VS_IDS["boost"]: { + "id": _VS_IDS["boost"], + "name": "Boosted Level (template)", + "source_type": "template", + "return_type": "float", + "description": "Jinja combinator: clamps 1.5x the Base Level into [0,1]", + "tags": ["demo"], + "template": "clamp(level * 1.5)", + "inputs": [{"name": "level", "value_source_id": _VS_IDS["level"]}], + "default_value": 0.0, + "eval_interval": None, + "created_at": _NOW, + "updated_at": _NOW, + }, + } + + # ── Scene Presets ────────────────────────────────────────────────── diff --git a/server/src/ledgrab/core/processing/value_kinds.py b/server/src/ledgrab/core/processing/value_kinds.py index 62c3d4c..bfba59c 100644 --- a/server/src/ledgrab/core/processing/value_kinds.py +++ b/server/src/ledgrab/core/processing/value_kinds.py @@ -267,6 +267,20 @@ def _build_http(source, d: ValueStreamDeps): ) +def _build_template(source, d: ValueStreamDeps): + # References other value sources via d.value_stream_manager (recursively + # acquired in start()), exactly like _build_gradient_map. + from ledgrab.core.processing.value_stream import TemplateValueStream + + return TemplateValueStream( + template=source.template, + inputs=source.inputs, + default_value=source.default_value, + eval_interval=source.eval_interval, + value_stream_manager=d.value_stream_manager, + ) + + # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- @@ -290,6 +304,7 @@ STREAM_BUILDERS: dict[str, StreamBuilder] = { "system_metrics": _build_system_metrics, "game_event": _build_game_event, "http": _build_http, + "template": _build_template, } diff --git a/server/src/ledgrab/core/processing/value_stream.py b/server/src/ledgrab/core/processing/value_stream.py index 7790615..c1d66ff 100644 --- a/server/src/ledgrab/core/processing/value_stream.py +++ b/server/src/ledgrab/core/processing/value_stream.py @@ -34,6 +34,11 @@ import numpy as np from ledgrab.core.processing import metric_readers as _metric_readers from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.utils import get_logger +from ledgrab.utils.template_expr import ( + TemplateValidationError, + compile_template, + finalize_result, +) # Compiled once — used by ``_extract_simple_path`` on every poll. _NAME_HEAD_RE = re.compile(r"^([^\[]*)") @@ -53,6 +58,12 @@ if TYPE_CHECKING: logger = get_logger(__name__) +# Runtime cap on recursive value-stream acquisition (referencing sources like +# template / gradient_map re-enter acquire() from start()). Higher than the +# storage-level MAX_VALUE_SOURCE_DEPTH (8) so legitimate chains never trip it; +# it only fires on a cycle that bypassed storage validation. +_MAX_ACQUIRE_DEPTH = 12 + # --------------------------------------------------------------------------- # Base class @@ -1365,6 +1376,168 @@ class GradientMapValueStream(ValueStream): self._inner_stream = None +# --------------------------------------------------------------------------- +# Template (Jinja expression combinator) +# --------------------------------------------------------------------------- + + +class TemplateValueStream(ValueStream): + """Evaluates a hardened sandboxed-Jinja expression over the live values of + other value sources (the system's float combinator). + + Acquires each referenced input stream from the manager on ``start()`` and + releases it on ``stop()`` — the same ref-counted protocol as + :class:`GradientMapValueStream`, but over a *set* of inputs. Acquisition is + tracked per unique ``value_source_id`` so two variables bound to the same + source share one ref. ``get_value()`` builds a primitives-only context + (each input's normalized ``get_value()`` plus a float-only ``raw`` dict), + evaluates the compiled expression, then coerces / NaN-guards / clamps the + result. Any error — or an uncompilable template — falls back to + ``default_value``. An optional ``eval_interval`` caches the last result to + bound steady-state evaluation cost. + """ + + def __init__( + self, + template: str, + inputs: List[dict], + default_value: float = 0.0, + eval_interval: float | None = None, + value_stream_manager: "ValueStreamManager" | None = None, + ): + self._template = template + self._inputs = [dict(i) for i in (inputs or [])] + self._default = max(0.0, min(1.0, float(default_value))) + self._eval_interval = float(eval_interval) if eval_interval else 0.0 + self._vsm = value_stream_manager + self._streams_by_id: Dict[str, ValueStream] = {} # value_source_id -> stream + self._expr = self._compile(template) + self._last_value: float = self._default + self._last_eval: float = 0.0 + self._has_value = False + self._error_logged = False + + @staticmethod + def _compile(template: str): + """Compile once; return ``None`` (→ always default) on invalid template. + + Creation should already have rejected invalid templates via the factory; + this is defense in depth so a bad row never crashes the engine. + """ + try: + return compile_template(template) + except TemplateValidationError as e: + logger.warning("TemplateValueStream: invalid template, using default (%s)", e) + return None + + @staticmethod + def _unique_ids(inputs: List[dict]) -> set: + return {i["value_source_id"] for i in inputs if i.get("value_source_id")} + + def start(self) -> None: + if not self._vsm: + return + for vs_id in self._unique_ids(self._inputs): + try: + self._streams_by_id[vs_id] = self._vsm.acquire(vs_id) + except Exception as e: + logger.warning("TemplateValueStream: failed to acquire input %s: %s", vs_id, e) + + def stop(self) -> None: + if self._vsm: + for vs_id in list(self._streams_by_id): + try: + self._vsm.release(vs_id) + except Exception as e: + logger.debug("TemplateValueStream: release %s failed: %s", vs_id, e) + self._streams_by_id.clear() + self._has_value = False + + def get_value(self) -> float: + if self._expr is None: + return self._default + + if ( + self._eval_interval > 0.0 + and self._has_value + and (time.monotonic() - self._last_eval) < self._eval_interval + ): + return self._last_value + + try: + ctx: Dict[str, Any] = {} + raw: Dict[str, float] = {} + for inp in self._inputs: + name = inp.get("name") + vs_id = inp.get("value_source_id") + if not name or not vs_id: + continue + stream = self._streams_by_id.get(vs_id) + if stream is None: + continue + ctx[name] = float(stream.get_value()) + getter = getattr(stream, "get_raw_value", None) + if getter is not None: + rv = getter() + if rv is not None: + try: + raw[name] = float(rv) + except (TypeError, ValueError): + # Non-numeric raw values never cross into the sandbox. + pass + ctx["raw"] = raw + # Globals (min/max/abs/round/clamp) resolve from SANDBOX_ENV.globals. + value = finalize_result(self._expr(**ctx), self._default) + except Exception as e: + if not self._error_logged: + logger.warning("TemplateValueStream eval error (using default): %s", e) + self._error_logged = True + value = self._default + + self._last_value = value + self._last_eval = time.monotonic() + self._has_value = True + return value + + def update_source(self, source: "ValueSource") -> None: + from ledgrab.storage.value_source import TemplateValueSource + + if not isinstance(source, TemplateValueSource): + return + + if source.template != self._template: + self._template = source.template + self._expr = self._compile(source.template) + self._error_logged = False + + self._default = max(0.0, min(1.0, float(source.default_value))) + self._eval_interval = float(source.eval_interval) if source.eval_interval else 0.0 + + new_inputs = [dict(i) for i in (source.inputs or [])] + old_ids = set(self._streams_by_id) + new_ids = self._unique_ids(new_inputs) + + if self._vsm: + # Release-before-acquire (mirrors GradientMapValueStream); safe under + # ref-counting. Unchanged ids keep their existing stream untouched. + for vs_id in old_ids - new_ids: + try: + self._vsm.release(vs_id) + except Exception as e: + logger.debug("TemplateValueStream: release %s failed: %s", vs_id, e) + self._streams_by_id.pop(vs_id, None) + for vs_id in new_ids - old_ids: + try: + self._streams_by_id[vs_id] = self._vsm.acquire(vs_id) + except Exception as e: + logger.warning("TemplateValueStream: acquire %s failed: %s", vs_id, e) + + # Rebuild inputs (re-keys variable names on rename even when id unchanged, + # since get_value() maps name -> stream via value_source_id each tick). + self._inputs = new_inputs + self._has_value = False + + # --------------------------------------------------------------------------- # CSS Extract # --------------------------------------------------------------------------- @@ -1644,6 +1817,10 @@ class ValueStreamManager: self._http_endpoint_store = http_endpoint_store self._streams: Dict[str, ValueStream] = {} # vs_id → stream self._ref_counts: Dict[str, int] = {} # vs_id → ref count + # Recursion-depth backstop for referencing sources (template / gradient + # map). A cycle that slipped past storage validation (e.g. a hand-edited + # DB or restored backup) would otherwise overflow the stack at acquire(). + self._acquire_depth = 0 # Tracks which clock_id (if any) was acquired for each stream so we # can release/swap it without re-querying the store at teardown time. self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id @@ -1659,9 +1836,29 @@ class ValueStreamManager: logger.info(f"Shared value stream {vs_id} (refs={self._ref_counts[vs_id]})") return self._streams[vs_id] + if self._acquire_depth >= _MAX_ACQUIRE_DEPTH: + logger.warning( + "Value source acquire depth limit (%d) reached at %s; returning " + "static fallback (possible reference cycle)", + _MAX_ACQUIRE_DEPTH, + vs_id, + ) + # The intermediate referencing streams built while descending a + # cyclic chain are not stop()'d here — but this only triggers on a + # stored cycle that storage validation already rejects (e.g. a + # hand-edited DB / corrupt restore), so those transient objects are + # simply garbage-collected. Normal graphs never reach this depth. + return StaticValueStream(0.5) + source = self._value_source_store.get_source(vs_id) - stream = self._create_stream(source, vs_id) - stream.start() + # Increment around create+start: a referencing stream (template / + # gradient_map) re-enters acquire() from its own start(). + self._acquire_depth += 1 + try: + stream = self._create_stream(source, vs_id) + stream.start() + finally: + self._acquire_depth -= 1 self._streams[vs_id] = stream self._ref_counts[vs_id] = 1 logger.info(f"Acquired value stream {vs_id} (type={source.source_type})") diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 3b95299..21afe0d 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -298,6 +298,214 @@ select.field-invalid { line-height: 1.3; } +.field-ok-msg { + display: flex; + align-items: center; + gap: 4px; + color: var(--success-color); + font-size: 0.78rem; + margin-top: 4px; + line-height: 1.3; +} + +.field-ok-msg .icon { width: 14px; height: 14px; } +.field-error-msg .icon { width: 14px; height: 14px; vertical-align: -2px; margin-right: 3px; } + +.field-warn-msg { + display: block; + color: var(--text-muted); + font-size: 0.76rem; + margin-top: 4px; + line-height: 1.35; +} + +/* ── Jinja expression editor ───────────────────────────────────── + A transparent + + + + +
+ Expression help +
+
+ Bound inputs +
+
+
+ Globals +
min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)
+
+
+ Raw values +
raw[name] gives the un-normalized value of an input that has one.
+
+
+ Examples +
    +
  • min(audio * 2, 1)
  • +
  • clamp((temp - 18) / 10, 0, 1)
  • +
  • (a + b) / 2
  • +
+
+
+ Tip: for time-of-day logic, bind an Adaptive (Time) or Daylight source as an input. +
+
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+ +