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: aroomsetting 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),outdoorandnone. - Name is what people call it, in snake_case:
floor_lampis 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
| Class | What it is | Fields that matter | Controllable |
|---|---|---|---|
light | A light, dimmable or not | on, brightness, color_temp_kelvin, rgb | usually |
outlet | An on/off plug | on | usually |
switch | A wall switch or button | none: it reports actions | no |
contact | A door or window sensor | open, battery_percent | no |
motion | A motion sensor | motion, illuminance_lux, battery_percent | no |
cover | A blind, curtain or shutter | position | usually |
sensor | Anything that only reports | any field, and readings | no |
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:
| Field | Type | Unit and range |
|---|---|---|
on | bool | |
brightness | u8 | percent, 0–100 |
color_temp_kelvin | u16 | kelvin, e.g. 2200 (warm) to 6500 (cool) |
rgb | {r, g, b} | 0–255 each |
temperature_celsius | f32 | °C |
humidity_percent | f32 | relative humidity, 0–100 |
battery_percent | u8 | 0–100 |
open | bool | true is open |
motion | bool | true while it sees movement |
illuminance_lux | f32 | lux |
position | u8 | percent, 0 shut, 100 all the way open |
readings | list 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 calledtemperature: rules look for it in one place. The namestemperature,humidity,battery,brightnessandilluminanceare 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.Nonefor a plain number, such as a rank or a count. - Merged by name, like the fields: reporting
powerleavesenergyas it was, and an empty list says nothing. There is no way to clear one; report a new value, orset_available(false)when the device stops answering. - Measured, never set.
set_devicenever 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:?}"))?;upsertadds a device or replaces its class, capabilities and controllability; itsstateis merged like a report. Call it ininitfor every device, every time: it is cheap, and it is how a changed class reaches Niles.report_statemerges 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_actionis forswitchdevices: 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.trueonce it answers again. Devices start available.removetakes 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 ininitand 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(())
}desiredholds 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
Errwith 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_deviceonce 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_deviceabout 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.