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"
| "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
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)
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
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.
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).