A plugin adds a feature to Sonorium without changing Sonorium: a doorbell chime, an alarm clock, a connector to another home system, a remote control, an importer. It is a folder with a Python file. Sonorium loads it and offers it a set of services: follow what happens (events), act on channels (actions), run at set times (the scheduler), be called by other devices (webhook triggers), add pages and settings to the web UI, play sounds over channels, ramp volume, add audio outputs, take commands from remotes, and use MQTT, Home Assistant and text to speech.
Think of Sonorium as a phone and a plugin as an app. The app doesn't open the phone and rewire it; it asks the phone's services for the camera or the speaker. Those services are documented here and keep working across Sonorium versions: names and fields are only added, never renamed or removed.
This page describes the plugin API of Sonorium 1.7.1 (plugin API 1.1). The repository's guide is docs/PLUGIN_DEVELOPMENT.md, with a second example in docs/examples/trigger_play_channel. Installing and managing plugins as a user is on Plugins; the HTTP routes are on API Reference.
What a plugin can do
| Service | What it gives | In the plugin |
|---|---|---|
| Settings | A form under the plugin in Settings › Plugins, saved as it changes | get_settings_schema(), get_setting() |
| Events | Follow what happens in Sonorium; publish the plugin's own events | self.subscribe(), self.emit() |
| Actions | Everything the web UI does with channels | self.actions |
| Scheduler | Jobs at set times, kept across restarts | self.schedule(), on_schedule() |
| Webhook triggers | URLs other devices call, with tokens | get_triggers(), on_trigger() |
| Pages | Entries in the left menu, drawn by Sonorium from data | get_pages(), get_page(), page_action() |
| Connections | A row in Settings › Connection with fields, secrets and a test | get_connections(), connection_values() |
| Sounds and announcements | Sounds over channels with ducking; announcements | self.audio |
| Text to speech | Provide or use voices for announcements | self.audio.register_tts(), self.audio.tts() |
| Volume ramps | Gradual volume changes on a channel | self.ramps |
| Audio outputs | New places a channel can play: a sound card, a Bluetooth speaker | self.audio_outputs |
| Command maps | Remote buttons and other codes, mapped by the user to actions | get_command_maps(), self.commands |
| MQTT | Publish and subscribe on Sonorium's broker | self.mqtt |
| Home Assistant | Home Assistant's REST API through Sonorium's connection | self.homeassistant |
| Themes | Create themes and add files, presets and groups to them | self.new_theme(), self.open_theme() |
| Packages | Python packages from PyPI and programs per platform | manifest requirements, binaries |
A first plugin: Doorbell
A doorbell button (an ESP32, a Zigbee button through Home Assistant, a shell script) calls a URL; Sonorium plays a chime over the chosen channels, ducking what plays. The plugin has settings, a webhook trigger, an event subscription, an event of the plugin and a page with a button.
The files
doorbell/
├── manifest.json
└── plugin.py
doorbell/manifest.json:
{
"id": "doorbell",
"name": "Doorbell",
"version": "1.0.0",
"description": "Plays a chime over channels when the doorbell trigger is called.",
"author": "Your name",
"entry_point": "plugin.py",
"plugin_class": "Doorbell",
"api_version": "1.1",
"requires": ["triggers", "pages", "audio.sounds"]
}
doorbell/plugin.py:
"""Doorbell: a webhook trigger that plays a chime over Sonorium's channels."""
from __future__ import annotations
from datetime import datetime, timezone
from sonorium.plugins.actions import ActionError
from sonorium.plugins.base import BasePlugin
class Doorbell(BasePlugin):
id = "doorbell"
name = "Doorbell"
version = "1.0.0"
description = "Plays a chime over channels when the doorbell trigger is called."
author = "Your name"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.rings: list[dict] = [] # newest first, kept in memory for the page
# Settings: drawn as a form under the plugin in Settings > Plugins
def get_settings_schema(self) -> dict:
return {
"chime_url": {"type": "url", "label": "Chime", "required": True,
"help": "An http(s) address of an MP3 or WAV file"},
"channels": {"type": "string", "label": "Channels", "default": "all",
"help": "Channel names or ids, separated by commas, or all"},
"volume": {"type": "number", "label": "Volume", "min": 0, "max": 200, "default": 100},
}
# Lifecycle: subscriptions made here are removed when the plugin is disabled
async def on_enable(self) -> None:
self.subscribe("sound.ended", self.on_sound_ended)
async def on_sound_ended(self, event) -> None:
if event.data.get("origin") != f"plugin:{self.id}":
return # a sound someone else played
for ring in self.rings:
if ring["sound_id"] == event.data.get("sound_id"):
ring["result"] = event.data.get("reason", "")
# Webhook trigger: POST or GET /api/triggers/doorbell/ring
def get_triggers(self) -> list[dict]:
return [{"id": "ring", "title": "Ring", "methods": ["POST", "GET"],
"description": "Play the chime. Optional payload: door (a name shown on the page)."}]
async def on_trigger(self, trigger: str, payload: dict, context: dict) -> None:
await self.ring(str(payload.get("door") or "Front door"), context.get("client", ""))
async def ring(self, door: str, source: str) -> dict:
url = self.get_setting("chime_url")
if not url:
raise ActionError("invalid", "Set the chime in the plugin's settings first")
wanted = str(self.get_setting("channels") or "all").strip()
channels = "all" if wanted.lower() == "all" else [c.strip() for c in wanted.split(",") if c.strip()]
result = await self.audio.announce(channels, sound=url, volume=self.get_setting("volume"))
sound_id = (result.get("chime") or {}).get("sound_id", "")
self.rings.insert(0, {"id": sound_id or str(len(self.rings)), "sound_id": sound_id, "door": door,
"at": datetime.now(timezone.utc).isoformat(), "from": source,
"channels": len(result.get("played") or []), "result": "playing"})
del self.rings[20:]
self.emit("rang", {"door": door, "channels": result.get("played") or []})
return result
# A page in the left menu (Plugins section)
def get_pages(self) -> list[dict]:
return [{"id": "rings", "title": "Doorbell", "icon": "mdi:doorbell"}]
async def get_page(self, page_id: str) -> dict:
return {
"title": "Doorbell",
"intro": "The latest rings since Sonorium started.",
"actions": [{"action": "test", "label": "Ring now", "style": "primary"}],
"refresh": 10,
"sections": [{
"id": "rings", "title": "Latest rings",
"items": [
{"type": "table", "id": "rings", "empty": "No rings yet",
"columns": [{"key": "at", "label": "When", "format": "datetime"},
{"key": "door", "label": "Door"},
{"key": "channels", "label": "Channels", "format": "number"},
{"key": "result", "label": "Result"}],
"rows": self.rings},
{"type": "button", "action": "clear", "label": "Clear the list", "style": "secondary",
"confirm": "Clear the list of rings?"},
],
}],
}
async def page_action(self, page_id: str, action: str, data: dict) -> dict:
if action == "test":
try:
result = await self.ring("Test", "Ring now")
except ActionError as e:
return {"success": False, "message": e.message}
return {"success": True, "message": f"Ringing on {len(result.get('played') or [])} channels"}
if action == "clear":
self.rings.clear()
return {"success": True, "message": "Cleared"}
return await super().page_action(page_id, action, data)
What each part does:
get_settings_schema()makes three fields under the plugin in Settings › Plugins: the chime's address, the channels, the volume.on_enable()subscribes tosound.ended, so the page can show how each ring ended. The subscription is removed when the plugin is switched off.get_triggers()declares one webhook trigger,ring, served at/api/triggers/doorbell/ring.on_trigger()runs after Sonorium has checked the caller's token and answered 202.ring()sends an announcement withself.audio.announce(): a chime, no text, on the channels from the settings. Announcements start a stopped channel's speakers for their length and override zones and quiet hours.self.emit("rang", …)publishesplugin.doorbell.rang, which other plugins and programs can follow.get_pages(),get_page()andpage_action()add Doorbell to the left menu: a table of rings, a Ring now button and Clear the list.
Install it
- Zip the folder, so the zip holds
doorbell/manifest.jsonanddoorbell/plugin.py.- Linux and macOS:
zip -r doorbell.zip doorbell - Windows (PowerShell):
Compress-Archive -Path doorbell -DestinationPath doorbell.zip
- Linux and macOS:
- Open Settings in the left menu.
- Click Plugins.
- Click Upload plugin (top right).
- Pick
doorbell.zip. Doorbell appears under Installed, switched off. - Click the On switch on the Doorbell row.
- Click the arrow at the left of the row to show its settings.
- Type the chime's address in Chime. Any MP3 or WAV file reachable over http(s) works; Sonorium's own test chime is
http://192.0.2.20:8008/api/test-tone.mp3. - Type the channel names in Channels (for example
Kitchen, Living Room), or leaveall.
From a terminal instead. Installing and configuring plugins needs an Admin, and keys can't have that permission, so these calls work while Require login is off (or with a signed-in Admin's cookie and CSRF token, see API Reference):
B=http://192.0.2.20:8008/api
curl -F "file=@doorbell.zip" $B/plugins/upload
curl -X PUT $B/plugins/doorbell/enable
curl -X PUT $B/plugins/doorbell/settings -H "Content-Type: application/json" \
-d '{"settings": {"chime_url": "http://192.0.2.20:8008/api/test-tone.mp3", "channels": "Kitchen", "volume": 80}}'
Ring it
A trigger needs a key or a token. Either:
- a key with Run triggers (Settings › Keys › New key, see API Reference), which opens every trigger of every plugin; or
- a token for this trigger alone:
POST /api/plugins/doorbell/triggers/tokenwith{"trigger": "ring"}(Admin). It answers{"token": "…", "trigger": "ring"}once.
TOKEN=EXAMPLEtoken0000000000000000000000000000000
curl -X POST http://192.0.2.20:8008/api/triggers/doorbell/ring \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"door": "Back door"}'
{"accepted": true, "event_id": 44}
A device that can only open a URL sends the same as a GET:
http://192.0.2.20:8008/api/triggers/doorbell/ring?door=Garage&token=EXAMPLEtoken0000000000000000000000000000000
See it run
- The chime plays on the channels. A stopped channel's speakers start for it, then stop again.
- Doorbell is in the left menu, in the Plugins section. Its table lists each ring; Result changes from
playingtofinishedwhen the chime has played. Ring now rings it from the page. - The event stream shows the call, the plugin's event and the end of the chime:
event: trigger.received
data: {"id": 44, "type": "trigger.received", "origin": "sonorium", "data": {"plugin_id": "doorbell", "trigger": "ring", "payload": {"door": "Back door"}, "method": "POST"}, ...}
event: plugin.doorbell.rang
data: {"id": 45, "type": "plugin.doorbell.rang", "origin": "plugin:doorbell", "data": {"door": "Back door", "channels": ["2c62267b"]}, ...}
event: sound.ended
data: {"id": 61, "type": "sound.ended", "origin": "sonorium", "data": {"sound_id": "s_27ad7fdc1282", "channel_id": "2c62267b", "name": "Kitchen", "label": "Chime", "origin": "plugin:doorbell", "reason": "finished", "capped": false, "played": 3.0}, ...}
Note the two origins on sound.ended: the event's origin is sonorium (the mixer published it), while data.origin says who played the sound. That is why on_sound_ended() checks event.data["origin"].
Folder layout and manifest
my_plugin/
├── manifest.json what the plugin is
├── plugin.py the plugin class (or the file named in entry_point)
└── ... anything else it needs: modules, images, bin/
Installed plugins live in the plugins folder of Sonorium's config folder (/config/sonorium/plugins/ on the Home Assistant app and Docker), kept across updates. A folder whose name starts with . is skipped, and so is one without plugin.py.
manifest.json:
| Key | Type | Default | Meaning |
|---|---|---|---|
id |
string | the folder name | Letters, digits, _ and -. Used in URLs, settings and event names. An upload keeps only lowercase letters, digits and _, so use those. |
name |
string | the id in title case | Shown in Settings › Plugins |
version |
string | Required. MAJOR.MINOR.PATCH, optionally -pre (1.0.0, 2.1.0-beta). An upload without one, or with another format, is refused. |
|
description |
string | "" |
Shown in Settings › Plugins |
author |
string | Unknown |
Shown in Settings › Plugins |
entry_point |
string | plugin.py |
The Python file Sonorium loads |
plugin_class |
string | the first BasePlugin subclass in the file |
The class name |
api_version |
string | "1.0" |
The plugin API it was written for, MAJOR.MINOR (below) |
requires |
string[] | [] |
Capabilities it can't work without |
settings_schema |
object | Settings fields, when the class doesn't define get_settings_schema() |
|
triggers, pages, connections, command_maps |
array | Declarations, when the class doesn't define the matching get_…() |
|
requirements |
string[] | Python packages from PyPI (below) | |
binaries |
object | Programs per platform, with their SHA-256 |
Without manifest.json, Sonorium makes one from the folder name (version 1.0.0). Write one anyway: the version decides updates.
Import only from sonorium.plugins.* (base, actions, events, scheduler, theme_api) and, for an audio output, sonorium.core.audio_outputs (OutputType, Sink). Everything else in Sonorium is internal and changes without notice.
The plugin class
from sonorium.plugins.base import BasePlugin
class MyPlugin(BasePlugin):
id = "my_plugin"
name = "My Plugin"
version = "1.0.0"
description = "What it does, in one line."
author = "Your name"
Sonorium creates one instance: MyPlugin(plugin_dir, settings, audio_path). Call super().__init__(*args, **kwargs) first when you define __init__.
Attributes and properties Sonorium sets:
| Name | Type | Meaning |
|---|---|---|
self.plugin_dir |
Path |
The plugin's folder |
self.settings |
dict | The saved settings, with defaults for those not set |
self.audio_path |
Path |
The themes folder |
self.enabled |
bool | Switched on |
self.actions |
Actions | |
self.audio, self.ramps, self.audio_outputs, self.commands, self.mqtt, self.homeassistant |
The 1.1 services |
Methods a plugin may define (all optional):
| Method | Called | Returns |
|---|---|---|
async on_load() |
The plugin was loaded | |
async on_enable() |
Switched on (also at start-up when it was on) | |
async on_disable() |
Switched off | |
async on_unload() |
Sonorium unloads it (reload, uninstall, shutdown) | |
get_settings_schema() |
To draw the settings form and check values | {name: field} |
async on_settings_changed(settings) |
After settings were saved | |
get_ui_schema() |
To draw buttons under the settings form | {"actions": [{"id", "label", "primary"}]} |
async handle_action(action, data) |
One of those buttons was clicked | {"success", "message", "data"?} |
async on_schedule(job) |
A job is due | |
get_triggers() |
To list its webhook triggers | [{"id", "title", "description", "methods"}] |
async on_trigger(trigger, payload, context) |
A trigger was called | |
get_pages() |
To list its menu entries | [{"id", "title", "icon"}] |
async get_page(page_id) |
A page is opened (and on each refresh) | the page |
async page_action(page_id, action, data) |
A page button or row action | {"success", "message", "refresh"?, "data"?} |
get_connections() |
To list its Connection rows | [{"id", "title", "description", "icon", "fields", "test"}] |
async test_connection(connection_id, values) |
Test was clicked | {"success", "message"} |
connection_status(connection_id) |
To show the row's status | {"state", "message"} |
async on_connection_changed(connection_id, values) |
A connection was saved | |
get_command_maps() |
To list its command maps | [{"id", "title", "codes"}] |
async on_theme_created(theme_id, theme_path) |
A theme was created (a new theme, an import, or a plugin action) | |
async on_theme_deleted(theme_id) |
A theme was deleted |
Helpers it inherits:
| Method | Does |
|---|---|
get_setting(key, default=None) |
One setting |
update_settings(values) |
Change settings in memory (they are saved by the settings route) |
subscribe(patterns, handler) |
Events |
emit(name, data=None) |
Publish plugin.<id>.<name> |
schedule(…), unschedule(job_id), scheduled_jobs() |
Scheduler |
connection_values(connection_id) |
A connection's saved values, secrets included |
binary(name) |
A program it ships, for this platform, or None |
list_themes(), new_theme(name, …), open_theme(theme_id) |
Themes |
Everything runs on Sonorium's event loop: write async code, and run slow blocking work with await asyncio.to_thread(...) so playback controls stay responsive. Log with the standard logging module (logger sonorium); the lines appear in Settings › Logs.
Lifecycle
| Step | What happens |
|---|---|
| Start-up or reload | Sonorium reads manifest.json, checks api_version and requires before loading any code, loads the class, creates the instance, calls on_load(). |
| Switched on | on_enable(). Start things here. A plugin that raises in on_enable() stays off. |
| Switched off | on_disable(). Sonorium removes its event subscriptions, MQTT subscriptions, audio output types, text to speech providers and sound gates; its pages and triggers stop answering (409 for triggers). Jobs stay and skip their runs. |
| Uninstalled | on_unload(); its folder, scheduled jobs, trigger tokens, saved connection values, command maps and Python packages are removed. |
| Upload of the same id | The folder is replaced and plugins are reloaded. Settings, jobs, tokens and connection values are kept. |
Time limits: an event handler 30 s, a scheduled job 5 minutes, a trigger handler 60 s, get_page() 10 s, page_action() and on_connection_changed() 30 s, test_connection() 20 s, text to speech 60 s. A handler that raises or runs over is logged and published as an error event; a page call answers 502 The plugin failed: …. Nothing a plugin does holds up playback or other plugins.
Settings
Settings are values the user sets once. Sonorium draws them under the plugin in Settings › Plugins (the arrow at the left of the row) and saves each one as it changes.
def get_settings_schema(self):
return {
"channel": {"type": "string", "label": "Channel", "default": ""},
"volume": {"type": "number", "label": "Volume", "min": 0, "max": 100, "default": 40},
"voice": {"type": "select", "label": "Voice", "options": [{"value": "en", "label": "English"}, "de"]},
}
| Type | Value | Extra keys |
|---|---|---|
string |
text | placeholder |
url |
empty or an http(s) URL | placeholder |
number |
an int or float (not a boolean) | min, max |
boolean |
true or false | |
select |
one of the options | options: [{"value", "label"}] or plain values |
Every field may have label, help, default and required. A field of an unknown type is left out (and logged). PUT /api/plugins/{id}/settings with {"settings": {…}} checks each value against its field: an unknown name or a wrong value answers 400 and nothing is saved. Then on_settings_changed(settings) runs, with self.settings already updated.
get_ui_schema() can add buttons under the form; each calls handle_action(action, data) (POST /api/plugins/{id}/action with {"action", "data"}). A result with "refresh_themes": true makes Sonorium rescan the themes. New plugins usually use a page instead.
Events
Sonorium announces what happens as events: a type, a time, an origin and data. A plugin subscribes to the types it needs, like following a few accounts on a news feed.
import logging
async def on_enable(self):
self.subscribe("channel.started", self.on_started) # one type
self.subscribe(["speaker.*", "error"], self.on_trouble) # a prefix, or several
async def on_started(self, event):
# event.type, event.data, event.time (ISO 8601, UTC), event.origin, event.id
logging.getLogger("sonorium").info("%s started", event.data["name"])
- A pattern is a type (
channel.started), a prefix (channel.*) or*. subscribe()returns a subscription; itsclose()removes it before the plugin is switched off.- An
asynchandler runs on the event loop; a plain function runs in a worker thread. - Each subscription has a queue of 256 events: a slow handler falls behind and then loses the oldest. A handler that raises or runs over 30 s is logged and reported as an
errorevent. event.originissonorium(the web UI, the API, Home Assistant, timers) orplugin:<id>for changes a plugin made through its actions. Check it to ignore your own changes. Events published by the mixer (sound.*,ramp.*) carry the requester indata.origin.
self.emit(name, data) publishes plugin.<id>.<name>; name is lowercase letters, digits, _, - and ., up to 64 characters. Other plugins can subscribe to it, and programs see it on the event stream.
The list of event types and their data is on API Reference › Event catalogue.
Actions
self.actions is what a plugin may do with channels. Each method is async, returns plain data and does what the same change in the web UI does (events, Home Assistant entities, fades). A failure raises ActionError (from sonorium.plugins.actions import ActionError) with .code (not_found, invalid, conflict, unavailable) and .message. Changes made here have the origin plugin:<id>, and they act as an Admin (zones don't refuse them).
| Method | Returns | Does |
|---|---|---|
list_channels() |
channel[] | Every channel |
get_channel(channel_id) |
channel | One channel; not_found |
create_channel(theme_id=None, speakers=None, speaker_group_id=None, name=None, preset_id=None, volume=None) |
channel | A new channel, stopped. speakers: speaker ids |
start_channel(channel_id) |
channel | Play on its speakers; invalid without a theme or speakers |
stop_channel(channel_id) |
channel | Stop (fades out) |
pause_channel(channel_id) |
channel | Pause |
stop_all() |
int | Stop every channel; how many |
set_theme(channel_id, theme_id, preset_id=None) |
channel | Change the theme (crossfades while playing); no preset: the theme's default |
set_preset(channel_id, preset_id) |
channel | Glide to a preset of its theme; "" = none |
set_speakers(channel_id, speakers=[…]) or set_speakers(channel_id, speaker_group_id=…) |
channel | Which speakers play it |
set_volume(channel_id, volume) |
channel | 0-100 |
mute_speakers(channel_id, muted=True) |
channel | Silence what the speakers get (listeners keep hearing it) |
start_sequence(channel_id, sequence_id) / stop_sequence(channel_id) |
channel | Run or stop a sequence of its theme |
set_playlist(channel_id, playlist_id) / clear_playlist(channel_id) |
channel | Play or stop a playlist |
next_step(channel_id) |
"playlist", "sequence" or "end" |
Next entry or step; conflict when it isn't playing or nothing runs |
list_themes() |
[{"id", "name"}] |
Themes, by name |
list_presets(theme_id) |
[{"id", "name", "is_default"}] |
A theme's presets |
list_sequences(theme_id) |
[{"id", "name", "presets"}] |
A theme's sequences |
list_playlists() |
[{"id", "name", "entries"}] |
Playlists (entries: how many) |
list_speakers() |
[{"id", "name", "type", "online", "area_id", "area_name", "enabled"}] |
Speakers |
list_speaker_groups() |
[{"id", "name"}] |
Speaker groups |
play_sound(channels, source, volume=100, duck=True, policy="queue", priority=0, start_stopped=False) |
play result | A sound over channels (self.audio.play() has every option) |
cancel_sound(sound_id) |
bool | Stop a sound |
A channel here:
{"id": "2c62267b", "name": "Kitchen", "theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4", "theme_name": "Tavern",
"preset_id": "slow_night_at_the_inn", "sequence_id": null, "playlist_id": null, "volume": 40, "is_playing": true,
"speakers_muted": false, "announcements": true, "speakers": ["media_player.kitchen"], "speaker_group_id": null,
"entity_slug": "kitchen"}
entity_slug is the part of the channel's Home Assistant entity ids after sonorium_.
channel = await self.actions.create_channel(theme_id="df297db5-a98d-4372-8029-2c49d1e280e4",
speakers=["media_player.bedroom"], name="Wake up", volume=20)
await self.actions.start_channel(channel["id"])
Scheduler
A plugin registers jobs; Sonorium keeps them in a file, so they survive restarts, and calls the plugin when one is due. Like an alarm clock that remembers its alarms through a power cut.
async def on_enable(self):
self.schedule("wake", "weekly", {"time": "07:00", "days": ["mon", "tue", "wed", "thu", "fri"]},
name="Weekday wake-up", data={"channel": "Bedroom"}, missed="skip")
async def on_schedule(self, job):
channel = next(c for c in await self.actions.list_channels() if c["name"] == job["data"]["channel"])
await self.actions.start_channel(channel["id"])
self.schedule(job_id, kind, spec, *, name="", data=None, missed="skip", callback=None, timezone=None) adds a job, or replaces the job with the same id, and returns it. A bad job raises ScheduleError (from sonorium.plugins.scheduler import ScheduleError).
| Kind | Spec | Runs |
|---|---|---|
once |
{"at": "2026-12-24T07:30"} |
Once, then the job is removed |
daily |
{"time": "07:30"} |
Every day |
weekly |
{"time": "07:30", "days": ["mon", "fri"]} |
On those days (sun … sat) |
cron |
{"cron": "30 7 * * 1-5"} |
Minute, hour, day of month, month, day of week (0 or 7 = Sunday; names like mon, jan work) |
interval |
{"seconds": 900, "start": "2026-10-12T08:00"} |
Every N seconds (at least 10), counted from start when given |
| Option | Meaning |
|---|---|
job_id |
Letters, digits, _, -, .; up to 64. At most 200 jobs per plugin. |
name |
Shown in lists |
data |
Any JSON object; it comes back as job["data"] |
missed |
A run due while Sonorium was off: skip (default) moves on to the next time; run_once runs it once at start-up with job["missed"] = True. A run due within the last 60 s runs as usual. |
timezone |
An IANA name such as Europe/London. Default: the scheduler's (PUT /api/schedules/settings), else the TZ environment variable, else the system's |
callback |
A function to call instead of on_schedule(). It lasts while the plugin runs: set it again in on_enable(); the job itself is kept regardless. |
Times without an offset are local to the time zone. On a daylight-saving change, a time in the skipped hour runs at the same clock time after the change, and a time that happens twice runs once.
The job (self.scheduled_jobs(), GET /api/plugins/{id}/schedules):
{"id": "evening", "plugin_id": "doorbell", "name": "Evening chime", "kind": "weekly",
"spec": {"time": "18:30", "days": ["mon", "fri"]}, "data": {"door": "Kitchen"}, "missed": "skip",
"enabled": true, "timezone": null, "created": "2026-10-12T16:21:09.246207+00:00",
"next_run": "2026-10-12T23:30:00+00:00", "last_run": null, "last_status": null, "last_error": null}
last_status: ok, error, missed or skipped. on_schedule(job) gets these fields plus scheduled_for and missed. self.unschedule(job_id) removes a job (True if it existed). Jobs of a plugin that is off skip to their next time. Each run publishes schedule.fired; a job that raises or runs over 5 minutes is recorded in last_error and published as error.
Over HTTP (Admin): GET /api/schedules, PUT /api/schedules/settings {"timezone"}, GET|POST /api/plugins/{id}/schedules ({"id", "kind", "spec", "name", "data", "missed", "timezone", "enabled"}), DELETE /api/plugins/{id}/schedules/{job}, PUT /api/plugins/{id}/schedules/{job}/enabled {"enabled": bool}.
Webhook triggers
A trigger is a URL that a device or another system calls to make the plugin act: a motion sensor, an ESP32 button, an IR receiver, a Home Assistant automation.
def get_triggers(self):
return [{"id": "play", "title": "Play", "methods": ["POST", "GET"],
"description": "Play a channel. Payload: channel, volume."}]
async def on_trigger(self, trigger, payload, context):
# context: {"method": "POST", "client": "192.0.2.31", "event_id": 44}
...
| Declaration key | Default | Meaning |
|---|---|---|
id |
required | Letters, digits, _, -; up to 64 |
title |
the id | Shown in lists |
description |
"" |
What it does and its payload |
methods |
["POST"] |
POST, GET or both |
Sonorium serves each trigger at:
POST /api/triggers/{plugin_id}/{trigger} JSON object or form fields = payload
GET /api/triggers/{plugin_id}/{trigger}?k=v query values = payload (as text)
Sonorium answers 202 {"accepted": true, "event_id": n} at once and runs on_trigger() afterwards, so a slow handler never keeps the device waiting. token is removed from the payload.
Tokens. Every call needs one of:
- a key with Run triggers (opens every trigger of every plugin);
- the plugin's token (opens all its triggers):
POST /api/plugins/{id}/triggers/tokenwith{}; - one trigger's token (opens only that one; give each device a separate token): the same with
{"trigger": "play"}.
A token is 32 random bytes, shown once; making a new one replaces the old one. DELETE /api/plugins/{id}/triggers/token[?trigger=play] revokes it; GET /api/plugins/{id}/triggers lists the triggers, their URLs and which tokens are set. Send it as Authorization: Bearer …, X-Sonorium-Token: … or ?token=…. Triggers need a token or a key even while sign-in is off.
| Answer | Why |
|---|---|
| 202 | Accepted |
| 400 | A bad plugin or trigger id; a body that isn't a JSON object |
| 401 | No token or a wrong one (also for a plugin that doesn't exist) |
| 404 | The plugin has no such trigger |
| 405 | The trigger doesn't take this method (Use POST or GET) |
| 409 | The plugin is switched off |
| 413 | A body over 16 KB |
| 429 | More than 10 calls in a burst, then 2 a second, per trigger; or more than 10 wrong tokens from one address (then one every 6 s). Retry-After says when. |
Each accepted call publishes trigger.received.
From an ESP32 (Arduino), when a button is pressed:
#include <WiFi.h>
#include <HTTPClient.h>
const char* URL = "http://192.0.2.20:8008/api/triggers/doorbell/ring";
const char* TOKEN = "EXAMPLEtoken0000000000000000000000000000000";
void ring() {
HTTPClient http;
http.begin(URL);
http.addHeader("Authorization", String("Bearer ") + TOKEN);
http.addHeader("Content-Type", "application/json");
int code = http.POST("{\"door\": \"Front door\"}"); // 202 = accepted
http.end();
}
From a Home Assistant automation, with a rest_command whose URL is the trigger's and whose Authorization header is Bearer and the token.
Pages
A plugin can add entries to the left menu, each with a page. The plugin describes the page as data (sections of text, fields, tables, buttons) and Sonorium draws it in the app's style, so a plugin page looks and works like the rest of the app. A plugin can't send HTML or scripts.
def get_pages(self):
return [{"id": "alarms", "title": "Alarms", "icon": "mdi:alarm"}]
async def get_page(self, page_id):
return {
"title": "Alarms",
"intro": "Plays a channel at set times.",
"actions": [{"action": "add", "label": "Add alarm", "style": "primary"}],
"refresh": 30,
"sections": [{
"id": "add", "title": "New alarm",
"items": [
{"type": "field", "name": "time", "field": {"type": "time", "label": "Time"}, "value": "07:00"},
{"type": "field", "name": "days", "field": {"type": "multiselect", "label": "Days",
"options": ["mon", "tue", "wed", "thu", "fri"]}},
{"type": "button", "action": "add", "label": "Add", "style": "primary"},
]}, {
"id": "list", "title": "Alarms",
"items": [
{"type": "table", "id": "alarms", "empty": "No alarms yet",
"columns": [{"key": "time", "label": "Time"}, {"key": "days", "label": "Days"},
{"key": "state", "label": "State", "format": "status"}],
"rows": [{"id": "a1", "time": "07:00", "days": "Mon-Fri", "state": {"state": "ok", "text": "On"}}],
"row_actions": [{"id": "delete", "label": "Delete", "style": "danger",
"confirm": "Delete this alarm?"}]},
]}],
}
async def page_action(self, page_id, action, data):
# data: {"values": {field name: value}, "row": the row's id for a row action, else None}
if action == "add":
...
return {"success": True, "message": "Alarm added"}
if action == "delete":
...
return {"success": True, "message": "Deleted"}
return await super().page_action(page_id, action, data)
The menu entry (get_pages()): id (letters, digits, _, -; up to 64), title, icon. It appears in the Plugins section of the left menu while the plugin is on.
The page (get_page()), built fresh each time it is opened:
| Key | Meaning |
|---|---|
title |
The page's title (default: the menu entry's) |
intro |
One line under the title |
actions |
Buttons in the page's header: {"action", "label", "style", "confirm"?} |
refresh |
Reload the page every N seconds |
sections |
[{"id", "title", "description", "items": [item]}] |
Items:
type |
Keys |
|---|---|
text |
text (plain text), style: normal, muted, success, warning, error |
field |
name, field (a field, below), value (the current value) |
table |
id, columns (key, label, format: text, number, boolean, datetime, status), rows (each may have an id), empty, row_actions (id, label, style, confirm) |
button |
action, label, style: primary, secondary, danger; confirm: a question asked first |
status |
state: ok, warning, error, off, unknown; text |
command_map |
map: one of the plugin's command maps, drawn as a list the user fills |
Field types: the settings types plus textarea, time (HH:MM), date (YYYY-MM-DD), datetime (ISO 8601) and multiselect (a list of option values).
A status column takes a state, or {"state": "ok", "text": "On"} for a chip with words. On a phone, the columns other than the first, status and ⋯ fold into a line under the first.
Icons are a name (alarm, mdi:alarm-clock) or a small inline SVG (at most 8 KB; no scripts, event attributes, links or foreignObject).
Buttons. A button or row action sends the values of every field on the page. Sonorium checks them against the fields (400 for a wrong value), and refuses an action the page doesn't show (404), before calling page_action(). The result {"success", "message", "refresh", "data"?} shows message as a notice; the page reloads unless "refresh": false.
Limits: 50 sections, 200 items per section, 20 columns, 1000 rows, 4000 characters of text. An item Sonorium doesn't know is left out.
Over HTTP (pg.use): GET /api/extensions lists every page; GET /api/plugins/{id}/pages/{page} returns one; POST /api/plugins/{id}/pages/{page}/actions/{action} with {"values", "row"} clicks a button. The Doorbell's page:
{"id": "rings", "title": "Doorbell", "icon": {"type": "name", "value": "mdi:doorbell"}, "plugin_id": "doorbell",
"path": "/api/plugins/doorbell/pages/rings", "intro": "The latest rings since Sonorium started.", "refresh": 10,
"actions": [{"type": "button", "action": "test", "label": "Ring now", "style": "primary", "confirm": null}],
"sections": [{"id": "rings", "title": "Latest rings", "description": "", "items": [
{"type": "table", "id": "rings", "columns": [{"key": "at", "label": "When", "format": "datetime"}, "..."],
"rows": [{"id": "s_27ad7fdc1282", "door": "Back door", "at": "2026-10-12T16:18:46.621436+00:00",
"channels": 1, "result": "finished"}], "empty": "No rings yet", "row_actions": []},
{"type": "button", "action": "clear", "label": "Clear the list", "style": "secondary",
"confirm": "Clear the list of rings?"}]}]}
Connections
A plugin that talks to another system (a hub, a chat bot, a cloud service) adds a row to Settings › Connection, next to Home Assistant and MQTT: its icon, name (marked Plugin), status, what it points at, Test, Edit (a dialog with the fields) and Remove. Saving needs no restart.
def get_connections(self):
return [{"id": "hub", "title": "Hubitat", "icon": "hub", "test": True,
"description": "The Maker API of your Hubitat hub.",
"fields": {
"host": {"type": "string", "label": "Hub address", "required": True},
"app_id": {"type": "number", "label": "Maker API app id", "min": 1},
"token": {"type": "secret", "label": "Access token"},
}}]
async def test_connection(self, connection_id, values):
ok = await self.try_hub(values["host"], values.get("token"))
return {"success": ok, "message": "Connected" if ok else "No answer from the hub"}
def connection_status(self, connection_id):
return {"state": "connected", "message": "12 devices"}
async def on_connection_changed(self, connection_id, values):
await self.reconnect(values)
| Declaration key | Meaning |
|---|---|
id |
Letters, digits, _, -; up to 64 |
title |
Up to 60 characters |
description |
One line |
icon |
As for pages |
fields |
Page field types plus secret |
test |
true shows Test and calls test_connection() |
connection_status() states: connected, disconnected, error, not_configured, unknown. The plugin reads the saved values, secrets included, with self.connection_values("hub").
Secret fields are write-only: the API never returns them; GET shows token_set: true instead. Saving with an empty secret keeps the saved one; "clear": ["token"] removes it. Secrets are kept apart from other values in a file only Sonorium's user can read.
Over HTTP (Admin): GET /api/plugins/{id}/connections/{conn} (fields, values without secrets, status), PUT … with {"values": {…}, "clear": [names]} (checked against the fields first), POST …/test with {"values": {…}} (the saved values with the form's unsaved ones on top).
Sounds and announcements
self.audio (capability audio.sounds) plays sounds over channels. A sound is mixed into each channel's stream, so every speaker, browser and cast device of the channel hears it. While it plays, the channel ducks (gets quieter) and comes back afterwards, the way a radio presenter talks over the music.
result = await self.audio.play(["kitchen-id", "office-id"], "https://example.com/ghost.mp3",
volume=90, duck={"db": 18, "attack": 0.3, "release": 2.0},
policy="now", label="Ghost")
await self.audio.play(channels, source=None, *, samples=None, sample_rate=None, volume=100, duck=True, policy="queue", priority=0, max_seconds=None, start_stopped=False, override=False, label="", announcement=False)
| Argument | Meaning |
|---|---|
channels |
Channel ids, 1-50 |
source |
A file path, an http(s) URL, or the bytes of an audio file (anything FFmpeg reads) |
samples, sample_rate |
Instead of source: a numpy array (mono (n,) or (channels, n); float -1..1 or int16) |
volume |
0-200; 100 is the file's level |
duck |
True (Settings › Advanced's Duck amount, attack and release), False, or {"db", "attack", "release"} |
policy |
now: at once, over anything playing. queue: after the sounds playing now, higher priority first. replace: cuts what plays or waits with the same or a lower priority; a higher one keeps playing and this one waits |
priority |
A whole number; announcements use 10 |
max_seconds |
Cut it sooner than Settings › Advanced › Longest sound (60 s; 0 there is no limit) |
start_stopped |
Start a stopped channel's speakers for the sound (at most Advanced › Wait for speakers, then Lead), play it, and stop them again (after Tail). The channel never shows as playing. Default false: a stopped channel is skipped |
override |
Skip gates (zones, quiet hours). For announcements and alarms |
label |
A name for it in events and lists |
announcement |
Channels with Announcements off skip it |
It returns {"sound_id", "length", "capped", "channels": {id: {"state", "message"}}}; a channel's state is playing, queued, starting or skipped (with the reason in message). A channel whose speakers are muted plays the sound to browsers only.
| Method | Does |
|---|---|
await self.audio.cancel(sound_id, channel=None) |
Stop it (on one channel, or all); True if it was playing or waiting |
await self.audio.sounds() |
What plays and waits, on every channel |
await self.audio.announce(channels, text=None, *, sound=None, provider=None, language=None, volume=100, duck=True, start_stopped=True) |
An announcement (below) |
self.audio.add_gate(gate) / remove_gate() |
Refuse sounds (below) |
Announcements take one path, whoever sends them (Home Assistant, MQTT, POST /api/announce, a plugin): a chime and/or text spoken, ducking, admin-level, starting stopped channels.
result = await self.audio.announce("Kids Room", "Dinner is ready", sound="https://example.com/chime.mp3")
channels: ids, names (not case-sensitive), a list, or "all". sound: a URL, a path or bytes, played first. duck: True, False, a number of dB, or {"db", "attack", "release"}. It returns {"played", "skipped", "channels", "chime", "speech", "notice"}: played the channel ids it plays on, skipped those with Announcements off, chime and speech the play results, notice why the text wasn't spoken when only the chime played. Text with no text to speech and no sound raises ActionError (unavailable); an unknown channel name not_found.
Each channel has an Announcements switch (on by default; PUT /api/sessions/{id}/announcements, and switch.sonorium_<channel>_announcements in Home Assistant). With it off, no announcement plays there.
Gates. A plugin that owns channels (zones) can refuse sounds that aren't admin-level. One gate per plugin:
def gate(channel, request):
# channel: the channel (channel.id, channel.name); request: sound_id, label, origin, priority
return "Quiet hours" if self.quiet(channel.id) else None # None allows it
self.audio.add_gate(gate)
A sound with override=True skips every gate.
Timing. The sound is exact to the stream. Network speakers (Cast, Sonos, AirPlay, DLNA) buffer a few seconds, so they play it that much later: fine for an announcement, noticeable for an effect that has to match a motion sensor. Audio outputs and browsers are close to instant.
Text to speech
A provider turns text into audio for announcements (capability audio.tts). The built-in Announcements plugin provides Home Assistant's (homeassistant), ready while Home Assistant is linked. The first provider registered is the default; an announcement may name one.
async def on_enable(self):
self.audio.register_tts("piper", "Piper (local)", self.speak, ready=lambda: True)
async def speak(self, message, language=None, **options) -> bytes:
return wav_or_mp3_bytes
| Method | Does |
|---|---|
self.audio.register_tts(provider_id, title, speak, ready=None) |
provider_id: letters, digits, _, -, ., up to 64. speak: async (message, language=None, **options) -> bytes of an audio file, at most 60 s to answer. ready(): whether it can speak now; when it can't, announcements play only their chime |
self.audio.tts_providers() |
[{"id", "title", "plugin_id"}] |
await self.audio.tts(message, provider=None, **options) |
The audio bytes |
Providers are removed when the plugin is switched off. GET /api/plugin-framework lists them as tts_providers.
Volume ramps
A ramp makes a channel louder or quieter gradually (capability audio.ramps): an alarm that rises over 20 minutes, a sleep timer that fades out over an hour. The gain is worked out for each sample, so it is smooth. It applies to the channel's mix: announcements play at their own level, and the speakers' volume stays as it is (set it with set_volume(), then ramp within it).
await self.ramps.start("2c62267b", to=1.0, seconds=1200, from_level=0.0, curve="db", start=True) # wake-up
await self.ramps.start("2c62267b", to=0.0, seconds=3600, then="stop") # sleep timer
await self.ramps.cancel("2c62267b", reset=False) # hold where it is
await self.ramps.status("2c62267b") # {"channel_id", "level", "ramp": {...} or None}
await self.ramps.start(channel, to, seconds, *, from_level=None, curve="db", then=None, start=False)
| Argument | Meaning |
|---|---|
to, from_level |
0 (silent) to 1 (the mix as it is), up to 4 (+12 dB). No from_level: from where it is |
seconds |
How long; 0 is at once |
curve |
db (default): even to the ear. linear, ease (slow, faster, slow), exp (slow at first, fast at the end) |
then |
stop or pause the channel when it ends; None keeps it playing |
start |
Play the channel first if it is stopped |
It returns {"ramp_id", "channel_id", "from", "to", "seconds", "curve", "then"}. cancel(channel, reset=False) holds the level reached (reset=True: back to 1) and returns {"ramp_id", "level", "reset"} or None. A finished ramp holds its level until the channel stops or another ramp starts. Events: ramp.started, ramp.finished, ramp.cancelled.
Audio outputs
A channel normally goes to speakers that pull its stream over the network. An output type sends it somewhere else (capability audio.outputs): the sound card of the machine Sonorium runs on, a Bluetooth speaker, a file. The plugin registers the type; Sonorium asks it for its devices, and for a sink when a channel is sent to one.
from sonorium.core.audio_outputs import OutputType, Sink
from sonorium.plugins.base import BasePlugin
class HostAudio(OutputType):
id = "host_audio"
title = "This computer"
def available(self):
try:
import sounddevice
sounddevice.query_devices()
return True, ""
except Exception as e:
return False, f"No sound card here ({e})"
async def discover(self):
import sounddevice
return [{"id": str(i), "name": d["name"]} for i, d in enumerate(sounddevice.query_devices())
if d["max_output_channels"] > 0]
def open(self, device_id, sample_rate, options):
return HostSink(int(device_id), sample_rate)
class HostSink(Sink):
latency = 0.1
def __init__(self, device, rate):
import sounddevice
self.stream = sounddevice.OutputStream(device=device, samplerate=rate, channels=1, dtype="int16")
self.stream.start()
def write(self, block): # int16, shape (1, n), mono, at sample_rate
self.stream.write(block.reshape(-1, 1)) # may block: it runs in a separate thread
def close(self):
self.stream.stop()
self.stream.close()
class HostAudioPlugin(BasePlugin):
id = "host_audio"
name = "Host audio"
version = "1.0.0"
async def on_enable(self):
self.audio_outputs.register_type(HostAudio())
OutputType member |
Meaning |
|---|---|
id, title |
The type's id and name |
available() |
(usable here, why not) |
async discover() |
[{"id", "name", …}]; extra keys reach the UI |
open(device_id, sample_rate, options) |
A Sink for a channel |
Sink member |
Meaning |
|---|---|
latency |
Seconds the device adds |
write(block) |
A numpy int16 block, shape (1, n), mono, at sample_rate. It runs in a separate thread and may block; a slow sink falls behind alone |
close() |
The channel stopped or was taken off the output |
self.audio_outputs also has unregister_types(), await types(), await devices(type_id), await attach(channel, type_id, device_id, options=None), await detach(channel, attachment_id) and await attachments(channel=None). Types are removed when the plugin is switched off.
sounddevice isn't part of every install: the plugin brings it with "requirements": ["sounddevice"]. Where host audio can work: Windows, Linux and macOS installs; Docker on Linux with the sound device (/dev/snd) or the host's sound server socket passed in; not the Home Assistant app.
A user sends a channel to a device with POST /api/sessions/{id}/audio-outputs {"type", "device", "options"}. A channel's outputs are remembered and start again when it plays. Each reads the channel's stream like a speaker: it keeps the channel from going idle and follows the speakers-muted switch.
Command maps
An input plugin turns what it receives (an IR code, a button on an RF remote, an MQTT message) into a code and hands it over (capability commands). The user decides what each code does in a command map: a list of codes, each with an action, a channel and its arguments.
def get_command_maps(self):
return [{"id": "remote", "title": "TV remote",
"codes": [{"code": "KEY_VOLUMEUP", "label": "Volume +"}]}] # known codes, optional
async def on_ir(self, code):
await self.commands.received("remote", code)
The plugin shows the map on its page with {"type": "command_map", "map": "remote"}. There, Learn waits for the next code the plugin receives (press the button, up to 60 s), and Add adds one by hand. An entry:
{"code": "KEY_VOLUMEUP", "label": "Volume +", "action": "channel.volume_up",
"channel": "2c62267b", "args": {"step": 5}, "enabled": true}
| Action | Arguments |
|---|---|
channel.play, channel.stop, channel.toggle (play or stop), channel.next, channel.mute (mute or unmute speakers) |
|
channel.volume |
volume 0-100, or the number sent with the code: received(map, code, value=42) |
channel.volume_up, channel.volume_down |
step 1-50 (default 5) |
channel.theme, channel.preset, channel.sequence, channel.playlist |
theme_id, preset_id, sequence_id, playlist_id |
channel.sound |
url, volume 0-200 (default 100) |
stop_all, nothing |
| Method | Does |
|---|---|
await self.commands.received(map_id, code, value=None) |
Runs the user's entries for the code (or learns it) → {"learned", "ran", "errors"} |
await self.commands.entries(map_id) |
The map's entries |
await self.commands.set_entries(map_id, entries) |
Replace them (up to 300; codes up to 200 characters) |
The actions run through the plugin's actions, so they behave like the same change in the UI. Every code received publishes command.received. Over HTTP (Admin): GET /api/command-actions, GET /api/plugins/{id}/command-maps, PUT …/command-maps/{map} {"entries": [...]}, POST …/learn, POST …/test {"code"}.
MQTT
The broker Sonorium is connected to (capability mqtt): the Mosquitto app on Home Assistant, or the one set in Settings › Connection.
async def on_enable(self):
self.mqtt.subscribe("zigbee2mqtt/front_door", self.on_door) # "+" and "#" wildcards
async def on_door(self, topic, payload):
if payload.get("contact") is False:
await self.ring("Front door", "MQTT")
await self.mqtt.publish("doorbell/state", {"last_ring": "Front door"}, retain=True) # a dict is sent as JSON
| Member | Meaning |
|---|---|
self.mqtt.available |
A broker is connected. Without one, publishing does nothing and subscriptions start when one is linked |
await publish(topic, payload, retain=False) |
Publish; a dict or list is sent as JSON |
subscribe(topic_filter, handler) |
handler(topic, payload), async or not, at most 30 s; up to 100 subscriptions per plugin |
unsubscribe(topic_filter=None) |
One filter, or all; how many were removed |
prefix |
Sonorium's topic prefix (sonorium) |
device_info |
The "Sonorium" device, to put a plugin's Home Assistant entities next to Sonorium's |
Messages reach Sonorium's own entities first, then plugins. Subscriptions are removed when the plugin is switched off.
Home Assistant
Calls to Home Assistant's REST API through the connection Sonorium already has (capability homeassistant): the Supervisor on the Home Assistant app, the saved URL and token on Docker and Windows. The token never reaches the plugin, and only Home Assistant's /api/... paths are allowed.
if self.homeassistant.available:
status, states = await self.homeassistant.request("GET", "/api/states")
audio = await self.homeassistant.tts("Dinner is ready", engine="tts.piper")
| Member | Meaning |
|---|---|
available |
Home Assistant is linked |
await request(method, path, json=None) |
(status, answer); 20 s timeout |
await fetch(path) |
The bytes at a Home Assistant path (e.g. a TTS proxy URL) |
await tts(message, engine=None, language=None, options=None) |
Speech as audio bytes (up to 32 MB), through Home Assistant's tts_get_url and its TTS proxy |
await tts_engines() |
The tts.* entities |
Failures raise ActionError (unavailable).
Themes from a plugin
A plugin that creates a theme or adds to one (an importer, a generator) goes through ThemeEdit, so it never writes metadata.json or presets.json itself. Nothing is written before commit(); a failure leaves the themes as they were.
from pathlib import Path
edit = self.new_theme("Rainy Tavern", description="Rain on the windows of a busy inn", icon="🌧️")
fire = edit.add_file(downloaded_bytes, "Fireplace.mp3", settings={"playback_mode": "continuous"})
edit.add_file(Path("/tmp/door.wav"), group="Doors", settings={"playback_mode": "sparse", "presence": 0.4})
edit.set_group("Doors", {"volume": 0.8})
edit.add_preset("Quiet night", {fire: {"volume": 0.5}})
edit.add_extra_file("ATTRIBUTION.md", "Sounds by ...")
result = edit.commit() # {"theme_id", "folder", "name", "is_new", "added"}
| Call | Does |
|---|---|
self.list_themes() |
[{"id", "name", "folder", "track_count", "preset_count"}] |
self.new_theme(name, description="", icon="", attribution=None, categories=None) |
A ThemeEdit for a new theme |
self.open_theme(theme_id) |
A ThemeEdit for a theme, by id or folder; ThemeEditError when missing or broken |
edit.add_file(source, filename=None, group=None, settings=None) |
A track from a Path or bytes (bytes need filename); a taken name gets (2); default Background. Returns the track key |
edit.add_link(theme_id, track, group=None, settings=None) |
Link another theme's track |
edit.set_track(key, **fields) |
Change a track's settings |
edit.set_group(name, settings) |
Merge a group's settings |
edit.add_preset(name, tracks, groups=None, is_default=None, mute_others=True) |
A preset: track key → settings; tracks it doesn't name are muted in it. Returns the preset id |
edit.add_extra_file(filename, content) |
A file that isn't a track, at the top of the folder; never replaces one |
edit.commit() |
Write everything |
Track settings: playback_mode (continuous Background, sparse Intermittent, presence Ebb & Flow, unassigned), volume, presence, volume_low, rise (0-1), muted, seamless_loop, exclusive, pause_between (booleans), fade_in, fade_out, pause_min, pause_max (seconds). The theme format is in docs/THEME_FORMAT.md. A plugin action that changed themes returns "refresh_themes": true so Sonorium rescans them.
Python packages and programs
Python packages (capability packages.pip): "requirements": ["pyserial==3.5"] in the manifest, up to 30, each a package name with optional extras and version conditions (no URLs, paths or pip options).
- Off until an Admin switches on Settings › Advanced › Plugin packages (then restarts). A package comes from the internet and runs with Sonorium's rights, like the plugin. A plugin that needs packages while the switch is off is refused with that sentence.
- Installed with pip, wheels only (nothing is built and no setup script runs), into
plugin_packages/<plugin id>/in the config folder, once, and again when the requirements or Python change. Removed with the plugin.GET /api/plugins/{id}/packagesshows their state. - That folder is added to the end of Python's search path: Sonorium's packages always win.
| Install | Wheels |
|---|---|
| Home Assistant app | Alpine Linux: musllinux |
| Docker | Debian: manylinux |
| Windows, Linux, macOS | The platform's |
Programs (capability packages.binaries), such as ffmpeg:
"binaries": {"ffmpeg": {
"linux-x86_64": {"path": "bin/linux-x86_64/ffmpeg", "sha256": "…"},
"linux-aarch64": {"path": "bin/linux-aarch64/ffmpeg", "sha256": "…"},
"windows-amd64": {"path": "bin/windows-amd64/ffmpeg.exe", "sha256": "…"}}}
Each file is checked when the plugin loads: it must be inside the plugin's folder (no links) and match its SHA-256; a mismatch refuses the plugin. self.binary("ffmpeg") is the path for this platform, or None. Platforms: linux-x86_64, linux-aarch64, windows-amd64, darwin-arm64, … On the Home Assistant app, build for musl (or statically).
API version and capabilities
The plugin API has a version, MAJOR.MINOR; Sonorium 1.7.1 has 1.1. Adding a service or a field raises the minor version: a plugin written for 1.0 runs on 1.1 and every later 1.x, because nothing it uses is renamed or removed. The major version goes up only for a change old plugins would notice.
Sonorium reads api_version and requires before it loads any of the plugin's code. A plugin that needs a newer API, another major version, or a capability this install doesn't offer is refused with a sentence that says why, for example:
Remote needs plugin API 1.3; this Sonorium has 1.1. Update Sonorium to use it.
A refused plugin is listed in Settings › Plugins with that sentence (compatible: false and problem in GET /api/plugins), the web UI shows an alert, and none of its code runs.
| Capability | Since | What it is |
|---|---|---|
events |
1.0 | Follow and publish events |
actions |
1.0 | Control channels, themes and speakers |
scheduler |
1.0 | Persistent scheduled jobs |
triggers |
1.0 | Webhook triggers with tokens |
pages |
1.0 | Left-menu pages drawn from a schema |
connections |
1.0 | Rows in Settings › Connection |
audio.sounds |
1.1 | Sounds over channels, with ducking; announcements |
audio.ramps |
1.1 | Volume ramps |
audio.outputs |
1.1 | Audio output types |
audio.tts |
1.1 | Text to speech providers |
commands |
1.1 | Command maps |
mqtt |
1.1 | Sonorium's MQTT broker |
homeassistant |
1.1 | Home Assistant's API |
packages.pip |
1.1 | Python packages |
packages.binaries |
1.1 | Programs per platform |
GET /api/plugin-framework gives the version, every capability and those this install offers now (offered). Each 1.1 service is a facade on the plugin (self.audio, self.ramps, self.audio_outputs, self.commands, self.mqtt, self.homeassistant); every method that can fail raises ActionError, as self.actions does.
Packaging, installing and updating
A zip. Zip the plugin folder so the zip holds my_plugin/plugin.py and my_plugin/manifest.json (a zip with the files at its top works too; the id then comes from the manifest, or the zip's name). Sonorium uses the first plugin.py it finds.
Upload (an Admin):
- Open Settings in the left menu.
- Click Plugins.
- Click Upload plugin.
- Pick the zip.
- Switch the plugin on with its On switch.
⋯ › Plugin requirements on that page lists what a zip has to contain. Or POST /api/plugins/upload (multipart, field file). The upload is refused (400) without a version in the manifest or class, with a version that isn't MAJOR.MINOR.PATCH, with a path outside the folder, or without plugin.py. A plugin with the same id is replaced; its settings, jobs, tokens and connection values are kept. The answer has the plugin as GET /api/plugins/{id} shows it, or "status": "warning" when the files were installed but the plugin didn't load (the log says why).
Uninstall: ⋯ › Uninstall… on its row, or DELETE /api/plugins/{id}.
The Catalog. Settings › Plugins › Catalog lists the plugins in plugins/catalog.json of the Sonorium repository, each with its zip next to it:
{"version": 1, "updated": "2026-10-11",
"plugins": [{"id": "theme_merge", "name": "Theme Merge",
"description": "Merge two themes together - combines tracks, groups and presets",
"version": "2.0.0", "author": "Sonorium", "category": "utilities", "zip_file": "theme_merge_v2.0.0.zip"}]}
Install downloads and installs the zip; Update appears when the Catalog's version is newer than the installed one, so raise version for every release. The zip is fetched from the plugins folder next to catalog.json. Built-in plugins that were uninstalled are listed there too, to install again.
Storage and security
- Plugins run inside Sonorium with its rights: they can read its files and the network. Install plugins you trust, as you would an add-on.
- Settings are in
state.json; jobs, plain connection values, the scheduler's time zone, command maps and audio output choices inplugin_framework.json; token hashes and secret connection values inplugin_secrets.json(readable only by the user Sonorium runs as). All in Sonorium's config folder, kept across updates. Keep anything else the plugin needs to remember in a file in the config folder, or in memory as the Doorbell does. - Tokens (triggers, the event stream) are 32 random bytes. Sonorium keeps only their SHA-256 hash and compares in constant time; a wrong token and a missing plugin get the same answer.
token=is masked in the log. - The management routes (installing, settings, tokens, jobs, connections) need an Admin. The trigger and event stream routes are the ones meant for other devices, and they need a token or a key.
- A plugin never logs secrets or tokens;
connection_values()is for the plugin's use only.
Testing
The quickest test is the Doorbell's: install the zip on a test Sonorium, switch it on, call its trigger and page with curl, and follow GET /api/events/stream?types=* while you do. Errors in a handler appear as error events and in Settings › Logs.
The repository's tests load plugins without Home Assistant or speakers; copy the pattern of tests/test_plugin_framework_api.py:
- Build a session manager with fake speakers (
build()intests/test_sequences_playlists.py). - Make a
PluginHost(with a freshEventBus) and aPluginManager(state_store, plugins_dir=..., host=host), with the plugin folder copied intoplugins_dir. - Mount
create_plugin_framework_router(manager, host)in a FastAPI app and useTestClientas a context manager; run async calls withclient.portal.call(...)(manager.initialize,host.start,manager.enable_plugin). - Call the triggers, pages and actions over HTTP and check the events in
bus.history()and the channels' state.
Run the suite with python -m pytest -q tests.