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:
- 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.
- A second arm of the same action replaces the deadline and dispatches nothing.
- A failed revert alarms and stops -- never a blind replay, because a dedicated
CLOSEis a no-op while a sequential impulse re-opens what it just closed. - 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.valuechanges, the bound alias reflects the new value immediately andequipment.data.changedis 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
powercategory. Submeter enrolment is a blocklist, so any equipment carrying a numericpowerbinding joins the house consumption breakdown. A UPS wattage is derived fromload % × nominal— an estimate — and would double-count whatever real meter already covers the circuit. Expose it as agenericnumeric if you want it visible. - Declare
powerSource: "mains"on the device. The low-battery monitor (spec 143) assumes a device with abatterycategory 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_currentis notset_setpoint— that is the thermostat identity; reusing it would offer the charger as a thermostat.ev_session_energyis notenergy—energyis an additive delta; a per-session counter there would be summed into nonsense. Publish increments underenergy, the session total underev_session_energy.- Declare
valueOn/valueOffon 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 onmain_energy_meterandenergy_production_meterequipments. - Power submeter integrator (
src/energy/power-submeter-integrator.ts) -- integrates instantaneous power into Wh onenergy_meterequipments without an energy counter (state persisted insubmeter_integrator_state). - Pool runtime tracker (
src/equipments/pool-runtime-tracker.ts) -- daily ON-time ofpool_pumpequipments (state inpool_runtime_state). - Pool water temperature tracker (
src/equipments/pool-water-temp-tracker.ts) -- last active water temperature onpool_heat_pump(state inpool_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 theinternal_rulestable mentioned in earlier versions of this document. They were never implemented. Automation logic now lives in Recipes (section 10).