Device Drivers
Written by ddxfish on .
- Docs
- Plugin Author Guide
- Device Drivers
- All docs ↓
You are reading the dev branch of the docs, which may describe unreleased changes. Read the main docs.
Teach Sapphire to use a machine or a gadget. Devices is part of core: it owns the Settings > Devices page, the device list, and the three tools Sapphire uses. Your plugin brings a driver: one manifest declaration and one small module. Hardware code and its dependencies stay in your plugin, never in core. User guide: Devices.
Anything can be a device: a thermostat, a television, a robot arm. If your code can reach it, Sapphire can use it, and it gets the same page, the same three tools and the same help as every built-in device.
Reference drivers: core/plugins/ssh (stored logins, user-written commands), the MIDI plugin (two device types, every action runs one of the plugin's own tools; the synth it talks to is a profile, not code), and the three that ship inside core, core/devices/drivers/satellite.py, computer.py and sapphire.py (another Sapphire).
What Sapphire sees
Three tools. Their descriptions carry the fleet: every device's name, place,
online or offline, and what it can do, so she usually starts at
device_action(device) without a list call. With ten devices or fewer the
device argument is an enum of their names.
device_list() every device, online or offline, what it can do
device_status("desktop") one device, asked right now
device_action("desktop") how it is, EVERY action with the value it takes, one example call
device_action("desktop","ssh") the same, one capability
device_action("desktop","ssh","volume","40") runs one
So an action is two calls: device_action(device), then the run. A wrong
guess answers with the screen one level up. Core writes all of this help from
what your driver's describe() returns, so every device reads the same to her.
One screen is kept under about a thousand tokens: a capability with many
actions is cut to its first eight and says how to see the rest.
Sapphire cannot add, change, or remove a device. Only the Devices page can.
Your first driver, start to finish
This is a whole driver: a lamp on your network that takes three commands. Copy
the two files, change what _send does, and you have a device.
my-lamp/
plugin.json # the manifest, with capabilities.devices
device_driver.py # describe, status, run, and optionally validate
plugin.json
{
"name": "my-lamp",
"version": "1.0.0",
"description": "A desk lamp Sapphire can switch and dim",
"author": "you",
"capabilities": {
"devices": [
{
"driver": "lamp",
"label": "Desk lamp",
"icon": "💡",
"module": "device_driver.py",
"capabilities": ["light"],
"config_schema": [
{"key": "host", "type": "string", "label": "Address", "placeholder": "192.168.0.20"},
{"key": "token", "type": "string", "widget": "password", "secret": true, "label": "Token"}
]
}
]
}
}
device_driver.py
import requests
class Problem(Exception):
"""A reason fit to show as it is."""
def _send(config, secrets, path):
"""One request to the lamp. The only part that knows the hardware."""
try:
r = requests.get(f"http://{config['host']}{path}",
headers={"Authorization": "Bearer " + secrets.get("token")}, timeout=5)
except requests.exceptions.RequestException:
raise Problem(f"Could not reach the lamp at {config['host']}. Is it plugged in?")
if r.status_code != 200:
raise Problem(f"The lamp answered HTTP {r.status_code}.")
return r.json()
def validate(config):
"""Rules at save time. Returns (config, error). Optional."""
config["host"] = str(config.get("host") or "").strip()
return config, "" if config["host"] else "The lamp's address is needed."
def describe(device, config):
"""What this device can do. Called often: keep it cheap and offline."""
return {
"light": {
"label": "Light", # the tab name
"help": "switch and dim it", # under 120 characters
"actions": {
"on": {"help": "switch it on", "example": ""},
"off": {"help": "switch it off", "example": ""},
"dim": {"help": "brightness, 0-100", "example": "40"},
},
},
}
def status(device, config, secrets):
"""Is it reachable right now? Never raise for a device that is off."""
try:
state = _send(config, secrets, "/state")
except Problem as e:
return {"online": False, "detail": str(e)}
return {"online": True, "detail": config["host"],
"readings": {"brightness": f"{state['brightness']}%"}}
def run(device, capability, action, value, config, secrets, call_tool):
"""Do one thing. Returns (text, ok). value is always a string, maybe empty."""
try:
if action == "on":
_send(config, secrets, "/on")
return f"{device['label']} is on.", True
if action == "off":
_send(config, secrets, "/off")
return f"{device['label']} is off.", True
if action == "dim":
if not value.strip().isdigit() or not 0 <= int(value) <= 100:
return f"dim takes a number from 0 to 100, not '{value}'.", False
_send(config, secrets, f"/dim?to={int(value)}")
return f"{device['label']} is at {int(value)}%.", True
except Problem as e:
return str(e), False
return f"The lamp has no {capability} / {action}.", False
This example is run by tests/test_docs_device_driver_example.py, straight from
this page, through the real engine. If it stops working, that test fails.
Try it
- Put the folder in
user/plugins/. - Settings > Plugins: enable it. A plugin that is not signed needs sideloading switched on.
- Settings > Devices > + Add Device. "Desk lamp" is in the Type list.
- Open the device. The Light tab has a Try button for each action.
- Ask Sapphire to dim the lamp. She finds it with
device_list.
A change to device_driver.py takes effect when the plugin is reloaded. No
restart is needed.
What the manifest fields mean
| Field | Required | Description |
|---|---|---|
driver |
yes | Slug, [a-z0-9][a-z0-9_-]{0,32}, unique across plugins. First plugin to register a name keeps it |
label |
no | Name in the Add Device picker |
kind |
no | What the thing is: Computer, Camera, MIDI. Drivers of one kind are listed together in the picker and can be connections of one device. Left out, the driver is a kind of its own |
icon |
no | Emoji |
module |
yes | Plugin-relative path to a .py file. It may not leave the plugin folder |
capabilities |
yes | 1 to 16 slugs. Each becomes a tab in the device window and the second argument of device_action |
config_schema |
no | Per-device fields, in the settings field shape. Up to 40 |
locked_by_default |
no | Capabilities Sapphire may not use until the user allows it. See Power below |
presence |
no | true: its things come and go, and the driver is kept told. See Things that come and go |
learns_address |
no | true: the device calls Sapphire with its own key, and the address it calls from becomes its url (see SATELLITE-PROTOCOL.md, "Its own address is learned") |
devices is a list. One plugin may bring several device types, each with its
own module. The MIDI plugin brings two: a hardware synth, and a plain MIDI keyboard.
Fields land on the tab of the driver's first capability. Add "capability": "sound" to a field to put it on another tab, or "tab": "Status" to put it with the device's health. How to reach a device belongs on Status when the driver has several capabilities.
Setup. Add "setup": true to the fields a device needs before it can work at all - an address, a key, a user name. Add Device asks for those and nothing else (plus any field that shows only because of one of them, through show_if); every other field keeps its default until the user opens the device window. A driver with no setup fields asks for a name only.
Secrets. A field with "secret": true is never stored in the device row. It goes to the device secrets file, scrambled with the machine-bound key, outside user/ and outside backups. The page shows "Set", never the value. Your driver receives it at call time.
What the functions are given
| Argument | What it is |
|---|---|
device |
{"id", "label", "location"}. Location is the room or place the user gave it, or empty |
config |
This device's values for your non-secret config_schema fields |
secrets |
This device's secret fields. secrets.get("token") returns the value. Printing the object never shows one |
value |
Her input for this action, as text. Parse it yourself and say plainly what is wrong |
call_tool |
call_tool(name, args) runs one of your own plugin's tools and returns (text, ok) |
describe may differ per device. The SSH driver lists the commands the user wrote for that one machine.
example is the value she would pass, not the whole call. Core builds the call. Every example must run exactly as written.
Two optional flags on an action: "owner": true keeps it for the person at the Devices page only (she never sees it in her help, and run() refuses her); "danger": "Formats the card. Every backup on it is erased." makes the page ask for I UNDERSTAND before its Try button runs, with those words; it also keeps the action from her (she cannot answer that question), so danger implies owner. Use both for anything that destroys data. A third, "wait": 300, is the seconds a slow action may take (a firmware update, a long recording): the page's Try button waits that long instead of its usual 30 s, so a slow success is never shown as a timeout. Up to 900.
describe may return fewer capabilities than the manifest lists. A gadget that reports only a light gets only a Light tab.
A device that says what it has
One driver often serves boards that differ: one has a light, the next has none.
Ask the device, and pass its answer on in status():
def status(device, config, secrets):
info = ask_the_board(config) # {"has": ["light", "button"], ...}
return {"online": True, "detail": info["name"], "has": info["has"]}
Core keeps the list with the device. From then on the device shows only those
capabilities, to her and on the page, also while it is offline. A settings
field with "capability": "light" is hidden on a device without a light, and
its value is kept.
has |
Result |
|---|---|
| Not answered, ever | The device has everything describe returns |
["light"] |
Only light |
[] |
Nothing |
| A name that is not in your manifest | Left out, and logged once |
Do not cache the list yourself. describe is given no secrets and must not
call the device.
Settings that live on the device
Some settings belong on the hardware: the looks of a ring, the hours it is
lit. Give your driver apply(device, config, secrets) and core calls it
after every save. Hand the settings over there:
def apply(device, config, secrets):
r = requests.put(config["url"] + "/led/looks", json={...}, headers=auth(secrets), timeout=6)
if r.status_code >= 400:
raise RuntimeError("the board did not take the looks: " + r.text[:100])
The save holds either way. What you raise is shown beside "Saved": Saved, but
the device did not take its settings (satellite: ...). A device that is
turned off is not told. Keep apply short; it runs while the user waits.
Rules
- Do not reimplement your plugin. If a tool of yours already does the work, run it with
call_tool. It goes through the function manager, so it uses the same state and settings as the tool itself (it runs even when the tool's toolset is off: the driver owns the call). - Do not import your modules under a second name. A module imported twice is two copies of its state. If you must read a helper, take it from the module the loader already holds (
plugins.<your-plugin>.tools.<file>). - Secrets stay out of text. Never put a secret in a result, a log line, or a URL. Core scrubs results as a second line of defense, not a first.
- Quote what she types. If her value reaches a shell, quote it (
shlex.quote). Refuse templates that would put it inside quotes. - Anything that moves needs a bound. An action that starts something must end without a second call: a duration, a watchdog, or a device-side timeout. A dead controller means OFF.
- A keeper calls
status()on its own clock (every 30 s while a device is online, every 90 s while it is offline) and keeps one belief per device: online or offline. One answer makes it online; two misses in a row make it offline; the last state survives a restart. The page anddevice_listread the belief and never wait on you.device_statusand the Test button ask fresh. Keepstatus()under a few seconds for an offline device.
Testing your driver
A driver is three plain functions, so it is tested like any other code. No Sapphire has to run. Fake the one function that touches the hardware:
from unittest.mock import patch
import device_driver as lamp
DEVICE = {"id": "desk", "label": "Desk lamp", "location": "Office"}
CONFIG = {"host": "192.168.0.20"}
def test_dim():
with patch.object(lamp, "_send", lambda config, secrets, path: {"ok": True}):
assert lamp.run(DEVICE, "light", "dim", "40", CONFIG, {}, None) == ("Desk lamp is at 40%.", True)
assert lamp.run(DEVICE, "light", "dim", "bright", CONFIG, {}, None)[1] is False
Two tests are worth having in every driver:
- Every example in your help really runs. Loop over
describe()and run each action with its ownexample. Sapphire copies those examples. - Say what the value is. An action may carry
values, a short grammar of its value:<color> [pulse|blink] [5s]. Angle brackets for what is needed, square for what may be left out,|for a choice. It sits beside the action's name on her screen, so she does not have to guess, or call again to find out. Put what the words mean inhelp. A long menu (every color, 128 instruments) stays an action of its own, one call away. - What is missing is said plainly. An unplugged device must answer with a sentence, never a stack trace.
Names that mean the same everywhere
Use these capability names when they fit. Sapphire then reads every device the same way, and core knows what to do with them.
| Capability | Actions | What core adds |
|---|---|---|
power |
restart, shutdown, sleep |
A "Sapphire may use this" switch |
camera |
look |
Pictures reach her eyes. The same switch |
ptz |
move, zoom, goto, stop |
The same switch. A camera that turns: answer a move with the new view |
screen |
look |
The same |
mic |
listen |
The same switch. With chat and voice_key fields, wake word questions |
speaker |
say |
Her answers are spoken there |
light |
set, off |
|
sound |
read, set, up, down, mute |
|
storage |
backup, list |
The same switch. A driver that also offers storage_target(device, config, secrets) (a core.backup_targets.Target) becomes a place the nightly backup is shipped to, sealed, with the device's keep_daily/weekly/monthly fields (core.devices.storage.KEEP_FIELDS) |
Any other name works too. It gets a tab and a place in her help like the rest.
Power: a device that can restart or switch off
Name the capability power. Use these action names where they fit, so every
device reads the same to her: restart, shutdown, sleep.
- Answer first, act a moment later. The answer has to leave the device.
- Say in the result how the device comes back. After a shutdown, someone has to walk over to it.
powercarries a switch on the Devices page, Sapphire may use this. Core enforces it. Your driver does nothing for it.camera,screenandmiccarry the same switch.- To have the switch start out off, add
"locked_by_default": ["power"]to your driver's declaration. Do that for anything the user would miss, like their own computer.
Pictures: a device with a camera
An action may answer with pictures. Return this in place of the text:
return {"text": "A picture from the camera of kitchen.",
"images": [{"data": base64_text, "media_type": "image/jpeg"}]}, True
She sees the pictures if her model can see. If it cannot, core describes them to her in words. The Try button shows them on the page.
| Limit | Value |
|---|---|
| Pictures in one answer | 4 |
| Types | image/jpeg, image/png, image/webp |
| Size of one picture | 12 MB of base64 text |
Anything outside those limits is dropped. Name the capability camera and the
action look so every camera reads the same to her. Warn the room before you
capture: a light, a sound, or both.
Moving pictures on a board's glass
A plugin that paints frames on a satellite's screen (FlashHead puts her talking
face there) uses two in-process calls in core.devices.glass, never the
driver's private ones. There is no door for it: each board is reached with its
own secrets, as every call is.
face_boards()returns[(device_id, side)]: every enabled satellite whose glass is on and takes a JPEG frame (firmware 0.7.7+,screen.jpegin its/health).sideis the largest square it paints, a multiple of 16.face_frame(device_id, jpeg, seconds=3)paints one JPEG of that size straight to the panel. The board's own page comes backsecondsafter the last frame. It raises when the board did not take it. Send the newest frame only: a small board paints about 8 a second.
Voice: a device with a microphone or a speaker
Core's voice pipeline uses devices through a few conventions. Follow them and your device becomes a satellite with no other code.
A speaker. Declare a capability named speaker with an action say. Its
value is the text. Get her voice as bytes from core, then play it:
from core.devices import voice
audio, kind = voice.render(text) # (bytes, "audio/ogg") or (None, reason)
render applies the privacy gate for the chat that produced the text. When it
returns None, return the reason as your result and send nothing.
A small board plays one format. Let it state that format, and have core convert:
plays = {"type": "audio/wav", "rate": 16000, "channels": 1} # as the board stated it
audio, kind = voice.fit(audio, kind, plays) # (bytes, "audio/wav") or (None, reason)
audio/wav is always 16 bit PCM. It is the only type today. rate is 8000 to
48000, channels is 1 or 2.
A microphone. Declare a capability named mic, and these config fields:
| Field | Purpose |
|---|---|
chat |
The chat this device's questions land in. Empty means the last chat used |
voice_key (secret) |
The key the device sends with each question |
The device then posts what it heard:
POST /api/devices/<device name>/voice
Authorization: Bearer <voice_key>
Content-Type: audio/wav
<the recording>
A form with the file field audio works too. The full contract of a satellite
board is in Satellite Protocol.
Core transcribes it and answers at once with {ok, heard, accepted, chat}.
accepted: true means a turn has started. Its answer comes later, through
your speaker / say. The device must not wait on this response for her
answer.
She reads one line above what was heard, naming the device and its location:
[Voice from device "kitchen" (Kitchen Pi). Location: Kitchen. Your reply is spoken aloud there by itself: just answer, never call say on that device for it.]
A chat that is mid-turn is waited for, up to 20 seconds. One device may have
3 questions waiting. More are refused with busy: true.
A light. The device may hold one stream open to learn what its light should show:
GET /api/devices/<device name>/events
Authorization: Bearer <voice_key>
It is a server-sent event stream. Each data: line is JSON with a state:
state |
Meaning |
|---|---|
connected |
The stream is open |
thinking |
She is working on what this device heard |
tool |
She is running a tool for it. tool_name names it |
idle |
The turn is over. Sent after the answer has played |
error |
The person got no answer |
A device hears only about its own turns. A stream that opens in the middle of
a turn is sent the current state first. Lines that start with : keep the
stream alive and carry nothing. The stream ends when the key changes.
Show thinking yourself from the moment accepted arrives, and put a bound
on it. If core restarts in the middle of a turn, no idle will come.
To transcribe inside an action of your own, use voice.stt_refusal() first,
then voice.transcribe(audio_bytes).
Things that come and go
A keyboard is plugged in. A board is pulled out. A Bluetooth speaker walks out of range. A driver can ask to be kept told, so its heavy part (a synth, an open port, a connection) runs only while it has something to run for.
Add "presence": true to the manifest entry, and up to three functions to the
module. All three are optional.
| Function | What it is for |
|---|---|
discover(config) |
What you can see right now. Returns [{"id", "name", "kind"}] |
watch(changed, stopped) |
Call changed() when that changes. Runs on a thread of its own until stopped is set |
tend(device, config, secrets, present, leaving) |
Make the device match what is here |
tend is handed the whole truth every time. Never "this one arrived".
present is everything that is here and counts as this device. It is called
when something changed, when the user saved the device, and every 15 seconds
regardless. So tend must be safe to repeat: start what is not running, leave
alone what is. In return a missed event is repaired by the next call, and so is
a process of yours that fell over.
leaving is true once, when the device is switched off or removed, when your
plugin unloads, and when Sapphire stops. present is empty then. Let go of
everything.
id must hold across a replug. Use the hardware's own name or serial
number, not a number the system hands out afresh each time. name is what the
user reads. kind is a short word like USB or Bluetooth.
The pick list
A field of type found lets the user say which of the found things count. Core
draws it from discover(), stores the choice, and applies it. Your driver
never sees the filter, only present.
(•) Whichever is plugged in "many": false shows one dropdown
( ) Only these "many": true (the default) shows ticks
[x] Board A USB
[ ] Board B USB
| Field key | What it does |
|---|---|
"type": "found" |
The pick list. One per driver |
"many": false |
The device is ONE thing: present holds at most one |
"all_label", "only_label" |
The words for the two choices |
Setting a thing up before it is a device. A thing in the list may carry a
note (a line under it, 200 characters) and actions (up to three buttons,
[{"do": "pair", "label": "Pair"}]). A button calls your module's
setup(thing_id, action, config) -> (text, ok); the words come back under
the list and the list is looked at again. While any thing shows a button the
list looks again on its own every few seconds, so a state that changes on the
other side (a phone that asks to pair) shows up by itself. A phone is paired
this way in the Add window, and its driver's validate refuses to add one
that is not paired: a setup that does not set the thing up is no setup
(core/plugins/kdeconnect).
A board on USB
Two boards are plugged in. Each is its own device, and each device picks its board. The manifest entry:
{
"driver": "board",
"label": "USB board",
"module": "device_driver.py",
"capabilities": ["light"],
"presence": true,
"config_schema": [
{"key": "which", "type": "found", "many": false, "tab": "Status",
"label": "Which board", "all_label": "Whichever is plugged in"}
]
}
What the module adds to describe, status and run:
import os
BY_ID = "/dev/serial/by-id" # the system lists boards here by make and serial number
_held = {} # device id -> {"path", "port"}
def discover(config):
"""Every board that is plugged in right now."""
try:
names = sorted(os.listdir(BY_ID))
except OSError:
return [] # no folder: nothing is plugged in
return [{"id": name, "name": name.split("_")[1] if "_" in name else name, "kind": "USB"}
for name in names]
def watch(changed, stopped):
"""Look once a second. If your system can tell you when something changed,
read that in place of looking."""
seen = None
while not stopped.wait(1):
now = discover({})
if now != seen:
seen = now
changed()
def tend(device, config, secrets, present, leaving):
"""Hold the board's port open while it is here. Let go when it is not."""
want = "" if leaving or not present else os.path.join(BY_ID, present[0]["id"])
held = _held.get(device["id"])
if held and held["path"] != want:
held["port"].close()
del _held[device["id"]]
if want and device["id"] not in _held:
_held[device["id"]] = {"path": want, "port": open_port(want)}
open_port is yours to write. run then uses _held[device["id"]]["port"],
and answers "The board is not plugged in." when there is none.
What core does for you
- One watcher per driver, however many devices it has.
changed()costs nothing to call twice: a burst is gathered into one look. - Each device is tended by itself. One that is slow does not hold the others. The same device is never tended twice at once.
- A
watchthat ends or fails is started again, a little later each time. The 15 second look covers until then. - Status gains a
connectedline, said the same way for every driver. - Before your plugin unloads, every device of yours is tended with
leaving=True. You have 8 seconds.
Rules
- One thing shared by several devices is yours to add up. The MIDI keyboard driver has one synth for all its devices. It keeps what each device wants and plays for all of them together.
discovermust be quick and must not change anything. It runs on every look and when the user opens the pick list.- Start nothing at import. A thread that starts when your module loads
runs in tests, and runs when no device exists.
tendis where things start.
Drivers that ship inside core
Three drivers live in core/devices/drivers/ because every install should have
them: satellite (a room box with mic, speaker, light and camera),
computer (the machine Sapphire runs on) and sapphire (another Sapphire). Their ids are reserved: a plugin
cannot register a driver with one of those names, and cannot call itself
core. Everything in this guide applies to them unchanged. Their manifest
entry is a SPEC dict in the module.
One thing only a core driver may do: name tools of other plugins in its SPEC
(uses_tools) and run them through call_tool. The computer sees its screen
through the screenshot plugin's own tool that way, so Sapphire has one
screenshot program, not two. A plugin's driver runs its own plugin's tools and
no others.
Settings fields added for device forms
These work in any plugin's settings schema.
| Field | What it does |
|---|---|
"widget": "rows" with "columns": ["name", "command"] |
A list of objects with add and delete. Columns may also be {key, label, placeholder}. Optional add_label |
"show_if": {"auth": "password"} |
The field shows only while another field has that value. Hidden fields still save |
"widget": "textarea" with "secret": true |
A multi-line secret (a pasted key). Never echoed. Empty means keep what is stored |
"type": "found" with "found_url" |
A live pick list. The URL answers {found: [{id, name, kind}]}. The value is {all, only: [{id, name}]}. Device forms set the URL themselves |
Reference for AI
- Manifest:
capabilities.devices: [{driver, label?, icon?, module, capabilities[], config_schema[]?, locked_by_default[]?, presence?}]. Registered by the loader intocore/devices/registry.py; unregistered on unload. Bad declarations are skipped with a log line, never a failed load. - Module functions:
describe(device, config) -> {capability: {label, help, actions: {name: {help, example, values?, owner?, danger?, wait?}}}},status(device, config, secrets) -> {online, detail, readings?, has?, takes?}, optionalapply(device, config, secrets)(after a save; raiseDeviceErrorto be heard),run(device, capability, action, value, config, secrets, call_tool) -> (text, ok), optionalvalidate(config) -> (config, error), optionaloptions(device, config, key) -> [{value, label}]for a dynamic select (below). - A select whose choices are the device's own:
{"key", "type": "select", "dynamic": true}inconfig_schema, and the module'soptions(device, config, key);devicecarrieshas,takesandfingerprint. The satellite driver'swake_modeloffers the wake words of the family that board runs, fromcore.wakeword.library. The stored value is always among the choices, so a save round-trips when the driver cannot say. A field withtakes("takes": "wake.model") shows only on a device whosestatus()answered that name intakes. - Engine:
core/devices/engine.py, loaded on first use. Tools:functions/devices.py. Rows inuser/plugin_state/devices.jsonunder keydevices:{id, label, location, enabled, created, locked: [capability], parts: [{driver, plugin, config}]}.locked= what she may not use; only capabilities inengine.LOCKABLEcan be locked;engine.run(..., owner=True)is the user's own button and passes the lock. Secrets incore/devices/secret_store.pyunder<driver>.<field>. - The engine re-checks that the owning plugin is enabled and loaded on every call and drops cached driver modules when the registry generation changes.
- Hosted (managed) installs: the tools refuse, the routes answer 404, the tab is hidden.
call_toolrefuses tools owned by another plugin. It callsexecute_function(name, args, allowed_tools={name}, with_success=True).- Voice:
core/devices/voice.py(hear,say,cue,render,transcribe,key_ok,origin_line).hearanswers once the words are known and runs the turn on its own thread. Reply lane:cadence.run_turn(chat, text, speak='device:<id>', on_event=...). Doors:POST /api/devices/{id}/voiceandGET /api/devices/{id}/events, device key, each 30 a minute per caller address, counted before the key is checked. - Core drivers:
registry.CORE_DRIVERS, registered by the engine on first use withbuiltin=True. - Self-report:
status()may answerhas: [capability].engine._learnkeeps it on the row asparts[].has(written only on a change, names outside the manifest dropped with one warning).engine.capabilities(part, spec)is the manifest list held to it;describe,public,runandvoice._voice_partall use it.publichides schema fields whosecapabilitythe device lacks. No key = never said = everything. - Sound format:
voice.fit(audio, kind, plays)andvoice.wanted(plays). numpy and soundfile only. Going down in rate it low-pass filters first. - After a save:
engine.tell(row)calls each part'sapplyand returns problems in words; the routes put them in the save'swarning.voice.clock()={now, tz}(POSIX zone from/etc/localtime's footer) rides on the events stream'sconnectedline. - Routes (
core/routes/devices.py; a login session or a Bearer API token, which runs as the owner):GET|POST /api/devices,GET|PUT|DELETE /api/devices/{id},POST /api/devices/{id}/test,POST /api/devices/{id}/run,GET /api/devices/found/{driver}?device=,POST /api/devices/provision,GET /api/devices/here,/api/devices/flash/*,GET /api/devices/firmware[/{board}/{part}]; with the device's own key:/api/devices/{id}/voice|wake|text|events. Scheduled Device tasks run as the owner too (locks and owner-only actions pass;dangeractions cannot be scheduled). Limits: 240 reads and 60 writes a minute.PUTtakeslockedas a map{capability: bool}that changes only what it names (a list replaces the whole set).foundis a reserved device name. - Presence:
core/devices/presence.py, started after the plugin scan (sapphire.py), stopped before plugin services. Manifest"presence": trueplus optional module functionsdiscover(config) -> [{id, name, kind}],watch(changed, stopped),tend(device, config, secrets, present, leaving).tendis level triggered: called on a poke (watcher, device saved, plugin loaded), on the 15 s heartbeat, and once withleaving=True, present=[]when the device is disabled or removed, the plugin unloads (presence.release(plugin), called by the loader before it unregisters the drivers) or the app stops. One keeper thread, one watcher thread per driver with an enabled device, tends on a pool of 4, never two at once for one device. Pokes are gathered for 0.3 s. - Filter: a
config_schemafield of"type": "found"(one per driver). Stored in the part's config as{all: bool, only: [{id, name}]}.engine.found(driver, config)cleans whatdiscoverreturns (64 at most; a thing may carrynoteandactions: [{do, label}]).engine.passing(spec, config, things)applies the filter."many": falsekeeps one.engine.setup_found(driver, thing_id, action, config)runs the module'ssetup()for a thing's button:POST /api/devices/found/{driver}/do{id, do, config?}.
Edit this page on GitHub · docs/plugin-author/devices.md @ 15706a7 · synced
