Writing add-ons

Devices

Device classes, state fields and units, controllable devices and naming.

Interface 0.2, and 0.1, which Niles still runs. Niles 0.2.0 runs them and installs them from GitHub: the runtime, niles plugin check, niles plugin dev, Add-on from GitHub with its approval screen, updates, rollback, turning off and removing, and in the app the generated setup panel, the plugin's log, and its pages and widgets.

A plugin with devices = true can add devices to the house. Once added, Niles treats them like its own: they appear on their room's card in the app, voice commands reach them, rules can trigger on them and act on them, and lights follow the daily lighting curve.

Naming

A device id is room/name, both parts lowercase letters, digits and _: living_room/floor_lamp, garage/door, system/electricity.

  • Room is one of the house's rooms (kitchen, living_room). The plugin rarely knows which, so ask: a room setting gives the user a drop-down of the house's rooms. Three room names are reserved for things that are not in a room: system (virtual devices, like an electricity price), outdoor and none.
  • Name is what people call it, in snake_case: floor_lamp is said "floor lamp". Niles's model and voice commands use these words, so pick the name a person would say, not the vendor's model number. If the device has a name in its own app, normalise that (lowercase, spaces to _, drop other characters) rather than inventing one.

Inside Niles your devices' ids are prefixed with your source, x_<plugin-id>: (x_spotprice:system/electricity), which keeps them apart from Zigbee or WLED devices with the same room/name. People and the model see room/name; you never write the prefix.

To move a device to another room, remove the old id and upsert the new one.

Classes

ClassWhat it isFields that matterControllable
lightA light, dimmable or noton, brightness, color_temp_kelvin, rgbusually
outletAn on/off plugonusually
switchA wall switch or buttonnone: it reports actionsno
contactA door or window sensoropen, battery_percentno
motionA motion sensormotion, illuminance_lux, battery_percentno
coverA blind, curtain or shutterpositionusually
sensorAnything that only reportsany field, and readingsno

The class decides how Niles treats a device: only lights are "the lights" of a room and follow the curve (unless you keep one out, see below); outlets switch but never dim; contact and motion have their own rule triggers; a cover is never one of the lights. Choose by what the device is, not what it reports right now.

For a light, LightCapabilities says what it can be told beyond on/off and brightness: color_temp (takes a colour temperature) and rgb (takes a colour). Take them from what the device is, not what it last reported: a strip in white mode is still an RGB strip.

State fields and units

The same fields and units as everywhere in Niles:

FieldTypeUnit and range
onbool
brightnessu8percent, 0–100
color_temp_kelvinu16kelvin, e.g. 2200 (warm) to 6500 (cool)
rgb{r, g, b}0–255 each
temperature_celsiusf32°C
humidity_percentf32relative humidity, 0–100
battery_percentu80–100
openbooltrue is open
motionbooltrue while it sees movement
illuminance_luxf32lux
positionu8percent, 0 shut, 100 all the way open
readingslist of {name, value, unit}any other number; see Readings

Convert at the edge. If your device reports brightness as 0–254, or colour temperature in mireds (1,000,000 ÷ mireds = kelvin), or a contact sensor's "contact: true" meaning closed, translate before reporting. The fields mean the same thing for every source, which is what lets rules and the app treat every device alike.

Every field is optional, and None means "not reported", never "off". Niles merges a report field by field into what it knows, so report what changed and leave the rest None. Reporting {on: Some(false)} keeps the known brightness; it does not clear it.

Readings

Any other number goes in readings: a list of {name, value, unit}. A price, a power draw, CO₂, a tank level.

use niles::integration::types::Reading;

let mut state = empty_state();
state.readings = vec![
    Reading { name: "power".into(), value: 340.0, unit: Some("W".into()) },
    Reading { name: "energy".into(), value: 12.4, unit: Some("kWh".into()) },
];
devices::report_state("kitchen/dishwasher", &state).map_err(|e| format!("{e:?}"))?;
  • Names are [a-z0-9_]+, the word a person would use: price, power, co2, soil_moisture. Rules and the assistant use the name ("when the power of the kitchen dishwasher drops below 5"), so keep it stable across versions.
  • Not for the fixed fields. A temperature is temperature_celsius, never a reading called temperature: rules look for it in one place. The names temperature, humidity, battery, brightness and illuminance are taken.
  • The unit is yours, sent with every report and passed on as given: W, kWh, kr/kWh, ppm, µg/m³. Niles never converts it, so pick one and keep it. None for a plain number, such as a rank or a count.
  • Merged by name, like the fields: reporting power leaves energy as it was, and an empty list says nothing. There is no way to clear one; report a new value, or set_available(false) when the device stops answering.
  • Measured, never set. set_device never carries readings, and scenes do not keep them.
  • At most 32 per device.

Rules follow readings with the same numeric trigger and condition as a temperature: "measure": "price", "below": 1, with for if it must hold a while. The app shows them on the device's row (340 W · 12.4 kWh) and lists them in the automation editor with their units; the assistant sees them with the device's state. The worked example reports an electricity price this way.

Adding and reporting

use niles::integration::devices;
use niles::integration::types::{Device, DeviceClass, LightCapabilities};

devices::upsert(&Device {
    id: "living_room/floor_lamp".into(),
    class: DeviceClass::Light,
    light: LightCapabilities { color_temp: true, rgb: false },
    controllable: true,
    state: empty_state(),
})
.map_err(|e| format!("{e:?}"))?;

let mut state = empty_state();
state.on = Some(true);
state.brightness = Some(60);
devices::report_state("living_room/floor_lamp", &state).map_err(|e| format!("{e:?}"))?;
  • upsert adds a device or replaces its class, capabilities and controllability; its state is merged like a report. Call it in init for every device, every time: it is cheap, and it is how a changed class reaches Niles.
  • report_state merges a change. Report when something changed, not on every poll if nothing did; Niles coalesces reports for one device that come faster than every 100 ms.
  • report_action is for switch devices: a press, not a state. single, double, hold, release, or Zigbee2MQTT's names where they fit (on_press, off_press, up_hold). Actions fire rules once and are never replayed.
  • set_available(id, false) when the device cannot be reached: it is greyed out in the app and rules skip it. true once it answers again. Devices start available.
  • remove takes a device out of the house. Rules that name it are kept and shown as broken, so remove only devices that are really gone.
  • registered() lists the devices Niles holds for you, including ones remembered from before a restart. Compare it with what you found in init and remove the leftovers.

Niles remembers your devices while your plugin is stopped, shown as unavailable, so rules that name them stay valid through a restart or an update.

At most 256 devices per plugin.

Controllable devices

Mark a device controllable: true and Niles sends it commands through your set_device export: from voice ("turn the floor lamp down to 30%"), the app, rules, scenes and, for lights, the lighting curve.

fn set_device(id: String, desired: DeviceState) -> Result<(), String> {
    let lamp = find_lamp(&id).ok_or("I don't know that lamp.")?;
    if let Some(on) = desired.on {
        lamp.switch(on)?;
    }
    if let Some(brightness) = desired.brightness {
        lamp.dim(brightness)?; // 0-100; convert to the device's own scale here
    }
    if let Some(kelvin) = desired.color_temp_kelvin {
        lamp.white(kelvin)?;
    }
    // Report what the device now says, not what was asked for.
    devices::report_state(&id, &lamp.read_state()?).map_err(|e| format!("{e:?}"))?;
    Ok(())
}
  • desired holds only the fields to change. Ignore fields the device cannot do, rather than failing the whole command.
  • Report the result. The app shows the change straight away and settles it when your report arrives; a command that is never confirmed rolls back. If the device confirms asynchronously, report from the timer or event that sees it.
  • Return Err with a sentence when it did not work ("The pump did not answer."); voice reads it, the app shows it.
  • Be quick. You have 10 s, but someone is waiting, and a light on the curve gets a set_device once a minute. Send the command and return; do not poll until it settles.

Lights and the room

A light is one of its room's lights. That means Niles switches it with everything else that acts on the room's lights as a group:

  • "turn off the office lights", "dim the office", "make it warmer in here", and "turn off all the lights";
  • the room's wall switch, and the room and house switches in the app;
  • scenes, which save and restore the room's lights;
  • presence: when everybody leaves, every light that is on goes off;
  • the lighting curve and the morning routine, which send it a set_device about once a minute while it is on, with the curve's brightness and colour temperature. A light the household lists as ambient is held at its own setting instead.

Some lights are not the room's to switch. A 3D printer's chamber light lights the camera and the failure detection, and must not go off when someone says "turn off the office lights" mid-print. Keep such a light out (interface 0.2.1):

devices::upsert(&chamber_light).map_err(|e| format!("{e:?}"))?;
devices::set_room_light("office/chamber_light", false).map_err(|e| format!("{e:?}"))?;

It is then left alone by everything in the list above. It is still a light: it is switched when asked for by name ("turn on the printer light"), by the assistant's tools, by rules that name it, and from its own row on the room's card, which shows it but does not count it in "2 of 3 on" or switch it with the room. The flag is kept with the device, across upsert calls and restarts; call it again with true to put the light back among the room's.

A plug (outlet) is one of the lights only when the household lists it as a lamp; set_room_light(id, false) keeps it out even then. Other classes are never lights, and the flag changes nothing for them.

Actions

A device reports an action when something happens on it once: a press on a button, or an event on any other device, such as a print finishing. An action is not state: it fires once, can start rules, and is never replayed.

A wall switch or remote is a switch device that never takes commands and reports actions:

devices::report_action("hall/remote", "double").map_err(|e| format!("{e:?}"))?;

The household then writes a rule on it ("when the hall remote is pressed twice, turn everything off"). A press on a switch that Niles knows the meaning of (on, off, on_press, up_hold...) also switches its room's lights, as a wall switch does.

Any other class may report actions too:

devices::report_action("office/printer", "finished").map_err(|e| format!("{e:?}"))?;

The rule editor then offers the device for "when … reports", and reads the rule as "When the office printer reports finished". Niles remembers that a device has reported an action, so it stays on offer after a restart. Only a switch's actions switch lights: the printer saying off switches nothing. Use short snake_case words, at most 64 bytes: finished, failed, paused.