Skip to content

Equipments

Layer 2 -- Functional: the user-facing functional units. Each equipment lives in a Zone and binds to one or more physical Devices through aliases.

See also: Devices for the physical layer underneath.

Equipment

An Equipment is the user-facing functional unit. It is the primary entity users interact with in the UI, recipes, and external integrations (MQTT publishers, notifications).

1 Interface

type EquipmentType =
  | "light_onoff"
  | "light_dimmable"
  | "light_color"
  | "shutter"
  | "awning"
  | "door" // spec 190 — binds like a shutter, counted apart
  | "switch"
  | "sensor"
  | "button"
  | "thermostat"
  | "weather"
  | "weather_forecast"
  | "gate"
  | "heater"
  | "water_heater"
  | "energy_meter"
  | "main_energy_meter"
  | "energy_production_meter"
  | "media_player"
  | "appliance"
  | "water_valve"
  | "pool_pump"
  | "pool_cover"
  | "pool_heat_pump"
  | "vmc" // Spec 153 — 2-speed mechanical ventilation (OFF / V1 / V2)
  | "ups" // Spec 156 — uninterruptible power supply, read-only
  | "ev_charger" // Spec 182 — EV charger (contract: src/shared/ev-charger-contract.ts)
  | "electric_vehicle"; // Spec 183 — electric or plug-in hybrid car (contract: src/shared/electric-vehicle-contract.ts)

interface Equipment {
  id: string; // UUID v4
  name: string;
  zoneId: string; // FK -> Zone (where the equipment functions)
  type: EquipmentType; // Drives UI rendering, aggregation, valid orders
  icon?: string; // Lucide icon name (overrides type default)
  description?: string;
  enabled: boolean; // Disabled equipments are ignored by the engine
  energyProfile?: EnergyLoadProfile; // Spec 140 — flexible-load declaration (class, nominal W, min-on/off, tolerated import W, learned)
  plugInHybrid?: boolean; // Spec 191 — electric_vehicle only: no cost per 100 km. Present only when true
  usualVehicleId?: string; // Spec 193 — ev_charger only: breaks a tie between matching cars. Present only when set
  createdAt: string;
  updatedAt: string;
}

2 Equipment vs Device

Device Equipment
Nature Physical hardware Functional abstraction
Discovery Auto-discovered from a plugin Manually created by user
Identity (integrationId, sourceDeviceId) UUID + user-chosen name
Location Optional zoneId (where installed) Required zoneId (where used)
Cardinality 1 Device -> N Equipments possible 1 Equipment -> N Devices possible
User interaction Never (technical layer) Always (primary interface)

Examples:

  • 1 Device -> 1 Equipment: Aqara temperature sensor -> "Temperature Cuisine"
  • 1 Device -> N Equipments: Double relay -> "Lumiere Cuisine" + "Lumiere Cellier"
  • N Devices -> 1 Equipment: 3 PIR sensors -> "Detection Cuisine" (via multiple motion-aliased DataBindings)

3 Equipment with details

interface EquipmentWithDetails extends Equipment {
  dataBindings: DataBindingWithValue[];
  orderBindings: OrderBindingWithDetails[];
  /** Provider-supplied virtual data (e.g. energy aggregator cumuls). */
  computedData?: ComputedDataEntry[];
  /** Spec 174 -- the revert this equipment owes, while a window is running. */
  timedAction?: TimedAction;
}

3b Timed action (spec 174)

An equipment can carry one revert the engine owes it, and when. The action itself is an ordinary order, already dispatched; what is described here is the deadline.

interface TimedAction {
  alias: string; // order alias carrying both the action and its revert
  value: unknown; // what was dispatched when the window opened
  revertValue: unknown; // what will be dispatched at the deadline
  expiresAt: string; // ISO-8601 -- a UI ticks it down
  armedAt: string;
  armedBy?: string;
}

Held by TimedActionManager (src/equipments/timed-action-manager.ts) in the timed_actions table, one row per equipment, cascading on delete. The row -- not a setTimeout -- is what carries the obligation across a restart: on boot a deadline still ahead is re-scheduled on its remainder, and one that passed while the engine was down is fired on the way up.

Four rules govern its end, and they are the reason this lives in the engine rather than in each recipe that needs it:

  1. A hand-revert disarms it: the mirror binding reporting the revert value means the user already did it, and firing later would undo their own hand.
  2. A second arm of the same action replaces the deadline and dispatches nothing.
  3. A failed revert alarms and stops -- never a blind replay, because a dedicated CLOSE is a no-op while a sequential impulse re-opens what it just closed.
  4. Deleting the equipment takes the deadline with it.

The manager reaches executeOrder for both halves, so inversion (spec 154), value resolution (spec 150) and delivery confirmation (spec 141) all apply unchanged. It is registered through registerTimedActionProvider -- one provider, not a list: an equipment has at most one deadline standing.

4 SQLite Schema

CREATE TABLE equipments (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
  type TEXT NOT NULL DEFAULT 'generic',
  icon TEXT,
  description TEXT,
  enabled INTEGER DEFAULT 1,
  energy_profile TEXT,            -- Spec 140: EnergyLoadProfile JSON, NULL = not claimable
  metering_parent_id TEXT REFERENCES equipments(id) ON DELETE SET NULL,
                                  -- Spec 173: this meter's consumption is already counted by that one
  plug_in_hybrid INTEGER NOT NULL DEFAULT 0,
                                  -- Spec 191: a plug-in hybrid shows no cost per 100 km
  usual_vehicle_id TEXT,          -- Spec 193: an ev_charger's usual vehicle (cleared on its removal)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Spec 174: the revert the engine owes an equipment, and when.
CREATE TABLE timed_actions (
  equipment_id  TEXT PRIMARY KEY REFERENCES equipments(id) ON DELETE CASCADE,
  alias         TEXT NOT NULL,
  action_value  TEXT NOT NULL,  -- JSON: false and "false" must stay distinguishable
  revert_value  TEXT NOT NULL,  -- JSON: NULL is a legitimate value (a gate impulse)
  expires_at    INTEGER NOT NULL,
  armed_at      INTEGER NOT NULL,
  armed_by      TEXT
);

Data Binding

A DataBinding maps a DeviceData row to an Equipment-level alias. The alias is the stable name used in the UI, recipes, and history queries.

1 Interface

interface DataBinding {
  id: string;
  equipmentId: string; // FK -> Equipment
  deviceDataId: string; // FK -> DeviceData
  alias: string; // Equipment-level name: "state", "brightness", "temperature"
  /** NULL = follow category default. 1 = force historize ON. 0 = force OFF. */
  historize?: number | null;
}

interface DataBindingWithValue extends DataBinding {
  deviceId: string;
  deviceName: string;
  key: string;
  type: DataType;
  category: DataCategory;
  value: unknown;
  unit?: string;
  enumValues?: string[];
  lastUpdated: string | null;
  lastChanged: string | null;
  historize?: number | null;
}

2 How It Works

Device "Variateur #1"
+-- DeviceData: key="state",       category=light_state       <--+
+-- DeviceData: key="brightness",  category=light_brightness  <--+ DataBinding
+-- DeviceData: key="linkquality", category=generic              |
                                                                  |
Equipment "Spots Cuisine"                                         |
+-- alias "state"      ----------------------------------------- +
+-- alias "brightness" -----------------------------------------

3 Constraints

  • UNIQUE(equipment_id, alias) -- each alias is unique per Equipment.
  • When DeviceData.value changes, the bound alias reflects the new value immediately and equipment.data.changed is emitted.
  • The alias is used in zone aggregation (looked up by category), recipe slots, MQTT publisher mappings, notification mappings, history queries, and chart series.

4 Historization control

Each binding may override the default historization decision per category. Resolution order: explicit historize override -> alias name default -> category default. effectiveOn is exposed via HistoryBindingState.

5 SQLite Schema

CREATE TABLE data_bindings (
  id TEXT PRIMARY KEY,
  equipment_id TEXT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
  device_data_id TEXT NOT NULL REFERENCES device_data(id) ON DELETE CASCADE,
  alias TEXT NOT NULL,
  historize INTEGER DEFAULT NULL,
  UNIQUE(equipment_id, alias)
);

Order Binding

An OrderBinding maps a DeviceOrder to an Equipment-level command alias.

1 Interface

interface OrderBinding {
  id: string;
  equipmentId: string;
  deviceOrderId: string;
  alias: string; // Equipment-level command: "turn_on", "set_brightness"
}

interface OrderBindingWithDetails extends OrderBinding {
  deviceId: string;
  deviceName: string;
  key: string;
  type: DataType;
  category?: OrderCategory;
  min?: number;
  max?: number;
  enumValues?: string[];
  unit?: string;
}

2 Multi-Device Dispatch

An Equipment can have multiple OrderBindings sharing the same alias but pointing to different Devices. Executing the alias dispatches to all of them in parallel:

Equipment "Eclairage Cuisine"
+-- OrderBinding alias="state" -> DeviceOrder on Relais #1
+-- OrderBinding alias="state" -> DeviceOrder on Relais #2

2b VMC speed order (spec 153)

A vmc equipment binds up to two on/off relay orders under fixed aliases low (petite vitesse, required) and high (grande vitesse, optional). It also accepts a logical order speed (off/v1/v2) with no device binding: EquipmentManager.executeOrder decomposes it into sequenced, break-before-make relay orders (never both windings energized at once — the universal VMC wiring invariant). See src/equipments/vmc-controller.ts. The observed speed is exposed as a computed speed value derived from the low/high relay state.

2c UPS telemetry (spec 156)

A ups equipment is read-only — it declares no orders. It binds whatever telemetry its plugin reports, as a single "all data" candidate, and renders only the rows that are actually bound.

Three categories are specific to it:

Category Type Unit Meaning
ups_status enum — Where the load is powered from
battery_runtime number s Autonomy remaining at the current load
ups_load number % Output load as a percentage of nominal power

ups_status is a closed, severity-ordered set — UPS_STATUS_VALUES in src/shared/constants.ts: online, on_battery, bypass, overload, low_battery, offline. Vendor protocols report status as an additive flag set (NUT: OL CHRG, OB LB), so the plugin resolves the flags to exactly one value, keeping the most severe. Secondary flags worth showing (charging, self-test result, model) stay generic bindings.

Two rules bind the plugin rather than the core, and both matter:

  • The load is a percentage, never the power category. Submeter enrolment is a blocklist, so any equipment carrying a numeric power binding joins the house consumption breakdown. A UPS wattage is derived from load % × nominal — an estimate — and would double-count whatever real meter already covers the circuit. Expose it as a generic numeric if you want it visible.
  • Declare powerSource: "mains" on the device. The low-battery monitor (spec 143) assumes a device with a battery category and no declared power source runs on a cell. Left undeclared, every outage would raise a "replace the battery" alarm against the UPS. Outage alarms belong to the plugin, worded for what actually happened.

2d Thermostat contract (spec 177)

A thermostat equipment has a declared core, in src/shared/thermostat-contract.ts and nowhere else; the UI binding tables, the device selector and the thermostat card import it.

Alias Data Order Resolved from
temperature ✓ data category temperature
setpoint ✓ ✓ data category setpoint, order category set_setpoint
state ✓ the device's boolean power reading (spec 176), re-tagged appliance_state
power ✓ ✓ order category toggle_power; data: the wattage on a submetered unit, or a legacy boolean bound before spec 176
operationMode ✓ ✓ data category operation_mode, order category set_operation_mode; values auto / heat / cool / dry / fan / off
outsideTemperature ✓ data category temperature_outdoor (optional)

Identity. A device is offered as a thermostat when it exposes a setpoint data point or a set_setpoint order. No raw key is ever required.

Extras. A thermostat auto-binds every order and every data point its device exposes: the core resolves to the aliases above, everything else binds under its own key. Those bindings are extras: they stay usable, vary per equipment, and define nothing — no core code path, no recipe contract and no card layout keys off them. The card renders them generically from the order's type (enum → buttons, boolean with a data mirror → toggle, boolean without → one-shot action, number → stepper; data without an order → read-only chip).

Until issue #922 makes the plugins publish canonical names, STANDARD_ALIASES.thermostat in bindingUtils.ts still maps targetTemperature → setpoint and insideTemperature → temperature; it is the compatibility layer, not the contract.

2e EV charger contract (spec 182)

An ev_charger is defined by a contract module, src/shared/ev-charger-contract.ts, imported by the binding code, the type metadata and every charger surface (the spec 177 pattern). Each core point is identified by its category, never by a plugin key; auto-binding turns the category into the contract alias.

Alias Side Category Unit Meaning
state data + order appliance_state (or light_state) / toggle_power (or light_toggle) — Charging enabled; start / stop
vehicle data ev_vehicle_state — disconnected, connected, charging (IEC 61851 A/B/C)
power data power W Live power delivered
energy data energy Wh Additive increments, for the energy history
charge_current data + order ev_charge_current / set_ev_charge_current A Charging current setpoint; the order's min/max bound it
session_energy data ev_session_energy kWh Energy since the vehicle was plugged in (resets)
current data current A Measured current
voltage data voltage V Measured voltage

A device is offered for an ev_charger when it declares ev_vehicle_state data or a set_ev_charge_current order; then, marked metered only, any other device with a power reading (spec 193, isMeteredChargerDevice). A charger bound without a vehicle point is a metered charger: its sessions follow its power (500 W, 30 minutes). Both creation paths — the UI and POST /api/v1/equipments with deviceIds (createWithAutoBindings) — bind the contract points first, under their contract alias; a vendor point whose key equals a contract alias cannot take it. The charger's temperature binds as charger_temperature, out of the zone average. generic data points bind as extras, shown on the detail page; other categorised points and configuration orders are opt-in. Three rules bind the plugin:

  • set_ev_charge_current is not set_setpoint — that is the thermostat identity; reusing it would offer the charger as a thermostat.
  • ev_session_energy is not energy — energy is an additive delta; a per-session counter there would be summed into nonsense. Publish increments under energy, the session total under ev_session_energy.
  • Declare valueOn / valueOff on the start/stop order — Sowel's on/off surfaces send "ON" / "OFF", mapped to the device's wire values only when it declares them.

For the arbiter, ev_charger defaults to deferrable, 10 min on / 5 min off; its power binding is the live draw and its state binding the on/off state.

2f Electric vehicle contract (spec 183)

An electric_vehicle (battery-electric or plug-in hybrid) is defined by src/shared/electric-vehicle-contract.ts. Every core point is identified by its category; both creation paths bind contract points first, under their contract alias.

Alias Side Category Unit Meaning
battery_level data ev_battery_level % Traction battery (not battery, the device-battery category)
range data ev_range km Electric range
plugged data ev_plugged — A cable is connected to the car
charging_state data ev_charging_state — unplugged, idle, scheduled, waiting, charging, completed, error
reported_at data ev_reported_at ISO When the car itself last reported
at_home data ev_at_home — At the home location (no coordinates are ever published)
mileage data ev_mileage km Odometer
charge_limit data + order ev_charge_limit / set_ev_charge_limit % The limit the car applies by itself; set it where allowed
wake order ev_wake — Wake the car (momentary); the plugin chooses the means
charge_start order ev_charge_start — Start a charge (momentary), where the maker allows it
refresh order ev_refresh — Read the car's latest report now (momentary, spec 184); never wakes the car
charge_power data ev_charge_power W The power the car says it charges at, optional (spec 194: tells two cars apart)

Spec 191 builds charge sessions on these two contracts: the charger's vehicle, power and energy, and the car's plugged, charging_state, battery_level, at_home, reported_at and mileage (see the data model index). A device is offered for an electric_vehicle when it declares ev_battery_level. A vehicle is never a submeter (NON_SUBMETER_TYPES), never a flexible load and never carries a timed command: the charger (spec 182) measures and is the load. Fuel and climate values of a plug-in hybrid are extras.

3 Per-binding category override

Migration 006_pool_runtime_and_category_override.sql added category_override so an Equipment of type pool_pump can re-tag a generic relay's toggle_power order as pool_pump_toggle without touching the device definition. Effective category is COALESCE(order_bindings.category_override, device_orders.category).

4 SQLite Schema

CREATE TABLE order_bindings (
  id TEXT PRIMARY KEY,
  equipment_id TEXT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
  device_order_id TEXT NOT NULL REFERENCES device_orders(id) ON DELETE CASCADE,
  alias TEXT NOT NULL,
  category_override TEXT,
  UNIQUE(equipment_id, alias)
);

Computed Data

Sowel does not persist computed data in a SQLite table. There is no expression language, and no computed_data table. Instead, the EquipmentManager exposes a provider registry that internal modules use to attach virtual data points to an Equipment at read time:

interface ComputedDataEntry {
  alias: string;
  value: unknown;
  unit?: string;
  category?: DataCategory;
  lastUpdated: string | null;
}

type ComputedDataProvider = (equipmentId: string) => ComputedDataEntry[];

Current providers in the codebase:

  • Energy aggregator (src/energy/energy-aggregator.ts) -- exposes daily/monthly cumuls on main_energy_meter and energy_production_meter equipments.
  • Power submeter integrator (src/energy/power-submeter-integrator.ts) -- integrates instantaneous power into Wh on energy_meter equipments without an energy counter (state persisted in submeter_integrator_state).
  • Pool runtime tracker (src/equipments/pool-runtime-tracker.ts) -- daily ON-time of pool_pump equipments (state in pool_runtime_state).
  • Pool water temperature tracker (src/equipments/pool-water-temp-tracker.ts) -- last active water temperature on pool_heat_pump (state in pool_water_temp_state).

Computed entries appear in EquipmentWithDetails.computedData and can be referenced from MQTT publishers, notifications, and chart series like any other binding alias.

Removed from the data model: the legacy expression language (OR(), AVG(), IF(), binding.<alias>, etc.) and the internal_rules table mentioned in earlier versions of this document. They were never implemented. Automation logic now lives in Recipes (section 10).