# Wolf Controller — AI Agent Guide

Complete reference for configuring and operating a Wolf Controller through its REST API.
Written for AI assistants and automation clients. Covers: auth, the store API, system/network
setup, onboard & expansion I/O, Modbus, DALI, and the rules engine.

Device: ARMv5 Linux controller, 128 MB RAM. HTTP server on port **5080** (plain HTTP).
All JSON field names are **camelCase** unless noted otherwise.

---

## 0. First steps — session protocol for AI agents

Follow these steps at the start of every session, before anything else:

1. **Ask the operator for the controller's address** (IP or hostname; the API is on port
   5080, plain HTTP). Never scan or probe the network to find the device — no nmap, no
   subnet sweeps, no port scans.
2. **Ask the operator for credentials.** Never guess or brute-force logins. Recommend a
   dedicated account for AI use (e.g. username `ai`) rather than the operator's own —
   its actions are then clearly identifiable in the audit log, and it can be removed
   afterwards. The operator can create it in the UI or you can (with their admin
   credentials) via `PUT /wolf/users`. Basic auth requires an account without TOTP.
3. **Verify the connection** with a harmless read: `GET /api/system` → report
   `wolfAppVer` back to the operator so both sides know you're on the right device.
4. **Before the first change, offer a backup:** "Do you want me to back up the current
   configuration first?" If yes: `GET /api`, save the JSON locally (it contains secrets —
   handle accordingly). It can be restored later with `PUT /wolf/restore`.
5. **At the end of the session**, if a temporary AI account was created, remind the
   operator to remove or disable it (`PUT /wolf/users`), and consider clearing test
   artifacts you created (test rules, test writes).

---

## 1. Authentication

Two ways to authenticate. All endpoints except `/auth/*` and the static UI require one of them.

**A. Session token (recommended for interactive use):**

```
POST /auth/login
{"username": "admin", "password": "secret"}
→ 200 {"token": "<base64url>", "expires_in_seconds": 28800}
```

Use it as `Authorization: Bearer <token>`. Tokens live 8 h, sliding (each use extends 8 h),
and survive an app restart.

If the response is `401 {"totp_required": true}`, the account has two-factor auth — complete
login with:

```
POST /auth/totp
{"username": "...", "password": "...", "code": "123456"}   # 6-digit TOTP
→ 200 {"token": "...", "expires_in_seconds": 28800}
```

**B. HTTP Basic (recommended for machine clients):** `Authorization: Basic base64(user:pass)`
on every request. Not usable if the account has TOTP enabled (always 401 `totp_required`).

TOTP management (both public, credential-checked):
- `POST /auth/totp/setup` `{username, password}` → `{secret_base32, otpauth_url, issuer}`.
  **Warning:** TOTP becomes required immediately — an abandoned setup locks password-only login.
- `POST /auth/totp/disable` `{username, password, code}` → 204, revokes all the user's sessions.

---

## 2. The store API — core concept

Almost everything on the Wolf is one JSON document (the "store"). The REST API exposes it at:

```
GET   /api/<path>      # read the subtree at that path
PATCH /api/<path>      # deep-merge the JSON body into that path
```

The URL path maps 1:1 to a JSON pointer: `GET /api/io/status/output3` reads `/io/status/output3`.
`GET /api` returns the entire store (this is how backups are made).

**GET rules:**
- Never 404s. A missing path returns `200` with body `null`.
- Scalar paths return the bare scalar (`true`, `21.4`, `"text"`).

**PATCH rules:**
- Body is any JSON value, deep-merged at the path. Only keys present in the body are touched.
- A non-object body is a leaf write: `PATCH /api/io/status/output3` with body `true`.
- `null` deletes a key.
- **Arrays are replaced wholesale** — there is no per-element merge or index addressing for
  writes. To edit one element of `rules/conf` or `modbus/definitions`: GET the array, modify
  it, PATCH the whole array back.
- Missing intermediate objects are auto-created. Patching *through* a scalar or array → 500.
- Response is `204 No Content`. Add header `Prefer: return=representation` to get `200` with
  the resulting subtree.
- `?force=true` re-emits the change event even if the value is unchanged. Needed to **re-send
  a command that is already at that value** (re-drive an output, re-send a DALI/Modbus write).
- The PATCH blocks until driver callbacks have run — by the time you get 204, the hardware
  write has been dispatched.
- A PATCH to a path nothing subscribes to **silently succeeds** (value stored, nothing
  happens). Getting a 204 does not prove the path was meaningful.

**Top-level store keys:** `io` (8DiDoAn card), `3oiro` (3-relay card), `dali`, `modbus`,
`modbusTCPServer`, `modbusTCPGateway`, `rules`, `mqtt`, `fox868`, `net` (status only),
`system` (read-only version/mem/load info), `site` (UI free-form, e.g. `{"name": "Site X"}`).

### 2.1 Persistence and applying config — the golden sequence

The store lives in RAM. Config subtrees (`*/conf`, `mqtt/config`, `modbus/*`, `rules/conf`,
`modbusTCPServer`, `modbusTCPGateway`) are read **once at application start**.
Therefore every config change requires:

```
1. PATCH /api/<config path>       # change it
2. GET   /wolf/save               # persist to flash (data.json) — otherwise lost on restart
3. GET   /wolf/restartapp         # restart wolf-app so the new config is loaded
```

Exceptions that apply live (no restart): writes to `*/status` paths (output control, DALI
levels/commands, Modbus register writes), `dali/conf/chN/power`, `io/status/pullUpN`,
`3oiro/status/lowPassFilterN`. DALI commissioning saves itself automatically.

`GET /wolf/save` strips all `status` subtrees before writing — live state is never persisted.
`GET /wolf/reboot` reboots the whole controller (saves first). Both return 200 immediately.

### 2.2 Backup / restore

- Backup: `GET /api` → save the JSON.
- Restore: `PUT /wolf/restore` with that JSON as body → writes it to flash and restarts.
  The device's **live network config is kept** (the backup's `net` key is ignored), so a
  restore cannot change/break networking.

---

## 3. System & network management (`/wolf/*`)

All protected. Unless noted, write endpoints return `200 {"status":"ok"}` and errors as
`400/500 {"error":"<code>"}`.

### 3.1 Users

- `GET /wolf/users` → `{"version": 2, "users": [{id, username, name, role, enabled,
  oneTimePassword, createdAt, passwordUpdatedAt, totpEnabled}]}`
- `PUT /wolf/users` — **full replacement**: any existing user missing from the array is
  deleted. Include `id` to update (omit `password` to keep it); omit `id` to create
  (`password` required, min 10 chars). `version` must be 2. At least one enabled `admin`
  must remain. Returns 204, or a **bare 400** with no reason in the body.

```json
{"version": 2, "users": [
  {"id": "existing-id", "username": "admin", "role": "admin", "enabled": true},
  {"username": "operator", "password": "longpassword1", "role": "user", "enabled": true}
]}
```

### 3.2 Network (netd)

`GET /wolf/netd` / `POST /wolf/netd`. One flat JSON both directions:

```json
{
  "lan": {
    "static": true,
    "address": "192.168.2.127", "netmask": "255.255.255.0",
    "gateway": "192.168.2.1", "dns": "1.1.1.1", "dns2": "8.8.8.8",
    "services": {"share": false, "dhcp": false, "rangeStart": "", "rangeEnd": ""},
    "ping": {"ip": "1.1.1.1", "ip2": "8.8.8.8", "interval": 300, "error": 3}
  },
  "wwan": {
    "enabled": false, "apn": "internet",
    "ping": {"ip": "1.1.1.1", "ip2": null, "interval": 300, "error": 3}
  },
  "primaryLink": "lan"
}
```

- **All fields are required** on POST except `lan.ping` and `ping.ip2` (nullable). A missing
  key is a 422.
- `static: false` = DHCP client on eth0; then address/netmask/gateway/dns are ignored.
- `services.share`/`dhcp` (internet sharing / DHCP server) require `static: true`; the DHCP
  range must sit inside the LAN subnet.
- `wwan` = 4G modem; `ping` there is the link watchdog (required valid when enabled).
- `primaryLink`: `"lan"` or `"modem"`; forced to `"lan"` when wwan is disabled.
- Error codes: `invalid_address`, `invalid_netmask`, `invalid_gateway`, `invalid_dns`,
  `invalid_dns2`, `invalid_wwan_ping`, `invalid_share_requires_static`,
  `invalid_share_range_start/end/subnet`, `invalid_share_range`.
- The endpoint only writes the config file — the new network settings take effect on the
  next **system reboot** (`GET /wolf/reboot`).
- Live network state (read-only): `GET /api/net/status` — per-interface
  `{name, up, mtu, mac, ipv4{addr,mask,broadcast}, rxBytes, txBytes}`, `defaultRoute`,
  and modem details under `modem/*` (imei, sim, signal).

**Lockout caution:** a wrong static IP/gateway makes the device unreachable after reboot.
Always confirm the new address is on a subnet the operator can reach before rebooting.

### 3.3 Static routes

- `GET /wolf/routing` → `[{"network": "10.0.0.0/24", "gateway": "192.168.2.1", "iface": "eth0"}]`
- `POST /wolf/routing` — bare array (same shape, full replacement). `network` must be CIDR
  with no host bits set.

### 3.4 NTP (chrony)

- `GET /wolf/chrony` → `{"servers": ["pool.ntp.org"], "custom": false}`
- `POST /wolf/chrony` `{"servers": ["ee.pool.ntp.org"]}` — max 4; empty list reverts to
  system default.

### 3.5 Firewall

- `GET /wolf/firewall` → `{"ports": [{"port": 5080, "action": "ACCEPT"}]}`
- `POST /wolf/firewall` — same shape, TCP only, action `ACCEPT`/`DROP`.

### 3.6 SSH keys

**Note: snake_case fields** (the one exception in the API):

- `GET /wolf/ssh-keys` → `[{"key_type": "ssh-ed25519", "key_data": "AAAA...", "comment": "erko@laptop"}]`
- `POST /wolf/ssh-keys` — bare array, **full replacement** of `authorized_keys`. `key_type`
  must start `ssh-` or `ecdsa-sha2-`; comment must not contain newlines.

### 3.7 WireGuard VPN

- `GET /wolf/wireguard` → `{address, peerPublicKey, peerPresharedKey, endpoint, allowedIps,
  persistentKeepalive, publicKey}` — `publicKey` is the device's own; the private key is
  never returned (auto-generated and preserved across updates).
- `POST /wolf/wireguard` `{"address": "10.8.0.2/32", "peerPublicKey": "...",
  "peerPresharedKey": null, "endpoint": "vpn.example.com:51820",
  "allowedIps": "10.8.0.0/24", "persistentKeepalive": 25}`
- `DELETE /wolf/wireguard` — removes the config.

### 3.8 Diagnostics

- `POST /wolf/ping` `{"target": "8.8.8.8"}` → `{target, success, stdout, stderr}` (4 pings).
- `GET /wolf/errors?src=modbus|mqtt|rules|dali` (omit `src` for all) → newest-first
  `[{src, id, message, code, detail, timestamp}]` (`timestamp` in ms; `src` short codes:
  `rt` modbus-RTU, `tp` modbus-TCP, `ts` TCP-server, `tg` gateway, `ru` rules, `mq` mqtt,
  `da` dali). In-memory ring buffer, **25 entries per source, lost on restart** — read it
  promptly after a test.
- `DELETE /wolf/errors?src=...` — clear (useful before a test to get a clean view).
- `GET /wolf/audit-log?n=30&offset=0` — who changed what, newest first. Entries: store patch
  `{"t","u","m":"P","c":[{"p": path, "o": old, "n": new}]}`; endpoint write
  `{"t","u","m":"PO","e": endpoint, "b": body}`; auth events.

### 3.9 Firmware

`POST /wolf/firmware` — multipart upload of a `firmware.itb` (max 16 MB). Then
`GET /wolf/reboot` to apply. Caution: the endpoint returns 200 even if the write failed.

---

## 4. Onboard & expansion I/O

One expansion card at a time, auto-detected at boot. Cards: **8DiDoAn1Ro** (8 combined
digital-in/digital-out/analog channels + 1 relay, store key `io`), **3OiRo** aka 3Oi3Ro
(3 opto inputs + 3 relays, store key `3oiro`), 2-port RS-485 (no I/O), DALI (section 6).

To detect which card is present: `GET /api/io/status` — if it returns an object, the
8DiDoAn card is active; else `GET /api/3oiro/status` for the 3OiRo; else no I/O card
(both `null`). (There is no explicit card-type endpoint.)

### 4.1 8DiDoAn1Ro — config `/api/io/conf`

Each channel `io1`…`io8` can be input, output and/or analog (enable what's wired):

```json
{"io3": {
  "input":  {"enabled": true, "pullUp": true},
  "output": {"enabled": true, "defaultOn": false},
  "adc":    {"enabled": true, "mode": "adc", "multiplier": 1, "offset": 0,
             "threshold": 0.1, "beta": 3435}
}, "relay": {"enabled": true, "defaultOn": false}}
```

- `adc.mode`: `"adc"` = 0–10 V voltage reading (float volts); `"ntc"` = 10 kΩ NTC
  thermistor → °C, using `beta` (e.g. 3435, 3950).
- `multiplier`/`offset` scale the final value; `threshold` is a publish deadband (value
  updates only when it moves more than this).
- Config changes need save + restartapp (section 2.1).

### 4.2 8DiDoAn1Ro — state & control `/api/io/status`

Flat keys, only enabled channels appear:

| Key | Type | Direction |
|---|---|---|
| `input1..8` | bool | read (true = high) |
| `adc1..8` | float | read (volts or °C) |
| `output1..8` | bool | **write to control** |
| `relay` | bool | **write to control** |
| `pullUp1..8` | bool | write (live, input pull-up) |
| `wiegand1..4` | `{card, facility, last_read}` | read (26-bit Wiegand reader, inputs paired 1+2→1, 3+4→2 …) |

```
PATCH /api/io/status              {"output3": true}      # switch output 3 on
PATCH /api/io/status/relay        true                    # close the relay
```

Outputs accept **booleans only**. There is no built-in pulse/toggle/timed output — do
momentary actions with a rule (section 7 example). Re-asserting the same value needs
`?force=true`.

### 4.3 3OiRo — `/api/3oiro/...`

Config `/api/3oiro/conf`: `input1..3 {enabled, lowPassFilter}`, `relay1..3 {enabled, defaultOn}`.
Status `/api/3oiro/status`: `input1..3` (bool, opto-isolated, true = active),
`relay1..3` (bool, write to control), `lowPassFilter1..3` (bool, live).

```
PATCH /api/3oiro/status   {"relay2": true}
```

Note: I/O drivers report no errors to `/wolf/errors`; if outputs don't respond, check
physical wiring and that the channel is `enabled` in conf (a PATCH to a disabled channel
succeeds silently but does nothing).

---

## 5. Modbus (master)

Wolf polls Modbus RTU (RS-485) and Modbus TCP devices. Model: reusable **definitions**
(register maps per device type) + **buses** with **devices** referencing a definition.
Values appear under `/api/modbus/status`.

### 5.1 Configuration — `/api/modbus`

PATCH the whole object (arrays replace wholesale; then save + restartapp):

```json
{
  "definitions": [
    {"device": "energy_meter_x", "registers": [
      {"enabled": true, "name": "activePower", "registerType": "inputRegister",
       "registerAddress": 3000, "dataType": "float", "byteOrder": "wordSwap",
       "multiplier": 0.1, "offset": 0, "threshold": 0.5, "pollFrequency": 5000,
       "readOnce": false, "writable": false},
      {"enabled": true, "name": "setpoint", "registerType": "holdingRegister",
       "registerAddress": 40120, "dataType": "uint16", "pollFrequency": 10000,
       "writable": true},
      {"enabled": true, "name": "serialNumber", "registerType": "holdingRegister",
       "registerAddress": 100, "dataType": "uint32", "readOnce": true},
      {"enabled": true, "name": "relay1", "registerType": "coil",
       "registerAddress": 0, "dataType": "bool", "pollFrequency": 2000, "writable": true}
    ]}
  ],
  "rtu": [
    {"port": "/dev/ttyS6", "baudRate": 9600, "parity": "even", "stopBits": 1,
     "dataBits": 8, "pollingDelay": 5, "responseTimeout": 250,
     "devices": [{"id": 1, "name": "Meter A", "type": "energy_meter_x", "address": 1}]}
  ],
  "tcp": [
    {"ipAddress": "192.168.1.50", "port": 502, "connectTimeout": 5000,
     "responseTimeout": 1000,
     "devices": [{"id": 2, "name": "Meter B", "type": "energy_meter_x", "address": 1}]}
  ]
}
```

Field reference:
- `registerType`: `coil` (FC01) | `discreteInput` (FC02) | `holdingRegister` (FC03) |
  `inputRegister` (FC04). Function code is implied.
- `dataType`: `bool`, `uint8/16/32/64`, `int8/16/32/64`, `float`, `hex` (raw block; needs
  `count` = number of registers 1–123, value is an uppercase hex string, 4 chars per register).
- `byteOrder`: `bigEndian` (default) | `littleEndian` | `wordSwap` | `wordByteSwap`.
- Reads compute `multiplier * raw + offset`; writes invert it. `threshold` = deadband.
- `pollFrequency` in ms; `0` = never polled (write-only register); `readOnce: true` = read
  once per connection (serial numbers etc.).
- `writable: true` required for writes; only holding registers and coils are writable.
  Write function code is automatic: FC06 for a single register, FC16 whenever the value
  spans more than one register (uint32/64, float, hex blocks). `fc16Force: true` forces
  FC16 even for single-register writes (for devices that only accept FC16). Coils: FC05
  single, FC15 for hex coil blocks.
- Device `address` = Modbus unit/slave ID; device `id` = key in the status tree.
- RTU `parity`: `"none"` | `"even"` | `"odd"`. TCP `ipAddress` must be an IPv4 literal (no DNS).
- Each bus (RTU and TCP) accepts `"enabled": false` to park it without deleting its
  config — the poller is not started and (RTU) the serial port stays free. A missing
  `enabled` key means enabled.
- If a slow slave causes "mismatching headers"/timeout errors, raise that bus's
  `responseTimeout` (not `pollingDelay`).

### 5.2 Reading values

```
GET /api/modbus/status
→ {"1": {"activePower": 21.4, "setpoint": 45, "relay1": true}, "2": {...}}
```

Keyed by device `id`, then register `name`. A value of **`null` means the device is
offline** (3 consecutive failures). No timestamps — values update on change only.

### 5.3 Writing registers

```
PATCH /api/modbus/status?force=true
{"1": {"setpoint": 45}}
```

Always use `?force=true` (without it, writing the current value does nothing). The write is
queued and retried up to 3×; the 204 does **not** confirm bus success — check
`GET /wolf/errors?src=modbus` afterwards ("Modbus write failed" / timeout entries).

### 5.4 Testing a device — `POST /wolf/modbus/test`

One-shot read/write that needs **no configuration** — ideal for verifying wiring, address,
baud rate and register map before writing config:

```json
{"protocol": "rtu", "operation": "read", "registerType": "holdingRegister",
 "address": 3000, "dataType": "float", "byteOrder": "wordSwap",
 "responseTimeout": 500,
 "rtu": {"port": "/dev/ttyS6", "baudRate": 9600, "parity": "even",
          "stopBits": 1, "dataBits": 8, "unitId": 1}}
→ 200 {"status": "ok", "value": 21.4}
```

`responseTimeout` is in ms, optional (default 3000, clamped 50–60000). For scanning a bus
for devices — looping unitId 1–247 and checking which answer — use a short timeout
(200–500 ms) so absent addresses fail fast; a timeout shows up as
`{"error": "modbus_error: ..."}` mentioning a timeout, while a live device returns a value.

TCP variant: `"protocol": "tcp", "tcp": {"host": "192.168.1.50", "port": 502, "unitId": 1}`.
Write: `"operation": "write", "value": 45` (optional `"functionCode": "fc16"`).
Errors: `{"error": "..."}` — `rtu_busy` means the configured poller owns that serial port.
To free it, temporarily disable that bus (never delete its config): GET `/api/modbus`,
set `"enabled": false` on the bus, PATCH it back, `/wolf/save` + `/wolf/restartapp`;
test; then set `"enabled": true` again, save + restart. Timeout is fixed 3 s.

Recommended Modbus commissioning flow:
1. `POST /wolf/modbus/test` until the device answers with sane values.
2. Build `definitions` + bus config, `PATCH /api/modbus`.
3. `GET /wolf/save` → `GET /wolf/restartapp`.
4. Wait ~10 s, `GET /api/modbus/status` — expect values, not nulls.
5. `GET /wolf/errors?src=modbus` — should be empty or quiet.

### 5.5 Related: TCP server & gateway

- `/api/modbusTCPServer` — expose Wolf's own data as a Modbus TCP **slave**:
  `{"port": 1502, "registersConf": [{"path": "/io/status/input1", "registerAddress": 0,
  "registerType": "inputRegister", "dataType": "bool", "forceEvent": false}]}`.
  A master writing a holding register writes into the store (can control outputs).
- `/api/modbusTCPGateway` — transparent TCP→RTU bridge:
  `{"conf": [{"rtu": {"port": "/dev/ttyS6", "baudRate": 9600, "parity": "none",
  "stopBits": 1, "dataBits": 8, "timeout": 250, "pollingDelay": 5}, "tcp": {"port": 502}}]}`.

Both boot-time config (save + restartapp).

---

## 6. DALI lighting

Two channels `ch1`/`ch2` on the DALI expansion card. Config under `/api/dali/conf`,
live state and **all commands** under `/api/dali/status`. Gear (lights) use short
addresses 0–63; DALI-2 input devices (buttons, sensors) have their own 0–63 space.

Channel config: `PATCH /api/dali/conf/ch1 {"enabled": true, "power": true}` —
`enabled` needs restart; `power` (bus power) applies live.

### 6.1 Commissioning (adding lights)

Commands are written to the channel's commissioning object; the PATCH returns immediately
and the scan runs in background — **poll for progress**:

```
PATCH /api/dali/status/ch1/commissioning   {"command": "gearScanNew"}
GET   /api/dali/status/ch1/commissioning
→ {"state": "running", "target": "gear", "devicesFound": 3}     # poll ~1 s until "done"/"error"
```

| Command | Use |
|---|---|
| `gearScanNew` | **Safe default.** Addresses only unaddressed gear; existing lights untouched |
| `gearScanAll` | **Destructive** — wipes ALL short addresses and the channel's gear config, re-addresses from scratch. Only for a fresh bus or deliberate re-commissioning |
| `gearDiscover` | Non-destructive probe of addresses 0–63; registers already-addressed gear |
| `inputScanNew` / `inputScanAll` | Same pair for DALI-2 input devices |

Found gear is auto-added to `/dali/conf/chN/gear/<addr>` with `name`, `type`, `typeName`
(LED, Emergency, Relay, …) and the config auto-saves. **Restart the app after
commissioning** — newly added devices only get their control subscriptions at startup.

- Identify (flash a lamp): `PATCH /api/dali/status/ch1/commissioning {"identify": 5}`
  (input device: `{"identifyInput": 2}`).
- Move a light to another address: `PATCH /api/dali/status/ch1/gear/5 {"setAddress": 12}`.

### 6.2 Control

All control writes should use `?force=true` (repeat commands don't fire without it):

```
PATCH /api/dali/status/ch1/gear/3?force=true    {"level": 127}          # 0-254 (0=off, 254=max)
PATCH /api/dali/status/ch1/gear/3?force=true    {"command": "off"}      # named or raw opcode 0-255
PATCH /api/dali/status/ch1/group/2?force=true   {"level": 254}          # groups 0-15
PATCH /api/dali/status/ch1/broadcast?force=true {"level": 0}            # everything on channel
PATCH /api/dali/status/ch1?force=true           {"scene": 3}            # recall scene 0-15
```

Commands: `off`, `up`, `down`, `stepUp`, `stepDown`, `recallMaxLevel`, `recallMinLevel`,
`stepDownAndOff`, `onAndStepUp`.

Group membership & scene levels (read-modify-write triggers on the gear path):

```
PATCH .../gear/3 {"readGroups": true}    → then GET gear/3, see "groups": [0,3,7]
PATCH .../gear/3 {"groups": [0,3]}       then {"writeGroups": true}
PATCH .../gear/3 {"readScenes": true}    → "scenes": {"0": 254, "3": 128}
PATCH .../gear/3 {"scenes": {"0": 200}}  then {"writeScenes": true}
```

Gear parameters (min/max level, power-on level, fade time/rate, emergency-test settings…):
`{"readGearConfig": true}` → GET `gearConfig` (a self-describing array of
`{key, name, description, type, value, min, max, unit}`) → PATCH edited array →
`{"writeGearConfig": true}`. Same pattern for input devices with
`{"readInstanceConfig": <instanceNumber>}` / `writeInstanceConfig`.

Emergency lighting (DT1): `{"dtCommand": "startFunctionTest"}` (also `startDurationTest`,
`stopTest`, `inhibit`, `rest`, …), then `{"readDtStatus": true}` and read `dt1*` fields.

### 6.3 Reading state

```
GET /api/dali/status/ch1/gear/3
→ {"level": 127, "online": true, "status": 4, "statusLampFailure": false,
   "statusLampArcPowerOn": true, ...}
```

- `online` goes false after 3 failed polls. Level polls every 30 s, status every 300 s by
  default (per-gear `poll/level` / `poll/status` seconds in conf; 0 disables).
- Device info: `{"readDeviceInfo": true}` → `gtin`, `serial`, `fwVersion`, `hwVersion`.
- Input events (button presses, sensors): latest at `/api/dali/status/chN/input/event`
  (`{addr, instance, type, typeName, event, ...}` — rules can subscribe to this path);
  last 20 in `/api/dali/status/chN/input/events/`.
- Bus health: `GET /api/dali/status/bus` → per channel `{bus, power, currentMa, txCount,
  rxCount, collisions, busErrors, timeouts}`; `bus` ∈ `ok`, `stuck_low`, `stuck_high`,
  `not_powered`, `short`, `overcurrent`, `unknown`. Card link: `/api/dali/status/connected`.
- Errors: `GET /wolf/errors?src=dali`.

### 6.4 Typical "add DALI lights" flow

1. Ensure channel on: `PATCH /api/dali/conf/ch1 {"enabled": true, "power": true}` (+ save
   + restartapp if `enabled` changed).
2. `PATCH /api/dali/status/ch1/commissioning {"command": "gearScanNew"}`; poll until `done`.
3. `GET /api/dali/conf/ch1/gear` — see found lights; rename via
   `PATCH /api/dali/conf/ch1/gear/3 {"name": "Hall spot 1"}` (+ `/wolf/save`).
4. `{"identify": <addr>}` to physically match each light; test with `{"level": 254}` /
   `{"command": "off"}`.
5. `GET /wolf/restartapp` so new gear is fully subscribed.

---

## 7. Rules engine

Rules live in one array at `/api/rules/conf`. Editing = replace the whole array
(GET → modify → PATCH), then **save + restartapp** (rules load only at startup).

```json
{"id": 12, "enabled": true, "name": "Hall light auto-off",
 "conf": {"type": "lua", "script": "..."}}
```

`id` (unique number) and `enabled` are required. Types: `lua` (preferred, full scripting),
`ifThen` (declarative condition→action), `pulseEmitter` (heartbeat timestamp to
`/rules/status/<id>/lastEmit` every `pulseInterval` ms).

### 7.1 Lua rules

The script runs once at startup and registers its triggers. Available API:

**`sys` (global):**
- `sys.get(path, default?)` — read any store path (e.g. `"/io/status/input1"`).
- `sys.set(path, value, force?)` — write any store path; `force=true` re-emits unchanged values.
- `sys.json_decode(str)` / `sys.json_encode(value)` — return `(result, err)`.
- `sys.http{url=, method=, headers=, body=, timeout_ms=, retries=, parseJson=, id=}` —
  fire-and-forget HTTP; response lands at `/net/http/response/status/<id>` as
  `{status, body, error, elapsedMs, ts}` (subscribe to it for the reply).

**`rule` (lua rules only):**
- `rule.subscribe(path, fn)` — `fn(path, value, old)` on every change of that exact path
  (no wildcards). Fires once at startup with current value and `old = nil`.
- `id = rule.setInterval(fn, ms)` / `id = rule.setTimeout(fn, ms)` — timers (min 50 ms).
- `rule.clearTimer(id)` — cancels either kind.

Sandbox: `math`, `string` (incl. `string.pack`/`unpack`), `table`, `utf8`,
`os.time/date/difftime/clock`, `print` (stdout only). No `io`, `require`, `load`.
Callbacks have a 300 ms CPU budget. Keep rules event-driven; don't busy-loop.

Example — button hold-to-light with 60 s auto-off:

```lua
local BUTTON = "/io/status/input1"
local LIGHT  = "/io/status/output7"
local off_timer = 0

rule.subscribe(BUTTON, function(path, value, old)
  if old == nil then return end        -- skip startup fire
  if value ~= true then return end
  sys.set(LIGHT, true)
  rule.clearTimer(off_timer)
  off_timer = rule.setTimeout(function() sys.set(LIGHT, false) end, 60 * 1000)
end)
```

Useful paths to subscribe to: I/O inputs (`/io/status/input1`), ADC values, Modbus values
(`/modbus/status/1/activePower`), DALI input events (`/dali/status/ch1/input/event` —
re-fires on repeats), pulseEmitter heartbeats (`/rules/status/<id>/lastEmit`).
Rules can also *write* Modbus registers and DALI levels via `sys.set(path, value, true)`
(pass `force=true` for command-style writes).

### 7.2 ifThen rules

```json
{"type": "ifThen", "ignoreLastValue": false,
 "check": [{"op": "equal", "pointer": "/io/status/input2", "value": true}],
 "if":    [{"op": "change", "pointer": "/io/status/input1"}],
 "then":  [{"op": "set", "pointer": "/io/status/output1", "value": true,
            "switchbackDurationMs": 5000}]}
```

`if` ops: `equal`, `more`, `moreEqual`, `less`, `lessEqual`, `change` (trigger paths);
`check` = extra conditions evaluated at trigger time; `then` ops: `set`, `toggle`
(plus advanced `evalexpr`/`lua` script ops). `switchbackDurationMs` reverts the value
after the delay (built-in pulse!). `forceEvent: true` = force-write.

### 7.3 Debugging rules

- Load/compile/runtime errors: `GET /wolf/errors?src=rules`.
- There is no rule log endpoint; verify behaviour by reading the affected store paths.
- A broken rule is skipped at load; other rules still run.

---

## 8. Other subsystems (brief)

- **MQTT client** — `/api/mqtt/config`: `broker{enabled, host, port, clientId, auth{...},
  tls{...}, lwt{...}}`, `publish[]` (store path → topic), `subscribe[]` (topic → store path),
  `groups[]` (periodic batched publish). Boot-time config. Status: `/api/mqtt/status`
  (`connected`, `lastError`). Note: `GET /api` output includes these credentials — treat
  full-store dumps as secrets.
- **SMS** (if 4G modem fitted): send by `PATCH /api/net/status/modem/smsOutgoing`; incoming
  under `/api/net/status/modem/smsIncoming`.
- **fox868 radio sensors** — readings at `/api/fox868/status/<node>`:
  `{temperature, voltage, batteryVoltage, rssi, signal, updated}`.
- **System info** — `GET /api/system`: `wolfAppVer`, `wolfOsVer`, `kernelVer`,
  `status/mem{totalKb, availableKb}`, `status/load`.

---

## 9. Safety rules for AI agents

1. **Config ≠ applied.** After config PATCHes: `GET /wolf/save`, then `GET /wolf/restartapp`.
   Without save the change is lost; without restart it never takes effect. Batch related
   changes and restart once.
2. **Arrays replace wholesale.** Always GET → modify → PATCH full arrays (`rules/conf`,
   `modbus/definitions`, users, ssh-keys). Never PATCH a partial array — you'd delete the rest.
3. **Full-replacement endpoints** (`PUT /wolf/users`, `POST /wolf/ssh-keys`,
   `POST /wolf/routing`): omitting an existing entry deletes it. Always start from a GET.
4. **Network changes can lock the operator out.** Before `POST /wolf/netd` + reboot, verify
   the new settings with the operator; keep at least one enabled admin user; never touch the
   firewall port the API itself uses (5080) or SSH (22) without explicit confirmation.
5. **`gearScanAll` erases every DALI short address on the bus** and the channel's gear
   config. Default to `gearScanNew` / `gearDiscover`.
6. **Commands need `?force=true`** whenever re-sending a value that may be unchanged
   (outputs, DALI levels/commands, Modbus writes).
7. **204 does not mean success on the wire.** Modbus/DALI writes are fire-and-forget —
   verify by reading the value back and checking `GET /wolf/errors?src=...`.
8. **A PATCH to a wrong/disabled path succeeds silently.** Verify effects, not responses.
9. **The device is small** (128 MB RAM, slow flash). Poll modestly (≥1 s intervals), avoid
   `GET /api` full dumps in loops, keep rules event-driven.
10. **Full-store dumps contain secrets** (MQTT credentials, TLS keys). Handle backups
    accordingly; never echo them into logs or chats.
