Skip to content

Latest commit

 

History

History
501 lines (418 loc) · 22 KB

File metadata and controls

501 lines (418 loc) · 22 KB

Conditions, Scripted Actions & Sticky Values

Overview

A task's condition, a script action's code, and a set action's expr all run in the same small, sandboxed subset of Python — enough to combine several devices' state, spawn/cancel tagged follow-up tasks, and read/reset a sticky min/max window, without writing a new device module or extension:

tasks:
  - tag: intrusion
    condition:
      refs: { armed: "security.armed", motion: "hallway.motion" }
      expr: "armed.state == 1 and motion.changed and motion.state == 1"
    min_interval: 5m   # don't refire more than once every 5 minutes
    action:
      kind: script
      code: |
        log("intrusion detected")
        set_state("siren.state", 1)
        create_task({ tag: "siren_off", time: "+3m",
                       action: { kind: "set", device: "siren.state", value: 0 } })

No imports, no method-call chains beyond what's explicitly allowed, no unbounded loops — this is mistake-containment for a trusted, locally- authored YAML file, not a sandbox against a hostile author (see phc/core/scripting.py's own docstring). The rest of this page covers what the shared sandbox offers all three surfaces, the two ways to write a task's condition, the different kind:s an action can take and when to reach for each, and the sticky/history mechanisms available throughout.

The Shared Sandbox

condition.expr, a script action's code, and a set action's expr all run against one shared set of functions, so these three surfaces can never expose different capabilities by accident:

  • Always available: state(ref), changed(ref), text(ref), event(ref), sticky(ref) (see Sticky values, below), history(ref), fractile(ref, f), median(ref), average(ref) (see Value history & fractiles, below), available(ref) and age(ref) (see Is this reading still trustworthy?, below), and devices(pattern) (a glob, e.g. "house.*/*", usable as a for target).
  • Only in a script action (never in a condition or a set action's expr, both of which must stay side-effect-free): set_state(ref, value), create_task(spec) (same shape as a top-level tasks: entry), kill_task(*tag_globs) (remove matching tasks — the declarative form is the kill_task action kind), reset_sticky(ref), and log(msg).
  • ref is a "device.endpoint" string, either inline (state("a.b")) or bound to a short name via an optional refs: { name: "a.b" } map for the name.state/name.changed/... attribute form used above. refs: is accepted as a sibling key at all three of these surfaces -- condition, a script action's code, and a set action's expr -- not just on the condition shown above.
  • Attribute access on a bound ref is limited to .state/.changed/ .text/.event/.sticky/.history. Indexing (ref[i], event[2]) and dict-key access (d['key']) work on any value a function/attribute returns, e.g. to pull one element out of a multi-item event list; slicing (x[1:3]) is not supported.

An endpoint's read_transform/write_transform (see Endpoint types, units & text) reuse the same underlying expression compiler but not the same namespace: they only see value (the raw or logical value being corrected) plus the sandbox's safe builtins — no state()/changed()/refs, since a transform runs on one endpoint's own value in isolation, not against the wider device tree.

Conditions

A task's schedule (time/repeat) and its condition are independent gates — see Tasks for how they combine — and a condition can be written two ways: the {device, changed, value} shorthand, for gating on one endpoint, or expr:, a general boolean expression, for anything involving more than one device or richer logic.

The {device, changed, value} Shorthand

changed and value are independent filters, ANDed together — either one left out is trivially satisfied and doesn't constrain the result at all:

  • changed: true — holds only on a tick the endpoint has a fresh change event (event(ref) is not None).
  • changed: false — the negation: holds only on a tick with no change event. This is a real filter, not "ignore changes" — leave changed: out entirely for that.
  • value: X — holds whenever the endpoint's current state equals X, regardless of whether it just changed. value: alone is therefore a level check ("holds every tick state currently matches X"); paired with changed: true, it becomes an edge check ("holds only the one tick state transitions to X"), since state and the change event coincide exactly on the tick of a change.
  • Neither given: no constraint at all — the condition is unconditionally True every tick.
condition: { device: "relay_a.state", changed: true }                 # any change
condition: { device: "surveillance.armed", changed: true, value: 1 }  # armed, just now
condition: { device: "surveillance.armed", value: 1 }                 # armed, any tick
condition: { device: "surveillance.armed", changed: false, value: 1 } # armed, steady (not the arriving tick)

The level-check reading (value: with changed left out, or explicitly changed: false) combined with min_interval: is a pattern that used to need a time:-driven task plus a manual expr: check inside the action:

tasks:
  - tag: nag_while_armed
    condition: { device: "surveillance.armed", value: 1 }
    min_interval: 1h   # at most once an hour, for as long as armed stays 1
    action:
      kind: mail_alert
      instance: "mail_alert.house"
      title: "Still armed"
      message: "System has been armed for a while"

expr:

For anything beyond one endpoint's own changed/value, expr: is a restricted-Python boolean expression evaluated against the shared sandbox:

condition:
  refs: { armed: "security.armed", motion: "hallway.motion" }
  expr: "armed.state == 1 and motion.changed and motion.state == 1"

Five Equivalent Ways to Say the Same Thing

To compare the shorthand against expr:'s different styles directly, here are five conditions that all fire on the exact same tick — the one surveillance.armed transitions to 1:

# 1. The shorthand
condition: { device: "surveillance.armed", changed: true, value: 1 }

# 2. expr, refs-bound attribute style
condition:
  refs: { armed: "surveillance.armed" }
  expr: "armed.changed and armed.state == 1"

# 3. Same, using .event instead of .changed + .state
condition:
  refs: { armed: "surveillance.armed" }
  expr: "armed.event == 1"

# 4. expr, inline function-call style (no refs:)
condition: { expr: "changed('surveillance.armed') and state('surveillance.armed') == 1" }

# 5. Same, using event() instead of changed() + state()
condition: { expr: "event('surveillance.armed') == 1" }

Forms 2/3 and 4/5 are equivalent pairs because event(ref) is state(ref) on the tick of a change (both come from the same update_state() commit — see phc/core/endpoint.py) and None on every other tick, so event(ref) == 1 already implies "changed, to 1" in one comparison. Reach for the shorthand (form 1) when a single endpoint's value is all the condition needs — it's the shortest, and doesn't require naming any of the sandbox's functions at all; reach for expr: when the condition spans more than one device or needs boolean logic the shorthand can't express.

Actions

A task's action/actions: list dispatches on kind:. Nine kinds are registered across the codebase:

kind what it does
set Set the target endpoint to a literal value: or a dynamic expr: result.
toggle Flip the target endpoint between its two declared values, or "on"/"off".
log Log a message template ({state}/{text} available) against the target.
create_task Build and register a new task at runtime from a nested specs:, or from a named template: (see Reusable task templates).
kill_task Remove every task whose tag matches any of tags (fnmatch glob).
script Run a restricted-Python script against the shared sandbox, writable.
mail_alert Send one message through a configured SMTP instance — see mail alerts.
log_db Sample a configured logdb instance — see log database.
random_light Run or force a random_light instance's randomize pass — see random light control.

set and script are the two kinds this sandbox actually powers, and often overlap — the same effect can usually be written either way.

The Same Fixed Effect, Three Ways

Turning the siren off is a fixed target (0), achievable with any of the three general-purpose kinds:

actions:
  - { kind: set, device: "siren.state", value: 0 }        # a literal value
  - { kind: set, device: "siren.state", expr: "0" }        # expr producing a constant
  - { kind: script, code: "set_state('siren.state', 0)" }  # a one-line script

All three write the same raw value the same way (Endpoint.from_text()/ set_text()), so for a fixed target the plain literal value: is the simplest choice — reach for expr:/script once the target stops being a constant.

The Same Dynamic Effect, Two Ways

value: can only ever be a literal — deriving a value from another endpoint needs expr: or script:

# expr: a single expression, re-evaluated fresh each time the action fires
actions:
  - { kind: set, device: "relay_b.state", expr: "state('relay_a.state')" }
# script: the same mirror, spelled out as a statement
actions:
  - kind: script
    code: "set_state('relay_b.state', state('relay_a.state'))"

Since the result is written exactly like a literal value: would be, a values-mapped endpoint whose labels aren't a plain on/off pair may need the expr to produce the target's own raw value or label explicitly, e.g. a ternary: expr: "'clear' if not motion.state else 'motion'" (ternaries, comparisons, and/or/not, and arithmetic are all allowed).

Beyond a Single Value: When to Reach for script

set's expr: is a single expression — it can only ever produce the one value it writes. script's code: is a sequence of statements, and is the only place set_state/create_task/kill_task/reset_sticky/log are available at all — so anything with more than one step (logging and writing and scheduling a follow-up, say) needs script, not set. The intrusion example at the top of this page is exactly that case: one action logs, sets two endpoints, and schedules a timed follow-up task, all together.

See examples/virtual_surveillance-task_defs_3-coded.yaml for a fuller worked example (arm/disarm, retriggered intrusion detection, timed follow-ups, mass-cancel on disarm) — paired with examples/virtual_surveillance-system_setup.yaml via !include (see Splitting configuration across files). The same task set is also available written two other ways: fully inlined (_1-nested.yaml) or via named templates (_2-tempated.yaml, see Reusable task templates below).

Reusable Task Templates

A create_task action's nested specs: can get deeply repetitive when the same follow-up shape is spawned from several places, or when a task's own actions spawn further nested tasks (a create_task whose specs: itself contains a create_task). The top-level task_specs: section holds named, reusable task definitions — each entry the same shape as a tasks: entry — that a create_task action can instantiate by name instead of repeating the whole definition inline:

task_specs:
  - tag: clear_alert
    time: "+1s"
    action: { kind: set, device: "siren.state", value: 0 }

tasks:
  - tag: intrusion
    condition: { device: "hallway.motion", changed: true, value: 1 }
    actions:
      - { kind: set, device: "siren.state", value: 1 }
      - { kind: create_task, template: clear_alert }

template: and specs: are mutually exclusive — give exactly one. Resolution happens lazily, the first time the create_task action actually fires (matching a literal specs:'s own laziness): an unknown template: name only raises once something tries to instantiate it, not at config-load time. This also means a task_specs: entry that's never referenced by any create_task is simply never built into a Task — task_specs: on its own declares nothing live.

The same template: key works from a script's create_task() call, since both surfaces resolve through the same lookup:

actions:
  - kind: script
    code: "create_task({'template': 'clear_alert'})"

A task_specs: entry's own actions: can itself use create_task/ template: to spawn further tasks — useful for pulling a task's nested follow-ups out of a script action's code:, so a create_task call doesn't need a code: string embedded inside another code: string. See examples/virtual_surveillance-task_defs_3-coded.yaml's surv_intrusion template for a worked example of exactly that.

Is This Reading Still Trustworthy?

state(ref) returns the last value a device successfully reported. If that device has since stopped answering, the value is still there and still looks perfectly normal — which is the trap: a frozen reading is indistinguishable from a steady one.

Two functions tell them apart:

  • available(ref)True when the device's last poll succeeded and this endpoint has produced at least one real reading. A device that has never been polled is not "failing", but its endpoints aren't usable either, so both halves matter.
  • age(ref) — seconds since this endpoint last produced a reading, or None if it never has. Note this counts reads, not changes: a thermostat sitting at 20.0 all afternoon has an age near zero, because it is answering; it is a sensor that stops answering whose age climbs.
tasks:
  - tag: frost_warning
    condition:
      # Without available(), an outdoor sensor that died in mild weather
      # would keep reporting its last reading forever -- and this task
      # would never fire, silently, exactly when it is needed.
      expr: 'available("outdoor.temperature") and state("outdoor.temperature") < 0'
    action: { kind: log, message: "freezing outside" }

  - tag: sensor_watchdog
    condition:
      expr: 'age("outdoor.temperature") > 1800'
    min_interval: 1h
    action: { kind: log, message: "outdoor sensor has been quiet for 30 minutes" }

Both are also available in the refs: attribute form (sensor.available, sensor.age).

A device's health is visible outside the sandbox too: the debug portal marks a failing device in its poll queue, the web UI marks affected widgets "not responding", and a device changing state is logged on the phc.health logger — once when it starts failing and once when it recovers, rather than on every tick.

Sticky Values & History

Sticky Values

sticky(ref) reads a since-last-reset_sticky() min/max window on one endpoint — the same mechanism phc/extensions/logdb uses to make sure a brief spike between two samples isn't lost. Every endpoint a condition/script/set expr: references this way is subscribed under the owning task's tag (task_tag, typically the task's own tag:) as its subscriber_id, so two different tasks tracking the same endpoint each get their own independent window — resetting one doesn't affect the other's:

tasks:
  - tag: report_daily_peak
    time: "07:00"
    repeat: 1D
    action:
      kind: script
      code: |
        log(f"yesterday's peak was {sticky('outdoor_temp.value')}")
        reset_sticky('outdoor_temp.value')

sticky(ref) returns None until at least one value has been observed since the last reset (or since startup) — the same "nothing yet" convention as state()/history()/fractile().

Value History & Fractiles

An endpoint can keep a short in-memory buffer of its own past numeric values, sampled on a cadence, for combining several recent readings into one smoothed value inside a condition/script/set expr: — e.g. damping a noisy sensor, or reproducing a hysteresis band that shouldn't react to a single outlier reading. Opt in with history: on the endpoint:

endpoints:
  - key: temperature
    type: float
    history: 4                              # shorthand for {size: 4}
  - key: pressure
    type: float
    history: { size: 8, interval: 5m }       # explicit sampling cadence

size is the number of past samples kept (oldest dropped once full — a plain bounded FIFO, not a time window). interval (optional) is how often a new sample is taken; if omitted, it defaults to the owning device's update: interval, i.e. one sample per poll. An interval shorter than the heartbeat just means "every tick" — not an error, but rarely useful. Give interval: explicitly when a device has no update: interval of its own (e.g. a virtual device, or any update: null device) — declaring history: there without an explicit interval: is a ConfigError, since there would be nothing driving the sample cadence.

Only a type: str endpoint is rejected outright (it can never produce a numeric sample); an untyped endpoint is allowed, since type: itself is optional. A None/not-yet-read value, any non-numeric value, and a float NaN are silently skipped when sampling — one NaN in the buffer would otherwise make every subsequent read return nonsense for as long as it stays in the window. A bool-typed endpoint's history is fully supported (a 0/1 series is a legitimate median-filter debounce). Unlike update_state()'s change-only event, history samples the current value on every interval, whether or not it actually changed since the last sample — a stalled sensor's unchanged reading is deliberately re-recorded, the same way THC's original VHistory_Add behaved.

Four functions read a declared history, each accepting either a single "device.endpoint" ref or a list of refs — a list pools every listed endpoint's buffer into one combined set before computing the result, letting several sensors contribute to a single smoothed value:

  • history(ref) — the raw buffer, oldest sample first.
  • fractile(ref, f) — the value at relative position f (0.0–1.0) once the pool is sorted: f=0 the smallest sample, f=0.5 the middle one, f=1 the largest. Always one of the actually recorded samples, never an interpolated value in between. Raises if f is outside [0, 1].
  • median(ref) — shorthand for fractile(ref, 0.5). For an even-sized pool this is the lower of the two middle samples, not their average (ported from the original Tcl system's rounding rule, see below) — if you need the interpolated average of the two middle values instead, compute it yourself from sorted(history(ref)).
  • average(ref) — the arithmetic mean of the pool.

All four return None if the pool is empty (nothing recorded yet) — the same convention as state() on an unread endpoint. Since a script can't use if x is None: unless comparing against None this way, remember that x == None also works but is/is not reads more naturally and is supported. Pooling an endpoint that never declared history: raises a ValueError naming it — this can only be caught at the point a script actually runs, not at config load time, since a list argument (or a devices(pattern) selector) can't be checked ahead of time the way a single string-literal ref can.

A worked example — indoor/outdoor temperature smoothing across two sensors per side, biased toward "fan turns on sooner" by picking a lower fraction on the outside pool and a higher one on the inside pool:

devices:
  - id: living_room_sensor
    module: zway
    endpoints:
      - { key: temp, history: 4 }
  - id: cellar_sensor
    module: waveplus_bridge
    endpoints:
      - { key: temperature, history: 4 }

tasks:
  - tag: fan_control
    time: +1m
    repeat: 5m
    action:
      kind: script
      code: |
        inside = fractile(['living_room_sensor.temp',
                            'cellar_sensor.temperature'], 0.625)
        outside = fractile(['outdoor_sensor_a.temp',
                             'outdoor_sensor_b.temp'], 0.375)
        diff = inside - outside
        # ... hysteresis / threshold logic using diff ...

fractile()'s index rule (int((n - 1) * f + 0.5)) reproduces the previous Tcl-based system's VHistory_Get exactly, including its round-half-away-from-zero behavior — not Python's round() (banker's rounding), which agrees with it only for some pool sizes/fractions.

A history buffer is in-memory only: it starts empty on every restart and fills back up from scratch as new samples arrive (no persistence, and deliberately not restored by phc/extensions/recovery — a days-old reading would actively corrupt the smoothing for a full window after restart). It is a different mechanism from phc/extensions/logdb: logdb is a long-term, disk-backed, graphable series driven by its own log_db task; history: is a short, volatile ring buffer read directly by a script/condition/set expr:, with no separate task or storage of its own. Nothing stops you from also publishing a fractile() result to a virtual device's endpoint via a set action's expr: if you want it graphable too — see Endpoint and device profiles and the dynamic-mirror example above for the pattern.