Architecture Overview¶
This document describes Sowel's technical architecture: the tech stack, project structure, reactive pipeline, key domain concepts, and design system.
Tech Stack¶
Backend¶
| Technology | Role |
|---|---|
| Node.js 24+ | Runtime |
| TypeScript (strict mode) | Language |
| Fastify | HTTP framework |
| SQLite (better-sqlite3) | Primary database (synchronous API, WAL mode) |
| InfluxDB 2.x | Time-series storage (history, energy) |
| ws | WebSocket server |
| mqtt.js | MQTT client for device integrations |
| pino | Structured JSON logging |
Frontend¶
| Technology | Role |
|---|---|
| React 19 | UI framework |
| TypeScript | Language |
| Vite | Build tool and dev server |
| Tailwind CSS 4 | Styling (utility classes only). v4 is config-less: there is no tailwind.config.js, tokens live in design-system/tokens.css |
| Zustand | State management |
| Lucide React | Icon library (stroke 1.5px) |
Infrastructure¶
| Technology | Role |
|---|---|
| Docker + docker-compose | Containerized deployment |
| Docker restart policy | Process supervision (production) |
Key Domain Concepts¶
| Term | Role |
|---|---|
| Device | Physical hardware, auto-discovered from integrations. Exposes raw Data and Orders. |
| Equipment | User-facing functional unit. Binds to one or more Devices. Can have computed Data and dispatched Orders. |
| Zone | Spatial grouping (nestable tree). Auto-aggregates Equipment Data (motion=OR, temperature=AVG, lightsOn=COUNT, etc.). |
| Recipe | Automation template with typed parameter slots: trigger(s) -> condition(s) -> action(s). A configured, running copy is an instance. |
| Mode | Named state (e.g. "Night", "Away") with zone-level impacts. Can be activated manually, by calendar, or by button press. |
Guiding principle: A Device is what's on the network. An Equipment is what's in the room.
Reactive Pipeline¶
The core data flow is fully event-driven. Every integration message propagates through the entire stack:
Integration message (MQTT, cloud API poll, etc.)
-> Integration Plugin (receives + parses)
-> Device Manager (updates DeviceData)
-> Event Bus: "device.data.updated"
-> Equipment Manager (re-evaluates bindings + computed Data)
-> Event Bus: "equipment.data.changed"
-> Zone Manager (re-evaluates aggregations)
-> Event Bus: "zone.data.changed"
-> Recipe Engine (evaluates triggers -> conditions -> actions)
-> Actions may emit Orders -> Integration Plugin -> device
-> MQTT Publish Service (outbound to external brokers, with optional on-change filter)
-> Notification Publish Service (Telegram, etc.)
-> WebSocket pushes to UI clients
Event Bus¶
The Event Bus is a typed EventEmitter using TypeScript discriminated unions (EngineEvent type). It is the backbone connecting all managers. Key rules:
- All handlers must be non-blocking and must never throw.
- Events are batched (200ms interval) before being sent to WebSocket clients.
- High-frequency data events (
device.data.updated,equipment.data.changed,zone.data.changed) are deduplicated per batch -- only the latest value per key is sent.
Event Types¶
| Event | Payload | When |
|---|---|---|
device.discovered |
device: Device |
New device found |
device.removed |
deviceId, deviceName |
Device deleted |
device.status_changed |
deviceId, deviceName, status |
Online/offline |
device.data.updated |
deviceId, deviceName, dataId, key, value, previous |
Property change |
equipment.data.changed |
equipmentId, alias, value, previous |
Bound data changed |
equipment.order.executed |
equipmentId, orderAlias, value, source? |
Order dispatched |
zone.data.changed |
zoneId, aggregatedData |
Aggregated data changed (whole set) |
system.started |
-- | Engine boot complete |
system.integration.connected |
integrationId |
Integration connected |
system.integration.disconnected |
integrationId |
Integration disconnected |
settings.changed |
keys |
Settings updated |
mode.activated |
mode details | Mode activated |
mode.deactivated |
mode details | Mode deactivated |
recipe.instance.state.changed |
instance details | Recipe state changed |
activity.added |
item: ActivityItem |
New activity item buffered (spec 101) |
Project Structure¶
sowel/
├── src/
│ ├── index.ts # Entry point
│ ├── config.ts # Env config loading
│ ├── core/ # event-bus, database (SQLite), influx, logger, settings-manager,
│ │ # timezone, version-checker, update-manager, shutdown
│ ├── integrations/ # IntegrationRegistry only — the runtime registry plugins
│ │ # register into. No integration lives here (spec 053)
│ ├── plugins/ # PluginLoader + scoped-deps (soft isolation, spec 111)
│ ├── packages/ # PackageManager: GitHub distribution, registry, personal sources
│ ├── devices/ # Device manager, auto-discovery, category inference
│ ├── equipments/ # Equipment manager, bindings, computed engine, order dispatcher,
│ │ # order confirmation tracker (spec 141)
│ ├── energy/ # Energy aggregator, tariff classifier, capacity arbiter
│ ├── zones/ # Zone manager, auto-aggregation engine, sunlight
│ ├── modes/ # Mode manager, calendar manager
│ ├── recipes/ # RecipeLoader + engine. Recipes themselves are packages
│ ├── weather/ # Weather aggregation, forecast models, PV forecast
│ ├── activity/ # Activity buffer + store (spec 147)
│ ├── backup/ # BackupManager (export/restore, local backups)
│ ├── buttons/ # Button action bindings (physical button -> mode/order)
│ ├── charts/ # Saved chart configurations
│ ├── history/ # InfluxDB history writer and query helpers
│ ├── mqtt-publishers/ # Outbound MQTT publishing (broker, publisher, on-change filter)
│ ├── notifications/ # Notification channels (Telegram, ntfy, web push)
│ ├── auth/ # JWT + API tokens, MFA, middleware, user manager, first-run setup
│ ├── api/ # Fastify server, WebSocket handler, route files
│ │ ├── server.ts # Server setup and route registration
│ │ ├── websocket.ts # WebSocket handler with topic subscriptions
│ │ └── routes/ # One file per domain (auth, devices, zones, etc.)
│ ├── test-helpers/ # Shared test fixtures
│ └── shared/ # types.ts (all interfaces), constants.ts, plugin-api.ts
├── ui/ # React frontend (separate Vite project)
│ └── src/
│ ├── store/ # Zustand stores (devices, equipments, zones, WebSocket)
│ ├── components/ # By domain: dashboard/, devices/, equipments/, energy/, recipes/
│ ├── pages/ # Dashboard, Devices, Equipments, Zones, Energy, Settings
│ └── i18n/ # Internationalization (en.json, fr.json)
├── plugins/ # Installed plugin directory + registry.json
├── migrations/ # SQLite migration SQL files
├── specs/ # Feature specifications (XXX-name/)
└── scripts/ # Maintenance & diagnostic scripts
├── energy/ # InfluxDB energy backfill, diagnostic, admin
└── logs/ # Log retrieval via API
Plugin Architecture V2 (current)¶
Since spec 053, all integrations and recipes are plugins distributed via GitHub. Nothing is built-in anymore — a fresh Sowel install has zero plugins and downloads them on demand from a registry.
Core services¶
| Service | File | Role |
|---|---|---|
| PackageManager | src/packages/package-manager.ts |
Downloads, installs, updates, and removes packages (integrations + recipes). Fetches manifests from GitHub releases. Maintains DB state in plugins table. |
| PluginLoader | src/plugins/plugin-loader.ts |
Integration-specific loader. Imports the plugin JS entry (dist/index.js), calls createPlugin, registers with IntegrationRegistry. Auto-downloads plugin files on startup if missing (e.g. after backup restore). |
| RecipeLoader | src/recipes/recipe-loader.ts |
Recipe-specific loader. Same model as PluginLoader but for recipe packages. |
| IntegrationRegistry | src/integrations/integration-registry.ts |
Runtime registry of connected integrations. Handles start/stop with staggering (to avoid simultaneous cloud API calls). |
Distribution model¶
Plugins live in separate GitHub repos (e.g. mchacher/sowel-plugin-zigbee2mqtt). Each release ships a prebuilt tarball. The registry — a list of available packages — is fetched from:
- Remote:
https://raw.githubusercontent.com/mchacher/sowel/main/plugins/registry.json(cache TTL 1h) - Fallback: local
plugins/registry.jsonshipped in the Docker image
Installation flow:
- User clicks "Install" in Admin → Plugins UI
- PackageManager calls GitHub releases API for the plugin's repo
- Downloads the latest release tarball + manifest
- Extracts to
plugins/<id>/on thesowel-pluginsvolume - Inserts a row in the
pluginsSQLite table - PluginLoader imports the entry and registers the integration
The plugins/registry.json on main is the source of truth for the official plugin list. Any user can point to their own fork.
Trust tiers (specs 089 + 136)¶
Three trust tiers govern where a package may come from and what integrity anchor protects it:
| Tier | Source of truth | Integrity anchor | Install friction |
|---|---|---|---|
| Official | Central registry, whitelisted owner | Registry SHA256 + maintainer review | None |
| Community | Central registry, third-party owner | Registry SHA256 + registry PR review | One-time confirmation modal |
| Personal | Admin-added GitHub repo (spec 136) | TOFU-pinned SHA256 | Confirmation at install and at every update |
Personal sources (spec 136) let an admin register their own public GitHub repos as plugin sources, bypassing the central registry entirely. PersonalSourceManager (src/packages/personal-sources.ts) owns the plugin_sources table and a cached view of each repo's latest release. Trust is on-first-use: the install downloads the tarball, shows its SHA256 in a confirmation modal, then pins the hash in plugins.pinned_sha256. Updates whose tarball differs from the pinned hash require a fresh confirmation; backup restores re-verify downloads against the pinned hash. All spec 089 tarball hardening (hash verification, tar flags, escaping-symlink refusal) applies identically, plus personal-only checks: the manifest repo must match the source, the plugin id must not shadow a registry id, and sowelVersion compatibility is enforced. Installed packages carry plugins.source (registry or personal); version checks for personal packages read the source repo's releases, never the registry.
Plugin manifest format¶
Each plugin ships a manifest.json with id, type (integration or recipe), name, description, icon (Lucide name), author, repo, version, tags. See plugin-development.md for the full spec.
Integration lifecycle¶
- Load —
PluginLoader.loadAll()scans thepluginstable, imports each enabled entry, callscreatePlugin(deps), registers withIntegrationRegistry. - Start —
IntegrationRegistry.startAll()starts plugins one after another, awaiting each. The 10 s stagger is apollOffsethanded to polling plugins, not a delay between starts. Each plugin'sstart()connects, discovers devices, begins polling.start()returning does not mean the plugin is reachable: MQTT connects and cloud logins complete asynchronously afterwards. - Connected — the registry samples every plugin's
getStatus()and emitssystem.integration.connected/system.integration.disconnectedon each transition. This is the engine's own signal, independent of what a plugin chooses to emit, and it is what makes an integration's recovery observable (see Order delivery below). - Runtime — Plugin pushes data via
deviceManager.updateDeviceData(). Orders go out viaplugin.executeOrder(). - Stop —
stop()cancels timers, closes connections. - Update — Unload →
PackageManager.updateFiles()→ reload. - Uninstall — Unload →
PackageManager.removeFiles().
Settings for integrations are stored in SQLite settings under integration.<id>.<key>, configured from the UI.
Order delivery when an integration is unreachable¶
An order dispatched at a disconnected integration never reaches the wire. Rather than being dropped with a log line, it is held by the order confirmation tracker (spec 141) and re-dispatched once when that integration connects, within a short window: a schedule-driven command replayed long after its slot would be worse than the one that was lost. The caller still gets the same error it always did.
Recipe instances are the reason this used to matter on every restart: they start at the very end of boot, behind a bounded wait on the integrations reporting connected, because an instance evaluates and dispatches as soon as it starts. The wait is capped so one unreachable cloud integration cannot hold every automation, and whatever still slips through is caught by the hold-and-replay above. The API and the recipe list do not wait; only running-instance state does.
Where an order's effect is observed¶
executeOrder succeeding means the order reached the integration, not that the device acted, so the tracker watches for the ordered value to appear and alarms when it does not. Which reading it watches is resolved in this order: a data binding carrying the order's alias on the very device the order was sent to; failing that, the binding on another device, when its type could report the ordered value; failing that, the ordered device's own data under the order key, which is where a cloud thermostat publishes a state nobody bound. When none of the three exists, the order is still tracked and replayed, but it stays out of the alarm surface, because an alarm nothing can resolve is noise.
The middle rule exists because an alias is not a vocabulary. On a submetered appliance power is both the boolean sent to the device and the wattage read from a clamp, and comparing the two can only ever be false (issue #901).
Current official plugin ecosystem¶
| Plugin | Repo | Type |
|---|---|---|
zigbee2mqtt |
mchacher/sowel-plugin-zigbee2mqtt |
integration |
lora2mqtt |
mchacher/sowel-plugin-lora2mqtt |
integration |
panasonic_cc |
mchacher/sowel-plugin-panasonic-cc |
integration |
mcz_maestro |
mchacher/sowel-plugin-mcz-maestro |
integration |
legrand_control |
mchacher/sowel-plugin-legrand-control |
integration |
legrand_energy |
mchacher/sowel-plugin-legrand-energy |
integration |
netatmo_weather |
mchacher/sowel-plugin-netatmo-weather |
integration |
weather-forecast |
mchacher/sowel-plugin-weather-forecast |
integration |
smartthings |
mchacher/sowel-plugin-smartthings |
integration |
motion-light |
mchacher/sowel-recipe-motion-light |
recipe |
motion-light-dimmable |
mchacher/sowel-recipe-motion-light-dimmable |
recipe |
switch-light |
mchacher/sowel-recipe-switch-light |
recipe |
presence-heater |
mchacher/sowel-recipe-presence-heater |
recipe |
presence-thermostat |
mchacher/sowel-recipe-presence-thermostat |
recipe |
state-watch |
mchacher/sowel-recipe-state-watch |
recipe |
state-trigger-light |
mchacher/sowel-recipe-state-trigger-light |
recipe |
The live list is in plugins/registry.json at the repo root.
Database Architecture¶
SQLite¶
- Library:
better-sqlite3with intentionally synchronous API (fast, no callback overhead). - WAL mode:
PRAGMA journal_mode=WALfor concurrent read/write. PRAGMA synchronous=NORMAL(issue #694): pinned rather than inherited. The effective value was already NORMAL, but only because better-sqlite3 compilesSQLITE_DEFAULT_WAL_SYNCHRONOUS=1and SQLite applies it to a database already in WAL mode at open; a brand-new file is created indeletemode, takes FULL, and switching it to WAL afterwards does not re-apply the WAL default, so a fresh install ran its first process lifetime at FULL. Setting it explicitly makes the choice ours instead of a dependency's compile flag, anddatabase.test.tspins it. The trade-off NORMAL accepts: a power loss or an OS crash can lose transactions committed since the last checkpoint (up to ~4 MB of WAL at the defaultwal_autocheckpoint, so potentially minutes of writes, though Linux writeback makes the realistic loss far smaller). It cannot corrupt the database, recovery is prefix-consistent, and a process crash or container restart loses nothing — but a VM hard-stop is a guest power loss, not a restart. Note this governs when SQLite fsyncs, never how many pages it writes: it is not a write-amplification fix.- Migrations: SQL files in
migrations/run automatically on startup. - Transactions: Used for batch operations.
- IDs: UUID v4 via
crypto.randomUUID(). - Dates: ISO 8601 format throughout.
InfluxDB¶
Energy and history data flows through a multi-bucket pipeline:
sowel (raw) -- 7-day retention -- raw data points
| task: sowel-energy-sum-hourly (every: 1h, lookback: -7h)
sowel-energy-hourly -- 2-year retention -- hourly sums
| task: sowel-energy-sum-daily (every: 1d, lookback: -2d)
sowel-energy-daily -- 10-year retention -- daily sums
Additional downsampled buckets (sowel-hourly, sowel-daily) exist for non-energy time-series data.
InfluxDB is optional -- a failed connection is logged and the engine keeps running, with history and energy aggregation degraded. When it does connect, Sowel auto-creates buckets, downsampling tasks, and energy aggregation tasks.
Energy deltas are accumulated, never sampled¶
An energy binding carries an additive delta (Wh since the previous tick), not a measurement. The deduplication that protects the other categories (deadband, 30 s min-write interval) would silently destroy energy: meters differ wildly in cadence -- a Shelly EM emits one tick a minute, a Tuya PJ-1203A emits ~30, of which a single one carries the 10 Wh counter jump.
So HistoryWriter accumulates live energy ticks per minute and writes one point aligned on the minute start, HP/HC split included. SelfConsumptionWriter accumulates the paired Grid + Solar minute the same way and derives autoconso / injection / household from the summed minute.
One authority per series¶
The two writers close their per-minute buckets on different triggers -- HistoryWriter per binding, SelfConsumptionWriter on the first tick of the next minute from either meter. Sharing a series between them would therefore be a last-write-wins race decided by which meter happens to tick first, so ownership of the grid meter's energy / energy_hp / energy_hc is exclusive:
- With an
energy_production_meterconfigured,SelfConsumptionWriteris their sole writer (household semantic), andHistoryWriterskips exactly those three aliases on themain_energy_meter. It keeps writing everything else: power, voltage,energy_forward/energy_reverse, every sub-meter, and the solar meter's own energy. - Solo grid (no production meter),
SelfConsumptionWriteris inert andHistoryWriterwrites the raw accumulated grid energy as usual.
Ownership follows the equipment cache, so adding or removing the production meter at runtime hands the series over without a restart.
Two consequences worth knowing:
- Every minute that saw a grid tick is written. A minute with no solar tick is not a hole: it is a pure grid import (
autoconso = 0). Only a solar-only minute is dropped -- injection is undefined without the grid side. - Ticks carrying an explicit
sourceTimestamp(plugins that post aligned historical windows, e.g. 30-min Netatmo/Legrand windows) are already aggregated, soHistoryWriterwrites them through unchanged and the HP/HC split is classified over 30 minutes rather than the 60 s of a live bucket.
Energy Capacity Arbiter (spec 140)¶
One core component (src/energy/capacity-arbiter.ts) is the single reader of
the grid meter for arbitration purposes and allocates the solar surplus
between declared flexible loads. Key invariants:
- Reservation accounting:
availableW = exportW + Σ effectiveWatts(grants)on the SIGNED meter reading — an export collapse caused by its own grants is never a deficit; an import is. Effective watts are three-tiered: fresh live draw (the load's own power binding), else a learned nominal (trimmed median of past runs, sub-threshold samples excluded), else the declared profile. - User-owned priority: one ordered list (settings) read top-down to grant,
bottom-up to revoke. Claims may self-demote (
slack) but can never step up. - The arbiter issues no orders in phase 1: recipes act through
ctx.helpers.energy.claimCapacity()callbacks; grants are runtime-only and rebuilt after a restart. Manual orders and wall-switch state divergences suspend arbitration per equipment (TTL, "resume control now" in the UI). A suspension answers an event, not a standing condition (#958): a load left running outside arbitration earns one, and the same unchanged state never arms another — otherwise the TTL expired into an identical suspension a minute later, for ever, and the load could never be adopted back. A pending claim does not exempt a load: the protection is deferred by one TTL, never removed, or a recipe claiming all day would leave a load somebody switched on open to being revoked out from under them. Past its one suspension the load is journaledunclaimed-run, which is what keeps the timeline and the daily metrics from reading it as idle. - Everything is journaled (bounded ring,
GET /api/v1/energy/arbiter) and surfaced on Energy → Live (allocation bar, day timeline, decision journal). Default off:energy.arbiter.enabled = falsemeans zero behavior change.
Daily metrics (spec 158)¶
The decision journal and the surplus series are purged after 7 days, which made
any retrospective study of the arbiter impossible past a week. An hour-aligned
rollup (src/energy/arbiter-metrics-rollup.ts) recomputes today and
yesterday on every tick into arbiter_daily_load_metrics and
arbiter_daily_home_metrics, kept 400 days. Today's row is partial by
construction (the window is clamped to now) and the first tick after midnight
completes it; recomputing yesterday every tick is what makes a restart across a
day boundary a non-event.
- It never touches the arbiter. Everything it needs is elsewhere: the two
persisted stores, the equipments' energy profiles, and two settings values.
capacity-arbiter.tsis not modified, so arbitration cannot be destabilised by a change to the metrics. - One definition of state: spans are derived with
sustainedAfter(), the same function the timeline paints with. A metric that disagreed with the ribbon on screen would be worse than no metric. - The headline figure is the short cycle: a grant revoked inside
minOnS + releaseHoldS, i.e. a load that started on a surplus that did not hold. That is the number every later tuning change is judged against. - Two export figures, kept apart because they answer different questions:
waitingExportWh(a load was claiming the surplus and did not get it — the arbiter's own miss, 3 % on the reference installation) andidleClaimableExportWh(a deferrable load was not running while the surplus covered its need — nobody asked, so it is the scheduling opportunity a planner would harvest, 46 %). Comfort loads are excluded from the idle figure: an idle heat pump means the house is comfortable. Merged into one number the figure read 75 % "missed" and made a healthy arbiter look broken. Both are estimates (5-minute sampling, profiles read at rollup time) and the API flags them inestimates. - Reads are capped at 20 000 decision rows per day and a truncation is logged, never silent. A whole tick is written in one transaction: one commit and one fsync per hour rather than fourteen, which is what keeps the write wear negligible on a flash-booted box.
Read through GET /api/v1/energy/arbiter/metrics or
scripts/energy/arbiter-metrics.ts, which opens SQLite directly and therefore
works against a restored backup with no running instance.
Full design, review log and rationale: specs/140-energy-capacity-arbiter/.
Authentication & Authorization¶
- Passwords: bcrypt (cost 12).
- JWT: HS256 via
jsonwebtoken. Access token TTL: 15 min. Refresh token TTL: 30 days. - API tokens:
swl_prefix, SHA-256 hash stored, generated viacrypto.randomBytes(32). Legacy prefixeswch_andcbl_also accepted. - Auth-by-default (spec 105): a global Fastify
onRequesthook enforces authentication on every/api/v1/*route. The list of public routes is thePUBLIC_ROUTESconstant insrc/auth/auth-middleware.ts(/health,/auth/status,/auth/setup,/auth/login,/auth/refresh,/auth/mfa/verify) plus OAuth callback paths. Any new route is protected unless explicitly added to that whitelist. - Roles:
admin>standard>viewer(hierarchical permissions). - First-run setup:
POST /api/v1/auth/setupcreates the first admin user.
Two-factor authentication (spec 151)¶
Optional per-user TOTP (RFC 6238, otplib) second factor, opt-in from Settings → Account. MfaService (src/auth/mfa-service.ts) owns enrollment, verification, single-use backup codes (10, SHA-256 hashed, regenerable), and trusted devices.
- Login flow:
AuthService.login()returns anMfaChallenge({ mfaRequired: true, mfaToken }) instead of full tokens when the account has confirmed MFA and no valid trusted-device token was presented.POST /auth/mfa/verify(public) exchanges a TOTP/backup code for full tokens. - Token purpose isolation:
JwtPayloadcarriespurpose: "access" | "mfa_pending".AuthService.verifyAccessToken()— used by the global auth hook on every protected route — rejects anymfa_pendingtoken outright, so a replayedmfaTokencan never grant partial API access before the second factor is checked. - Trusted devices: an opaque token (SHA-256 hashed server-side) lets a later login skip the MFA step. Duration is a per-user preference,
UserPreferences.mfaTrustedDeviceDays(1-90, default 30), clamped inPUT /me/preferences. Changing the account password revokes all trusted devices for that account. - Recovery: no email/SMS fallback. An admin can force-disable another user's MFA (
DELETE /users/:id/mfa); a self-locked-out admin uses the break-glass CLI,scripts/auth/reset-mfa.mjs <username>(viadocker exec). - Data model:
user_mfa_totp,user_mfa_backup_codes,mfa_trusted_devicestables (migration022_mfa_totp.sql) — no columns added tousers.
See specs/151-mfa-totp/ for the full design.
WebSocket authentication (spec 105)¶
The /ws endpoint requires authentication. Browser clients pass the token via the Sec-WebSocket-Protocol: bearer.<token> subprotocol (the WebSocket API does not allow custom headers). Non-browser clients (scripts, integrations) may use Authorization: Bearer <token> instead. Anonymous connections are refused with close code 4001. Connections with an Origin header not in the CORS whitelist are refused with 4003.
Security headers (spec 105)¶
The Fastify server registers @fastify/helmet with a Content-Security-Policy that allows only same-origin scripts, inline styles (for Tailwind), and WebSocket connections. X-Frame-Options: DENY, Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, and conditional HSTS (only when the request arrived over HTTPS) are emitted.
CORS defaults¶
CORS_ORIGINS defaults to http://localhost:3000,http://localhost:5173. Setting it to * is permitted but emits a startup warning, doubled when API_HOST is not loopback.
Frontend Architecture¶
State Management¶
- Zustand stores per domain: devices, equipments, zones, modes, recipes, etc.
- Stores are updated in real-time by WebSocket events.
- WebSocket auto-reconnects with state recovery (incremental or full).
Styling¶
- Tailwind CSS utility classes only -- no custom CSS files.
- Mobile-first responsive design (breakpoints: 640px, 1024px).
- Dark mode via Tailwind
classstrategy -- essential for nighttime dashboard use.
Internationalization¶
- English and French supported.
- Locale files:
ui/src/i18n/locales/en.json,ui/src/i18n/locales/fr.json. - Recipe translations travel with the recipe class (see
i18nfield), not in platform locale files.
Design System¶
| Property | Value |
|---|---|
| Body font | Inter |
| Monospace font | JetBrains Mono (values, logs) |
| Primary color | #1A4F6E (ocean blue), hover #144159, light #EEF5F8 |
| Accent color | #F2C035 (amber), hover #D4A41C |
| Spacing base | 4px |
| Border radius | 6px / 8px / 12px (--radius-sm/md/lg) |
| Body font size | 14px (dense dashboard) |
| Data values | 28px (readable at a glance) |
| Icons | Lucide React, stroke 1.5px |
Backup & Restore¶
Backups capture the full system state as a single ZIP archive and restore it atomically.
Service¶
BackupManager in src/backup/backup-manager.ts is the central service. It is called by:
- HTTP routes
GET/POST /api/v1/backup(manual export/import) - UpdateManager (automatic pre-update backup — see self-update section)
- Local backup routes
GET /api/v1/backup/local,POST /api/v1/backup/restore-local
Archive format¶
A backup ZIP contains:
| Entry | Content |
|---|---|
sowel-backup.json |
SQLite export as JSON, structured per table (version 2 format) |
influx-raw.lp |
Raw InfluxDB data as line protocol (last 7 days) |
influx-hourly.lp |
Downsampled hourly data (last 90 days) |
influx-daily.lp |
Downsampled daily data (last 5 years) |
influx-energy-hourly.lp |
Energy hourly sums (last 2 years) |
influx-energy-daily.lp |
Energy daily sums (last 10 years) |
data/* |
All non-DB files from data/ (token secrets, etc.), dynamically scanned against the restore whitelist, excluding .db, .pid, .log files and the .instance-id / .shadow-target local markers |
The SQLite JSON export covers a curated list of tables (BACKUP_TABLES constant in backup-manager.ts) in dependency order (parents first for restore).
The .instance-id marker is excluded on purpose, in both directions: it describes the deployment currently running, and an archive that carried it would hand a restoring instance the identity of the machine the data came from, disarming the #401 restored-data guardrail. See the "Restoring a backup from another deployment" section of deployment.md.
On restore, data/ entries face an extension whitelist (spec 089 C2): anything that is not a known data or image extension is refused, so an archive cannot smuggle a script or a native module into the data directory. A leading-dot filename counts as its own extension, which is how .jwt-secret and .influx-token are named in that whitelist and how any other dotfile is refused. A name with no extension at all is refused too (issue #829).
The export scan applies the same whitelist, so an archive can never contain an entry its own restore would drop. That property is what the spec 089 risk note rests on, and without it a file could be archived and then silently vanish on restore. Anything left out is named in the export log, and a restore reports filesSkipped alongside filesRestored so a partial restore is visible rather than quiet.
Local backups (data/backups/)¶
Separate from manual export, BackupManager.exportToFile() writes backups to data/backups/sowel-backup-<name>.zip on the persistent volume. Used by:
- UpdateManager before any self-update:
data/backups/sowel-backup-pre-v<version>-<timestamp>.zip - Rotation via
rotateLocalBackups(keep)— keeps only the N most recent files
The UI (Admin → Backup) lists local backups and offers one-click restore via POST /api/v1/backup/restore-local { filename }.
Restore flow¶
- Validate ZIP structure and JSON schema
- Disable FK constraints (outside transaction — SQLite limitation)
- Delete all rows in reverse dependency order (children first)
- Insert new rows in parent-first order
- Run
PRAGMA foreign_key_check— abort transaction if violations - Ensure InfluxDB buckets exist (
influxClient.ensureBuckets()andensureEnergyBuckets()) - POST each
.lpfile to InfluxDB/api/v2/writein batches of 5000 lines - Restore dynamic data files
- Respond with
restartRequired: true— user must restart sowel to reload state
See spec 060 for the latest backup design and src/backup/backup-manager.ts for the implementation.
Self-Update (spec 060)¶
Sowel can update itself from the UI when running under docker compose. The design survives the "process kills itself" paradox via a helper container pattern (similar to Watchtower).
Detection¶
VersionChecker in src/core/version-checker.ts polls https://api.github.com/repos/mchacher/sowel/releases/latest every 1 hour (also at T+10s after boot). When a newer semver is found, it emits system.update.available on the EventBus, which is broadcast to UI clients via WebSocket. The UI displays a badge in real time. A manual "Check now" button hits POST /api/v1/system/version/check which forces an immediate poll.
GET /api/v1/system/version returns { current, latest, updateAvailable, releaseUrl, dockerAvailable, composeManaged }. composeManaged is derived from the running container's labels (com.docker.compose.*); if absent, self-update is disabled with a tooltip.
Upgrade flow¶
UpdateManager in src/core/update-manager.ts orchestrates the upgrade:
- Pre-update backup via
backupManager.exportToFile()→data/backups/sowel-backup-pre-v<X>-<ts>.zip - Rotate backups (keep 3 most recent)
- Detect compose context from current container labels:
com.docker.compose.project.working_dir,com.docker.compose.project,com.docker.compose.service - Spawn helper container via dockerode:
- Image:
docker:25-cli(hasdocker composebuilt-in) - Mounts:
/var/run/docker.sock+ the compose working dir as/workdir - Cmd:
sh -c "sleep 5 && docker compose pull <service> && docker compose up -d <service>" AutoRemove: false, deliberately, sodocker logs sowel-updatersurvives the run- Return from API immediately — the helper survives sowel's death
- UI shows overlay ("Updating...") during the swap, polls
/system/versionevery 3s - On version change →
window.location.reload()
Why a helper? Calling dockerode.stop() on the current container from within the current process kills the Node runtime via SIGTERM before the remove/create/start sequence can run. The helper is a separate process in a separate container that survives the swap.
Requirements on the host:
/var/run/docker.sockmounted into the sowel container- The compose working dir must be accessible from the host filesystem (any bind mount path works — Sowel reads it from container labels)
docker compose upmust use a standarddocker-compose.yml/compose.ymlfilename (non-standard file names need-f, not currently handled)
CI/CD & Releases (spec 055)¶
GitHub Actions workflow¶
.github/workflows/release.yml triggers on pushed tags matching v*. It runs:
- verify-release-notes job: fails the workflow before any layer is built if the tagged commit has no release-notes anchor (spec 108)
- ci job: typecheck, lint, tests (backend + UI)
- docker jobs: build
linux/amd64andlinux/arm64with Buildx in parallel, each pushing its own tag (:<version>, and:<version>-arm64plus:latest-arm64for the arm64 job) - promote-manifest job: merges the two into one multi-architecture
:<version>, then points:latestat it - release job: creates the GitHub Release with auto-generated notes
- arm64-required job: fails the run when the release was withheld, so an arm64 failure is not merely a grey job on a green run
- prune-ghcr job: keeps the 30 newest GHCR versions after a successful release, never touching the floating
:latesttags
The Docker build is multi-architecture: amd64 and arm64 are built in parallel jobs and merged into a single manifest, so docker pull resolves the right one.
The ordering of the last three steps is a correctness requirement, not a convenience. UpdateChecker polls the GitHub Release rather than the GHCR manifest, so a Release published while :<version> is still amd64-only would announce an update to every Raspberry Pi and then fail their docker pull. Both promote-manifest and release are therefore gated on the arm64 build succeeding, and :latest is published only by promote-manifest, never by the amd64 job. An arm64 failure leaves :<version> pushed as amd64-only and withholds both :latest and the Release, so nothing announces a version half the fleet cannot pull. Recover with Re-run all jobs on that run, never by dispatching the workflow on the tag: the dispatch path resolves the version from test_tag, so it builds test images and publishes nothing under the tag's own version.
Release script¶
scripts/release.sh <version>:
- Validates semver format and clean working tree
- Asserts
package.jsonandui/package.jsonare already at that version, and exits otherwise. It does not bump them: a normal PR does, and merges before the tag - Tags
vX.Y.Zand pushes the tag to origin - GitHub Actions takes over from there
It runs no validation of its own, and it does not commit. Branch protection means the version bump and release notes land through a PR first.
A Claude Code skill wraps this at .claude/skills/sowel-release/SKILL.md.
Docker image (Dockerfile)¶
Multi-stage build:
- backend-build — Node 24,
tscbackend - ui-build — Node 24, Vite UI build
- runtime — Debian Trixie (for Python 3.13), Node 24 installed via NodeSource, Python 3.13 + venv for plugins that need it (e.g. Panasonic CC),
better-sqlite3rebuilt for the platform
Runtime image is ~950 MB uncompressed (~210 MB content). The Python 3.13 requirement dates from the Panasonic CC plugin needing f-string syntax unavailable in Python 3.11.
Activity Buffer (spec 101)¶
src/activity/activity-buffer.ts keeps the last 7 days of zone-scoped engine events (capped at 2000 items in memory), persisted to the activity_log table and reloaded on boot. It powers the Activity panel in the zone view (user guide).
Event flow¶
- The buffer subscribes to a curated set of
EngineEvents:equipment.order.executed,equipment.data.changed(filtered by binding category tomotion,water_leak,smoke),recipe.instance.started/stopped/error,mode.activated/deactivated,sunlight.changed,system.alarm.raised,system.alarm.resolved. A resolution is filed in the zone its event carries, falling back to the zone of the raise the buffer saw (global when it has neither, e.g. an alarm raised before a restart). - For each event it resolves equipment / recipe names and the relevant
zoneIdvia the equipment, recipe, zone and sunlight managers, then builds anActivityItem. - It pushes the item to the ring buffer (capping by count, purging stale entries past the TTL) and emits an
activity.addedevent on the bus. - The WebSocket layer broadcasts that event to clients subscribed to the
activitytopic. Clients also bootstrap fromGET /api/v1/activityon mount.
Source attribution¶
executeOrder() accepts an optional 4th source argument of type OrderSource. The recipe SDK exposes a per-instance ctx.dispatchOrder() closure that pre-binds the recipe's source, so internal helpers never have to thread source themselves. Modes, button bindings and API routes pass source inline. External recipe plugins keep working without attribution (graceful degradation).
Memory footprint¶
At ~400 bytes per item, 2000 items = ~800 KB — under 0.3 % of a typical Sowel container's RSS. Unlike the logs ring buffer, the feed survives a restart: it is backed by SQLite (spec 147).
Logging¶
Strategy¶
Pino structured JSON logging with multistream output (see src/core/logger.ts):
- Ring buffer — in-memory circular buffer for UI log viewer (always captures debug level)
- stdout — raw JSON in production (captured by Docker logs), pino-pretty in development
- File transport — in production only, via
pino-rolltodata/logs/sowel.<yyyy-MM-dd>.N.log, daily rotation, keep 14 files (retention also applies to files left by previous containers)
Log file location¶
/app/data/logs/sowel.<yyyy-MM-dd>.N.log inside the container (on the sowel-data volume). One calendar day maps to one predictable file, even across container restarts and self-updates. Survives container recreation — essential for post-incident investigation after a self-update.
Example retrieval:
docker exec sowel sh -c 'cat /app/data/logs/sowel.2026-04-11.1.log | grep -E "2026-04-11T07:" | grep error'
Before v1.39, files were named sowel.N.log with a rotation number picked per process: several restarts a day interleaved time ranges across files, and one file could span months. When investigating an incident older than the format switch, grep ALL sowel.*.log files rather than trusting one. Legacy numbered files are invisible to the new retention, so the engine purges them automatically at boot once they are older than 14 days; no manual action is needed.
Log level guidance¶
| Level | Purpose |
|---|---|
fatal |
Process crash imminent |
error |
Operation failed, engine continues (always with { err }) |
warn |
Self-recovering degradation (reconnect, stale data) |
info |
Significant business events, one per operation |
debug |
Developer troubleshooting detail |
trace |
High-volume hot path (every event, every MQTT message) |
Conventions:
- Every module creates a child logger with
{ module: "module-name" } - Structured context as first argument object:
logger.info({ deviceId, status }, "Device status changed") - Passwords/tokens/secrets are auto-redacted by pino config
- Never use
console.*— bypasses ring buffer, file rotation, and redaction
Retrieval helpers¶
- From UI — Admin → Logs page (reads the ring buffer)
- Via API —
GET /api/v1/logs?module=X&level=Y&limit=N(ring buffer only, lost on restart) - From file —
docker execinto/app/data/logs/sowel.*.log(persistent) - Helper script —
scripts/logs/fetch-logs.py <module> <level> <limit>withSOWEL_URL+SOWEL_PASSWORDenv vars
Timezone handling (spec 061)¶
Sowel backend logic depends heavily on local time: calendar cron slots (croner), energy HP/HC tariff classification, energy day boundaries, sunrise/sunset display, notifications. All use native Date methods that depend on process.env.TZ.
Detection strategy¶
At startup, src/core/timezone.ts determines the timezone with this priority:
TZenv var — if set indocker-compose.ymlor the host env, Sowel respects it (explicit override wins)home.latitude/home.longitude— if configured in Settings, Sowel passes them totz-lookupto derive the IANA timezone name (e.g.Europe/Paris)- Fallback to UTC — with a loud WARN log inviting the user to configure a home location
process.env.TZ is set before createLogger() in src/index.ts. This is critical — pino's first new Date() call caches the TZ in V8, and process.env.TZ changes after that have no effect on already-loaded Date.prototype methods. See the boot sequence in src/index.ts.
Restart required after location change¶
Node caches the TZ on first use. If the user changes home.latitude / home.longitude in Settings at runtime:
- The settings route logs a warn and emits
system.restart_requiredon the EventBus - The UI receives the event via WebSocket and displays
RestartToastwith a "Restart now" button - Clicking the button calls
POST /api/v1/system/restartwhich spawns adocker:25-clihelper container (same pattern as spec 060 self-update) that runsdocker compose up -d --force-recreate sowel(without--force-recreatecompose sees no diff and silently does nothing) - The helper survives Sowel's death and recreates the container, which picks up the new env and re-runs
detectTimezone()with the new coordinates - The existing
UpdateOverlayreloads the UI on WS reconnect
Exposing the TZ in the UI¶
GET /api/v1/system/timezonereturns{ tz, source, offsetHours }to any authenticated userui/src/store/useTimezone.tscaches the result in a Zustand store, fetched once at app mount fromAppLayout.tsx- The Settings → Home section displays the TZ read-only with its source label (auto / env / fallback)
- The
CurrentTimePillin the header banner displays the home time (not the browser local time), computed viaIntl.DateTimeFormat(undefined, { timeZone: tz, ... })— useful when accessing Sowel from a device in a different timezone
See spec 061 at github.com/mchacher/sowel/tree/main/specs/061-timezone-from-home-location.
Environment Variables¶
All settings are optional with sensible defaults -- Sowel runs zero-config out of the box. Override via .env if needed:
| Variable | Default | Notes |
|---|---|---|
SQLITE_PATH |
./data/sowel.db |
SQLite database path |
API_PORT |
3000 |
HTTP server port |
API_HOST |
0.0.0.0 |
Bind address |
JWT_SECRET |
auto-generated | Persisted in data/.jwt-secret on first launch |
JWT_ACCESS_TTL |
900 |
Access token TTL in seconds (15 min) |
JWT_REFRESH_TTL |
2592000 |
Refresh token TTL in seconds (30 days) |
LOG_LEVEL |
info |
Pino log level |
CORS_ORIGINS |
localhost:3000,localhost:5173 |
Comma-separated allowed origins. * is permitted but warns at startup |
INFLUX_URL |
http://localhost:8086 |
InfluxDB 2.x URL |
INFLUX_TOKEN |
shared default constant | Matches docker-compose.yml. data/.influx-token is read if present, never written |
INFLUX_ORG |
sowel |
InfluxDB organization |
INFLUX_BUCKET |
sowel |
InfluxDB primary bucket |
TZ |
system default (UTC in Docker) | IANA timezone. Set explicitly in docker-compose to fix time-based logic. |
Integration settings (MQTT, cloud credentials, polling intervals) are configured from the UI, not from .env.