# busware KNX stick — commissioning runbook

What this stick can do that an ordinary KNX gateway cannot: it speaks the
**connection-oriented management services** ETS uses, so it can give a device
its address and write its tables, not just relay group telegrams.

Read the safety section before the first mutating call.

## The names on this surface

**You are talking to the stick itself.** Its tools ARE its admin verbs, and the
name of a tool is its verb with the slashes turned into dashes:

```
verb  knx/mem/write        tool  knx-mem-write
verb  knx/tables/write     tool  knx-tables-write
verb  attr/set             tool  attr-set
verb  mqtt/set_creds       tool  mqtt-set_creds     (underscores inside a
                                                     segment stay)
```

`tools/list` is generated from the verb table, so it is always the truth — if a
name here and a name there ever disagree, believe `tools/list`.

Two consequences worth knowing before you start:

- **`describe` is the STICK. `knx-describe` is a DEVICE on the bus.** The first
  takes no arguments and tells you about this box; the second takes `device`
  and interviews somebody else's actuator. They are easy to confuse and answer
  entirely different questions.
- **There is no `stick_rpc` and no reboot tool here.** Every verb is already a
  tool of its own, and this firmware registers no reboot verb — power-cycle the
  stick if you truly need one.

A host-side bridge may offer convenience tools this surface does not have
(`knx_inventory`, `knx_dpt_assign`, `knx_param_declare`). They are compositions
of the verbs below; where one is useful, this runbook shows how to drive it
by hand.

Mutating verbs are gated once an operator has set an admin token
(`auth-set_token`): pass `"token": "<...>"` alongside the arguments.
`describe` reports whether this stick is SECURED (`auth.token_set`).

## First contact: does this stick have a broker yet?

**Check `describe` before you expect anything on MQTT.** A stick ships with no
broker configured — it cannot be expected to know one — and MQTT output stays
OFF until an operator names it:

```
describe -> "mqtt": {"broker": "", "connected": false}
```

While that is the case, **everything in this runbook still works**: the tools,
the bus, the commissioning. What is missing is the *output* side — decoded group
telegrams on `busware/knx/state/…` and the Home-Assistant discovery that
`knx-ha-announce` publishes. If you are waiting for a state topic that never
arrives, this is the first thing to check, not the wiring.

To fix it, ask the user for their broker and set it:

```
mqtt-set_creds {"broker": "mqtt://<host>:1883"}      # user/pass optional
```

It applies on the **next boot** (the MQTT client copies its config at init), and
it is C3 because the body carries the broker password. An empty body clears the
provisioning; sending user/pass without a broker is refused rather than silently
reverting to anonymous.

Plain `mqtt://` or `ws://` only. This firmware gives its MQTT client no CA to
verify a server against, so `mqtts://` and `wss://` are refused with
`broker_tls_unsupported` (from 0.25.2). An older stick stores them and then
fails every connect: a TLS broker in `describe` next to `connected: false` is
that case.

Note the asymmetry, because it is deliberate: this very verb reaches you over
the stick's own MCP endpoint, which does **not** need MQTT. That is what makes a
freshly flashed stick configurable at all.

## Safety, in four sentences

1. **Every C1 and C2 tool carries `dry_run`, and the firmware default is
   `true`.** A dry run performs every read — occupancy probes, scans, state
   read-backs — and every validation, and withholds only the change, so its
   report is truthful about whether the real thing would work. The reply names
   the outcome in the tense it happened in: `would_write` / `would_queue` /
   `would_delete` / `would_submit` / `would_reset` / `would_announce` /
   `would_store` when nothing went out, and `written` / `queued` / `deleted` /
   `accepted` / `reset` / `entities` / `stored` when it did. **C3 tools
   (`auth-set_token`, `auth-clear_token`, `mqtt-set_creds`) have no dry run** —
   they carry secret material and act at once; each tool's own description says
   which kind it is.
   *Before 0.22.0 the promise was printed on verbs that ignored the field —
   `knx-tx-send` sent on every call until 0.21.0 (it moved a real relay during a
   dry run), and five C1 verbs ignored it until 0.22.0. Check
   `describe.api_version` before trusting a dry run on an unknown stick.*
2. Pass `dry_run: false` only when the user has asked for the change.
3. A wrong `A_Memory_Write` can leave a device needing ETS recovery. Use the
   high-level path (`knx-tables-build` → `knx-tables-write`) rather than
   hand-rolled addresses.
4. Probing the surface with empty arguments is safe: a mutating verb called
   with `{}` refuses. Most refuse because they need a real argument; the two
   that would otherwise destroy something on an argument-less call —
   `knx-observe-reset` (it discards the survey) and `knx-ha-announce` in its
   all-datapoints form — demand `confirm: true`.

Each tool's description carries its danger class: **C0** read-only, **C1**
changes this stick only, **C2** changes a device or puts a telegram on the bus,
**C3** changes the stick's own security state.

## Addressing

| | form | names | example |
|---|---|---|---|
| Individual address | `a.b.c` | a **device** | `1.1.5` |
| Group address | `main/middle/sub` or `main/sub` | a **function** | `11/0/8` |

The tools take the text form. The stick's own individual address is
`describe.knx.own_address`; it must be unique on the line like any other
device.

### This stick's own address — check it before you commission anything

`describe.knx.own_address_source` says where that address came from, and the
two answers mean very different things:

| source | meaning |
|---|---|
| `build` | the address compiled into the firmware — **the same on every stick flashed from this image** |
| `nvs` | somebody chose it for this unit |

`build` is not a problem by itself, and on a bus with one busware stick it never
becomes one. It becomes one the moment a **second** stick from the same image
joins the same line: both answer as the same device. Group communication
survives that (a group telegram addresses a function, not a device), but every
connection-oriented service — the whole commissioning plane, `knx-describe`,
`knx-mem-read`, the load state machines — sends a `T_Connect` to an individual
address, and now two devices answer it. Measured on the bench 2026-08-14 with a
C3 and a C6 on one bus, both `1.1.250`.

Give this stick its own address:

```
knx-scan-range { "from": "1.1.240", "to": "1.1.254" }    # is it free? C0, but bus traffic
knx-ia-set { "address": "1.1.249", "dry_run": false }
knx-restart-self  →  there is none: power-cycle, or reboot from the web UI
```

Three things this verb deliberately does **not** do, so you have to:

- **It does not check whether the address is free.** A probe would be bus
  traffic, and this verb is C1 — it writes NVS and transmits nothing. Scan
  first. Handing it an address another device already holds recreates exactly
  the ambiguity you are fixing.
- **It does not take effect until the next boot.** The reply says
  `effective_after_reboot: true`. The transport task reads the address for
  every frame it filters and acknowledges, so it cannot be swapped underneath a
  running link. Read `describe.knx.own_address` after the reboot to confirm —
  the reply's `current` is still the OLD address, on purpose.
- **It refuses `x.y.0`.** That is a line coupler's address.

`{"clear": true, "dry_run": false}` forgets the stored address and falls back to
the compiled one at the next boot.

## Updating this stick's firmware

The image does **not** come through this surface. A firmware is ~1.5 MB and an
admin verb answers in a 512-byte buffer with no streaming, so the upload is
HTTP — on the same server that serves this skill:

```
http://<this stick>/update          the page: current version, file picker, progress
POST http://<this stick>/api/ota    the raw .bin, for scripts
```

The images are published next to the web flasher:
https://install.busware.de/TUL/mcp/ links the current one for each chip. An
update replaces the firmware only; the pass and the stored settings stay.

Two verbs cover what the page cannot:

```
ota-status {}                                      # C0
ota-rollback { "confirm": true, "dry_run": false }  # C2 — boot the other slot
```

Read `ota-status.running.state` after an update: `valid` means confirmed,
`pending_verify` means still on probation and a reset right now would revert it.
A new image confirms itself 60 s after a complete boot — the bar is "it stayed
up", not "WiFi and the bus are fine", because reverting firmware is the wrong
answer to a stick that was moved to another network.

`other.version` of `-` means the second slot is empty: nothing to roll back to.

**The upload is validated before a single byte is written** (`esp_ota_begin`
erases the target slot, so checking afterwards would destroy the fallback):
not-an-ESP-image, a build for a different chip, or a different project are all
refused with the running firmware untouched. The version is reported, never
enforced.

⚠️ `no_ota_partition` means this stick still runs the single-app table. It has
nowhere to put a second image and cannot be updated over the network at all —
it needs one pass through the web flasher first.

## Runbook A — commission a fresh device

### 1. Find it

```
knx-addr-scan {}
```

`count: 0` — nobody pressed a programming button; ask the user to press it.
`count: 1` — good.
`count: 2` or more — an assign would give **all** of them the same address.
Ask the user to release all but one before continuing.

### 2. Give it an address

```
knx-addr-assign { "address": "1.1.5" }              # dry run
knx-addr-assign { "address": "1.1.5", "dry_run": false }
```

The verb re-runs the safety procedure itself and you cannot skip a step:
target-occupancy check, exactly-one-in-programming-mode gate, broadcast, verify
at the **new** address, leave programming mode.

`rc` values that are answers rather than faults:

| rc | reason | what to do |
|---|---|---|
| `-9` | target address already in use | pick another address, or find out what is already there with `knx-describe` |
| `-10` | more than one device in programming mode | back to step 1 |
| `-11` | no device in programming mode | ask for the button press |

Success looks like `written: true, verified: true`.

### 3. Interview it

```
knx-describe { "device": "1.1.5" }
```

Gives the **mask version** (which decides which Load State Machines exist), the
authorization level the default key gets, and each LSM's state. `Loaded` means
its tables are in use; `Unloaded` means they are not.

### 4. Build the wiring

```
knx-tables-build {
  "device": "1.1.5",
  "associations": [ { "ga": "11/0/8", "com_obj": 133 } ],
  "addrtab_size": 32, "assoctab_size": 48,
  "addrtab_address": 16384, "assoctab_address": 16416
}
```

**Pure** — it touches neither bus nor device, so iterate freely. It returns the
octets for both tables plus the decoded wiring, so you can read back what you
just described and check it says what you meant.

Sizes and addresses come from the product's `.knxprod`. Passing the sizes makes
a table that does not fit get **refused here** instead of truncated into the
device.

Which `com_obj` corresponds to which function is **product semantics** and
lives in the `.knxprod`. This server does not know it. If you do not know it,
say so and ask — do not guess.

### 5. Compare with what is there

```
knx-tables-read { "device": "1.1.5",
                  "addrtab_address": 16384, "assoctab_address": 16416 }
```

`own_address_matches: false` is the one cross-check worth making every time:
TSAP 0 is maintained by the device, so a mismatch means the table you just read
is stale and must **not** be written back as-is.

### 6. Install each table

Twice — LSM 1 is the address table, LSM 2 the association table. **Use the
atomic verb**:

```
knx-tables-write { "device": "1.1.5", "lsm": 1,
                   "segments": [ {"address":16384,"size":32,"access":242,
                                  "mem_type":3,"seg_flags":128} ],
                   "address": 16384, "data": "0511055800580158085809",
                   "restart": false, "dry_run": false }
knx-tables-write { "device": "1.1.5", "lsm": 2, "segments": [ … ],
                   "address": 16896, "data": "…", "dry_run": false }
```

One call is one whole load session: begin → chunked verified writes → finish →
restart, and the firmware guarantees the finish runs even when a write fails —
a mid-sequence interruption can no longer strand the device in Loading, where
it resolves ZERO group addresses and a power cycle does not reliably heal it.
`restart` defaults to true; the first call of a pair passes false so the pair
ends with one restart.

On failure the reply's `rc`/`failed_at`/`written` say what the device now
holds (old or partial table, but Loaded) — fix by running the install again.

The manual sequence (`knx-load-begin` → `knx-mem-write` ×N →
`knx-load-finish`) remains for tables beyond 96 octets — the admin payload
cannot carry more. If you must walk it: each write verifies by read-back, 12
octets per write (the device's measured ceiling, below the protocol's 15), and
**always pair a begin with a finish** — checking that the `steps` trace ends in
state `1` / `Loaded`.

### 7. Make it take effect

```
knx-restart { "device": "1.1.5", "dry_run": false }
```

A device reads its tables and parameters **when its application starts**, so a
change written outside a load session — or one whose session you closed without
a restart — is on the device but not yet acting. This is how you make it act.

There is no reply by design: success means the request reached the bus. Wait
~2 s, then confirm with `knx-describe`.

**`acked:false` is the normal answer, not a failure.** A device drops its
transport layer the moment it acts on the restart, so it never acknowledges the
frame that caused it — measured on the reference actuator, where the device was
back and answering group addresses two seconds later. Do not resend a restart
because of it; read the device back instead. `acked:true` only means this
particular device managed to acknowledge before it went.

And know the cost before you send it —
the device leaves the bus for a second or two, and **a blind actuator loses its
position reference** until its next full travel, so a house full of shutters is
a reason to ask the user first. Never restart a device that is in somebody
else's load session.

### On masks without memory-mapped load control

Everything above assumes the load state machine is reachable through memory,
which is true of the BimM112 family (mask `0x0700/0701/0705`). It is not
universal:

| mask | how to drive it |
|---|---|
| `0x0700/0701/0705` BimM112 | memory — `knx-mem-write`, as above |
| `0x0020/0021/0025` Bcu2, `0x07B0` SystemB, `0x0300` | properties — `knx-prop-read` / `knx-prop-write` on object 1/2/3, PID 5 |
| `0x0010`..`0x0013` BCU1 | **no properties and no LSM at all** — plain memory writes, and `knx-describe` takes a separate branch for them |

`knx-prop-write` returns `echo`: the value the DEVICE reports after the write.
Compare it against what you sent — a property that clamps or ignores the write
says so there, and that is your read-back verification. Note that PID 5
(`PID_LOAD_STATE_CONTROL`) takes an **event**, not a state: writing back the
value you just read starts a load session rather than changing nothing.

**Ask before you write.** `knx-prop-desc` is the device's own verdict on one of
its properties:

```
knx-prop-desc { "device": "1.1.5", "object": 3, "pid": 13 }
  -> present, writable, write_level, read_level, pdt, max_elements
```

Read **`writable`** — it is the field that predicted behaviour on the reference
actuator: the write it calls writable was accepted, the one it does not was
refused. That matters because a manufacturer's product data describes a
**mask**, not the device in front of you, and there it marked both writable.
The access levels come along (read in the high nibble, write in the low one),
but that device answers the same pair for nearly everything it has — evidence,
not permission. With `pid: 0`
and `index: 1, 2, 3 …` the same verb walks an object's properties, so you can
find out what a strange device has without knowing its numbers.

## Runbook B — make the stick understand a group address

Receiving is not decoding. Without an assignment the stick logs the telegram
and publishes no value.

There is no dedicated DPT tool on this surface: an assignment is an entry in
the engine's attr KV, keyed by the group address in **wire hex**.

```
attr-set { "key": "dpt:5808", "value": "9.001", "dry_run": false }   # 11/0/8 -> 0x5808
attr-list {}                                          # what is assigned
attr-get  { "key": "dpt:5808" }
```

The conversion, because getting it wrong assigns a *different* group and then
mis-decodes silently instead of failing:

```
3-level  main/middle/sub   (main << 11) | (middle << 8) | sub
2-level  main/sub          (main << 11) | sub
11/0/8 -> (11<<11) | 8      = 0x5808
11/3/6 -> (11<<11)|(3<<8)|6 = 0x5B06
```

Supported today: **1.x** boolean, **5.x** 8-bit unsigned (incl. `5.001`
percent), **9.x** 2-byte float. Others are stored but the decoder reports it
cannot decode them. An empty value removes the assignment.

Assignments are runtime data — no reflash, and they survive reboot.

To test a decoder without touching the bus:

```
knx-inject { "ga": "11/0/8", "tpdu": "0080", "dry_run": false }
```

`accepted: false` is a result, not an error: the codec refused the telegram,
which is what a malformed-input test wants to see.

**`accepted: true` does not mean decoded.** The verb replies once the frame is
handed to the decode worker; the result appears afterwards on MQTT:

```
busware/knx/rx/11/0/8        always — the raw telegram, even with no assignment,
                             because traffic you cannot yet decode is how you
                             find out a datapoint exists
busware/knx/state/5808/value only when a DPT is assigned and decodes; retained,
                             keyed in wire hex like the dpt: attr above, with
                             the readable "ga" inside the payload
```

Because of that ordering, an assignment issued immediately after an inject can
land **before** the worker reads it — measured, 1 run in 3 lost the decode that
way. Assign first, inject second, and do not change the assignment you are in
the middle of testing.

## Runbook C — operate

```
knx-tx-send { "ga": "11/0/0", "value": 1 }                    # DRY: encodes only
knx-tx-send { "ga": "11/0/0", "value": 1, "dry_run": false }  # switch on
knx-tx-send { "ga": "11/0/8", "hex": "0C1A", "dry_run": false }   # 2-byte DPT 9
knx-tx-send { "ga": "11/0/8", "svc": "read", "dry_run": false }   # ask
```

This moves relays in someone's building — send only what the user asked for.

**Dry run is the default here too, since 0.21.0.** Before that this verb sent on
every call and ignored `dry_run` entirely, including an explicit `"dry_run":
true` — so on an older stick a probe switches the building. Check
`describe.api_version` before you trust a dry run on this verb.

The reply names what happened in the tense it happened in: `queued: true` when
it went out, `would_queue: true` next to `dry_run: true` when it did not. The
encoding runs either way, so `tpdu` shows the exact octets in both cases.

**`queued`, not delivered**: the L_Data confirmation arrives later on the
transport task, so this layer cannot honestly claim delivery. A `svc: "read"`
answer likewise arrives asynchronously on the MQTT stream, not in the reply.

## Several sticks on one broker

The telegram, state and command topics carry no stick identity:
`busware/knx/rx/11/0/8`, `busware/knx/state/5808/value` and
`busware/knx/cmd/5808/<kind>/set` are spelled the same on every stick, and every
stick subscribes to `busware/knx/cmd/+/+/set`. Two sticks on one broker
therefore publish into the same topics — `state` is retained, so the last writer
wins — and a command is carried out by **every stick that has a DPT assigned
to that group address**, each onto its own line. From 0.25.2 that assignment
is required for every kind; before, `read` and the cover's `OPEN`/`CLOSE` went
out from each stick on the broker, assigned or not. Only
`busware/knx/avail/<id>`, the Home-Assistant config topics and the admin plane
are per stick; the prefixes are fixed in this firmware. On a shared broker,
find out what the other sticks serve before you assign a DPT.

## Runbook D — put it in Home Assistant

`knx-ha-announce` projects what the stick has been taught into HA's MQTT
discovery. It reads the attr KV and guesses nothing: no DPT, no entity; no
`role`, read-only entity.

```
attr-set { "key": "role:5B06", "value": "switch", "dry_run": false }
knx-ha-announce { "ga": "11/3/6", "dry_run": false }
```

`ga` announces one datapoint; `confirm: true` announces all of them (that form
demands confirm because a verb that changes the world when probed with `{}` is
indistinguishable from a trap). `remove: true` retracts.

All attr keys carry the group address in the same wire hex as `dpt:`:
`dpt: role: name: state: pos: setpos: stop:`.

### Command and status on different addresses

This is the KNX norm, not an exception — an actuator takes orders on one group
address and reports what it did on another. Declare the reporting address with
`state:`, and keep it from becoming an entity of its own with `role:none`:

```
attr-set  dpt:FFFD   = "1.001"     # the status address needs its own DPT …
attr-set  role:FFFD  = "none"      # … but is not an entity in its own right
attr-set  role:FFFE  = "switch"
attr-set  state:FFFE = "FFFD"      # the switch listens HERE
```

Written out, each of those is a full call — and `attr-set` is dry-run-by-default
like everything else, so all four need the flag or nothing is stored:

```
attr-set { "key": "state:FFFE", "value": "FFFD", "dry_run": false }
```

`attr-list` is the witness: if the key is not in it afterwards, the flag was
missing. A dry run answers `ok: true` with `would_delete`, which is easy to read
as success.

The switch then carries `command_topic .../FFFE/...` and `state_topic
.../FFFD/...`. Without `state:` a switch listens on its own address, which is a
lie whenever the actuator answers elsewhere — HA would show the last command
instead of what the relay did. Covers have had this all along under a different
name (`pos:` for the position feedback, `setpos:` for absolute positioning).

`role:none` on a REMOVE run retracts from all five component topics, because
the role it was announced under is by then gone.

**An object that reports nothing at all is a different problem** and it is not
solved here: a KNX object needs its association, its group-object-table
descriptor AND its application parameter to line up, and each one missing is
silent. Fix the device first, then announce it.

## Runbook E — make a device parameter editable

A parameter is not a group address: it lives in the device's memory and is
written inside a load session. Two declarations are needed, both local (C1),
and then the parameter appears in HA as a `number`.

```
knx-loadproc-set { "device": "1.1.5", "lsm": 3, "dry_run": false,
                   "segments": [ {"address":…,"size":…,"access":…,
                                  "mem_type":…,"seg_flags":…} ],
                   "task_segment": { … }, "task_ctrl1": { … } }
attr-set { "key": "parm:travel1", "value": "1.1.5,18986,16,10,64000,10,s",
           "dry_run": false }
knx-ha-announce { "confirm": true, "dry_run": false }
```

The load procedure is **the same JSON shape `knx-load-begin` takes, plus
`device`** — the segment list and task records out of the `.knxprod`, stored
verbatim in NVS (≤ 640 B) and validated by actually parsing it before a byte is
kept: a declaration that cannot drive a session is refused rather than stored.
Declare it once per device, at commissioning time.

Why the declaration is mandatory rather than convenient: outside a load session
the device **silently discards** a changed write into parameter memory —
authorized or not, one byte or two, before and after a restart. Inside a
session the same write verifies and survives the restart (measured: travel 95 s
after writing factor 900, against 128 s before). So a parameter write with no
declared procedure is refused, not attempted.

The `parm:` value is a comma-separated declaration:

```
device , address , width , raw_min , raw_max , per_unit [, unit]
```

- `width` is **8 or 16 — byte-aligned only**. Several parameters often share
  one byte and this plane does no read-modify-write, so an 8-bit declaration
  **writes the whole byte**. Check the product file: declare a byte only when
  that parameter is its sole occupant, or you silently reset its neighbour.
- `per_unit` is how many raw steps make one displayed unit, so a human sees
  seconds instead of a magic number: a travel-time factor counting in 100 ms
  takes `per_unit` 10, and HA then shows 45 s for raw 450.
- The whole value must stay under 64 characters, and the name after `parm:`
  must carry no `/` (it becomes an MQTT topic segment) and no `:` (it would
  break the attr key).

This is an **allowlist, and that is the point**: the command plane is fenced by
the broker ACL, not by the admin token, so no memory address ever arrives over
MQTT — only what is declared here can be addressed at all, and only within the
declared bounds. Without a stored load procedure for the same device the write
is refused rather than sent for the device to discard.

Where a parameter lives, how wide it is and what its range means are **product
data from the `.knxprod`**. This stick does not know them and will not guess.

### The load procedure belongs to the PRODUCT, not to the mask

**Never copy a load procedure from another device, not even one with the same
mask version.** The addresses in it are per-application. Two actuators of one
family, both mask `0x0701`, both jalousie/switch actuators:

| `<LoadProcedures>` step | application A | application B |
|---|---|---|
| `LdCtrlTaskSegment` LSM 3 | **18422** | 17886 |
| `LdCtrlTaskCtrl1` LSM 3 | **18408** | 17864 |
| `LdCtrlAbsSegment` | 1792/**384**, 17408/**1373** | 1792/160, 17408/837 |

The task segment pointer tells the BCU where the application's task structure
lives. Hand it an address from a different product and the BCU never finds the
application: the session completes, **every LSM reads `Loaded`**, and the
application never runs again — across `knx-restart` and across a power cycle.
Measured on the bench 2026-08-14.

Take the values from the `<LoadProcedures>` block of the `.knxprod` application
the target device actually runs; it is a literal transcript of what ETS does.
Two steps are deliberately left OUT of `knx-loadproc-set`: a 25856-byte segment
(that region is not addressable over the bus and runs into a timeout) and any
`LsmIdx="5"` step (a conversion artefact — mask `0x0701` has no LSM 5).

### Which application does the device actually run?

Ask the device, not the project file:

```
knx-prop-read { "device": "1.1.5", "object": 3, "pid": 13 }   # ApplicationId
```

Manufacturer(2) + application number(2) + version(1), e.g. `000C570110`.
**A device may answer `0000000000`** — then it is not telling you, and choosing
the procedure by mask version or by what a sibling runs is a guess. Identify it
from parameter memory instead: applications carry identification bytes whose
defaults differ per application, and the `.knxprod` lists those defaults.
Three of them were enough to tell two sibling applications apart on the bench.
A second tell is the per-channel stride (102 bytes vs 92 for that pair): if the
channel formulas from one product yield nonsense on the device — an operating
mode outside its own enum, say — the product file is the wrong one. **Stop and
identify before writing.**

### `RUN_STATE` is the acceptance criterion — read it after every session

```
knx-prop-read { "device": "1.1.5", "object": 3, "pid": 5 }   # LOAD_STATE → 01 Loaded
knx-prop-read { "device": "1.1.5", "object": 3, "pid": 6 }   # RUN_STATE  → 01 Running
```

`PID 5` alone is not enough, and neither is `knx-describe`: both report a
healthy `Loaded` while the application is halted, and the device still answers
management reads perfectly. **`PID 6 == 01` is what says the device is alive**;
`00` means the application is halted and the session was wrong. The device stops
acting on group telegrams entirely and its RUN LED goes dark — nothing in the
LSM report shows it.

### Recovering a halted application

`RUN_STATE 00` is recoverable over the bus — no ETS, no application download.
Re-run the session with the **correct** procedure for that product
(`knx-loadproc-set`, then change any declared parameter, which makes the
firmware run the session), and read `PID 6` again.

What does NOT work, so nobody spends a bench hour on it: writing `PID 6`
directly is refused by the device; `knx-load-begin` + `knx-load-finish` walk the
state machine cleanly (Unloaded → Loading → Loaded) **without** starting the
application; `knx-restart` does not start it; a power cycle does not either —
the halted state is persistent. Correcting only the ApplicationId does not start
it. Only the right procedure does.

### Verify at the DEVICE, not in the reply

A read-back proves the byte landed, not that the device acts on it. On a
jalousie channel that is measurable — declare the travel time, change it, and
time the position feedback against the command (bench 2026-08-14: declared
121.0 s → measured 129.2 s; declared 30.0 s → 32.4 s; the constant offset is the
actuator's own additional running time and scales with the setting).

⚠️ A load session **restarts the device**, and a jalousie channel loses its
position reference across a restart: absolute position commands do nothing until
it has made one full travel into an end position. Expect the first move after a
parameter change to be a reference run.

## Surveying an installation

`knx-observe-list` first, always: it reports what the stick has passively
HEARD, transmits nothing, and on an installation in service it is often the
only survey you need. `knx-observe-reset { "confirm": true }` starts a fresh
window.

When you must probe actively, `knx-scan-range` does **one chunk** and you drive
the loop:

```
knx-scan-range { "from": "1.1.1", "to": "1.1.255" }
   -> probed, found, devices[{address, mask}], next: "1.1.34", done: false
knx-scan-range { "from": "1.1.34", "to": "1.1.255" }    # feed `next` back
```

Loop until `done: true`. `hits_full: true` means the chunk's result buffer
filled before its range ran out — `next` is still correct, so keep going;
nothing was lost, it was just cut short.

- `budget_ms` is capped at 8000 by the firmware — the verb blocks the stick's
  MQTT task and must return before the broker keepalive expires. That cap is
  why the sweep is chunked at all.
- `probe_ms` below ~250 does not speed the sweep up, it only misses devices:
  the transceiver alone needs ~200 ms to finish repeating an unacked frame.
- Pause between chunks on a bus in service; this IS traffic.
- `mask: null` on a hit means the address acknowledged but sent no device
  descriptor. It is still a device.

To identify what answered, read the **ApplicationId** — object 3, PID 13:

```
knx-prop-read { "device": "1.1.5", "object": 3, "pid": 13 }
```

manufacturer(2) + application number(2) + version(1). It is the key that maps a
device onto its `.knxprod`, and therefore onto an order number — but it names
an application, i.e. a product FAMILY: one program often serves the 2-, 4- and
8-fold variants of an actuator. Serial and order info (PIDs 11/15) are
frequently left unwritten — all `FF` is normal, not a fault.

`knx-tables-read` per device then reconstructs the group-address wiring: the
ETS project read back out of the devices themselves.

## When something does not answer

- `"not connected"` / `rc -5` — the device did not accept a T_Connect. Check
  the address with `knx-addr-scan` (if it is in programming mode) or
  `knx-describe` on a neighbour to confirm the bus itself is alive.
- `"timeout"` / `rc -1` — no T_ACK inside the window. On a healthy line this is
  usually the wrong address, not a broken device.
- `describe.knx.tpuart_ready: false` — the stick is not talking to its
  transceiver at all; nothing bus-facing will work until that is fixed.
- `describe.knx.rx` counts what came off the wire (`ours` = addressed to a
  group we know, `foreign` = everything else). Both stuck at 0 on a live
  installation means the stick is not hearing the bus.
- `stick_busy_lock_timeout` — a management dialogue is already running. They
  are serialized on purpose: two interleaved connection-oriented sessions would
  answer each other's requests. Retry.

## Reading the survey — and what `repeats` means

`knx-observe-list` reports what the stick has passively HEARD. It never
transmits, so on an installation in service it is the first thing to run and
often the only one.

`telegrams` counts everything; `repeated` how many of those the link layer
marked as a **retransmission**, and each row carries its own `repeats` (the
field is OMITTED where it is zero). A repetition means nobody acknowledged the
frame — the sender said it again. Above the link layer a repeat is
byte-identical to its original, so this distinction cannot be recovered after
the fact; it is either counted here or lost.

Reading it:

- a row with `count: 5, repeats: 3` is one exchange plus three retries, not
  five events. Counting telegrams without this over-reports activity.
- **Check `describe.knx.auto_ack` before calling it a fault.** With
  auto-acknowledge OFF the stick does not acknowledge frames addressed to it,
  so every answer to a read WE sent is repeated three times by a perfectly
  healthy device. Measured on a live installation: two devices at `count: 4,
  repeats: 3` for exactly this reason, while a third device's ordinary bus
  traffic carried no repeats at all.
- repeats on traffic between OTHER devices, with our own reads excluded, is the
  signal worth chasing: an addressing or wiring fault on that line.
