Sowel -- Data Model¶
Version: 2.0 -- 2026-05-10
This index covers the cross-cutting parts of Sowel's data model: the three-layer architecture, the entity relationship diagram, the plugin distribution layer, the reactive data flow, the event bus events, and the API surface.
Per-entity details are split across five focused pages aligned with the core concepts:
- Devices -- physical layer, normalized data model
- Equipments -- functional units, bindings, computed data
- Zones -- spatial topology, aggregation rules, auto-orders
- Modes -- orchestration, button bindings, calendar scheduling
- Recipes -- automation templates, instances, runtime helpers
1. Three-Layer Architecture¶
Sowel separates concerns into three distinct layers, with orchestration entities (Modes, Recipes, Calendars) operating across them.
+-------------------------------------------------------------+
| ORCHESTRATION |
| Modes -- Recipes -- Calendars -- Buttons |
| Cross-cutting state machines that drive Equipments / Zones |
+-------------------------------------------------------------+
| LAYER 1 -- TOPOLOGY (Zones) |
| Spatial structure of the home |
| Hierarchical: Home -> Floor -> Room |
| Aggregates data automatically from child Equipments |
+-------------------------------------------------------------+
| LAYER 2 -- FUNCTIONAL (Equipments) |
| What the user sees and controls |
| Placed IN a Zone |
| Binds to one or more physical Devices |
+-------------------------------------------------------------+
| LAYER 3 -- PHYSICAL (Devices) |
| Hardware discovered from integration plugins |
| Auto-discovered, raw data and orders |
| Never directly manipulated by the end user |
+-------------------------------------------------------------+
Guiding principle: A Device is what's on the network. An Equipment is what's in the room.
2. Entity Relationship Diagram¶
Plugin (PackageType: integration | recipe)
+-- (integration) discovers --> Device
+-- (recipe) registers --> RecipeDefinition
Zone (hierarchy: parent -> children)
|
+-- Equipment (functional unit, user-facing)
+-- DataBinding --> DeviceData (readable, alias-named)
+-- OrderBinding --> DeviceOrder (writable, alias-named)
+-- ComputedDataEntry (provider-supplied, no DB row)
Device (physical, auto-discovered, optional zoneId)
+-- DeviceData (readable properties)
+-- DeviceOrder (writable commands)
Mode --< ZoneModeImpact >-- Zone
|
actions: order | recipe_toggle | recipe_params
RecipeInstance --(params)--> RecipeDefinition (from a recipe plugin)
+-- RecipeState (key/value store, persisted)
+-- RecipeLog (event log)
CalendarProfile --< CalendarSlot (cron-scheduled mode changes)
ButtonActionBinding (Equipment.action -> mode/order/recipe/zone-order)
3. Plugin¶
Since spec 053, everything that ships out of band is a plugin -- both integrations and recipes. The PackageManager downloads them from GitHub and the appropriate loader (PluginLoader for integrations, RecipeLoader for recipes) instantiates them at runtime.
3.1 Interfaces¶
type PackageType = "integration" | "recipe";
interface PluginManifest {
id: string; // unique plugin id, used in DB and registry
name: string;
version: string; // semver
description: string;
icon: string; // Lucide icon name
repo: string; // GitHub owner/repo -- required for backup/restore reinstall
type?: PackageType; // defaults to "integration"
author?: string;
sowelVersion?: string; // minimum compatible Sowel version
settings?: IntegrationSettingDef[];
}
interface InstalledPackage {
manifest: PluginManifest;
enabled: boolean;
installedAt: string;
type: PackageType;
}
interface PluginInfo extends Omit<InstalledPackage, "type"> {
status: IntegrationStatus; // connected | disconnected | error | not_configured
deviceCount: number;
offlineDeviceCount: number;
latestVersion?: string; // populated when registry has a newer version
}
3.2 SQLite Schema¶
CREATE TABLE plugins (
id TEXT PRIMARY KEY, -- manifest.id
version TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
manifest TEXT NOT NULL, -- JSON: full PluginManifest
type TEXT NOT NULL DEFAULT 'integration',
source TEXT NOT NULL DEFAULT 'registry',-- spec 136: 'registry' | 'personal'
pinned_sha256 TEXT -- spec 136: TOFU hash (personal only)
);
-- Spec 136: admin-added GitHub repos serving as personal plugin sources
CREATE TABLE plugin_sources (
repo TEXT PRIMARY KEY, -- "owner/repo"
added_at TEXT NOT NULL
);
Integration settings are stored in the generic settings table under keys like integration.<id>.<settingKey>.
4. Reactive Data Flow¶
The complete event-driven pipeline:
Integration message (MQTT, cloud API poll, etc.)
|
v
Integration Plugin (receives + parses)
|
v
Device Manager (updates DeviceData, persists, emits)
|
+--> Event: "device.data.updated"
|
v
Equipment Manager
+-- Updates bound aliases via DataBindings
+-- Pulls ComputedDataEntry from registered providers (energy, pool, ...)
|
+--> Event: "equipment.data.changed"
|
+--> Button Action Manager (if alias=="action")
| +-- Dispatches: mode activate/toggle, equipment order, recipe toggle, zone order
|
v
Zone Aggregator
+-- Recomputes Zone aggregated data, propagates up the tree
|
+--> Event: "zone.data.changed"
|
v
Recipe Engine
+-- Each running RecipeInstance reacts via its createInstance() handle
+-- May emit equipment orders (back into the equipment manager)
|
+--> Events: "recipe.instance.state.changed" / ".error"
|
v
Side effects (parallel, all subscribe to the bus)
+-- WebSocket Server -> pushes events to UI clients
+-- History Writer -> writes points to InfluxDB
+-- MQTT Publishers -> emit on configured outbound topics
+-- Notification Publishers -> Telegram/webhook on threshold
Mode/Calendar/Button paths:
CalendarManager (cron) -> ModeManager.activate/deactivate
ButtonActionBinding -> ModeManager / EquipmentManager / RecipeManager / Zone order
ModeManager.activate -> equipment orders + recipe toggles + recipe param updates
5. Event Bus Events¶
All events are typed via EngineEvent, a TypeScript discriminated union. Handlers must never throw (wrap in try/catch + structured log).
System¶
| Event | Payload |
|---|---|
system.started |
-- |
system.integration.connected |
integrationId |
system.integration.disconnected |
integrationId |
system.error |
error |
system.alarm.raised |
alarmId, level, source, message, messageKey?, messageParams?, zoneId? |
system.alarm.resolved |
alarmId, source, message, messageKey?, messageParams?, zoneId? |
system.update.available |
current, latest, releaseUrl |
system.update.progress |
step, message |
system.update.error |
error |
system.restart_required |
reason |
Device¶
| Event | Payload |
|---|---|
device.discovered |
device: Device |
device.removed |
deviceId, deviceName |
device.status_changed |
deviceId, deviceName, status |
device.data.updated |
deviceId, deviceName, dataId, key, value, previous, timestamp, sourceTimestamp? |
device.heartbeat |
deviceId, timestamp |
Zone¶
| Event | Payload |
|---|---|
zone.created |
zone: Zone |
zone.updated |
zone: Zone |
zone.removed |
zoneId, zoneName |
zone.data.changed |
zoneId, aggregatedData: ZoneAggregatedData |
Equipment¶
| Event | Payload |
|---|---|
equipment.created |
equipment: Equipment |
equipment.updated |
equipment: Equipment |
equipment.removed |
equipmentId, equipmentName, zoneId |
equipment.data.changed |
equipmentId, alias, value, previous, sourceTimestamp? |
equipment.order.executed |
equipmentId, orderAlias, value |
equipment.order.failed |
equipmentId, orderAlias, value, error |
equipment.order.outcome |
equipmentId, orderAlias, value, status, expected?, detail?, source? |
equipment.order.outcome (spec 188) is the outcome of a gate impulse or a shutter Open / Close, read by the spec 141 OrderConfirmationTracker from the state the device reports (pending, moving, confirmed, fault, unconfirmed), and broadcast on the equipments topic. Such orders are never re-dispatched. Their alarms (source order-confirmation) carry messageKey / messageParams (alarms.orderOutcome.*): the banner and the Activity feed word them in the viewer's language, Web Push in each recipient's preferences.language, and message is the same text in English (Telegram, logs). A key on any other source is ignored, and stripped from plugin-emitted alarms.
Recipe¶
| Event | Payload |
|---|---|
recipe.instance.created |
instanceId, recipeId |
recipe.instance.removed |
instanceId, recipeId |
recipe.instance.started |
instanceId, recipeId |
recipe.instance.stopped |
instanceId, recipeId |
recipe.instance.state.changed |
instanceId, recipeId |
recipe.instance.error |
instanceId, recipeId, error |
Mode / Calendar / Sunlight / Settings¶
| Event | Payload |
|---|---|
mode.created |
mode: Mode |
mode.updated |
mode: Mode |
mode.removed |
modeId, modeName |
mode.activated |
modeId, modeName |
mode.deactivated |
modeId, modeName |
calendar.profile.changed |
profileId, profileName |
sunlight.changed |
-- |
settings.changed |
keys: string[] |
Shared access (spec 181)¶
Admin-only on the WebSocket: the labels name people.
| Event | Payload |
|---|---|
shared_access.changed |
-- (any owner or plugin write) |
shared_access.opened |
accessId, label, equipmentId |
shared_access.refused |
accessId, label, equipmentId, reason: SharedAccessRefusal |
An opening is an ordinary order, attributed with the OrderSource member
{ kind: "shared_access", accessId, label }, so the Activity feed names the access even once it is
deleted. The wrong-code alert and the phone count use system.alarm.raised under source
shared-access.
Tables (migrations 035_shared_access.sql, 036_shared_access_tombstones.sql and 037_shared_access_outcome.sql), none of them a column on equipments:
| Table | Holds |
|---|---|
shared_accesses |
One access: label, code (clear, optional), link version and link-token hash, dates in force, source and owner widening for a plugin's access, time windows, hold / revoke. |
shared_access_gates |
The gates an access opens, with the command value when the gate's command has several. Cascades from equipments. |
shared_access_disarmed |
One row per disarmed gate. |
shared_access_profiles |
What a plugin's accesses open: dates, time windows, code or not, the plugin it is granted to, is_default. |
shared_access_profile_gates |
A profile's gates. Cascades from equipments. |
shared_access_phones |
Phones set up on an access: token hash, first and last seen. |
shared_access_journal |
Openings, refusals and changes, with no foreign key so it outlives the access. A year, 50 000 lines at most. outcome / outcome_expected: what became of a press (spec 188). |
shared_access_tombstones |
The (plugin, externalId) of an external access the owner deleted, so a later upsert of that stay answers revoked (migration 036). Purged after a year. |
All eight ride in the backup.
The link token is derived, not random. It is
HMAC-SHA256(sharedAccess.linkSecret, "<accessId>:<linkVersion>") sliced to 22 base64url
characters (deriveLinkToken in src/shared-access/codes.ts), and only its SHA-256 is stored, in
link_token_hash. The derivation exists so the owner can copy an access's link again at any time,
and a plugin can read its invitation back. « Change the code » bumps link_version, which changes
the token and kills the old link.
sharedAccess.linkSecret is one random secret per house, generated on first use and kept in the
settings table. It is never returned by GET /api/v1/settings, cannot be written through
PUT /api/v1/settings, and is out of every plugin's settings scope. It rides in the backup with the
other settings, so a backup can rebuild every live link: keep backups like a key.
EV charge sessions (spec 191)¶
| Event | Payload |
|---|---|
energy.ev_session.updated |
session: EvChargeSession |
energy.ev_session.deleted |
sessionId, chargerId |
Both route to the energy WebSocket topic. A session is one plug-in on an ev_charger (src/energy/ev-sessions/), or on a metered charger one stretch of power at 500 W or more (spec 193). Three SQLite tables (migration 039):
ev_charge_sessions: the charger's meter (energy_wh), the 5-minute window sums (est_car_wh,est_grid_hp_wh,est_grid_hc_wh,est_cost_hp_eur,est_cost_hc_eur) scaled to the meter on read,attribution(pending,vehicle,unattributed,other) withattribution_source(auto,manual: final) andattribution_reason, the vehicle id and name (kept withvehicle_deletedwhen the car is deleted),battery_start/battery_end, andevidence(JSON: what each vehicle reported during the session).ev_vehicle_daily_driving: per vehicle and local day,km_total,km_measured(legs with no charge on the way) andbattery_drop, for the cost per 100 km.ev_vehicle_leg_state: the open driving leg of each vehicle.
MQTT / Notification publishers¶
| Event | Payload |
|---|---|
mqtt-broker.created / .updated |
broker: MqttBroker |
mqtt-broker.removed |
brokerId |
mqtt-publisher.created / .updated |
publisher: MqttPublisher |
mqtt-publisher.removed |
publisherId, publisherName |
mqtt-publisher.mapping.created |
publisherId, mapping: MqttPublisherMapping |
mqtt-publisher.mapping.removed |
publisherId, mappingId |
notification-publisher.created / .updated |
publisher: NotificationPublisher |
notification-publisher.removed |
publisherId, publisherName |
notification-publisher.mapping.created |
publisherId, mapping: NotificationPublisherMapping |
notification-publisher.mapping.removed |
publisherId, mappingId |
6. API Endpoints Summary¶
This is a non-exhaustive index. See API Reference for the full surface.
Zones¶
| Method | Route | Description |
|---|---|---|
| GET | /api/v1/zones |
List all zones (tree structure) |
| GET | /api/v1/zones/:id |
Get zone with aggregated data |
| POST | /api/v1/zones |
Create zone |
| PUT | /api/v1/zones/:id |
Update zone |
| DELETE | /api/v1/zones/:id |
Delete zone |
| POST | /api/v1/zones/:id/orders/:orderKey |
Execute zone-level bulk order |
Equipments¶
| Method | Route | Description |
|---|---|---|
| GET | /api/v1/equipments |
List all equipments |
| GET | /api/v1/equipments/:id |
Get equipment with bindings + data |
| POST | /api/v1/equipments |
Create equipment |
| PUT | /api/v1/equipments/:id |
Update equipment |
| DELETE | /api/v1/equipments/:id |
Delete equipment |
| POST | /api/v1/equipments/:id/orders/:alias |
Execute an equipment order |
| GET | /api/v1/equipments/:id/camera/snapshot |
Camera media proxy — current frame (binding-gated, spec 133) |
| GET | /api/v1/equipments/:id/camera/stream |
Camera media proxy — live stream (binding-gated, spec 133) |
Devices, Plugins, History¶
| Method | Route | Description |
|---|---|---|
| GET | /api/v1/devices |
List discovered devices |
| GET | /api/v1/devices/:id |
Device with data + orders |
| GET | /api/v1/integrations |
Runtime integration status |
| GET | /api/v1/plugins |
Installed plugins (manifest + status) |
| POST | /api/v1/plugins/install |
Install a plugin from registry / GitHub |
| DELETE | /api/v1/plugins/:id |
Uninstall plugin |
| GET | /api/v1/history |
Time-series history query |
Modes / Recipes / Calendar / Buttons¶
| Method | Route | Description |
|---|---|---|
| GET | /api/v1/modes |
List modes |
| POST | /api/v1/modes |
Create mode |
| POST | /api/v1/modes/:id/activate |
Activate a mode (apply impacts) |
| POST | /api/v1/modes/:id/deactivate |
Deactivate a mode |
| PUT | /api/v1/modes/:id/zones/:zoneId/impact |
Set the zone-impact for a mode |
| GET | /api/v1/recipes |
List available recipe definitions |
| POST | /api/v1/recipes/:recipeId/instances |
Create a recipe instance |
| GET | /api/v1/recipes/instances/:id |
Get instance + recent log |
| GET | /api/v1/calendar/profiles |
List calendar profiles |
| PUT | /api/v1/calendar/active |
Switch active profile |
| POST | /api/v1/calendar/profiles/:id/slots |
Add a scheduling slot |
| GET | /api/v1/button-actions |
List button bindings |
| POST | /api/v1/button-actions |
Create a button binding |
MQTT / Notifications / Dashboard / Charts¶
| Method | Route | Description |
|---|---|---|
| * | /api/v1/mqtt-brokers/... |
CRUD MQTT brokers |
| * | /api/v1/mqtt-publishers/... |
CRUD MQTT publishers + mappings |
| * | /api/v1/notification-publishers/... |
CRUD notification publishers |
| * | /api/v1/dashboard/... |
Dashboard widgets |
| * | /api/v1/charts/... |
Saved chart configs |
| * | /api/v1/energy/... |
Energy dashboard, history, by-usage |
For the complete and authoritative reference, see API Reference.