Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

173 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pylon Home Control logo

Pylon Home Control (PHC)

Pylon Home Control (PHC) is a small, YAML-configured home automation framework. It polls and controls a tree of pluggable devices (weather stations, sun position, virtual/test devices, and anything you add), and runs tasks — condition- or time-driven automations — against their state, all on a fixed-heartbeat scheduler.

Concepts

  • Device — a node in a tree that exposes zero or more endpoints (readable/writable state) and may have child devices. Devices are backed by a plugin module (e.g. meteoswiss, open_meteo, waveplus_bridge, zway, sun, virtual, host), declared in a system YAML file.
  • Module — a device plugin: a devices/<name>/device.py (the Device subclass) plus a devices/<name>/module.yaml describing its parameters and endpoints declaratively. Modules are discovered automatically at startup.
  • Task — an automation triggered either by a schedule (time/repeat) or by a device endpoint changing (condition), performing one or more actions (set, toggle, log, create_task, kill_task, script, ...), with an optional min_interval retrigger cooldown.
  • Scheduler — drives each device's fetch on its own interval and evaluates tasks once per heartbeat tick, running device I/O concurrently.

Endpoint types, units & text

Unless otherwise specified, an endpoint's value is untyped and passes through unchanged. An endpoint definition may opt into:

  • typeint, float, bool, or str.
  • unit — a display unit, e.g. "°C", appended when formatting a numeric value as text.
  • values — a raw value → text label mapping, e.g. { 0: "off", 1: "on" }.
  • min/max — a numeric range hint, stored only (never enforced against a write) — e.g. used by extensions/web_ui/ to decide whether a writable numeric endpoint gets a bounded slider.
  • format — a Python format-spec string (e.g. ".2f") applied by to_text(). Defaults to ".1f" for a float endpoint, since str() on a raw float otherwise shows however many digits happen to round-trip (e.g. 3.140000000000001); set format: "" to opt back into full, unrounded precision. Other types default to no formatting.
  • name — an optional per-instance display label (e.g. "Corridor Light"), distinct from description (free-form documentation text). A UI prefers name over description over the endpoint's own key when picking a label. Typically left unset on a module's own endpoints/profiles (which don't know what a specific installation will call the thing) and set at the system-config level instead — see Endpoint and device profiles.

Given these, Endpoint.to_text()/from_text() (and the matching Device.get_text()/set_text()) are the standard way to format a raw value as display text and to parse text (or a raw value/label, e.g. 1 or "on") back into the endpoint's raw value — used by the log action's {text} placeholder and the set action's value: parameter.

See examples/ for complete system configurations, and the module.yaml file in each devices/ subfolder for what parameters/endpoints a given device module supports.

extensions/ is the home for non-device PHC extensions (e.g. extensions/logdb/, a CSV-backed sample store, and extensions/random_light/, randomized light control), following the same package-plus-descriptor pattern as device modules.

Conditions, scripted actions & sticky values

Beyond the condition: { device, changed } shorthand (fire when one endpoint changes) and simple actions like set/toggle/log, a task's condition and a script action's code can use a 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 } })

Both condition.expr and a script action's code run in the same restricted sandbox (see core/scripting.py) — no imports, no attribute access beyond a bound ref's .state/.changed/.text/.event/.sticky, no method-call chains, no unbounded loops — against one shared set of functions (core/task.py's _build_rule_namespace), so a condition and a script can never expose different capabilities by accident:

  • Always available: state(ref), changed(ref), text(ref), event(ref), sticky(ref) (a since-last-reset_sticky() min/max window, the same mechanism extensions/logdb uses — see log_aggregation above), and devices(pattern) (a core/selectors.py glob, e.g. "house.*/*", usable as a for target).
  • Only in a script action (never in a condition, 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.

See examples/surveillance_system.yaml for a fuller worked example (arm/disarm, retriggered intrusion detection, timed follow-ups, mass-cancel on disarm).

Random light control

extensions/random_light/ randomizes a set of "light" devices to make an empty house look occupied — each light gets one or more on/off time-of-day windows (a fixed local "HH:MM", or "sunrise"/"sunset" plus/minus an offset, resolved against a devices/sun/ device's live sunrise/sunset), a minimum switch interval, and a probability of being on. windows/min_interval/ probability_on cascade three ways: extension.yaml's own default → this instance's own windows/min_interval/probability_on (applies to every light below that doesn't set its own) → each light's own override:

extensions:
  random_light:
    house:
      enable_ref: "surveillance.armed"   # optional: only randomize while armed
      pause_ref: "alarm.state"           # optional: skip entirely during an active alarm
      lights:
        - device: "hallway_light.state"
          default: true   # forced on if, after a pass, no light ended up on
          # no windows/min_interval/probability_on of its own -- inherits
          # extension.yaml's own defaults (see below)
        - device: "porch_light.state"
          windows:
            - { start: "sunset+12m", end: "23:30" }
            - { start: "06:00", end: "sunrise-10m" }
          min_interval: 15m
          probability_on: 0.4

tasks:
  - tag: random_light_tick
    time: "+5s"
    repeat: 1m
    action: { kind: random_light, instance: "random_light.house" }

A kind: random_light action with force: 0/force: 1 bypasses windows, probability, and enable_ref/pause_ref entirely, forcing every configured light to that value immediately — for a surrounding system to drop into its own tasks' actions: list (e.g. force everything off when arming/disarming, force everything on as a deterrent during an alarm), as seen throughout examples/surveillance_system.yaml.

Mail alerts

extensions/mail_alert/ sends a message through one configured SMTP server — an extensions.mail_alert.<instance> entry holds the server's connection details plus a default sender/recipient list, and a kind: mail_alert task action sends one message through it, alongside a task's other actions (set, random_light, ...). A recipient can be an ordinary mailbox or an email-to-SMS gateway address — both are just SMTP recipients as far as this extension is concerned. Because a task's actions run inline on the scheduler's own thread (see Task/Action above), the actual SMTP send happens on a small background thread pool instead, so a slow or unreachable mail server can't stall the whole system — delivery success/failure is logged, not surfaced back to the firing task:

extensions:
  mail_alert:
    house:
      smtp_host: "smtp.example.com"
      username: "alerts@example.com"
      password: "..."           # plain text -- phc has no secrets mechanism; guard this file accordingly
      from: "alerts@example.com"
      to:
        - "someone@example.com"
        - "15555550123@sms.example.com"   # an email-to-SMS gateway, same as any other recipient

tasks:
  - tag: intrusion_alert
    condition: { device: "hallway_motion.state" }
    min_interval: 5m   # don't refire more than once every 5 minutes
    actions:
      - kind: set
        device: "siren.state"
        value: 1
      - kind: mail_alert
        instance: "mail_alert.house"
        title: "Home Security - Alarm Alert"
        message: "Sensor triggered"

to/from on the action itself override the instance's defaults when given. See examples/surveillance_system.yaml for a fuller worked example, wired into its intrusion-detection task.

Razberry/zWay Z-Wave integration

devices/zway/ controls Z-Wave devices through a Razberry/zWay controller, via thc_zWay.js (https://github.com/Drolla/thc/tree/master/modules/thc_zWay), a small helper script ported from the earlier THC project that you install on the zWay server yourself (PHC does not push it there). One zway device is one physical Z-Wave node; give it whatever endpoints that node needs (a switch's state, a sensor's battery, ...), each naming its own zWay identifier via command_group and address — a declared endpoint parameter of this module (see devices/zway/module.yaml's endpoint_parameters:), written as an ordinary top-level field on the endpoint:

modules:
  zway:
    update: 30s
    base_url: "http://192.168.1.21:8083"
    user: admin
    password: admin

devices:
  - id: light_corridor
    module: zway
    endpoints:
      - key: state
        writable: true
        type: int
        values: { 0: "off", 255: "on" }
        command_group: SwitchBinary
        address: "7.1"

command_group is one of SwitchBinary, SwitchMultilevel, SwitchMultiBinary, SensorBinary, SensorMultilevel, Battery, or TagReader; address is an opaque zWay "node.instance[.datarecord]" identifier, passed through verbatim. A device with a TagReader endpoint additionally needs its own node param set (the zWay node number) — used for a one-time Configure_TagReader setup call the first time that device is polled, the same node used to fill in any {node} template below.

devices/zway/module.yaml ships a two-axis profile library instead of writing every endpoint out fully explicitly: an endpoint_profile (named after its command group, e.g. switch_binary, sensor_multilevel_temperature — no module-name prefix needed, since a profile is only ever resolved against the module of the device referencing it) captures the access pattern — type, units, writability — shared by every product using that command group, while a device_profile names one product — e.g. fibaro-fgs222, everspring-st814, popp-z-weather (see devices/zway/module.yaml for the full list) — supplying that product's own addresses, which an endpoint_profile never hardcodes (the same command group wires up differently on different hardware). Set node: and device_profile: directly on the device to get a whole product's endpoints at once, then complete them by key with a human-readable name::

devices:
  - id: light_corridor
    module: zway
    name: Corridor Light Switch
    device_profile: fibaro-fgs222
    node: 7
    endpoints:
      - { key: sw1, name: "Corridor Light" }
      - { key: sw2, name: "Closet Light" }

See Endpoint and device profiles below for how the two libraries combine, and examples/zway_system.yaml for a worked example with a relay switch, a PIR motion sensor, a temperature/humidity sensor with one address overridden, a tag reader, and a multi-endpoint weather station.

Every zway device behind the same controller (base_url) self-registers its endpoints' identifiers into a shared, module-level registry; whichever device is due first each poll window issues one combined status request covering every currently-registered identifier for that controller, cached for cache_time (default 30s) -- so a whole controller's worth of Z-Wave devices coalesces into a single HTTP request per poll, the same batching the old THC thc_zWay module did, rather than one round-trip per device. Set the same update interval on every device behind one controller to keep them polling together and get full sharing -- typically by setting both update and params once under modules.zway, as above, rather than repeating them on every device.

Web UI

extensions/web_ui/ is a small aiohttp.web server (sharing the scheduler's own event loop) that renders the live device tree as a browser dashboard — view current status and flip/slide/select new values — with no per-device UI code: each endpoint's widget is inferred purely from its existing metadata:

Endpoint Widget
not writable label
writable, type: bool toggle
writable, has values dropdown
writable, numeric, both min and max slider
writable, numeric, missing min or max number
writable, str or untyped text

Layout is either a single flat page (the selectors shorthand, default everything) or an explicit pages: list, each holding one or more collapsible sections: (folded by default) that pick their devices via the same selector syntax extensions/logdb uses:

extensions:
  web_ui:
    home:
      host: 127.0.0.1
      port: 8080
      refresh_interval: 2s
      pages:
        - id: overview
          title: Overview
          sections:
            - id: lights
              title: Lights
              collapsed: false
              selectors: ["house.*.light*/*"]
            - id: climate
              title: Climate
              selectors: ["house.*/temperature", "house.*/humidity"]

Writes POST through the same Device.set_text() path a task action uses; every widget independently polls its own small HTML fragment on refresh_interval to pick up live state (its own write included, once the next scheduler tick commits it — there is no WebSocket/push channel). Interactivity is HTMX and styling is Bootstrap (CSS only, no bootstrap.bundle.min.js or jQuery — only Bootstrap's pure-CSS form-control classes are used, so a widget stays fully styled and interactive immediately after its own HTMX poll swap, with no re-initialization needed). Both are vendored, unmodified, single-file static assets (see extensions/web_ui/static/), so no build tooling is needed beyond the project's own small pieces of JS: an inline snippet in base.html that mirrors OS light/dark preference onto Bootstrap's data-bs-theme attribute, and graph.js, which mounts a kind: graph panel's chart (see below).

A section's content is a list of panels, dispatched by kind (default "devices", the widgets described above) through a small registry local to this extension (extensions/web_ui/panels.py), independent of core/registry.py.

kind: graph renders a Dygraphs time-series chart over one or more endpoints' logged history, backed by a named extensions/logdb instance:

extensions:
  logdb:
    house_log:
      selectors: ["house.desk_lamp/*"]
      csv_path: "logs/house_log.csv"

  web_ui:
    home:
      pages:
        - id: overview
          sections:
            - id: history
              title: History
              panels:
                - kind: graph
                  id: desk_lamp_history
                  logdb_instance: "logdb.house_log"
                  selectors: ["house.desk_lamp/*"]
                  title: "Desk Lamp"
                  window: 6h
                  decimation:
                    - older_than: 25h
                      factor: 3
                    - older_than: 8D
                      factor: 8

id (required, unique across this web_ui instance) addresses the panel's own GET /api/graph/{id} JSON data route — fetched client-side by graph.js, not embedded in the page render. logdb_instance (required) is resolved lazily, at request time, so it may be declared either before or after this web_ui: instance. selectors picks which endpoints to plot (same syntax as extensions/logdb's own selectors) — each must also be covered by the referenced logdb instance, or its series is empty. window (default 24h) sets the chart's initial zoom; the full retained history is still fetched and pannable via the range selector. decimation (optional) is a list of {older_than, factor} tiers: samples older than older_than are averaged in groups of factor, bounding how much history data is shipped to the browser as it grows — see extensions/logdb/logdb.py's LogDb.get_decimated().

There is no authentication — bind host to a trusted interface only (defaults to 127.0.0.1, loopback-only). See examples/web_ui_system.yaml for a complete runnable example, and examples/logdb_system.yaml for kind: graph paired with the logdb instance it charts.

Requirements

  • Python >= 3.11
  • Dependencies: PyYAML, aiohttp, astral, Jinja2 (see pyproject.toml)

Install

pip install -e .

For running the test suite, install the dev extra instead:

pip install -e ".[dev]"

Usage

Run PHC against one of the example systems:

python phc.py --config examples/virtual_system.yaml

Useful flags:

  • --log-level LEVEL — default logging level (DEBUG, INFO, WARNING, ERROR); applies to every stream (stdout/stderr) destination in log:, never a file destination — see Logging below.
  • --log-level-module NAME=LEVEL — override the level of one logger (e.g. scheduler=DEBUG) on every stream destination; repeatable.

Stop with Ctrl+C (SIGINT) or SIGTERM for a graceful shutdown.

Logging

log: is a list of independently-levelled destinations:

log:
  - dest: stdout
    levels:
      default: INFO
      scheduler: DEBUG   # per-logger override, dotted suffix of "phc.<name>"
  - dest: warn_err.log    # any dest other than stdout/stderr is a file path,
    levels:               # resolved relative to this YAML's own directory
      default: WARNING    # and appended to

dest is stdout, stderr, or any other string (a file path). Each destination's levels map works like virtual_system.yaml's comment explains: default sets the base level for any logger that doesn't have its own entry; every other key overrides one logger by the dotted suffix of its "phc.<name>" name (scheduler, tasks, scripting, logdb, mail_alert, web_ui, ...) — name-agnostic, so a typo'd name is silently never matched rather than rejected. Destinations are independent: the same logger can be INFO on stdout and WARNING in a file at the same time. At most one destination (the first stream one) shows the scheduler's live in-place tick countdown; a file destination never receives it. --log-level/ --log-level-module only ever affect stream destinations, so a file destination configured to stay sparse can't be accidentally flooded from the command line.

There is no log_levels: top-level key — every destination carries its own levels: instead.

Splitting configuration across files

A system YAML can pull in another YAML file with !include <relative-path>, anywhere a value is expected -- a mapping value, a list item, nested arbitrarily deep:

devices:
  - !include common/living_light_device.yaml
  - id: sun
    module: sun
    update: 1h
    latitude: 47.3769
    longitude: 8.5417

The path is resolved relative to the file the !include appears in, not the root config or the current directory, so an included file can itself use !include to pull in further files. This is a plain substitution (the tagged node is replaced by the included file's parsed content) rather than a merge, so a shared fragment works best when it's a fully self-contained block, e.g. a whole device (as common/living_light_device.yaml is above).

For a fragment that only supplies some of a mapping's fields -- e.g. a device's or module's shared params, alongside other fields (update:, id:) that differ per file -- use <<: !include <relative-path> instead, which merges the included mapping's keys into the surrounding mapping rather than replacing it wholesale. The surrounding mapping's own keys win over the fragment's, the same precedence a plain YAML <<: *anchor merge already has:

# common/sun_zurich_params.yaml
latitude: 47.3769
longitude: 8.5417
timezone: Europe/Zurich
devices:
  - id: sun
    module: sun
    update: 1h              # this device's own field, not in the fragment
    <<: !include common/sun_zurich_params.yaml

See examples/common/ for fragments shared between several of the example systems, and any example file under examples/ that references them for real usage.

Modules and shared configuration

A module.yaml declares each parameter's scope (default device) and override (default allowed; required or none are the other two). A declared parameter is an ordinary top-level field, set directly on a device entry (scope: device) or under that module's entry in the top-level modules: section (either scope) -- there is no params: nesting. The top-level modules: section lets several devices of one module type share configuration instead of repeating it on every device:

modules:
  zway:
    update: zwave                       # falls between a device's own update: and module.yaml's default
    <<: !include common/zway_controller_params.yaml   # base_url/user/password/cache_time, shared by every zway device

devices:
  - id: light_corridor
    module: zway
    endpoints: [ ... ]   # no params of its own -- both come from modules.zway above
  - id: sensor_garage
    module: zway
    base_url: "http://a-different-controller:8083"   # overrides just this one param
    endpoints: [ ... ]

Precedence for a scope: device param: the device's own field → the same field set directly under modules.<name> → the module's own default:. A param declared override: required (e.g. zway's base_url) can be satisfied by either the device or the module-level value — this is what lets every device behind one controller omit base_url entirely once it's set under modules.zway. override: none rejects a value being set anywhere but the module's own default:. modules.<name>.update works the same way for a device's update interval: device update:modules.<name>.update → the module's own update: default. update is the one key reserved at the modules.<name> level -- a module cannot declare a parameter named update, or params (reserved even though it's no longer a device/modules key either, since a parameter literally named params would be indistinguishable from the old nested-dict spelling).

A parameter declared scope: module (e.g. meteoswiss's data_url/ cache_time) is different: it has exactly one value for every device of that module type, settable only directly under modules.<name> — setting it on a device is a ConfigError.

A module may similarly declare endpoint_parameters: — its own per-endpoint protocol fields (e.g. zway's command_group/address), a list of {name, description} entries mirroring parameters:'s device-level schema, but with no default/override/scope (an endpoint has no equivalent of modules.<name> to resolve against). A declared name becomes a legal top-level key on any endpoint spec of that module, folded into Endpoint.params once every profile/overlay/{param} step has resolved — see the next section for how that combines with profiles. There is no params: { ... } nesting on a device entry or an endpoint any more; an undeclared field anywhere on either (a typo, or a value meant for the other one) is a ConfigError naming the field.

Endpoint and device profiles

A module can also declare a reusable library of endpoints in its module.yaml, split along two independent axes: an endpoint profile captures the access pattern — type, units, writability, and (for a module with endpoint_parameters:) protocol fields like zway's command_group — shared by several products, while a device profile names one product, with optional brand/type/product/description metadata plus a keyed endpoints: list that completes each endpoint profile with what's specific to that product (typically an address, which an endpoint profile never hardcodes, since the same access pattern wires up differently on different hardware). A profile name never needs a module-name prefix — a device's device_profile:/endpoint_profile: only ever resolves against its own module:'s library, so e.g. a zway device can't accidentally reference a meteoswiss profile even if both declared one under the same name:

# devices/zway/module.yaml
endpoint_parameters:
  - name: command_group
  - name: address

endpoint_profiles:
  sensor_multilevel_temperature: { type: float, unit: "°C", command_group: SensorMultilevel }
  battery: { type: int, unit: "%", command_group: Battery }

device_profiles:
  everspring-st814:
    brand: Everspring
    product: ST814
    description: Temperature/Humidity Sensor
    endpoints:
      - { key: temp, endpoint_profile: sensor_multilevel_temperature, address: "{node}.0.1" }
      - { key: battery, endpoint_profile: battery, address: "{node}" }
devices:
  - id: multi_liv
    module: zway
    name: Living Room Multisensor
    device_profile: everspring-st814   # whole device, from device_profiles
    node: 11                           # fills in every {node} template above
    endpoints:
      - { key: temp, name: "Living Room Temperature" }   # complete a profile-derived endpoint by key
  - id: fus18_meteo
    module: zway
    node: 15
    endpoints:
      - key: f18_temp
        endpoint_profile: sensor_multilevel_temperature   # single endpoint, no device profile
        address: "{node}.0.1"

A device's own endpoints: overlays whatever its device_profile:/ endpoint_profile: provided, by key — replacing only the fields it sets (e.g. address:), so tweaking one value doesn't drop a profile-derived sibling like command_group. Writing an endpoint out fully explicitly, with neither key anywhere on the device, keeps working exactly as before — profiles are a shortcut, not a replacement for the underlying key/type/values/... spec plus whatever the module's own endpoint_parameters: declare.

{param} template substitution itself is independent of profiles: it runs on every endpoint's fields for every device of every module, whether that endpoint came from a profile, an instance override, or a module's own unconditional endpoints:. A module that never declares any templates (most of them, today) is unaffected, since a spec with no {...} anywhere just passes through unchanged.

See devices/zway/module.yaml for the full product list and examples/zway_system.yaml for a worked example mixing a whole-device profile with named endpoints, a device profile with one field overridden, and a single endpoint profile without a device profile.

Extending a module's profile library from a system config

A system config can add to a module's device_profiles/endpoint_profiles library too, under that module's own entry in the top-level modules: section (see "Modules and shared configuration" above), using exactly the same shape module.yaml uses:

modules:
  virtual:
    device_profiles:
      siren:
        endpoints:
          - key: state
            writable: true
            type: int
            values: { 0: "off", 1: "on" }
            default: 0

devices:
  - id: siren_hallway
    module: virtual
    device_profile: siren
  - id: siren_garage
    module: virtual
    device_profile: siren

Resolution stays module-scoped exactly like a module.yaml-declared profile — device_profile: siren above only resolves against virtual, invisible to a device of any other module. A name colliding with one module.yaml already declares is a ConfigError, not a silent override, and (as within module.yaml itself) device_profiles can't be combined with a module whose own endpoints: is non-empty (e.g. meteoswiss) — same base/overlay ambiguity either way.

Reach for this instead of editing the module's own module.yaml when a profile is specific to your setup rather than a real shared product — e.g. a virtual siren with no real hardware behind it doesn't belong in devices/virtual/module.yaml's generic library. It also composes with <<: !include for free, since that's a plain YAML merge key: a device_profiles: block can live in a shared common/*.yaml file included from multiple system configs, the same way examples/common/zway_controller_params.yaml is today. See examples/virtual_system.yaml for a worked example.

Adding a device module

A new device type is a new devices/<name>/ package containing:

  • device.py — a Device subclass decorated with @register_module("<name>").
  • module.yaml — its declared parameters, endpoints, and (if any endpoint needs a protocol field like zway's command_group/address) declared endpoint_parameters:.

See any existing module (e.g. devices/virtual/) for the minimal shape, devices/meteoswiss/ for a fuller, network-backed example, or devices/zway/ for one using endpoint_parameters: and a two-axis endpoint/device profile library.

Tests

pytest

License

MIT — see LICENSE.

About

PHC - Pylon Home Control

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages