Getting started
From nothing to a plugin installed on a Niles, in Rust.
Interface 0.3.
niles plugin dev,niles plugin check, installing from GitHub, and the plugin's setup panel, log, pages and widgets in the app are built (Niles 0.2.0). Not yet:niles plugin dev --live, and the template repo, which will be published alongside it.
From nothing to a plugin installed on a Niles, in Rust. For JavaScript and Python, see the snippets at the end of the worked example.
1. What you need
-
Rust (stable, 1.85 or later, for edition 2024) with the WebAssembly target:
rustup target add wasm32-wasip2 -
The
nilesbinary, forniles plugin devandniles plugin check. Either from a Niles release, or built from source:cargo install --locked --git https://github.com/MarkNygaard/niles niles-bin -
Optional:
wasm-tools(cargo install wasm-tools) to look inside a component:wasm-tools component wit integration.wasmprints what it imports and exports.
2. Start from the template
The template repo, niles-integration-template, does not exist yet. Until it
does, make a new crate and lay it out like this by hand, copying the WIT from
schemas/niles-integration.wit and
the code below. The worked example is a complete
plugin to copy from.
niles-integration.toml # the manifest: id, name, settings, permissions
logo.svg # 24x24 mark shown on the Add-ons page
wit/niles-integration.wit # the interface, copied from Niles (0.3.0)
src/lib.rs # every export stubbed out
Cargo.toml # crate-type = ["cdylib"], wit-bindgen
build.sh # builds and copies the component to integration.wasm
dev-settings.toml # setting values for local runs (gitignored)
.github/workflows/release.ymlbuild.sh builds the release component and puts it where a release carries
it and where niles plugin dev and niles plugin check look first:
#!/bin/sh
set -eu
cd "$(dirname "$0")"
cargo build --release --target wasm32-wasip2
cp target/wasm32-wasip2/release/my_plugin.wasm integration.wasmChange the manifest's id (lowercase letters, digits and _, starting with a
letter: it becomes your device source, tool prefix and secret namespace), its
name, description and authors, and the crate name in Cargo.toml.
src/lib.rs starts like this:
// The generated bindings have functions with many arguments
// (`on-action` among them), which clippy's default lint flags.
#![allow(clippy::too_many_arguments)]
wit_bindgen::generate!({
world: "plugin",
path: "wit",
});
struct MyPlugin;
export!(MyPlugin);
impl Guest for MyPlugin {
fn init() -> Result<(), String> {
Ok(())
}
fn tools() -> Vec<ToolDef> {
Vec::new()
}
fn call_tool(name: String, _args_json: String) -> Result<String, String> {
Err(format!("There is no tool called {name}."))
}
fn set_device(_id: String, _desired: DeviceState) -> Result<(), String> {
Err("Nothing here can be switched.".into())
}
fn on_timer(_id: String) {}
fn on_event(_event: Event) {}
fn on_mqtt_message(_conn: u32, _topic: String, _payload: Vec<u8>) {}
fn on_mqtt_status(_conn: u32, _status: ConnectionStatus) {}
fn render(_surface_id: String, _context: RenderContext) -> Result<UiTree, String> {
Err("No pages.".into())
}
fn on_action(
_surface_id: String,
_control_id: String,
_value: ActionValue,
_context: RenderContext,
) -> Result<ActionResult, String> {
Err("No pages.".into())
}
fn authorize_url(_callback_url: String, _state: String) -> Result<String, String> {
Err("No accounts to connect.".into())
}
fn on_callback(
_callback_url: String,
_query: Vec<(String, String)>,
_person: Option<String>,
) -> Result<String, String> {
Err("No accounts to connect.".into())
}
fn on_webhook(_request: InboundRequest) -> InboundResponse {
InboundResponse { status: 404, content_type: None, body: Vec::new() }
}
fn stop() {}
}The manifest says interface = "0.3" and min_niles = "0.3.0", the
interface the copied WIT is.
generate! reads wit/ and writes, inside your crate:
Guest, the trait with one method per export, andexport!, which wires your type to it;- one module per import, under
niles::integration:::log,config,storage,http,devices,timers,events,notify,ui,mqtt; - the types the exports use (
DeviceState,Event,ToolDef,RenderContext,UiTree,ActionValue,ActionResult,ConnectionStatus) at the crate root. Other types come from their interface, e.g.niles::integration::types::Device.
Every export must be there; return an empty list or an error from the ones you do not use. Read the host API for what each one is for.
To see the generated code, cargo doc --target wasm32-wasip2 --open.
3. Build
cargo build --release --target wasm32-wasip2The component is target/wasm32-wasip2/release/<crate_name>.wasm. Plain Rust
with serde_json comes out around 200 KB.
4. Run it locally
Put values for your settings in dev-settings.toml, keyed like the manifest:
area = "DK1"
cheap_hours = 4
# secrets too, for local runs only; never commit this file
api_key = "..."Then:
niles plugin dev .This builds nothing; it reads niles-integration.toml and a component, and
runs it in the same sandbox and with the same limits as a real Niles, against
a stand-in house. The component is the .wasm you name, or from a directory
the newer of integration.wasm and the newest
target/wasm32-wasip2/release/*.wasm; when both are there it says which it
took ("using …integration.wasm, which is newer than …"), so a stale
integration.wasm from your last release build never hides the build you
just made.
-
initruns, and log lines print as they happen; -
devices the plugin reports print as they change;
-
storage is kept in
.niles-dev/storage.json, so restarts keep it (add.niles-dev/to your.gitignore); -
timers fire on time;
-
HTTP goes out for real, checked against your manifest's permissions, so a missing permission fails here exactly as it would at home.
At its prompt:
> tools list the tools as the model sees them
> tool prices {"day":"tomorrow"} call one, print the result
> set system/lamp {"on":true} call set-device
> event device_state office/door {"open":true}
> event presence away something happening elsewhere
> render electricity draw a page as text
> action electricity refresh press a control
> restart stop, new instance, initSettings on the command line go over dev-settings.toml, typed by your
manifest: niles plugin dev . --settings area=DK2 --settings cheap_hours=6.
For a script or CI, --call prices '{"day":"today"}' and --render electricity do the same as the prompt once init has run, and --dry-run
exits after them instead of staying up for timers and the prompt. A plugin
with MQTT brokers is waited for first: until a connection reports its first
status (at most 10 s), then a second for the messages the broker kept.
--wait 5 waits five seconds after init instead, --wait 0 not at all.
Two more flags, for dev only:
--allow-loopbacklets the plugin reach this machine where a setting's address is127.0.0.1,::1orlocalhost, over HTTP and MQTT, so a fake device can run beside it. Without it, and always in an installed plugin, loopback is refused.--time-zone Europe/Copenhagenis whatconfig.time-zoneanswers; this machine's zone by default.
Planned, not built yet: niles plugin dev . --live https://niles.example.com --token $NILES_API_TOKEN would install it on a running Niles instead, as a
development plugin marked "Development" on its card, reinstall it each time the
.wasm changes, and stream its log to your terminal. Until then, try it in the
app by releasing it and installing it (steps 6 and 7).
5. Check it
niles plugin check .(or niles plugin check you/your-repo once it is released, to check the
release exactly as an install would download it) checks everything an install
would: the manifest (including its surfaces,
settings and permissions), the logo, that the component targets
the world its manifest's interface names (niles:integration/plugin@0.3.x)
and exports every function, and its tools'
names, descriptions and schemas, asked for with tools() before any init,
and that there are keywords if there are tools. Fix whatever it reports before
you publish; it exits non-zero while there is anything to fix.
6. Release
Bump version in the manifest, commit, then tag the same version:
git tag v0.1.0
git push origin v0.1.0The template's workflow builds the component and creates a GitHub release with
niles-integration.toml, integration.wasm, logo.svg and niles-installed
attached. Details in publishing.
7. Install
In the Niles app: Settings → Add-ons → Available → Add-on from GitHub (at the end of
the page), paste https://github.com/you/your-repo and press Preview.
Niles downloads your latest release, runs every check niles plugin check
runs, and shows the name, authors, repo, version, description, logo and the
permissions in plain words, with the settings it will ask for. Install
installs exactly the files that preview downloaded, and the plugin starts. It
is on the Connected tab, in its group with a Custom badge and a status
line; its panel has your repo, the version and install date, the settings form
generated from your manifest, where each widget goes, the last lines of its
log, and buttons to turn it off, check for updates, roll back an update and
remove it. Once your repo has the topic niles-addon, people also find it
under Browse (the add-on library).
Fill in the settings and press Save: the plugin restarts with them, and the status line says how that went ("Not set up: needs API key" until every required setting has a value). Secret settings are saved one at a time, each in its own field. Your pages are in the menu, and your widgets on the front page or their room; open one to see it drawn.
A script can do the same through the config API, which restarts the plugin
with the new values. $NILES_API_TOKEN here is the key from Settings → API
access:
curl -X PATCH https://niles.example.com/config \
-H "authorization: Bearer $NILES_API_TOKEN" -H "content-type: application/json" \
-d '{"plugins": {"spotprice": {"area": "DK2"}}}'and secret settings through PUT /secrets/plugins.<id>.<key> with
{"value": "…"}, which accepts only keys your manifest declares as secret.
The log is at GET /plugins/<id>/log.
A preview is kept for 15 minutes. Installing needs Niles's database; without
one the preview says so. The same flow is reachable without the app:
POST /plugins/install/preview with {"repo": "you/your-repo"}, then
POST /plugins/install with the token (and sha256) it answered.