Sowel Plugin Development Guide¶
This guide explains how to create a third-party plugin for Sowel. A plugin is a self-contained integration that can be installed, enabled, disabled, and removed at runtime without restarting the Sowel engine.
The sowel-plugin-weather-forecast plugin is used as the reference example throughout this document.
Table of Contents¶
- Overview
- Plugin Structure
- Manifest Schema
- PluginDeps API Reference
- IntegrationPlugin Interface
- Creating a Plugin Step by Step
- Device Discovery
- Device Data Updates
- Order Execution
- Settings
- Publishing and Versioning
- Troubleshooting
Overview¶
A Sowel plugin is an ESM (ECMAScript Module) Node.js package that exports a createPlugin factory function. When loaded, Sowel injects a PluginDeps object providing access to core services (logging, event bus, device management, settings). The plugin uses these dependencies to discover devices, push data updates, and handle orders -- exactly like built-in integrations.
Plugins live in the plugins/ directory at the Sowel root. Each plugin has its own subdirectory containing a manifest.json and compiled JavaScript in dist/.
Lifecycle:
- Sowel reads the
pluginsdatabase table on startup - For each enabled plugin, Sowel dynamically imports
plugins/<id>/dist/index.js(ESM) - The exported
createPluginfactory receivesPluginDepsand returns anIntegrationPlugininstance - Sowel registers the plugin with the
IntegrationRegistry - If the plugin is configured (
isConfigured()returns true), Sowel callsplugin.start() - On disable/uninstall, Sowel calls
plugin.stop()and unregisters from the registry
Plugin Structure¶
sowel-plugin-my-device/
manifest.json # Plugin metadata (required)
package.json # Node.js package descriptor ("type": "module")
tsconfig.json # TypeScript config (module: "NodeNext")
dist/
index.js # Compiled entry point (ESM)
index.js.map # Source map (optional)
src/
index.ts # TypeScript source (not loaded by Sowel)
Key rules:
- The entry point is always
dist/index.js-- this is hardcoded in the plugin loader - Use ESM format (
export { createPlugin }) -- Sowel uses dynamicimport()to load plugins - Set
"type": "module"inpackage.json - Set
"module": "NodeNext"and"moduleResolution": "NodeNext"intsconfig.json - The
src/directory is for development only; Sowel never reads it - Plugin-specific
node_modules/are isolated from Sowel's dependencies
Manifest Schema¶
The manifest.json file describes the plugin to Sowel. It lives at the root of the plugin directory.
Example (from sowel-plugin-weather-forecast):
{
"id": "weather-forecast",
"name": "Weather Forecast",
"version": "0.2.0",
"description": "Weather forecast via Open-Meteo API (free, no API key)",
"icon": "CloudSun",
"author": "mchacher",
"sowelVersion": ">=0.10.0",
"settings": [
{
"key": "polling_interval",
"label": "Polling interval (minutes)",
"type": "number",
"required": false,
"defaultValue": "30",
"placeholder": "Min 15, default 30"
}
]
}
Field Reference¶
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique plugin identifier. Lowercase with hyphens (e.g. weather-forecast). Must match the directory name under plugins/. |
name |
string | Yes | Human-readable display name shown in the UI. |
version |
string | Yes | SemVer version (e.g. 0.2.0). Must be updated with each release. See Versioning. |
description |
string | Yes | Short description (one sentence) shown in the plugin store and integrations page. |
icon |
string | Yes | Lucide icon name (e.g. CloudSun, Camera). Used in the UI for the integration card. |
author |
string | No | Author name or organization. |
sowelVersion |
string | No | SemVer range of compatible Sowel versions (e.g. >=0.10.0). |
settings |
IntegrationSettingDef[] | No | Array of setting definitions for the UI configuration form. See Settings. |
Fields that do NOT exist in the manifest: entry, integrationId, license, repository. Do not include these.
PluginDeps API Reference¶
When Sowel loads a plugin, it passes a PluginDeps object to the createPlugin factory function. This is the plugin's gateway to all Sowel core services.
interface PluginDeps {
logger: Logger;
eventBus: EventBus;
settingsManager: SettingsManager;
deviceManager: DeviceManager;
pluginDir: string;
}
logger¶
A pino child logger pre-configured with { module: "plugin:<pluginId>" }. Use this for all logging -- never use console.log.
deps.logger.info({ deviceCount: 5 }, "Devices discovered");
deps.logger.debug({ response }, "API response received");
deps.logger.error({ err }, "Poll failed");
Level guidelines:
| Level | Usage |
|---|---|
info |
Significant events: connected, devices discovered, poll completed |
debug |
Operational details: API responses, intermediate steps |
trace |
High-volume data: every message, every data point |
error |
Operation failed -- always include { err } with the Error object |
warn |
Unexpected but handled: retry, fallback, recoverable |
eventBus¶
The typed event emitter. Plugins typically emit integration lifecycle events:
deps.eventBus.emit({ type: "system.integration.connected", integrationId: "my-plugin" });
deps.eventBus.emit({ type: "system.integration.disconnected", integrationId: "my-plugin" });
settingsManager¶
Read and write settings stored in the SQLite settings table. Settings use a full key with the integration.<pluginId>. prefix.
// Read a setting -- returns string | undefined
const interval = deps.settingsManager.get("integration.weather-forecast.polling_interval");
// Read a global Sowel setting (no prefix)
const lat = deps.settingsManager.get("home.latitude");
// Write a setting
deps.settingsManager.set("integration.weather-forecast.last_poll", Date.now().toString());
Important: get() takes the full key (e.g. integration.weather-forecast.polling_interval) and returns string | undefined (not null). Settings declared in your manifest's settings array are automatically namespaced by Sowel under integration.<pluginId>.<key>.
Methods:
| Method | Signature | Description |
|---|---|---|
get |
(key: string) => string \| undefined |
Get a single setting by its full key |
set |
(key: string, value: string) => void |
Set a single setting |
getByPrefix |
(prefix: string) => Record<string, string> |
Get all settings starting with a prefix |
setMany |
(entries: Record<string, string>) => void |
Set multiple settings at once |
deviceManager¶
Manage devices discovered by your plugin. Two main methods are used:
| Method | Signature | Description |
|---|---|---|
upsertFromDiscovery |
(integrationId: string, source: string, discovered: DiscoveredDevice) => void |
Create or update a device from discovery data |
updateDeviceData |
(integrationId: string, sourceDeviceId: string, payload: Record<string, unknown>) => void |
Push new data values for an existing device |
See Device Discovery and Device Data Updates for detailed usage.
pluginDir¶
Absolute path to the plugin's installed directory (e.g. /app/plugins/weather-forecast). Use this for reading local files or storing plugin-specific data.
Note: There is no mqttConnector in PluginDeps. If your plugin needs MQTT, use the mqtt npm package directly as a plugin dependency.
IntegrationPlugin Interface¶
The createPlugin factory must return an object implementing the IntegrationPlugin interface:
interface IntegrationPlugin {
readonly id: string; // Unique integration ID (must match manifest.id)
readonly name: string; // Human-readable name
readonly description: string; // Short description for the UI
readonly icon: string; // Lucide icon name
getStatus(): IntegrationStatus;
isConfigured(): boolean;
getSettingsSchema(): IntegrationSettingDef[];
start(options?: { pollOffset?: number }): Promise<void>;
stop(): Promise<void>;
executeOrder(
device: Device,
dispatchConfig: Record<string, unknown>,
value: unknown,
): Promise<void>;
refresh?(): Promise<void>;
getPollingInfo?(): { lastPollAt: string; intervalMs: number } | null;
}
type IntegrationStatus = "connected" | "disconnected" | "not_configured" | "error";
Method Reference¶
| Method | Required | Description |
|---|---|---|
getStatus() |
Yes | Return the current connection status. Return "not_configured" if isConfigured() is false. |
isConfigured() |
Yes | Return true if all required settings are present. Sowel only calls start() when this is true. |
getSettingsSchema() |
Yes | Return the settings form definition (same as manifest settings). |
start(options?) |
Yes | Start the integration. pollOffset is provided by Sowel to stagger multiple polling plugins. |
stop() |
Yes | Stop gracefully: cancel timers, close connections. |
executeOrder() |
Yes | Execute a command on a device. Throw an error if the plugin does not support orders. |
refresh() |
No | Force an immediate data refresh (e.g. re-poll the cloud API). Called from the UI refresh button. |
getPollingInfo() |
No | Return last poll timestamp and interval. Shown in the integrations UI for polling-based plugins. |
Creating a Plugin Step by Step¶
1. Initialize the project¶
Edit package.json -- set "type": "module":
{
"name": "sowel-plugin-my-device",
"version": "0.1.0",
"description": "Sowel plugin: My Device integration",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
2. Configure TypeScript¶
Create tsconfig.json -- use NodeNext module format:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src"]
}
3. Define local type interfaces¶
Plugins do not import from Sowel source code. Instead, define local interfaces matching the PluginDeps shape. This keeps the plugin fully decoupled.
// src/index.ts -- local type definitions
interface Logger {
child(bindings: Record<string, unknown>): Logger;
info(obj: Record<string, unknown>, msg: string): void;
info(msg: string): void;
warn(obj: Record<string, unknown>, msg: string): void;
warn(msg: string): void;
error(obj: Record<string, unknown>, msg: string): void;
error(msg: string): void;
debug(obj: Record<string, unknown>, msg: string): void;
debug(msg: string): void;
}
interface EventBus {
emit(event: unknown): void;
}
interface SettingsManager {
get(key: string): string | undefined;
set(key: string, value: string): void;
}
interface DiscoveredDevice {
ieeeAddress?: string;
friendlyName: string;
manufacturer?: string;
model?: string;
data: {
key: string;
type: string;
category: string;
unit?: string;
}[];
orders: {
key: string;
type: string;
dispatchConfig: Record<string, unknown>;
min?: number;
max?: number;
enumValues?: string[];
unit?: string;
}[];
}
interface DeviceManager {
upsertFromDiscovery(integrationId: string, source: string, discovered: DiscoveredDevice): void;
updateDeviceData(
integrationId: string,
sourceDeviceId: string,
payload: Record<string, unknown>,
): void;
}
interface Device {
id: string;
integrationId: string;
sourceDeviceId: string;
name: string;
manufacturer?: string;
model?: string;
}
interface PluginDeps {
logger: Logger;
eventBus: EventBus;
settingsManager: SettingsManager;
deviceManager: DeviceManager;
pluginDir: string;
}
type IntegrationStatus = "connected" | "disconnected" | "not_configured" | "error";
interface IntegrationSettingDef {
key: string;
label: string;
type: "text" | "password" | "number" | "boolean";
required: boolean;
placeholder?: string;
defaultValue?: string;
}
interface IntegrationPlugin {
readonly id: string;
readonly name: string;
readonly description: string;
readonly icon: string;
getStatus(): IntegrationStatus;
isConfigured(): boolean;
getSettingsSchema(): IntegrationSettingDef[];
start(options?: { pollOffset?: number }): Promise<void>;
stop(): Promise<void>;
executeOrder(
device: Device,
dispatchConfig: Record<string, unknown>,
value: unknown,
): Promise<void>;
refresh?(): Promise<void>;
getPollingInfo?(): { lastPollAt: string; intervalMs: number } | null;
}
4. Implement the plugin¶
Below the type definitions, implement your plugin class and export the factory:
const PLUGIN_ID = "my-device";
const SETTINGS_PREFIX = `integration.${PLUGIN_ID}.`;
const SOURCE_DEVICE_ID = "My Device"; // Must match friendlyName in DiscoveredDevice
class MyDevicePlugin implements IntegrationPlugin {
readonly id = PLUGIN_ID;
readonly name = "My Device";
readonly description = "Integration with My Device API";
readonly icon = "Cpu";
private logger: Logger;
private settingsManager: SettingsManager;
private deviceManager: DeviceManager;
private eventBus: EventBus;
private pollTimer: ReturnType<typeof setTimeout> | null = null;
private lastPollAt: string | null = null;
private pollIntervalMs = 300_000;
private status: IntegrationStatus = "disconnected";
constructor(deps: PluginDeps) {
this.logger = deps.logger.child({ module: PLUGIN_ID });
this.settingsManager = deps.settingsManager;
this.deviceManager = deps.deviceManager;
this.eventBus = deps.eventBus;
}
getStatus(): IntegrationStatus {
if (!this.isConfigured()) return "not_configured";
return this.status;
}
isConfigured(): boolean {
return !!this.settingsManager.get(`${SETTINGS_PREFIX}api_url`);
}
getSettingsSchema(): IntegrationSettingDef[] {
return [
{
key: "api_url",
label: "API URL",
type: "text",
required: true,
placeholder: "http://192.168.1.50/api",
},
{
key: "polling_interval",
label: "Polling interval (seconds)",
type: "number",
required: false,
defaultValue: "300",
},
];
}
getPollingInfo(): { lastPollAt: string; intervalMs: number } | null {
return { lastPollAt: this.lastPollAt ?? "", intervalMs: this.pollIntervalMs };
}
async start(options?: { pollOffset?: number }): Promise<void> {
if (!this.isConfigured()) {
this.status = "not_configured";
return;
}
// Read polling interval from settings
const rawInterval = parseInt(
this.settingsManager.get(`${SETTINGS_PREFIX}polling_interval`) ?? "300",
10,
);
this.pollIntervalMs = Math.max(60_000, (isNaN(rawInterval) ? 300 : rawInterval) * 1000);
await this.poll();
this.schedulePoll(options?.pollOffset ?? 0);
this.status = "connected";
this.eventBus.emit({ type: "system.integration.connected", integrationId: this.id });
this.logger.info({ pollIntervalMs: this.pollIntervalMs }, "Plugin started");
}
async stop(): Promise<void> {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
this.status = "disconnected";
this.eventBus.emit({ type: "system.integration.disconnected", integrationId: this.id });
this.logger.info("Plugin stopped");
}
async executeOrder(
_device: Device,
_dispatchConfig: Record<string, unknown>,
_value: unknown,
): Promise<void> {
throw new Error("My Device plugin does not support orders");
}
async refresh(): Promise<void> {
await this.poll();
}
// --- Polling ---
private async poll(): Promise<void> {
try {
// 1. Fetch data from your API
// const data = await this.fetchData();
// 2. Upsert device definition
this.deviceManager.upsertFromDiscovery(PLUGIN_ID, PLUGIN_ID, {
friendlyName: SOURCE_DEVICE_ID,
manufacturer: "My Company",
model: "Sensor v2",
data: [
{ key: "temperature", type: "number", category: "temperature", unit: "C" },
{ key: "humidity", type: "number", category: "humidity", unit: "%" },
],
orders: [],
});
// 3. Update device data values
this.deviceManager.updateDeviceData(PLUGIN_ID, SOURCE_DEVICE_ID, {
temperature: 21.5,
humidity: 45,
});
this.lastPollAt = new Date().toISOString();
this.logger.info("Poll complete");
} catch (err) {
this.logger.error({ err }, "Poll failed");
throw err;
}
}
private schedulePoll(offsetMs: number): void {
if (this.pollTimer) clearTimeout(this.pollTimer);
const delay = offsetMs > 0 ? offsetMs : this.pollIntervalMs;
this.pollTimer = setTimeout(async () => {
try {
await this.poll();
} catch {
/* already logged */
}
this.schedulePoll(0);
}, delay);
}
}
// ============================================================
// Plugin factory -- this is the entry point Sowel calls
// ============================================================
export function createPlugin(deps: PluginDeps): IntegrationPlugin {
return new MyDevicePlugin(deps);
}
5. Build¶
This produces dist/index.js (ESM) ready for Sowel to load.
6. Create the manifest¶
Create manifest.json at the plugin root:
{
"id": "my-device",
"name": "My Device",
"version": "0.1.0",
"description": "Integration with My Device API",
"icon": "Cpu",
"author": "Your Name",
"sowelVersion": ">=0.10.0",
"settings": [
{
"key": "api_url",
"label": "API URL",
"type": "text",
"required": true,
"placeholder": "http://192.168.1.50/api"
},
{
"key": "polling_interval",
"label": "Polling interval (seconds)",
"type": "number",
"required": false,
"defaultValue": "300"
}
]
}
7. Test locally¶
Symlink the plugin directory into Sowel's plugins/ directory:
Then manually register it in the database (Sowel auto-loads from the plugins table):
# Using the Sowel API to install from a local path, or manually:
sqlite3 data/sowel.db "INSERT INTO plugins (id, version, enabled, installed_at, manifest) VALUES ('my-device', '0.1.0', 1, datetime('now'), readfile('plugins/my-device/manifest.json'));"
Start Sowel -- it will detect and load the plugin automatically. Check the logs for your plugin's output:
Device Discovery¶
When your plugin detects devices (from an API, MQTT, or local scan), register them with deviceManager.upsertFromDiscovery().
Signature¶
deviceManager.upsertFromDiscovery(
integrationId: string, // Your plugin ID (e.g. "weather-forecast")
source: string, // Device source identifier (typically your plugin ID)
discovered: DiscoveredDevice,
): void;
DiscoveredDevice format¶
interface DiscoveredDevice {
ieeeAddress?: string; // Optional hardware address (for Zigbee devices)
friendlyName: string; // Unique device name -- used as sourceDeviceId for data updates
manufacturer?: string; // Device manufacturer
model?: string; // Device model
data: {
// Data points this device exposes
key: string; // Data point key (e.g. "temperature", "j1_condition")
type: string; // "number" | "boolean" | "text" | "enum"
category: string; // "temperature" | "humidity" | "motion" | "battery" | etc.
unit?: string; // Unit of measurement (e.g. "C", "%", "km/h")
}[];
orders: {
// Commands this device accepts
key: string; // Order key (e.g. "set_monitoring")
type: string; // Value type: "boolean" | "number" | "enum" | "text"
dispatchConfig: Record<string, unknown>; // Integration-specific config for order dispatch
min?: number; // For numeric orders: minimum value
max?: number; // For numeric orders: maximum value
enumValues?: string[]; // For enum orders: allowed values
unit?: string; // Unit (e.g. "C")
}[];
}
Example (from weather-forecast)¶
const WEATHER_DISCOVERED_DEVICE: DiscoveredDevice = {
friendlyName: "Weather Forecast",
manufacturer: "Open-Meteo",
model: "Forecast API",
data: [
{ key: "j1_condition", type: "enum", category: "weather_condition" },
{ key: "j1_temp_min", type: "number", category: "temperature", unit: "C" },
{ key: "j1_temp_max", type: "number", category: "temperature", unit: "C" },
{ key: "j1_rain_prob", type: "number", category: "rain", unit: "%" },
{ key: "j1_wind_gusts", type: "number", category: "wind", unit: "km/h" },
// ... j2 through j5
],
orders: [],
};
// Call during poll
this.deviceManager.upsertFromDiscovery(PLUGIN_ID, SOURCE_DEVICE_ID, WEATHER_DISCOVERED_DEVICE);
Important:
friendlyNamebecomes thesource_device_idin the database. It must match thesourceDeviceIdargument used inupdateDeviceData().- Call
upsertFromDiscovery()on every poll cycle -- it is idempotent (creates on first call, updates metadata on subsequent calls). - Include all data points and orders in the
DiscoveredDevicedefinition. Stale data/order entries not in the current discovery are cleaned up automatically.
Device Data Updates¶
After device discovery, push data updates when new values arrive. Use deviceManager.updateDeviceData().
Signature¶
deviceManager.updateDeviceData(
integrationId: string, // Your plugin ID (e.g. "weather-forecast")
sourceDeviceId: string, // Must match friendlyName from upsertFromDiscovery
payload: Record<string, unknown>, // Flat key-value map of data points
): void;
Example (from weather-forecast)¶
const payload: Record<string, unknown> = {
j1_condition: "rainy",
j1_temp_min: 8.2,
j1_temp_max: 14.5,
j1_rain_prob: 75,
j1_wind_gusts: 42,
// ... j2 through j5
};
this.deviceManager.updateDeviceData(PLUGIN_ID, SOURCE_DEVICE_ID, payload);
Critical: the sourceDeviceId parameter must exactly match the friendlyName used in upsertFromDiscovery(). This is how Sowel looks up the device in the database. In the weather-forecast plugin, both are set to "Weather Forecast".
The payload is a flat Record<string, unknown> -- keys are data point names, values are the raw values (number, boolean, string). This is not a nested object with labels or units; those are defined once in upsertFromDiscovery().
This triggers the reactive pipeline:
- Device data is updated in SQLite
device.data.updatedevent is emitted- Equipment bindings are re-evaluated
- Zone aggregations are updated
- Scenario triggers are checked
- UI receives a WebSocket push
Order Execution¶
When a user or scenario sends a command to a device managed by your plugin, Sowel calls executeOrder() on your plugin instance.
Signature¶
executeOrder(
device: Device, // The target device object
dispatchConfig: Record<string, unknown>, // Integration-specific config from the order definition
value: unknown, // The value to set
): Promise<void>;
Example¶
async executeOrder(
device: Device,
dispatchConfig: Record<string, unknown>,
value: unknown,
): Promise<void> {
const action = dispatchConfig.action as string;
switch (action) {
case "set_monitoring":
await this.api.setMonitoring(device.sourceDeviceId, value as boolean);
break;
default:
this.logger.warn({ action }, "Unknown order action");
}
}
Order flow:
- User taps a button in the UI or a scenario action fires
- Equipment dispatches order to the bound device
- Sowel routes the order to the integration that owns the device
- Plugin's
executeOrder()is called with the full Device object, thedispatchConfigfrom the order definition, and the value - Plugin sends the command to the physical device
- On next poll (or immediate refresh), the new state is reflected
If your plugin does not support orders (e.g. a read-only weather plugin), throw an error:
async executeOrder(): Promise<void> {
throw new Error("Weather Forecast plugin does not support orders");
}
Settings¶
Plugins declare their settings in manifest.json and return the same schema from getSettingsSchema(). Sowel renders a configuration form in the UI automatically.
Setting Definition Schema¶
interface IntegrationSettingDef {
key: string; // Setting key (without prefix). Stored as "integration.<pluginId>.<key>"
label: string; // Display label in the UI
type: string; // One of: "text", "password", "number", "boolean"
required: boolean; // If true, must be filled before the plugin can start
placeholder?: string; // Placeholder text in the input field
defaultValue?: string; // Default value (always a string, even for numbers)
}
Example (from netatmo-security)¶
{
"settings": [
{
"key": "client_id",
"label": "Client ID",
"type": "text",
"required": true,
"placeholder": "From dev.netatmo.com"
},
{
"key": "client_secret",
"label": "Client Secret",
"type": "password",
"required": true
},
{
"key": "refresh_token",
"label": "Refresh Token",
"type": "password",
"required": true,
"placeholder": "With camera scopes"
},
{
"key": "polling_interval",
"label": "Polling interval (seconds)",
"type": "number",
"required": false,
"defaultValue": "300",
"placeholder": "Min 180, default 300"
}
]
}
Reading Settings at Runtime¶
Settings are stored with the full key integration.<pluginId>.<key>:
const SETTINGS_PREFIX = `integration.${PLUGIN_ID}.`;
// Read a plugin-specific setting
const interval = this.settingsManager.get(`${SETTINGS_PREFIX}polling_interval`);
// Returns "300" (string) or undefined if not set
// Read a global Sowel setting (no prefix)
const lat = this.settingsManager.get("home.latitude");
// Write a setting
this.settingsManager.set(`${SETTINGS_PREFIX}last_token_refresh`, Date.now().toString());
Important notes:
get()always returnsstring | undefined-- parse numbers withparseInt()/parseFloat()- The
defaultValuein the settings schema is for UI display only; always handle undefined in code - Use
"password"type for secrets -- the UI masks these values - There are no
"select","string", or"secret"types. The valid types are:"text","password","number","boolean"
Publishing and Versioning¶
Creating a Release Tarball¶
Plugins are installed from GitHub release tarballs. The tarball must include dist/ (compiled JS) and must exclude src/ and node_modules/.
# Build first
npm run build
# Create the release tarball
tar -czf sowel-plugin-my-device-0.1.0.tar.gz \
manifest.json \
package.json \
dist/
If your plugin has production dependencies (listed in dependencies, not devDependencies), also include package.json so that Sowel can run npm install --production after extraction. If there are no runtime dependencies, package.json is still recommended but node_modules/ should not be included.
Creating a GitHub Release¶
gh release create v0.1.0 \
sowel-plugin-my-device-0.1.0.tar.gz \
--title "v0.1.0" \
--notes "Initial release"
Installation flow: When a user clicks "Install" in the Sowel plugin store, Sowel:
- Fetches the latest release from the GitHub API
- Prefers an uploaded
.tar.gzasset (which includesdist/); falls back to the GitHub source tarball - Extracts to
plugins/<id>/ - Runs
npm install --productionifpackage.jsonexists - If
dist/is missing buttsconfig.jsonexists, attemptsnpx tscto build from source - Registers the plugin in the database and loads it
Best practice: always upload a pre-built tarball as a release asset. This avoids the need for the user's Sowel instance to have TypeScript installed.
Updating a Plugin¶
When a newer version is available in registry.json compared to the installed version, Sowel shows an update indicator in the UI (sidebar badge, header pill, and an "Update" button on the plugin card).
Update flow (triggered by clicking "Update"):
- Stops the running plugin
- Downloads the latest release from GitHub (same logic as install)
- Replaces the plugin files in
plugins/<id>/ - Runs
npm installand builds if needed - Updates the version and manifest in the database
- Restarts the plugin if it was enabled
What is preserved: all plugin settings, discovered devices, equipment bindings, and historization configuration. The update only replaces the plugin code — it does not touch the database.
What changes: the plugin files in plugins/<id>/ and the version recorded in the plugins table.
Registering in the Plugin Store¶
To make your plugin appear in the Sowel plugin store, submit a PR to the Sowel repository adding an entry to plugins/registry.json:
{
"id": "my-device",
"name": "My Device",
"description": "Integration with My Device API",
"icon": "Cpu",
"author": "Your Name",
"repo": "yourname/sowel-plugin-my-device",
"version": "0.1.0",
"tags": ["sensor", "api"]
}
Registry Entry Schema¶
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Must match the plugin's manifest.json id |
name |
string | Yes | Display name in the store |
description |
string | Yes | Short description |
icon |
string | Yes | Lucide icon name |
author |
string | Yes | Author name |
repo |
string | Yes | GitHub owner/repo path (used to fetch releases) |
version |
string | No | Latest available version (shown in the store "Available" tab) |
tags |
string[] | Yes | Searchable tags (e.g. ["camera", "security"]) |
Versioning¶
There are two places where the version matters, and they serve different purposes:
-
manifest.json(in the plugin repository) -- theversionfield here is read when the plugin is installed. It is stored in Sowel's database and displayed in the "Installed" tab of the Plugins UI. -
plugins/registry.json(in the Sowel repository) -- theversionfield here is displayed in the "Store" tab of the Plugins UI, showing users what version is available for installation.
Rules:
- Update
manifest.jsonversion with every release. This is how Sowel knows what version is installed. - Update
plugins/registry.jsonversion in the Sowel repo when you publish a new release. This is how users see that an update is available — Sowel compares the installed version (from manifest) against the registry version and shows an update badge when they differ. - Keep both versions in sync. If
manifest.jsonsays0.2.0butregistry.jsonsays0.1.0, the store will show an outdated version. - The version in the release tag (e.g.
v0.2.0) should matchmanifest.json.
Release Naming Convention¶
- Git tag:
v0.2.0(SemVer withvprefix) - Tarball asset:
sowel-plugin-<id>-<version>.tar.gz - The
versionin the tarball'smanifest.jsonmust match the release tag
Troubleshooting¶
Plugin not detected:
- Verify
plugins/<id>/manifest.jsonexists and is valid JSON - Check that the
idfield matches the directory name - Check the
pluginstable in SQLite -- the plugin must have a row withenabled = 1
Plugin fails to load:
- Ensure
dist/index.jsexists and exports acreatePluginfunction - Check Sowel logs for
plugin:<id>entries - Verify the plugin uses ESM (
export function createPluginorexport { createPlugin }) - The loader also checks
mod.default?.createPluginas a fallback
Plugin loads but does not start:
- Verify
isConfigured()returns true -- Sowel skipsstart()when it returns false - Check that all required settings are configured in the UI (Administration > Integrations)
Devices not appearing:
- Verify
integrationIdinupsertFromDiscovery()matches your plugin'sid - Check that
friendlyNameis non-empty and unique - Look for errors in the device manager logs
Data updates not working:
- Verify
sourceDeviceIdinupdateDeviceData()exactly matches thefriendlyNameused inupsertFromDiscovery() - Check that the data keys in the payload match the keys declared in the
DiscoveredDevice.dataarray
Orders not received:
- Verify your plugin implements
executeOrder()with the correct signature:(device: Device, dispatchConfig: Record<string, unknown>, value: unknown) - Check that the device's
ordersarray includes the order definition with adispatchConfig - Look for order dispatch events in the logs
Reference¶
- Sowel Architecture -- Technical architecture overview
- Weather Forecast Plugin -- Complete working example
- Netatmo Security Plugin -- Plugin with OAuth and orders