Skip to content

Declarative command validation

Drivers can declare command rules independently of protocols and UI layouts. The server evaluates them for every write: HTTP, CLI, automation and grouped command. A driver contains data only; expressions cannot execute code or read another device.

Attribute contract

- name: setpoint
  data_type: float
  read: GET /setpoint
  write: POST /setpoint
  default_value: 22
  write_constraints:
    minimum: {attribute: minimum}
    maximum:
      op: subtract
      args: [{attribute: maximum}, 1]
    step: 0.5
    sentinels: [0]
  write_rules:
    - condition:
        op: eq
        left: {attribute: locked}
        right: false
      reason:
        code: locked
        message:
          default: Controls are locked
          translations: {fr: Les commandes sont verrouillées}

default_value is an input suggestion. It never initializes reported telemetry or satisfies a dependency. A statically incompatible default fails driver validation. The attributes referenced in this example must also be declared by the driver.

write_constraints supports numeric literals and expressions for minimum, maximum and step. Steps are positive and anchored at zero. A sentinel bypasses numeric bounds and step checks, but still passes all option and command rules.

write_options is an ordered finite list of {value, allowed_when?, reason?}. Unavailable choices remain in the server projection with their reasons. Options inferred from ordinary codecs remain supported. A current value outside this list is still reported faithfully.

Rules have effect: require (default) or effect: warn. A required condition must be true. A warning is announced when its condition is true or unknown. Reasons use a stable snake-case code and optional localized message.

Expressions and missing observations

Operands are scalar literals, {attribute: sibling}, or {candidate: true}. Candidates are allowed inside write rules and option conditions. They do not replace the observed value of the target attribute.

Kind Shape
Comparison {op: eq|lt|lte|gt|gte, left: expression, right: expression}
Membership {op: in, value: expression, values: [scalar, ...]}
Knowledge {op: is_known, value: expression}
Negation {op: not, condition: condition}
Junction {op: all|any, conditions: [condition, ...]}
Arithmetic {op: add|subtract|min|max, args: [expression, expression, ...]}
Choice {op: if, condition: condition, then: expression, otherwise: expression}

A missing dependency produces unknown. all(false, unknown) is false and any(true, unknown) is true. A conditional evaluates only its selected branch; unknown never chooses a fallback branch. Booleans are distinct from numbers. Import validates operand types, local references and mapping calculation cycles. Renaming a referenced attribute rewrites these declarations; deleting one is blocked.

Evaluation performs no transport reads. Normal polling, push messages, manual refreshes and transport confirmation acquire observations. Persisted telemetry is shown after restart but is not trusted until observed again. Stopping acquisition invalidates knowledge, and an explicit failed read invalidates its attribute context. With healthcheck.expected_push_interval, knowledge expires at one expected interval after the last acquired observation, independently of health degradation thresholds. A manual refresh renews this command-knowledge window without resetting push health. A sample at 10:00 with a one-hour interval expires at 11:00.

The check, the encoding and the send of a write run under one per-device lock, so no concurrent write moves the context between them; confirmations wait outside it, and a value table that moved meanwhile is reported as mapping_changed. Sending a command invalidates knowledge of its target until a new observation arrives. The requested value is never published as telemetry, including when confirm: false is used: automations send unconfirmed writes, so their command rows record the request and the device keeps its last observation until it is polled or pushes again.

Instance-specific value mappings

A value_mapping transforms the result of the ordinary codec pipeline. Each device resolves the table from its own observations; codecs themselves remain mono-value.

value_mapping:
  duplicates: reject
  stop_value: END
  entries:
    - {code: 0, value: reserved, selectable: false}
    - {code: 1, value: {attribute: mode_1}}
    - {code: 2, value: {attribute: mode_2}}

The codec decodes a wire value to code; the table then resolves its semantic value. Writing performs the inverse before codec encoding. Reserved codes remain readable. stop_value terminates selectable entries. Duplicate semantic values are rejected unless duplicates: first explicitly selects the first match. Unknown entries that could change that decision prevent encoding. Unknown raw codes and unresolved table values retain raw_value and expose resolution_error.

A table update reinterprets the saved intermediate code, even without another sample of the mapped attribute. Confirmation uses the same mapping context as encoding; a changed context produces mapping_changed.

API and UI

Every device attribute exposes write_state: status, resolved numeric constraints, options with availability and reasons, warnings, missing-dependency flag and whether a candidate-specific preview is needed. Device DTOs include write_state_revision.

POST /devices/{id}/commands/preview accepts the same attribute/value body as command submission. It returns eligibility, normalized candidate, reasons, warnings and a revision without writing or creating history. Grouped preparation uses this evaluator for every member. Confirmation checks current eligibility and warnings again.

Submitted refusals are recorded directly as terminal command errors with structured validation; they never pass through pending execution and never reach transport. The commands migration adds nullable JSON fields for validation and the original submitted scalar, preserving invalid submissions without coercing them during history reads. Legacy rows remain readable.

WebSocket device_write_state events update eligibility independently of telemetry. These events also carry raw-code resolution diagnostics. Clients ignore old revisions and never create time-series points from these events. The UI displays server decisions; it does not resolve guard dependencies locally.

Presentation controls support visible_when and blocked_when under control-conditions/1; page nodes support visible_when under page-conditions/1. A variant page node (layout-variants/1) chooses the first matching {when, content} entry. These use the presentation binding condition syntax and are evaluated by the client that renders the document, like face visibility and colors. Groups use generic controls when member layout selections differ.

The two condition syntaxes have distinct jobs. Write rules and option conditions use the expression syntax and are evaluated only by the server: they decide whether a command is accepted. Presentation conditions use the binding syntax and are evaluated only by the client: they decide what a page shows. A control bound to an attribute already follows that attribute's write_state, so a restriction declared once as a write rule disables the control and refuses the API command without any blocked_when. Reserve blocked_when for purely graphical blocking; presentation visibility never forbids an API command.

Budgets and rollout

The server owns limits: expression depth 16, 64 rules per attribute, 256 list/table items, 10,000 operations per attribute evaluation and 100,000 per device projection. Budget exhaustion blocks the operation with a stable reason. No driver override can raise these limits.

Deploy the backend migration and API before the updated SDK/UI. Existing drivers continue using their declared codec options and numeric constraints. Specialized controls no longer invent thermostat mode choices. Driver-specific migration belongs to its own delivery; no firmware change is required.