Guide de développement de plugins Sowel¶
Ce guide explique comment créer un plugin tiers pour Sowel. Un plugin est une intégration autonome qui peut être installée, activée, désactivée et supprimée au runtime sans redémarrer le moteur Sowel.
Le plugin sowel-plugin-weather-forecast est utilisé comme exemple de référence tout au long de ce document.
Sommaire¶
- Vue d'ensemble
- Structure d'un plugin
- Schéma du manifest
- Référence de l'API PluginDeps
- Interface IntegrationPlugin
- Créer un plugin pas à pas
- Découverte de devices
- Mises à jour de données device
- Exécution d'ordres
- Réglages
- Publication et versioning
- Dépannage
Vue d'ensemble¶
Un plugin Sowel est un package Node.js ESM (ECMAScript Module) qui exporte une fonction usine createPlugin. Au chargement, Sowel injecte un objet PluginDeps qui donne accès aux services centraux (logs, event bus, gestion des devices, settings). Le plugin utilise ces dépendances pour découvrir des devices, pousser des mises à jour de données, et traiter des ordres, exactement comme les intégrations historiques.
Les plugins vivent dans le répertoire plugins/ à la racine de Sowel. Chaque plugin a son propre sous-répertoire qui contient un manifest.json et le JavaScript compilé dans dist/.
Cycle de vie :
- Sowel lit la table
pluginsde la base au démarrage - Pour chaque plugin activé, Sowel importe dynamiquement
plugins/<id>/dist/index.js(ESM) - La fonction usine
createPluginexportée reçoitPluginDepset retourne une instance d'IntegrationPlugin - Sowel enregistre le plugin auprès de l'
IntegrationRegistry - Si le plugin est configuré (
isConfigured()renvoie true), Sowel appelleplugin.start() - À la désactivation/désinstallation, Sowel appelle
plugin.stop()et le retire du registre
Structure d'un plugin¶
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)
Règles clés :
- Le point d'entrée est toujours
dist/index.js, c'est codé en dur dans le plugin loader - Utilisez le format ESM (
export { createPlugin }), Sowel utilise unimport()dynamique pour charger les plugins - Définissez
"type": "module"danspackage.json - Définissez
"module": "NodeNext"et"moduleResolution": "NodeNext"danstsconfig.json - Le répertoire
src/est pour le développement uniquement, Sowel ne le lit jamais - Les
node_modules/propres au plugin sont isolés des dépendances de Sowel
Schéma du manifest¶
Le fichier manifest.json décrit le plugin à Sowel. Il vit à la racine du répertoire du plugin.
Exemple (depuis 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"
}
]
}
Référence des champs¶
| Champ | Type | Requis | Description |
|---|---|---|---|
id |
string | Oui | Identifiant unique du plugin. Minuscules avec tirets (par ex. weather-forecast). Doit correspondre au nom du répertoire sous plugins/. |
name |
string | Oui | Nom d'affichage lisible présenté dans l'UI. |
version |
string | Oui | Version SemVer (par ex. 0.2.0). Doit être mise à jour à chaque release. Voir Versioning. |
description |
string | Oui | Courte description (une phrase) affichée dans le store de plugins et la page intégrations. |
icon |
string | Oui | Nom d'icône Lucide (par ex. CloudSun, Camera). Utilisé dans l'UI pour la carte d'intégration. |
author |
string | Non | Nom de l'auteur ou organisation. |
sowelVersion |
string | Non | Plage SemVer des versions Sowel compatibles (par ex. >=0.10.0). |
settings |
IntegrationSettingDef[] | Non | Tableau de définitions de réglages pour le formulaire de configuration UI. Voir Réglages. |
Champs qui n'existent PAS dans le manifest : entry, integrationId, license, repository. Ne les incluez pas.
Référence de l'API PluginDeps¶
Quand Sowel charge un plugin, il passe un objet PluginDeps à la fonction usine createPlugin. C'est la porte d'entrée du plugin vers tous les services centraux Sowel.
interface PluginDeps {
logger: Logger;
eventBus: EventBus;
settingsManager: SettingsManager;
deviceManager: DeviceManager;
pluginDir: string;
}
logger¶
Un child logger pino préconfiguré avec { module: "plugin:<pluginId>" }. Utilisez-le pour tous les logs, n'utilisez jamais console.log.
deps.logger.info({ deviceCount: 5 }, "Devices discovered");
deps.logger.debug({ response }, "API response received");
deps.logger.error({ err }, "Poll failed");
Conseils par niveau :
| Niveau | Usage |
|---|---|
info |
Événements significatifs : connecté, devices découverts, poll terminé |
debug |
Détails opérationnels : réponses d'API, étapes intermédiaires |
trace |
Données à haut volume : chaque message, chaque point de données |
error |
Opération échouée, incluez toujours { err } avec l'objet Error |
warn |
Inattendu mais géré : retry, fallback, récupérable |
eventBus¶
L'event emitter typé. Les plugins émettent typiquement des événements de cycle de vie d'intégration :
deps.eventBus.emit({ type: "system.integration.connected", integrationId: "my-plugin" });
deps.eventBus.emit({ type: "system.integration.disconnected", integrationId: "my-plugin" });
settingsManager¶
Lit et écrit les réglages stockés dans la table SQLite settings. Les réglages utilisent une clé complète avec le préfixe integration.<pluginId>..
// 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() prend la clé complète (par ex. integration.weather-forecast.polling_interval) et retourne string | undefined (pas null). Les réglages déclarés dans le tableau settings de votre manifest sont automatiquement namespacés par Sowel sous integration.<pluginId>.<key>.
Méthodes :
| Méthode | Signature | Description |
|---|---|---|
get |
(key: string) => string \| undefined |
Récupère un réglage par sa clé complète |
set |
(key: string, value: string) => void |
Définit un réglage |
getByPrefix |
(prefix: string) => Record<string, string> |
Récupère tous les réglages commençant par préfixe |
setMany |
(entries: Record<string, string>) => void |
Définit plusieurs réglages d'un coup |
deviceManager¶
Gère les devices découverts par votre plugin. Deux méthodes principales sont utilisées :
| Méthode | Signature | Description |
|---|---|---|
upsertFromDiscovery |
(integrationId: string, source: string, discovered: DiscoveredDevice) => void |
Crée ou met à jour un device depuis la découverte |
updateDeviceData |
(integrationId: string, sourceDeviceId: string, payload: Record<string, unknown>) => void |
Pousse de nouvelles valeurs de données pour un device |
Voir Découverte de devices et Mises à jour de données device pour l'usage détaillé.
Une nouvelle donnée n'atteint pas toute seule les équipements existants
Ajouter une clé dans DiscoveredDevice.data crée bien la ligne device_data
à la découverte suivante, mais la liaison automatique ne tourne qu'à la
création d'un équipement. Tout équipement déjà lié à ce device conserve
les liaisons qu'il avait : la nouvelle valeur est vivante côté device et
invisible côté équipement.
C'est volontaire : reconstruire les liaisons automatiquement remettrait
celles que le propriétaire a supprimées exprès, et modifierait en silence la
forme d'un équipement qu'il a configuré à la main. Il récupère les nouvelles
valeurs depuis la section **Liaisons** de l'équipement, qui signale ce qui
manque et le propose sous forme de liste à cocher.
Ce que cela implique quand vous publiez une version de plugin qui ajoute des
données : signalez-le dans vos notes de version, et attendez-vous à ce que
les installations existantes aient un clic à faire par équipement. Renommer
une clé existante est pire qu'en ajouter une : la liaison de l'ancienne clé
continue de pointer vers une ligne que plus rien ne met à jour. Préférez
ajouter à côté et déprécier.
pluginDir¶
Chemin absolu vers le répertoire installé du plugin (par ex. /app/plugins/weather-forecast). Utilisez-le pour lire des fichiers locaux ou stocker des données spécifiques au plugin.
Note : il n'y a pas de mqttConnector dans PluginDeps. Si votre plugin a besoin de MQTT, utilisez le package npm mqtt directement comme dépendance du plugin.
Scoping des plugins (spec 111)¶
Depuis Sowel v1.11.0, les trois services mutables de votre PluginDeps sont enveloppés sans condition dans des Proxies scopés. La forme et les signatures de PluginDeps sont bit-pour-bit identiques, donc vous n'avez pas à modifier votre code de plugin, mais le runtime applique quatre invariants :
1. Les settings sont scopés à votre plugin¶
// ✅ Autorisé — votre propre préfixe d'intégration
const refreshToken = deps.settingsManager.get(`integration.${INTEGRATION_ID}.refresh_token`);
deps.settingsManager.set(`integration.${INTEGRATION_ID}.refresh_token`, "new-value");
// ✅ Autorisé — clés globales explicites (position du foyer, fuseau)
const lat = deps.settingsManager.get("home.latitude");
// ❌ Renvoie undefined + log warn — vous ne pouvez pas lire les settings d'un autre plugin
const stolen = deps.settingsManager.get("integration.another-plugin.refresh_token");
// ❌ Throw — vous ne pouvez pas écrire sur une clé qui ne vous appartient pas
deps.settingsManager.set("integration.another-plugin.x", "evil");
// ❌ Renvoie vide — les lectures larges sont refusées
deps.settingsManager.getAll();
deps.settingsManager.getByPrefix("integration.");
Si votre plugin a besoin de lire légitimement un autre setting global, demandez son ajout à GLOBAL_READABLE_KEYS dans src/plugins/scoped-deps.ts via une PR sur le repo Sowel core.
2. Les events sont limités à une petite whitelist¶
Vous ne pouvez émettre que ces types :
system.integration.connected/system.integration.disconnectedsystem.alarm.raised/system.alarm.resolved
Tout autre type est silencieusement abandonné avec un log warn. Les events de domaine (device.data.updated, equipment.data.changed, etc.) sont émis par les managers de Sowel en réaction à vos appels (deviceManager.updateDeviceData, etc.) ; vous ne devriez jamais avoir à les émettre directement.
De plus, si vous mettez integrationId sur un event system.integration.*, il doit matcher votre propre id de plugin. L'usurpation est refusée.
3. Les mutations devices sont forcées à votre ownership¶
// ✅ Autorisé — votre propre integration id
deps.deviceManager.updateDeviceData(INTEGRATION_ID, sourceDeviceId, { state: "on" });
deps.deviceManager.upsertFromDiscovery(INTEGRATION_ID, "zigbee2mqtt", discovered);
// ❌ Throw — vous ne pouvez pas toucher aux devices d'une autre intégration
deps.deviceManager.updateDeviceData("another-plugin", "x", { state: "on" });
deps.deviceManager.migrateIntegrationId("old-id", "another-plugin");
// ❌ Throw — les actions admin ne sont pas disponibles aux plugins
deps.deviceManager.update(deviceId, { name: "renamed" });
deps.deviceManager.delete(deviceId);
Les méthodes de lecture (getAll, getById, getDeviceData, getDeviceDataValue, logSummary) passent inchangées ; lire les devices d'une autre intégration est autorisé (rare mais légitime, par exemple un plugin pool qui lit un équipement météo).
4. Les erreurs des méthodes lifecycle sont confinées¶
Un throw dans refresh(), getStatus(), isConfigured(), getSettingsSchema(), getPollingInfo(), getOAuthUrl() est loggé avec l'id de votre plugin et remplacé par une valeur par défaut sûre (undefined, "error", false, [], null). Le core Sowel continue à tourner.
Un throw dans start(), stop(), executeOrder() ou handleOAuthCallback() est toujours loggé mais rethrown parce que l'appelant (registry, moteur de recettes, route OAuth) doit pouvoir réagir.
Vous êtes libre de throw dans n'importe quelle méthode lifecycle sans craindre de faire tomber Sowel.
Ce contre quoi le Proxy ne protège pas¶
Pour transparence totale, l'isolation soft ne bloque pas :
- L'import direct de
better-sqlite3et la lecture dedata/sowel.db - La lecture de
process.env - Les boucles infinies ou fuites mémoire
- La pollution de prototype (
Object.prototype.X = ...) - Les appels
fetch()ou réseau sortants arbitraires process.exit()
Ces protections nécessiteraient une hard isolation via worker threads (une future spec Sowel). Pour l'instant, les auteurs de plugins doivent suivre l'esprit du contrat : n'accéder qu'à vos propres settings, n'émettre que vos propres events, ne muter que vos propres devices.
Interface IntegrationPlugin¶
La fonction usine createPlugin doit retourner un objet qui implémente l'interface IntegrationPlugin :
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, orderKey: string, value: unknown): Promise<void>;
refresh?(): Promise<void>;
getPollingInfo?(): { lastPollAt: string; intervalMs: number } | null;
}
type IntegrationStatus = "connected" | "disconnected" | "not_configured" | "error";
Référence des méthodes¶
| Méthode | Requise | Description |
|---|---|---|
getStatus() |
Oui | Retourne le statut de connexion courant. Retournez "not_configured" si isConfigured() est false. |
isConfigured() |
Oui | Retourne true si tous les réglages requis sont présents. Sowel n'appelle start() que si c'est true. |
getSettingsSchema() |
Oui | Retourne la définition du formulaire de réglages (identique au champ settings du manifest). |
start(options?) |
Oui | Démarre l'intégration. pollOffset est fourni par Sowel pour étaler plusieurs plugins en polling. |
stop() |
Oui | Arrêt propre : annule les timers, ferme les connexions. |
executeOrder() |
Oui | Exécute une commande sur un device. Levez une erreur si le plugin ne supporte pas les ordres. |
refresh() |
Non | Force un rafraîchissement immédiat (par ex. re-poll de l'API cloud). Appelé depuis le bouton refresh de l'UI. |
getPollingInfo() |
Non | Retourne le timestamp du dernier poll et l'intervalle. Affiché dans l'UI intégrations pour les plugins en polling. |
Créer un plugin pas à pas¶
1. Initialiser le projet¶
Modifiez package.json, définissez "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. Configurer TypeScript¶
Créez tsconfig.json, utilisez le format module NodeNext :
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src"]
}
3. Définir les interfaces de types locales¶
Les plugins n'importent pas depuis le code source de Sowel. À la place, définissez des interfaces locales qui correspondent à la forme de PluginDeps. Cela maintient le plugin entièrement découplé.
// 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;
powerSource?: "battery" | "mains" | "dc" | "unknown";
data: {
key: string;
type: string;
category: string;
unit?: string;
}[];
orders: {
key: string;
type: string;
category?: OrderCategory;
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, orderKey: string, value: unknown): Promise<void>;
refresh?(): Promise<void>;
getPollingInfo?(): { lastPollAt: string; intervalMs: number } | null;
}
4. Implémenter le plugin¶
Sous les définitions de types, implémentez votre classe de plugin et exportez l'usine :
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, _orderKey: string, _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¶
Cela produit dist/index.js (ESM) prêt à être chargé par Sowel.
6. Créer le manifest¶
Créez manifest.json à la racine du plugin :
{
"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. Tester localement¶
Faites un symlink du répertoire du plugin dans le plugins/ de Sowel :
Puis enregistrez-le manuellement dans la base (Sowel auto-charge depuis la table plugins) :
# 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'));"
Démarrez Sowel, il va détecter et charger le plugin automatiquement. Vérifiez les logs pour la sortie de votre plugin :
Découverte de devices¶
Quand votre plugin détecte des devices (depuis une API, MQTT, ou un scan local), enregistrez-les avec 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;
Format DiscoveredDevice¶
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
powerSource?: "battery" | "mains" | "dc" | "unknown"; // Spec 143 -- omettre si inconnu ; alimente la surveillance batterie
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"
category?: string; // Catégorie de l'ordre — c'est par elle que les liaisons se résolvent (spec 110)
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")
}[];
}
Exemple (depuis 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 :
friendlyNamedevient lesource_device_iddans la base. Il doit correspondre à l'argumentsourceDeviceIdutilisé dansupdateDeviceData().- Appelez
upsertFromDiscovery()à chaque cycle de poll, c'est idempotent (créé au premier appel, met à jour les métadonnées aux suivants). - Incluez tous les data points et ordres dans la définition
DiscoveredDevice. Les entrées de données/ordres obsolètes qui ne sont pas dans la découverte courante sont nettoyées automatiquement.
Mises à jour de données device¶
Après la découverte de devices, poussez les mises à jour de données quand de nouvelles valeurs arrivent. Utilisez 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;
Exemple (depuis 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);
Critique : le paramètre sourceDeviceId doit correspondre exactement au friendlyName utilisé dans upsertFromDiscovery(). C'est ainsi que Sowel retrouve le device dans la base. Dans le plugin weather-forecast, les deux sont définis à "Weather Forecast".
Le payload est un Record<string, unknown> plat, les clés sont les noms de data points, les valeurs sont les valeurs brutes (number, boolean, string). Ce n'est pas un objet imbriqué avec labels ou unités, ceux-ci sont définis une seule fois dans upsertFromDiscovery().
Cela déclenche le pipeline réactif :
- Les données du device sont mises à jour dans SQLite
- L'événement
device.data.updatedest émis - Les liaisons d'équipements sont réévaluées
- Les agrégations de zones sont mises à jour
- Les triggers de scénarios sont vérifiés
- L'UI reçoit un push WebSocket
Exécution d'ordres¶
Quand un utilisateur ou un scénario envoie une commande à un device géré par votre plugin, Sowel appelle executeOrder() sur l'instance de votre plugin.
Signature¶
executeOrder(
device: Device, // L'appareil visé
orderKey: string, // La `key` de l'ordre, telle que déclarée à la découverte
value: unknown, // La valeur à appliquer
): Promise<void>;
Exemple¶
async executeOrder(device: Device, orderKey: string, value: unknown): Promise<void> {
switch (orderKey) {
case "set_monitoring":
await this.api.setMonitoring(device.sourceDeviceId, value as boolean);
break;
default:
this.logger.warn({ action }, "Unknown order action");
}
}
Flux d'un ordre :
- L'utilisateur appuie sur un bouton dans l'UI ou une action de scénario se déclenche
- L'équipement dispatche l'ordre vers le device lié
- Sowel route l'ordre vers l'intégration propriétaire du device
- La méthode
executeOrder()du plugin est appelée avec l'objet Device complet, lakeyde l'ordre déclarée à la découverte, et la valeur - Le plugin envoie la commande au device physique
- Au prochain poll (ou refresh immédiat), le nouvel état est reflété
Important — plugins basés sur MQTT : résolvez le topic complet au runtime dans executeOrder(), à partir de la clé d'ordre et du réglage base_topic courant. Rien n'est persisté par ordre, donc un changement de base topic ne laisse pas de topics périmés derrière lui.
Si votre plugin ne supporte pas les ordres (par ex. un plugin météo en lecture seule), levez une erreur :
async executeOrder(): Promise<void> {
throw new Error("Weather Forecast plugin does not support orders");
}
Réglages¶
Les plugins déclarent leurs réglages dans manifest.json et retournent le même schéma depuis getSettingsSchema(). Sowel rend automatiquement un formulaire de configuration dans l'UI.
Schéma de définition d'un réglage¶
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)
}
Exemple (depuis 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"
}
]
}
Lire les réglages au runtime¶
Les réglages sont stockés avec la clé complète 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());
Notes importantes :
get()retourne toujoursstring | undefined, parsez les nombres avecparseInt()/parseFloat()- Le
defaultValuedu schéma de réglages est uniquement pour l'affichage UI, gérez toujours undefined dans le code - Utilisez le type
"password"pour les secrets, l'UI masque ces valeurs - Il n'y a pas de types
"select","string"ou"secret". Les types valides sont :"text","password","number","boolean"
Publication et versioning¶
Créer un tarball de release¶
Les plugins sont installés depuis des tarballs de release GitHub. Le tarball doit inclure dist/ (JS compilé) et doit exclure src/ et 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/
Si votre plugin a des dépendances de production, embarquez-les dans le bundle : Sowel n'exécute pas npm install après extraction, donc exclure node_modules/ sans bundler produit un artefact qui s'installe proprement puis échoue à l'import dynamique.
Créer une release GitHub¶
gh release create v0.1.0 \
sowel-plugin-my-device-0.1.0.tar.gz \
--title "v0.1.0" \
--notes "Initial release"
Flux d'installation : quand un utilisateur clique sur "Installer" dans le store de plugins Sowel, Sowel :
- Récupère la dernière release depuis l'API GitHub
- Exige un asset de release nommé
sowel-*.tar.gz. Il n'y a aucun repli : sans lui l'installation échoue - Vérifie son SHA256 contre la registry, puis extrait dans
plugins/<id>/ - Enregistre le plugin en base et le charge
Sowel n'exécute aucune étape de build : ni npm install, ni tsc. Le tarball est ce qui tourne, donc dist/ doit être construit et les dépendances runtime embarquées.
Mettre à jour un plugin¶
Quand une version plus récente est disponible dans registry.json par rapport à la version installée, Sowel affiche un indicateur de mise à jour dans l'UI (badge sidebar, pastille header, et un bouton "Update" sur la carte du plugin).
Flux de mise à jour (déclenché par un clic sur "Update") :
- Stoppe le plugin en cours
- Télécharge la dernière release depuis GitHub (même logique que l'install)
- Remplace les fichiers du plugin dans
plugins/<id>/ - Exécute
npm installet build si nécessaire - Met à jour la version et le manifest dans la base
- Redémarre le plugin s'il était activé
Ce qui est préservé : tous les réglages du plugin, les devices découverts, les liaisons d'équipement, et la configuration d'historisation. La mise à jour ne remplace que le code du plugin, elle ne touche pas la base.
Ce qui change : les fichiers du plugin dans plugins/<id>/ et la version enregistrée dans la table plugins.
S'enregistrer dans le store de plugins¶
Pour que votre plugin apparaisse dans le store de plugins Sowel, soumettez une PR sur le repo Sowel ajoutant une entrée à plugins/registry.json :
{
"id": "my-device",
"type": "integration",
"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"]
}
Schéma d'entrée du registre¶
| Champ | Type | Requis | Description |
|---|---|---|---|
id |
string | Oui | Doit correspondre à l'id du manifest.json du plugin |
type |
string | Oui | integration ou recipe, route vers PluginLoader ou RecipeLoader au moment de l'install |
name |
string | Oui | Nom affiché dans le store |
description |
string | Oui | Courte description |
icon |
string | Oui | Nom d'icône Lucide |
author |
string | Oui | Nom de l'auteur |
repo |
string | Oui | Chemin GitHub owner/repo (utilisé pour récupérer les releases) |
version |
string | Non | Dernière version disponible (affichée dans l'onglet "Available" du store) |
tags |
string[] | Oui | Tags cherchables (par ex. ["camera", "security"]) |
Récupération du registre distant¶
Depuis la spec 059, Sowel récupère plugins/registry.json depuis https://raw.githubusercontent.com/mchacher/sowel/main/plugins/registry.json avec un cache d'une heure, retombant sur le fichier local livré dans l'image Docker. Cela signifie :
- Publier une nouvelle version d'un plugin ne nécessite que la mise à jour de
plugins/registry.jsonsurmain, pas besoin de release Sowel - Les forks privés peuvent pointer Sowel vers leur propre registre en changeant
REGISTRY_URLdanspackage-manager.ts - Les plugins sont re-téléchargés automatiquement au démarrage du conteneur si leur répertoire est manquant (par ex. après une restauration de backup, spec 058)
Sources personnelles (spec 136)¶
Depuis la spec 136, un troisième niveau de confiance existe à côté d'officiel et communautaire : les sources personnelles. Un admin peut enregistrer son propre dépôt GitHub comme source de plugins directement depuis l'UI (page Plugins, onglet Store, section "Sources personnelles"), sans aucune PR sur le registre, ni pour la première publication ni pour les montées de version. C'est la boucle recommandée pendant le développement d'un plugin pour sa propre instance.
| Niveau | Listé dans | Ancre d'intégrité | Friction à l'installation |
|---|---|---|---|
| Officiel | registry.json central |
SHA256 du registre + revue mainteneur | Aucune |
| Communautaire | registry.json central |
SHA256 du registre + revue de la PR | Modal de confirmation unique |
| Personnel | Votre propre dépôt GitHub | SHA256 épinglé (TOFU) | Confirmation à l'installation et à chaque MAJ |
Prérequis pour qu'un dépôt fonctionne comme source personnelle :
- Dépôt GitHub public (pas encore de support de token).
- Une release GitHub dont les assets incluent un tarball
sowel-plugin-<id>-<version>.tar.gz(ousowel-recipe-...), le même artefact que pour le registre. - Le
manifest.jsondu tarball doit déclarer unrepoégal au dépôt source (un écart refuse l'installation). - L'
iddu plugin ne doit entrer en collision avec aucun id du registre central (masquer un plugin officiel est refusé).
Modèle de confiance (TOFU, Trust On First Use) : il n'existe pas de hash central pour une source personnelle, donc Sowel télécharge le tarball, calcule son SHA256 et l'affiche dans un modal de confirmation avec la version de la release. Une fois confirmé, le hash est épinglé en base. Tout changement de contenu, y compris une nouvelle release, déclenche une nouvelle confirmation montrant la nouvelle empreinte ; les restaurations de backup revérifient les téléchargements contre le hash épinglé. Retirer une source laisse les plugins installés en fonctionnement mais bloque installations et mises à jour futures depuis cette source.
Les plugins personnels exécutent du code non revu
Personne n'a relu le code d'un plugin personnel. Il s'exécute avec tous les privilèges du processus serveur Sowel (le scoping de la spec 111 est une frontière souple, pas un bac à sable). N'ajoutez que des dépôts que vous possédez ou en qui vous avez pleinement confiance.
Quand votre plugin est prêt à être partagé, le niveau communautaire est la voie de promotion : ouvrez une PR sur le registre comme décrit ci-dessus.
Versioning¶
Il y a deux endroits où la version compte, et ils ont des objectifs différents :
-
manifest.json(dans le repo du plugin) : le champversionici est lu lors de l'installation du plugin. Il est stocké dans la base de Sowel et affiché dans l'onglet "Installed" de l'UI Plugins. -
plugins/registry.json(dans le repo Sowel) : le champversionici est affiché dans l'onglet "Store" de l'UI Plugins, montrant aux utilisateurs quelle version est disponible à l'installation.
Règles :
- Mettez à jour la version dans
manifest.jsonà chaque release. C'est ainsi que Sowel sait quelle version est installée. - Mettez à jour la version dans
plugins/registry.jsondu repo Sowel quand vous publiez une nouvelle release. C'est ainsi que les utilisateurs voient qu'une mise à jour est disponible : Sowel compare la version installée (depuis le manifest) à la version du registre et affiche un badge de mise à jour si elles diffèrent. - Gardez les deux versions en phase. Si
manifest.jsondit0.2.0maisregistry.jsondit0.1.0, le store affichera une version périmée. - La version dans le tag de release (par ex.
v0.2.0) doit correspondre àmanifest.json.
Convention de nommage des releases¶
- Tag git :
v0.2.0(SemVer avec préfixev) - Asset tarball :
sowel-plugin-<id>-<version>.tar.gz - La
versiondans lemanifest.jsondu tarball doit correspondre au tag de release
Dépannage¶
Le plugin n'est pas détecté :
- Vérifiez que
plugins/<id>/manifest.jsonexiste et est un JSON valide - Vérifiez que le champ
idcorrespond au nom du répertoire - Vérifiez la table
pluginsdans SQLite, le plugin doit avoir une ligne avecenabled = 1
Le plugin échoue au chargement :
- Assurez-vous que
dist/index.jsexiste et exporte une fonctioncreatePlugin - Consultez les logs Sowel pour les entrées
plugin:<id> - Vérifiez que le plugin utilise ESM (
export function createPluginouexport { createPlugin }) - Le loader vérifie aussi
mod.default?.createPluginen fallback
Le plugin se charge mais ne démarre pas :
- Vérifiez que
isConfigured()renvoie true, Sowel sautestart()quand c'est false - Vérifiez que tous les réglages requis sont configurés dans l'UI (Administration > Intégrations)
Les devices n'apparaissent pas :
- Vérifiez que
integrationIddansupsertFromDiscovery()correspond à l'idde votre plugin - Vérifiez que
friendlyNameest non vide et unique - Cherchez les erreurs dans les logs du device manager
Les mises à jour de données ne fonctionnent pas :
- Vérifiez que
sourceDeviceIddansupdateDeviceData()correspond exactement aufriendlyNameutilisé dansupsertFromDiscovery() - Vérifiez que les clés du payload correspondent aux clés déclarées dans le tableau
DiscoveredDevice.data
Les ordres ne sont pas reçus :
- Vérifiez que votre plugin implémente
executeOrder()avec la bonne signature :(device: Device, orderKey: string, value: unknown) - Vérifiez que le tableau
ordersdu device déclare bien la clé d'ordre - Cherchez les événements de dispatch d'ordre dans les logs
Référence¶
- Architecture Sowel : vue d'ensemble de l'architecture technique
- Plugin Weather Forecast : exemple complet et fonctionnel
- Plugin Netatmo Security : plugin avec OAuth et ordres