---
title: "Discovering and Adopting Devices · RUAL Documentation"
description: "Pairing per radio, why adoption is a separate gate, names and rooms, forget versus unpair, and what survives a restart."
canonical: https://docs.rual.nl/home-automation/devices
language: en
---

# Discovering and Adopting Devices

A coordinator hears every device ever paired to its network. Adoption is the gate that turns a radio's earshot into a system you can reason about, and this is the loop around it.

Devices appear on their own. You do not register them, import them or describe them: the adapter announces whatever the radio reports and the node records it. What you do decide is which of those devices are part of your system, and that decision has a name.

### Why Adoption Exists

A discovered device is tracked, visible in the API, and has its state followed. It also **fires no triggers and refuses every command** until it is adopted.

That is not ceremony. A Zigbee coordinator hears every device in radio range that has ever been paired to its network, including the ones somebody paired to test and forgot. A second-hand Z-Wave stick inherits whatever the previous owner left in its NVM. Without an explicit adopt, "when any sensor activates" means "when anything within thirty metres twitches", which is not an automation anybody can reason about.

The gate is enforced in one place, in the trigger dispatcher and in the command path, rather than in each block. A new trigger block cannot forget it. There is exactly one exception, and it is the one that has to be: `device discovered` fires for unadopted devices, because noticing them is its entire purpose.

|  | Discovered | Adopted |
| --- | --- | --- |
| Appears in the device list | Only when you ask for unadopted devices | Yes, by default |
| State is tracked | Yes | Yes |
| Fires `device discovered` | Yes, once | Not applicable |
| Fires any other trigger | No | Yes |
| Accepts a command | No, `HOMEASSIST_DEVICE_NOT_ADOPTED` | Yes |
| Records history | No | Yes, per the sampling policy |

### The Onboarding Loop

- **Open the radio for joining.** The `pairing open` block, or `POST /_system/homeassist/pair`. The duration you ask for is clamped to the node's `pairing_window`, so a request for an hour becomes two minutes rather than being refused. Pass 0 to close pairing immediately.

- **Put the device into pairing mode.** Usually a long press, sometimes a specific number of taps. This is the vendor's business, not RUAL's.

- **It appears as discovered.** The `device discovered` trigger fires, once, the first time that device is ever seen.

- **Adopt it, and give it a name and a room.** From that moment its triggers fire and blocks can command it.

Step 4 can be automated: wire `device discovered` straight into `device adopt`, which takes optional name and room pins. That flow is worked through on [Example Flows](https://docs.rual.nl/home-automation/example-flows#auto-adopt). For a bench mesh, `auto_adopt = true` in the config skips the gate entirely, and it should stay off anywhere real.

#### Adopting by Hand

List what is waiting, then take it into service:

```
# Everything the radio has heard that you have not taken into service.
curl -s 'https://<node>/_system/homeassist/devices?unadopted=1' \
  -H 'Authorization: Bearer <token>'

# Adopt it, naming it and placing it in one call.
curl -X POST 'https://<node>/_system/homeassist/devices/zigbee:0x00158d0007e1a2b3/adopt' \
  -H 'Authorization: Bearer <token>' \
  -d '{"name":"Hallway motion","room":"Hallway"}'
```

`unadopted=1` is a separate query rather than the default, and the default list returns adopted devices only. A picker that offers devices a flow cannot command is a picker that produces flows which silently do nothing.

Adoption is idempotent. Adopting an already-adopted device succeeds and reports `newly = false`, so a flow can call it without checking first.

### Pairing Is Different on Every Radio

The `pairing open` block takes an adapter name and a duration, but what that means underneath varies more than the uniform interface suggests.

| Adapter | What "open pairing" does |
| --- | --- |
| `zigbee` | Permit-join on the coordinator, for the requested seconds. The coordinator enforces the window itself. |
| `zwave` | Starts inclusion, with S2 security where the device supports it. Z-Wave inclusion is not time-bounded by the controller the way permit-join is, so the node closes the window itself when the duration expires. |
| `matter` | Opens the fabric's commissioning window so *another* controller can join. Adding a new device to Matter is not "open a window and wait": it needs the 11-digit pairing code or the `MT:` string printed on the device, which is a different operation. |
| `hue` | Creates an application key. A Hue bridge has no inclusion mode: it is already a coordinator with its own devices, and what the node needs is authorisation. The bridge grants it only in the thirty seconds after somebody physically presses the link button, which is a better proof of presence than any credential we could store. |
| `daikin` | Not supported, and it says so: `HOMEASSIST_PAIRING_UNSUPPORTED`. A Daikin unit is on the WiFi or it is not. Add its IP to `[homeassist.daikin] hosts`. |
| `unifi` | Also unsupported, for a similar reason. UniFi devices are enrolled in the UniFi app, and the node reaches them through the console's integration APIs afterwards. What RUAL needs is a key per app, not an inclusion window: see [Setting Up a RUAL Nano](https://docs.rual.nl/home-automation/setting-up#config). |

### Identity, Names and Rooms

Every device carries two identifiers and one label:

| Field | Example | What it is for |
| --- | --- | --- |
| `id` | `zigbee:0x00158d0007e1a2b3` | The platform identifier, ` : `. Stable across restarts and renames, which is why blueprints pin to it. The prefix means two radios can carry the same native address without colliding. |
| `native_id` | `0x00158d0007e1a2b3`, `12`, a Hue UUID | The address in the adapter's own namespace. What you would type into Zigbee2MQTT or Z-Wave JS UI. |
| `name` | `Hallway motion` | What a human calls it. Seeded from the vendor's friendly name, then yours. |
| `room` | `Hallway` | Free text, set by you. Empty until somebody says. |

Any block that addresses a device accepts all three of the platform id, the native id and the **exact** name, case-insensitively. Name resolution exists because typing "Hallway motion" into a pin is the common case and should work. It is deliberately last and deliberately exact: a prefix match would make renaming one device silently re-point another's flows.

**Your name and room are yours.** Once set, a re-announce from the radio never overwrites them. A Zigbee re-pair resets the vendor's friendly name to the model number, and silently undoing "Hallway motion" would be worse than ignoring the vendor. Everything else merges: newly reported capabilities are added, a blank manufacturer or model is filled in.

**Renaming in RUAL does not rename at the radio.** The Zigbee2MQTT friendly name and the Hue bridge name are unchanged, so those apps keep showing the old name. Two names for one device is mildly annoying; a rename that silently rewrites another system's topic structure is worse.

#### Rooms Are Free Text

There is no room registry to create rooms in. A room exists because at least one device is assigned to it, and `get rooms` returns the distinct non-empty room names across all adopted devices, sorted. Room filters compare case-insensitively, so "Hallway" and "hallway" are the same room, but "Hall" is a different one.

Rooms are what make whole-house automations readable. A trigger with an empty device pin and `room = Hallway` is "any matching device in the hallway", which is one block instead of one flow per sensor.

### Where Device Knowledge Comes From

The obvious way to build a device database is to scrape a public one. This does not, and the reason is that the best such database is already on the wire. Zigbee2MQTT publishes its full device definition, the `exposes` tree covering every attribute a device reports with its type, unit and range, on `zigbee2mqtt/bridge/devices`, refreshed whenever the mesh changes. That is the same data a scraped copy would be a stale version of, it covers roughly 4,000 devices, and it arrives already matched to the specific device on your specific mesh. Z-Wave JS does the same through its own config index.

So the translation of that tree *is* the knowledge base, and a small profile table sits on top of it for the thing the wire cannot provide: devices whose exposes tree is technically correct but produces a bad default. Aqara sensors are the clearest case, reporting battery, voltage, link quality and device temperature alongside the one attribute anybody automates on. A profile guarantees the useful capabilities are present before the first report of that kind arrives, normalises the vendor string ("LUMI" becomes "Aqara"), and overrides the derived type where derivation is genuinely wrong, such as a button that would otherwise classify as a sensor.

A device with no profile is not degraded. It gets exactly what its exposes tree said, which is almost always right. Profiles are polish, and they are applied on discovery *and* on adopt, so a device paired before its profile existed picks it up the next time somebody adopts it.

### Forget Is Not Unpair

`device forget`, and `DELETE /_system/homeassist/devices/ `, drop the device from RUAL: its name, room and adoption are discarded. It does **not** unpair the device from the Zigbee, Z-Wave or Matter network. The device stays on the mesh and reappears, unadopted, on its next report.

That is why forgetting is a plain DELETE with no confirmation dance. "Stop automating this" and "evict this from my mesh" are different intentions, and conflating them makes the safe one irreversible. Unpairing is done in Zigbee2MQTT or Z-Wave JS UI, where it belongs, and is deliberately not exposed as a block.

A device that leaves the mesh on its own is treated the same way. When Zigbee2MQTT reports a device leaving, the node marks it unavailable and keeps it registered, because a device leaving is not you saying "forget this": it is unreachable, which is exactly what `available` reports. Deleting it there would silently discard its name, room and adoption the first time a battery died.

### What Survives a Restart

Identity and state both survive, in two different places, for two different reasons.

|  | Identity | Last-known state |
| --- | --- | --- |
| What | Which devices exist, name, room, adoption and when, capabilities shown, manufacturer, model, matched profile | The last value per capability: whether the light is on, the last temperature, whether the door is open |
| Where | The document store, alongside everything else on the node | Redis |
| Why there | It is an operator's work and must not be lost | It is hot, small, rewritten constantly and worthless once stale: the profile of a cache, and the opposite of identity |
| Written when | On the changes that matter: discovery, adoption, rename, a new capability | On every reading, coalesced per device and flushed every two seconds |
| Expires | Never | After 30 days, refreshed on each write |

Keeping state out of the document store is what stops a motion sensor in a busy hallway, reporting several times a minute, from becoming the node's dominant write load on the same backend that serves blueprints. Coalescing per device matters for the same reason: one Zigbee message routinely carries occupancy, illuminance, battery and link quality, which is four changed capabilities and one write.

#### A Restored Value Is a Belief, Not an Observation

Restoring state deliberately does **not** set `available` or touch `last_seen`. The node knows what the door was doing; it does not yet know whether the door is still there. A query that could not tell those apart would report a fleet of confidently-reachable devices seconds after boot, so `available` stays false until the device actually reports.

Momentary capabilities are excluded from the restored map entirely. Replaying the last button press as current state would make it look freshly pressed.

#### Why This Exists

The naive version of this feature has a memorable failure mode. With an empty registry at boot, the first report from every adopted sensor has no previous value to compare against, and treating "no previous" as a change means a window that has been open all night announces itself as "just opened" at 03:00: the alarm flow runs, the lights come on.

Two things prevent it. State is restored *before* the adapters connect, so the first real report has something to compare against and an unchanged re-announce stays unchanged. And independently of persistence, an unknown previous value seeds silently rather than firing, except for `smoke`, `gas` and `water_leak`, where a missed event is worse than a repeated one. The full rule is on [the trigger reference](https://docs.rual.nl/home-automation/triggers#unknown-previous).

Forgetting a device deletes its stored state too, so re-adopting one later does not restore a belief from before it was removed.

### In RUAL Studio

The device list, the per-device state and the history charts live under **Devices**, then **Home Automation**. That screen is what the `/_system/homeassist` routes exist for: the onboarding view (which uses the unadopted query), adopt, rename, forget, and a one-capability command so that having adopted a bulb you can check it actually responds without building a blueprint to find out.

Every trigger and action block that addresses a single device carries the metadata for an **in-editor device picker**: the block declares that it wants a device selector, and which device type to offer, so `light on` offers lights and not thermostats. Offering a thermostat there would invite a flow that fails at runtime with `HOMEASSIST_CAPABILITY_UNSUPPORTED` for a mistake the editor could have prevented.

**The picker UI itself is not shipped yet.** The block metadata and the API it reads are in place; the studio side is [admin-frontend PR 1627](https://github.com/rual/admin-frontend/pull/1627) and is not merged at the time of writing. Until it lands, set the device by typing an id or an exact name into the block's device pin, which is fully supported and is what the examples in this section do.

The screen does not poll. Device events are pushed onto the WebSocket bus as they arrive, on the `homeassistdevices:updates: ` channel for one device and `homeassistdevices:updates:all` for the fleet, which is the subscription an overview wants. Polling is the obvious alternative and it is wrong here for a specific reason: the thing an operator is looking at while onboarding hardware is whether the sensor they just triggered lights up, and at a three-second poll that reads as "the sensor is broken" often enough to send somebody hunting a fault that does not exist. Unlike the trigger dispatcher, the broadcast does **not** filter on adoption, because the onboarding screen exists precisely to show devices that are not adopted yet.

When the picker does land, one rule governs which wins: **the device pin beats the picker, always.** A flow that computes which lamp to address, from a room query or from the device pin of the trigger that started it, must not have that silently overridden by whatever somebody clicked in the editor months ago. The picker is a convenience for the fixed-device case; falling back the other way would make the wired case unreliable in a way that is invisible on the canvas, because both look like a configured block.

### The Device API

All of it is under `/_system/homeassist` and all of it requires the `blueprints` / `simulating` scope.

| Route | Does |
| --- | --- |
| `GET /status` | Per-adapter connection state and device counts. |
| `GET /devices` | The device list. Filters: `type`, `adapter`, `room`, `capability`, plus `unadopted=1` or `include_unadopted=1`. |
| `GET /devices/{id}` | One device, resolved by id, native id or exact name. |
| `GET /devices/{id}/history` | Recorded readings, and buckets when `buckets` is set. |
| `POST /devices/{id}/adopt` | Adopt, with an optional `name` and `room` body. |
| `POST /devices/{id}/command` | Write one capability. One per call, deliberately: the caller here is a human poking a slider, not an automation. |
| `POST /devices/{id}/rename` | Set name and/or room. |
| `DELETE /devices/{id}` | Forget. Does not unpair. |
| `POST /pair` | Open or close pairing on one adapter. |
| `GET /rooms` | The distinct room names in use. |
| `GET /capabilities` | The vocabulary itself: sensor capabilities, device types, per-capability units, decimals, axis bounds and chart type, and the adapter names. |

`GET /capabilities` needs no running service and therefore no node with a radio, because the studio has to populate capability dropdowns while editing a blueprint that will run somewhere else entirely. It is also where the presentation contract lives: units, decimals and chart types come from the platform rather than from a second copy in the studio that can drift.

### Next Steps

### Frequently asked

**Why does my RUAL device not respond even though it appears in the list?**

It is discovered but not adopted. An unadopted device is tracked and its state is followed, but it fires no triggers and refuses every command. Adopt it through the studio, through POST /_system/homeassist/devices/{id}/adopt, or from a flow with the device adopt block.

**Why does RUAL make me adopt devices instead of using all of them?**

A coordinator hears every device ever paired to its network, including ones somebody paired to test years ago, and a second-hand Z-Wave stick inherits whatever the previous owner left on it. Without an explicit adopt, a trigger like when any sensor activates would fire on hardware nobody remembers installing.

**Does forgetting a device in RUAL unpair it from the radio?**

No. Forget removes the device from RUAL, discarding its name, room and adoption, and the device stays paired to the Zigbee, Z-Wave or Matter network and reappears unadopted on its next report. Unpairing is done in Zigbee2MQTT or Z-Wave JS UI, because stop automating this and evict this from my mesh are different intentions.
