-
-
Notifications
You must be signed in to change notification settings - Fork 2
en Module Api Guide
Complete reference for building system and user modules that communicate with the SelenaCore smart home hub.
- Architecture Overview
- Module Types
- SystemModule API
- SmartHomeModule API
- EventBus Events Reference
- WebSocket Module Bus Protocol
- Intent System
- Widget and Settings HTML
- manifest.json Reference
- Examples
SelenaCore uses a hub-and-spoke architecture. The core process runs FastAPI on port 80 and manages all modules, devices, and events. Modules are fully isolated from each other. No module may import from another module. All inter-module communication passes through the core EventBus.
SelenaCore Process (port 80)
+-----------------------------+
| EventBus DeviceRegistry |
| IntentRouter ModuleBus |
+-----------------------------+
/ | \
importlib importlib WebSocket
| | |
[voice-core] [llm-engine] [user-module]
(SYSTEM) (SYSTEM) (Docker container)
| Type | Execution | Port | Communication | Container |
|---|---|---|---|---|
| SYSTEM | importlib in core process | None | Direct Python calls via SystemModule methods |
smarthome-core (shared) |
| UI | Docker sandbox | 8100-8200 | WebSocket Module Bus | smarthome-modules |
| INTEGRATION | Docker sandbox | 8100-8200 | WebSocket Module Bus | smarthome-modules |
| DRIVER | Docker sandbox | 8100-8200 | WebSocket Module Bus | smarthome-modules |
| AUTOMATION | Docker sandbox | 8100-8200 | WebSocket Module Bus | smarthome-modules |
SYSTEM modules have zero RAM overhead beyond their own objects. User modules run in isolated Docker containers and communicate exclusively over the WebSocket Module Bus.
Source: core/module_loader/system_module.py
System modules inherit from SystemModule and run inside the core process via importlib.
| Attribute | Type | Description |
|---|---|---|
name |
str |
Must match the "name" field in manifest.json. Set as a class attribute. |
Called by the module loader before start(). Injects core services. Do not call this yourself.
| Parameter | Type | Description |
|---|---|---|
bus |
EventBus |
Core event bus instance |
session_factory |
async_sessionmaker |
SQLAlchemy async session factory |
Initialize your service, subscribe to events, register intents. Called by the loader after setup().
async def start(self) -> None:
self.subscribe(["device.state_changed"], self._on_device_event)
self._task = asyncio.create_task(self._poll_loop())Cancel background tasks, release resources, unsubscribe from events.
async def stop(self) -> None:
self._task.cancel()
self._cleanup_subscriptions()Return a FastAPI APIRouter to expose REST endpoints. The router is mounted at /api/ui/modules/{name}/. Return None if no endpoints are needed.
def get_router(self) -> APIRouter:
router = APIRouter()
@router.get("/status")
async def get_status():
return {"active": True, "readings": self._readings}
self._register_html_routes(router, __file__)
self._register_health_endpoint(router)
return routerSubscribe to EventBus events with a direct async callback. Returns a subscription ID.
| Parameter | Type | Description |
|---|---|---|
event_types |
list[str] |
Event types to listen for (e.g. ["device.state_changed"]) |
callback |
Callable |
Async function with signature async def handler(event) -> None
|
Returns: str -- subscription ID
sub_id = self.subscribe(["voice.intent"], self._on_intent)
async def _on_intent(self, event) -> None:
intent = event.payload.get("intent", "")
if intent == "mymodule.action":
await self.speak("Done")Publish an event to the EventBus. All subscribed modules (system and user) receive it.
| Parameter | Type | Description |
|---|---|---|
event_type |
str |
Event type string (e.g. "device.state_changed") |
payload |
dict[str, Any] |
Event payload data |
await self.publish("device.state_changed", {
"device_id": "abc-123",
"state": {"temperature": 22.5},
"previous_state": {"temperature": 21.0},
})Publish a voice.speak event and block until TTS completes (voice.speak_done). This ensures speech finishes before subsequent actions (e.g., starting radio playback after an announcement).
| Parameter | Type | Default | Description |
|---|---|---|---|
text |
str |
-- | Text to speak via Piper TTS |
timeout |
float |
30.0 |
Maximum wait time in seconds |
await self.speak("Playing jazz radio")
# Speech is finished here; safe to start playback
await self._start_playback(station_url)Return all registered devices as plain dicts.
Returns: list[dict] with keys: device_id, name, type, protocol, state, capabilities, last_seen, module_id, meta
devices = await self.fetch_devices()
sensors = [d for d in devices if d["type"] == "sensor"]Return the state dict of a single device. Returns {} if not found.
| Parameter | Type | Description |
|---|---|---|
device_id |
str |
UUID of the device |
state = await self.get_device_state("abc-123")
temp = state.get("temperature", 0)Update a device's state in the registry. Auto-commits the transaction.
| Parameter | Type | Description |
|---|---|---|
device_id |
str |
UUID of the device |
state |
dict[str, Any] |
New state key-value pairs (merged with existing) |
await self.patch_device_state("abc-123", {"temperature": 23.0, "mode": "cool"})Register a new device in the registry. Returns the generated device_id.
| Parameter | Type | Description |
|---|---|---|
name |
str |
Human-readable device name |
type |
str |
"sensor", "actuator", "controller", or "virtual"
|
protocol |
str |
"zigbee", "mqtt", "wifi", "bluetooth", etc. |
capabilities |
list[str] |
List of capability strings (e.g. ["read_temperature"]) |
meta |
dict[str, Any] |
Protocol-specific metadata |
Returns: str -- the new device UUID
device_id = await self.register_device(
name="Living Room Temp",
type="sensor",
protocol="zigbee",
capabilities=["read_temperature", "read_humidity"],
meta={"zigbee_addr": "0x5678"},
)Register /widget and /settings HTML endpoints on the router. Serves widget.html and settings.html from the module directory. Call at the end of get_router().
| Parameter | Type | Description |
|---|---|---|
router |
APIRouter |
The router to register routes on |
module_file |
str |
Pass __file__ to locate HTML files relative to the module |
def get_router(self) -> APIRouter:
router = APIRouter()
# ... your routes ...
self._register_html_routes(router, __file__)
return routerRegister a minimal GET /health endpoint returning {"status": "ok", "module": name}. Use only for modules that need no extra health status fields.
| Parameter | Type | Description |
|---|---|---|
router |
APIRouter |
The router to register the endpoint on |
self._register_health_endpoint(router)
# GET /api/ui/modules/my-module/health → {"status": "ok", "module": "my-module"}Unsubscribe all direct EventBus subscriptions registered via subscribe(). Always call this in stop().
async def stop(self) -> None:
self._task.cancel()
self._cleanup_subscriptions()Async context manager yielding a raw SQLAlchemy AsyncSession. Use only when fetch_devices() / patch_device_state() are insufficient.
async with self._db_session() as session:
result = await session.execute(select(Device).where(Device.protocol == "zigbee"))
devices = result.scalars().all()Source: sdk/base_module.py (re-exported from sdk/smarthome_sdk/base.py)
User modules inherit from SmartHomeModule and communicate with core over the WebSocket Module Bus.
| Attribute | Type | Default | Description |
|---|---|---|---|
name |
str |
"unnamed_module" |
Module identifier, should match manifest.json |
version |
str |
"0.1.0" |
Semantic version string |
Register a regex intent handler. When the core routes a voice/text command to this module, matching handlers are called in order.
| Parameter | Type | Default | Description |
|---|---|---|---|
pattern |
str |
-- | Regex pattern (case-insensitive) |
order |
int |
50 |
Priority (lower = higher). Ranges: 0-29 system, 30-49 core, 50-99 user |
name |
str |
"" |
Intent name for LLM catalog (e.g. "email.check_inbox") |
description |
str |
"" |
Human-readable description for LLM context |
Handler signature: async def handler(text: str, context: dict) -> dict | None
Return value: {"handled": True, "tts_text": "..."} or {"handled": True, "data": {...}} or None (not handled)
@intent(r"weather|forecast|pogoda", name="weather.current", description="Get current weather")
async def handle_weather(self, text: str, context: dict) -> dict:
temp = await self._fetch_temperature()
return {"handled": True, "tts_text": f"Currently {temp} degrees"}Subscribe to an EventBus event type. Supports wildcards (device.* matches device.state_changed, device.offline, etc.).
| Parameter | Type | Description |
|---|---|---|
event_type |
str |
Event type or wildcard pattern (e.g. "device.*") |
Handler signature: async def handler(data: dict) -> None
@on_event("device.state_changed")
async def on_device_change(self, data: dict) -> None:
device_id = data.get("device_id")
new_state = data.get("state", {})
self._log.info("Device %s changed: %s", device_id, new_state)Run a method on a recurring schedule. Supports simple interval notation and standard cron.
| Format | Example | Description |
|---|---|---|
every:Ns |
every:30s |
Every 30 seconds |
every:Nm |
every:5m |
Every 5 minutes |
every:Nh |
every:1h |
Every 1 hour |
| cron | */5 * * * * |
Standard cron (requires apscheduler) |
@scheduled("every:5m")
async def poll_sensor(self) -> None:
reading = await self._read_sensor()
await self.publish_event("device.state_changed", {
"device_id": self._sensor_id,
"state": {"value": reading},
})Called once before the bus connection is established. Override for initialization.
async def on_start(self) -> None:
self._db = await self._init_database()
self._log.info("Database initialized")Called once during graceful stop. Use for resource cleanup (close connections, save state).
async def on_stop(self) -> None:
await self._db.close()
self._log.info("Resources released")Called when core sends a shutdown notification. Lightweight hook for last-moment state save. Do not do heavy cleanup here -- use on_stop() for that.
async def on_shutdown(self) -> None:
await self._save_state_snapshot()Publish an event via the Module Bus. Automatically buffers in an outbox (up to 500 messages) if disconnected.
| Parameter | Type | Description |
|---|---|---|
event_type |
str |
Event type string |
payload |
dict[str, Any] |
Event payload data |
Returns: bool -- True if sent or buffered, False if outbox is full
await self.publish_event("device.state_changed", {
"device_id": "plug-001",
"state": {"on": True, "power_w": 150},
})Send a re_announce message to hot-reload intents and subscriptions without reconnecting. Call after dynamically adding or removing intent handlers.
self._intent_handlers.append((new_pattern, 50, handler, name, desc))
await self.update_capabilities()Send an API request to core via the bus and wait for a response. Raises TimeoutError or ConnectionError on failure.
| Parameter | Type | Default | Description |
|---|---|---|---|
method |
str |
-- | HTTP method: "GET", "POST", "PATCH", "DELETE"
|
path |
str |
-- | API path (e.g. "/devices") |
body |
Any |
None |
Request body (JSON-serializable) |
timeout |
float |
10.0 |
Maximum wait time in seconds |
Returns: dict -- response body from core
devices = await self.api_request("GET", "/devices")
for d in devices.get("devices", []):
self._log.info("Found device: %s", d["name"])Fetch a single device from the registry via the bus. Returns None on error.
| Parameter | Type | Description |
|---|---|---|
device_id |
str |
UUID of the device |
device = await self.get_device("abc-123")
if device:
self._log.info("Device state: %s", device.get("state"))Override to handle incoming API requests from core (UI proxy). Default returns a 404-like error.
| Parameter | Type | Description |
|---|---|---|
method |
str |
HTTP method string |
path |
str |
Request path |
body |
Any |
Request body |
async def handle_api_request(self, method: str, path: str, body) -> dict:
if method == "GET" and path == "/status":
return {"power": self._current_power, "on": self._is_on}
return {"error": f"Not found: {method} {path}"}Translate a key using locale files from the module's locales/ directory. Falls back to English, then returns the raw key.
| Parameter | Type | Default | Description |
|---|---|---|---|
key |
str |
-- | Translation key |
lang |
str | None |
None (falls back to "en") |
Language code |
**kwargs |
Any |
-- | Interpolation values |
Locale files are loaded automatically from locales/en.json, locales/uk.json etc. next to the module file.
# locales/en.json: {"plug_on": "Smart plug turned on", "power": "Current power: {watts}W"}
msg = self.t("plug_on") # "Smart plug turned on"
msg = self.t("power", watts=150) # "Current power: 150W"
msg = self.t("plug_on", lang="uk") # Ukrainian translationCapabilities are built automatically from decorators and manifest.json, then sent to core during the announce handshake. Manifest intents take priority over decorator-discovered intents.
# Auto-built from @intent, @on_event decorators and manifest.json
{
"intents": [
{
"patterns": {"en": ["weather|forecast"], "uk": ["weather|forecast"]},
"priority": 50,
"name": "weather.current",
"description": "Get current weather"
}
],
"subscriptions": ["device.*"],
"publishes": ["custom.event"]
}Published only by the core. Modules cannot publish core.* events (403 Forbidden).
| Event | Description |
|---|---|
core.startup |
Core process started |
core.shutdown |
Core process shutting down |
core.integrity_violation |
Integrity Agent detected file changes |
core.integrity_restored |
Agent rolled back changes successfully |
core.safe_mode_entered |
System entered SAFE MODE |
core.safe_mode_exited |
SAFE MODE lifted |
| Event | Payload | Description |
|---|---|---|
device.state_changed |
{device_id, state, previous_state} |
Device state updated in registry |
device.registered |
{device_id, name, type, protocol} |
New device added |
device.removed |
{device_id} |
Device deleted from registry |
device.online |
{device_id} |
Device available after being offline |
device.offline |
{device_id} |
No heartbeat > 90 seconds |
device.discovered |
{ip, mac, manufacturer} |
Network scanner found new device |
device.command |
{device_id, command, params} |
Control command for a device |
device.protocol_heartbeat |
{protocol, devices_count} |
Protocol health ping |
| Event | Payload | Description |
|---|---|---|
module.started |
{name, version} |
Module started successfully |
module.stopped |
{name} |
Module stopped normally |
module.installed |
{name, version} |
Module installed and started |
module.removed |
{name} |
Module uninstalled |
module.error |
{name, error} |
Module crashed or returned error |
| Event | Payload | Description |
|---|---|---|
voice.wake_word |
{} |
Wake word detected |
voice.recognized |
{text, lang} |
STT transcription complete |
voice.intent |
{intent, params, source, raw_text, latency_ms, user_id, response, action} |
Intent router result |
voice.response |
{text, query} |
LLM/fallback response ready for TTS |
voice.speak |
{text, speech_id} |
TTS speech request |
voice.speak_done |
{speech_id} |
TTS speech completed |
voice.tts_start |
{speech_id} |
TTS engine started generating audio |
voice.tts_done |
{speech_id} |
TTS engine finished generating audio |
voice.privacy_on |
{} |
Privacy mode enabled (mic off) |
voice.privacy_off |
{} |
Privacy mode disabled (mic on) |
| Event | Description |
|---|---|
automation.triggered |
Automation rule fired |
sync.command_received |
Command from cloud platform |
sync.command_ack |
Command acknowledged |
registry.entity_changed |
Entity (device/scene/module/station) created/updated/deleted. Payload: {entity_type, entity_id, action}
|
User modules connect to the core via WebSocket.
URL: ws://core/api/v1/bus?token=<MODULE_TOKEN>
Environment variables (set by the container runtime):
| Variable | Description |
|---|---|
SELENA_BUS_URL |
WebSocket URL (default: ws://localhost/api/v1/bus) |
MODULE_TOKEN |
Authentication token for this module |
MODULE_DIR |
Path to the module directory |
Connection lifecycle:
connect(token) → announce → announce_ack → message_loop → reconnect (on error)
The SmartHomeModule base class handles connection, reconnection with exponential backoff, and message routing automatically. You do not need to manage the WebSocket directly.
| Type | Description | Payload |
|---|---|---|
announce |
Register capabilities on connect | {module, capabilities} |
re_announce |
Update capabilities without reconnect | {capabilities} |
intent_response |
Reply to an intent request | {id, payload: {handled, tts_text?, data?}} |
event |
Publish an event | {payload: {event_type, data}} |
api_request |
Request core API | {id, method, path, body} |
api_response |
Reply to incoming API request | {id, payload} |
pong |
Health check reply | {ts} |
| Type | Description | Payload |
|---|---|---|
announce_ack |
Confirm registration | {status, bus_id, warnings?} |
intent |
Route a voice/text command | {id, payload: {text, lang, context}} |
event |
Deliver a subscribed event | {payload: {event_type, data}} |
api_request |
Proxy an API request to module | {id, method, path, body} |
api_response |
Reply to module's API request | {id, status, body} |
ping |
Health check | {ts} |
shutdown |
Core is shutting down | {drain_ms} |
Sent during announce and re_announce:
{
"intents": [
{
"patterns": {
"en": ["weather|forecast|temperature outside"],
"uk": ["pogoda|prognoz|temperatura"]
},
"priority": 50,
"name": "weather.current",
"description": "Get current weather conditions"
}
],
"subscriptions": ["device.state_changed", "core.shutdown"],
"publishes": ["custom.weather_updated"]
}- intents: Regex patterns per language. Core builds a sorted index from all module intents.
-
subscriptions: Event types this module wants to receive (wildcards supported:
device.*). - publishes: Event types this module may publish (informational, used for ACL).
User voice and text commands flow through a multi-tier pipeline:
User text
|
v
Tier 1: FastMatcher — keyword/regex from YAML config (~0 ms)
|
Tier 1.5: IntentCompiler — YAML vocabulary -> compiled regex (~0 ms)
|
Tier 2: Module Bus — user module intents via WebSocket (~1-10 ms)
|
Cache: IntentCache — SQLite cache of previous LLM results (~0 ms)
|
Tier 3: Local LLM — Ollama (phi-3-mini / gemma-2b) (300-800 ms)
|
Tier 4: Cloud LLM — OpenAI-compatible API (optional) (1-3 s)
|
Fallback: "Sorry, I didn't understand"
Each tier is tried in order. The first match wins.
System modules declare their owned intents in OWNED_INTENTS + _OWNED_INTENT_META and call _claim_intent_ownership() from start(). There is no config/intents/ directory and no central seed script. The full walkthrough lives in system-module-development.md §IntentRouter Integration; the architecture deep dive is in intent-routing.md.
INTENT_CHECK_STATUS = "mymodule.check_status"
OWNED_INTENTS = [INTENT_CHECK_STATUS]
class MyModule(SystemModule):
name = "my-module"
_OWNED_INTENT_META = {
INTENT_CHECK_STATUS: dict(
noun_class="DEVICE", verb="query", priority=100,
description="Report the current operational status of my-module.",
),
}
async def start(self) -> None:
self.subscribe(["voice.intent"], self._on_voice_intent)
if self._session_factory is not None:
await self._claim_intent_ownership() # see device_control/module.py for the canonical impl
async def _on_voice_intent(self, event) -> None:
payload = event.payload or {}
if payload.get("intent") != INTENT_CHECK_STATUS:
return
await self.speak_action(INTENT_CHECK_STATUS, {"result": "ok"})That's it. Zero FastMatcher patterns required — the LLM tier sees the intent in its dynamic catalog (built from intent_definitions) and routes natural-language utterances in any language to it. If you want a 0 ms English shortcut as well, add a row to intent_patterns with source='manual', lang='en', your intent_id and a regex.
Option A -- @intent decorator:
class MyModule(SmartHomeModule):
name = "my-module"
@intent(r"check\s+(?:the\s+)?status", name="mymodule.check_status")
async def handle_status(self, text: str, context: dict) -> dict:
return {"handled": True, "tts_text": "All systems operational"}Option B -- manifest.json intents:
{
"intents": [
{
"patterns": {
"en": ["check\\s+status", "show\\s+status"],
"uk": ["перевір\\s+статус"]
},
"priority": 50,
"name": "mymodule.check_status",
"description": "Check module status"
}
]
}The handler is still needed in code (via @intent or handle_api_request). Manifest intents control what patterns are registered in the bus index; decorators control local dispatch.
System modules serve widget.html and settings.html as iframes inside the SelenaCore dashboard.
Compute the base URL from the iframe location. Never hardcode localhost:PORT.
// Correct — works in all environments
var BASE = window.location.pathname.replace(/\/(widget|settings)(\.html)?$/, '');
fetch(BASE + '/status')
.then(function(r) { return r.json(); })
.then(function(data) { /* ... */ });
// Wrong — breaks in production
var BASE = "http://localhost:8115"; // never do thisInclude the shared theme stylesheet for consistent appearance:
<link rel="stylesheet" href="/api/shared/theme.css">Every widget and settings page must implement EN/UK localization:
<script>
var LANG = (function () {
try { return localStorage.getItem('selena-lang') || 'en'; }
catch (e) { return 'en'; }
})();
var L = {
en: {
title: 'Sensor Status',
no_data: 'No data available',
refresh: 'Refresh'
},
uk: {
title: 'Стан сенсора',
no_data: 'Немає даних',
refresh: 'Оновити'
}
};
function t(k) { return (L[LANG] || L.en)[k] || k; }
function applyLang() {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
el.textContent = t(el.getAttribute('data-i18n'));
});
}
</script>
<h1 data-i18n="title"></h1>
<p data-i18n="no_data"></p>
<button data-i18n="refresh" onclick="refresh()"></button>Listen for theme and language changes from the parent dashboard:
window.addEventListener('message', function (e) {
if (e.data && e.data.type === 'lang_changed') {
try { LANG = localStorage.getItem('selena-lang') || 'en'; } catch (ex) {}
applyLang();
refresh(); // reload data in the new language
}
if (e.data && e.data.type === 'theme_changed') {
// Theme CSS variables update automatically via theme.css
// Re-render any manually styled elements here
}
});Call applyLang() before the first refresh() or load() call during initialization.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Unique module identifier (e.g. "weather-service") |
version |
string |
Yes | Semantic version (e.g. "1.0.0") |
type |
string |
Yes |
"SYSTEM", "UI", "INTEGRATION", "DRIVER", "AUTOMATION"
|
runtime_mode |
string |
Yes |
"always_on", "on_demand", "scheduled"
|
description |
string |
No | Human-readable description |
api_version |
string |
No | Core API version (e.g. "1.0") |
port |
integer |
No | User modules only. Listening port (8100-8200). SYSTEM modules must NOT have this field. |
group |
string |
No | Module group for UI grouping |
permissions |
array |
No | ["device.read", "device.write", "events.subscribe", "events.publish", "secrets.oauth", "secrets.proxy"] |
ui |
object |
No | {icon, widget: {file, size}, settings} |
intents |
array |
No | Intent patterns for Module Bus registration |
entities |
array |
No | Entity definitions for registry |
publishes |
array |
No | Event types this module may publish |
resources |
object |
No |
{memory_mb, cpu} resource limits |
author |
string |
No | Author name |
license |
string |
No | License identifier |
homepage |
string |
No | Repository or documentation URL |
SYSTEM module example:
{
"name": "sensor-aggregator",
"version": "1.0.0",
"type": "SYSTEM",
"runtime_mode": "always_on",
"description": "Aggregates sensor data from multiple protocols",
"permissions": ["device.read", "events.subscribe", "events.publish"]
}User module example:
{
"name": "smart-plug",
"version": "1.0.0",
"type": "UI",
"api_version": "1.0",
"runtime_mode": "always_on",
"port": 8101,
"permissions": ["device.read", "device.write", "events.subscribe", "events.publish"],
"ui": {
"icon": "icon.svg",
"widget": {"file": "widget.html", "size": "2x1"},
"settings": "settings.html"
},
"intents": [
{
"patterns": {"en": ["toggle\\s+plug", "plug\\s+(on|off)"], "uk": ["розетк"]},
"priority": 50,
"name": "plug.toggle",
"description": "Toggle smart plug on/off"
}
]
}A system module that collects temperature readings from all sensors and publishes an aggregate event every 60 seconds.
File structure:
system_modules/sensor_aggregator/
__init__.py
module.py
manifest.json
widget.html
__init__.py:
from .module import SensorAggregatorModule as module_class
__all__ = ["module_class"]manifest.json:
{
"name": "sensor-aggregator",
"version": "1.0.0",
"type": "SYSTEM",
"runtime_mode": "always_on",
"description": "Aggregates temperature data from all sensors",
"permissions": ["device.read", "events.subscribe", "events.publish"]
}module.py:
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter
from core.module_loader.system_module import SystemModule
logger = logging.getLogger(__name__)
class SensorAggregatorModule(SystemModule):
name = "sensor-aggregator"
def __init__(self) -> None:
super().__init__()
self._readings: dict[str, float] = {}
self._task: asyncio.Task | None = None
async def start(self) -> None:
self.subscribe(["device.state_changed"], self._on_state_changed)
self._task = asyncio.create_task(self._aggregate_loop())
logger.info("SensorAggregator started")
async def stop(self) -> None:
if self._task:
self._task.cancel()
self._cleanup_subscriptions()
logger.info("SensorAggregator stopped")
def get_router(self) -> APIRouter:
router = APIRouter()
@router.get("/summary")
async def get_summary() -> dict[str, Any]:
temps = list(self._readings.values())
return {
"sensor_count": len(temps),
"average": sum(temps) / len(temps) if temps else 0,
"min": min(temps) if temps else 0,
"max": max(temps) if temps else 0,
"readings": self._readings,
}
self._register_html_routes(router, __file__)
self._register_health_endpoint(router)
return router
async def _on_state_changed(self, event: Any) -> None:
device_id = event.payload.get("device_id", "")
state = event.payload.get("state", {})
if "temperature" in state:
self._readings[device_id] = state["temperature"]
async def _aggregate_loop(self) -> None:
while True:
await asyncio.sleep(60)
if not self._readings:
continue
temps = list(self._readings.values())
await self.publish("sensor.aggregate", {
"average": sum(temps) / len(temps),
"count": len(temps),
})The router is mounted at /api/ui/modules/sensor-aggregator/summary.
A user module running in Docker that controls smart plugs via voice and events.
File structure:
smart-plug-module/
main.py
manifest.json
locales/
en.json
uk.json
manifest.json:
{
"name": "smart-plug",
"version": "1.0.0",
"type": "UI",
"api_version": "1.0",
"runtime_mode": "always_on",
"port": 8101,
"permissions": ["device.read", "device.write", "events.subscribe", "events.publish"],
"intents": [
{
"patterns": {
"en": ["(?:turn\\s+)?(on|off)\\s+(?:the\\s+)?plug", "toggle\\s+plug"],
"uk": ["(?:увімкн|вимкн)\\w*\\s+розетк"]
},
"priority": 50,
"name": "plug.toggle",
"description": "Toggle smart plug on/off"
}
],
"publishes": ["plug.state_changed"]
}locales/en.json:
{
"plug_on": "Smart plug turned on",
"plug_off": "Smart plug turned off",
"power_report": "Current power consumption: {watts} watts"
}locales/uk.json:
{
"plug_on": "Розетку увімкнено",
"plug_off": "Розетку вимкнено",
"power_report": "Поточне споживання: {watts} ватт"
}main.py:
from __future__ import annotations
import asyncio
import logging
from sdk.base_module import SmartHomeModule, intent, on_event, scheduled
logger = logging.getLogger(__name__)
class SmartPlugModule(SmartHomeModule):
name = "smart-plug"
version = "1.0.0"
def __init__(self) -> None:
super().__init__()
self._is_on: bool = False
self._power_w: float = 0.0
self._device_id: str | None = None
async def on_start(self) -> None:
self._log.info("Smart Plug module initializing")
async def on_stop(self) -> None:
self._log.info("Smart Plug module stopping")
@intent(r"(?:turn\s+)?(on|off)\s+(?:the\s+)?plug|toggle\s+plug",
name="plug.toggle", description="Toggle smart plug")
async def handle_toggle(self, text: str, context: dict) -> dict:
lang = context.get("_lang", "en")
if "off" in text.lower():
self._is_on = False
return {"handled": True, "tts_text": self.t("plug_off", lang=lang)}
else:
self._is_on = True
return {"handled": True, "tts_text": self.t("plug_on", lang=lang)}
@on_event("device.state_changed")
async def on_device_change(self, data: dict) -> None:
if data.get("device_id") == self._device_id:
state = data.get("state", {})
self._is_on = state.get("on", self._is_on)
self._power_w = state.get("power_w", self._power_w)
@scheduled("every:30s")
async def poll_power(self) -> None:
if not self._device_id:
return
device = await self.get_device(self._device_id)
if device:
self._power_w = device.get("state", {}).get("power_w", 0)
await self.publish_event("plug.state_changed", {
"device_id": self._device_id,
"on": self._is_on,
"power_w": self._power_w,
})
async def handle_api_request(self, method: str, path: str, body) -> dict:
if method == "GET" and path == "/status":
return {"on": self._is_on, "power_w": self._power_w}
if method == "POST" and path == "/toggle":
self._is_on = not self._is_on
return {"on": self._is_on}
return {"error": f"Not found: {method} {path}"}
if __name__ == "__main__":
asyncio.run(SmartPlugModule().start())A module that bridges an external weather API to the SelenaCore device registry, publishing periodic updates.
main.py:
from __future__ import annotations
import asyncio
import logging
from sdk.base_module import SmartHomeModule, intent, scheduled
logger = logging.getLogger(__name__)
WEATHER_API_URL = "https://api.open-meteo.com/v1/forecast"
class WeatherBridgeModule(SmartHomeModule):
name = "weather-bridge"
version = "1.0.0"
def __init__(self) -> None:
super().__init__()
self._lat: float = 50.45
self._lon: float = 30.52
self._last_weather: dict = {}
async def on_start(self) -> None:
self._log.info("Weather Bridge starting (lat=%s, lon=%s)", self._lat, self._lon)
async def on_stop(self) -> None:
self._log.info("Weather Bridge stopped")
@intent(r"weather|forecast|temperature\s+outside",
name="weather.current", description="Current weather conditions")
async def handle_weather(self, text: str, context: dict) -> dict:
if not self._last_weather:
return {"handled": True, "tts_text": "Weather data not yet available"}
temp = self._last_weather.get("temperature", "unknown")
desc = self._last_weather.get("description", "")
return {
"handled": True,
"tts_text": f"Currently {temp} degrees, {desc}",
"data": self._last_weather,
}
@scheduled("every:5m")
async def fetch_weather(self) -> None:
try:
import urllib.request
import json
url = (
f"{WEATHER_API_URL}"
f"?latitude={self._lat}&longitude={self._lon}"
f"¤t_weather=true"
)
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
current = data.get("current_weather", {})
self._last_weather = {
"temperature": current.get("temperature"),
"windspeed": current.get("windspeed"),
"description": self._weather_code(current.get("weathercode", 0)),
}
await self.publish_event("weather.updated", self._last_weather)
except Exception as exc:
self._log.error("Weather fetch failed: %s", exc)
@staticmethod
def _weather_code(code: int) -> str:
codes = {0: "clear sky", 1: "mainly clear", 2: "partly cloudy",
3: "overcast", 45: "fog", 61: "light rain", 71: "light snow"}
return codes.get(code, "unknown")
async def handle_api_request(self, method: str, path: str, body) -> dict:
if method == "GET" and path == "/current":
return self._last_weather if self._last_weather else {"error": "No data yet"}
if method == "POST" and path == "/location":
self._lat = body.get("lat", self._lat)
self._lon = body.get("lon", self._lon)
return {"lat": self._lat, "lon": self._lon}
return {"error": f"Not found: {method} {path}"}
if __name__ == "__main__":
asyncio.run(WeatherBridgeModule().start())manifest.json:
{
"name": "weather-bridge",
"version": "1.0.0",
"type": "INTEGRATION",
"api_version": "1.0",
"runtime_mode": "always_on",
"port": 8102,
"permissions": ["events.publish"],
"intents": [
{
"patterns": {
"en": ["weather|forecast|temperature\\s+outside"],
"uk": ["погод|прогноз|температур\\w+\\s+надвор"]
},
"priority": 50,
"name": "weather.current",
"description": "Current weather conditions"
}
],
"publishes": ["weather.updated"],
"resources": {"memory_mb": 64, "cpu": 0.1},
"author": "SmartHome LK",
"license": "MIT"
}SelenaCore Module Developer API Guide -- SmartHome LK -- MIT License Repository: https://github.com/dotradepro/SelenaCore
🤖 This wiki is auto-synced from docs/ in the main repo. Hand-edits on the wiki UI get overwritten on the next push. Open a PR against the main repo instead.
MIT License · Sponsor · Ko-fi
SelenaCore
🇬🇧 English
Getting started
Architecture
Voice & translation
Hardware integration
Development
- Modules overview
- Module development
- System module development
- Module API guide
- Module bus protocol
- Widget development
- User manager / auth
Reference
🇺🇦 Українська
Початок
Архітектура
Голос і переклад
Інтеграція заліза
Розробка
- Розробка модулів
- Розробка системних модулів
- Module API
- Module bus
- Widget development
- User manager / auth
Довідник