bairelay 1.1.2

RTSP Relay for Reolink Baichuan cameras
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
# Bairelay — Testing Guide

Everything testing: workspace gates, the BcMedia fixture pipeline, the Home Assistant compatibility rig, the manual live-verify harness, and the project's testing discipline.

---

## Workspace gates

Run these locally before pushing; CI runs the equivalent set on Linux + macOS:

```
cargo fmt --all
cargo clippy --all-targets -- -D warnings
cargo test
```

`cargo fmt` needs `--all` because it doesn't honour the workspace's `default-members` setting (unlike `clippy` and `test`); the others would, so `--workspace` is redundant. CI swaps `cargo fmt --all` for `cargo fmt --all --check` so it verifies rather than rewrites — locally you want the auto-format variant.

`cargo test` exercises every unit + integration test in every workspace member (covered by `default-members` in the root `Cargo.toml`, so no `--workspace` needed). Current count: **>2000 tests**, 2 ignored (long-running flaky mock-timing scenarios documented in-code).

Coverage is measured via `cargo tarpaulin` from the project root — `tarpaulin.toml` at the repo root pins `workspace = true`, `all-targets = true`, `skip-clean = true`, so plain `cargo tarpaulin` reports the real number:

```
cargo tarpaulin
```

Current baseline is **~88 %**. See `docs/implementation.md` § Coverage policy for per-file targets and documented gaps.

A separate `cargo-fuzz` harness lives in `fuzz/`, excluded from the workspace so `cargo test` never compiles it (libfuzzer-sys is nightly-only). Seven targets cover the parsers + state machines facing untrusted bytes — the wake-server UDP entry point, BcUdp / Bc / BcXml deserialization, ADTS audio headers, the NAL split-and-decode hot path, and the per-packet `UdpFlowState` send/recv decision table. Day-to-day:

```
scripts/fuzz.sh                       # all targets, 10s each
scripts/fuzz.sh aac_parse_adts        # one target only
FUZZ_TIME=600 scripts/fuzz.sh         # longer window per target
```

The wrapper buries libfuzzer's per-iteration chatter in `fuzz/logs/<target>.log` and prints one verdict line per target (OK + exec count, or CRASH + panic line + reproducer path). Setup (rustup nightly + `cargo install cargo-fuzz`) and direct `cargo fuzz` invocations are in `fuzz/README.md`.

---

## Test layout

```
tests/
├── scripts/
│   ├── manual-verify.sh   # live-camera RTSP/MQTT compat harness
│   ├── ha-verify.sh       # end-to-end Home Assistant ingestion check
│   ├── ha-up.sh           # start HA container (+ colima on macOS); idempotent
│   ├── ha-down.sh         # stop HA; stop colima iff we started it (macOS)
│   ├── ha-config/         # in-repo HA configuration.yaml + state
│   └── go2rtc/            # vendored binary (gitignored)
├── logs/                  # transient output (gitignored except README)
├── ha-token               # long-lived HA access token (ships with rig)
├── fixtures/              # captured BcMedia (gitignored)
├── cli_oneshot_test.rs    # CLI subprocess integration tests
├── cli_test.rs            # CLI argument parsing
├── config_test.rs         # config validation
├── connection_test.rs     # MQTT connection scenarios
├── dispatch_test.rs       # control-command dispatch
├── fixture_replay.rs      # BcMedia fixture replay against real RtspServer
├── grace_period_test.rs   # wake-lock + grace-period state machine
├── integration_test.rs    # orchestrator + camera lifecycle
├── orchestrator_test.rs   # per-camera task tree
├── sample_config_parses.rs
├── wake_lock_test.rs      # dual-Notify wake lock
└── watchdog_test.rs       # periodic-sweep watchdog
```

---

## Test infrastructure crates expose

`bairelay_neolink_core`'s test helpers are gated behind the crate's `test-util` Cargo feature; the binary's `[dev-dependencies]` opts in. The other crates' helpers compile unconditionally. See `docs/implementation.md` § Test infrastructure for full API:

- `bairelay_neolink_core::bc_protocol::FakeCameraBuilder` + `FakeCalls`
- `bairelay_neolink_core::bc_protocol::connection::mock::MockConnection`
- `bairelay_neolink_core::bc_protocol::connection::discovery::test_support::ScriptedDiscoverer`
- `bairelay_mqtt::test_support::mock_client()` + `MockHandle`
- `bairelay::stream_source::PacketSource` (binary)
- `bairelay::oneshot::snapshot::MockVideoStream` (binary, test-only)

---

## Offline Bc-protocol decoder (`tests/scripts/decode-bc-pcap/`)

Replays a `tcpdump` capture of a Reolink Baichuan-over-UDP session through `bairelay_neolink_core`'s production parsers + AES-CFB primitives and prints each Bc message's header + decrypted payload. Use it to identify message IDs and XML schemas that bairelay does not yet model.

Standalone cargo project, excluded from the workspace, opts `bairelay_neolink_core` into the `pcap-decode-api` feature (off in production builds). Requires `tshark` on PATH for capture extraction.

Quick invocation:

```
BAIRELAY_DECODE_PASSWORD='...' \
  cargo run --manifest-path tests/scripts/decode-bc-pcap/Cargo.toml --quiet -- \
  tests/logs/real-pcap/<capture>.pcap <camera-ip>:<port> \
  --filter <msg_id>[,<msg_id>...] --brief
```

Full usage + flag table: `tests/scripts/decode-bc-pcap/README.md`. Captures go under `tests/logs/real-pcap/` (gitignored).

---

## BcMedia fixture pipeline

Captures real-camera `BcMedia` streams into files the test harness can replay later — no live camera in the loop. Drives `tests/fixture_replay.rs`.

### Prerequisites

- A real Reolink camera. The project targets **battery cameras only** (Argus series, Duo Battery, Go PT). Always-on Reolinks already expose RTSP natively and don't need bairelay.
- A working `config.toml`.
- Disk space: ~10 MB per minute of 4K HEVC main-stream capture, less for sub or extern.

### Capturing

Pass `--dump-bcmedia <dir>` on the `rtsp` or `mqtt-rtsp` subcommand. Every `BcMedia` packet pulled by the reader task gets mirrored to `<dir>/<camera>-<stream>.bcmedia`. With the flag absent, the hot path is a single `Option::is_some()` branch and no files are touched.

There is **no per-capture duration flag.** Capture runs as long as the process; stop with Ctrl+C.

```
RUST_LOG=bairelay=info cargo run -- mqtt-rtsp \
    --dump-bcmedia tests/fixtures \
    -c config.toml
```

Step by step:

1. Bairelay starts, connects every camera, runs the startup-wake cycle. Startup wake subscribes to Main just long enough to warm the last-frame buffer, so the first `<cam>-main.bcmedia` gets bytes immediately.
2. Whenever any `StreamSource` is active (startup wake, MQTT wakeup, RTSP client), packets append to the matching capture file. A sidecar `.meta.json` lands next to the first write.
3. Ctrl+C triggers the graceful shutdown path; the capture buffer flushes before the camera's stop-video handshake. No packets lost on a clean exit.

### Capturing specific streams

Main is automatic via startup-wake. For Sub / Extern, subscribe via RTSP in a second terminal so the corresponding `StreamSource` starts:

```
ffprobe -rtsp_transport tcp rtsp://127.0.0.1:8554/<cam>/sub
ffprobe -rtsp_transport tcp rtsp://127.0.0.1:8554/<cam>/extern
```

Many battery cameras fall back to Sub when Extern is unavailable (`subscribe_with_extern_fallback` in `crates/rtsp/src/server/`); if you get Sub bytes in an `<cam>-extern.bcmedia` file, that's the camera's choice, not a capture-pipeline bug.

### File layout

For each `(camera, stream_kind)` pair the dump writer (`src/bcmedia_dump.rs`) produces two files under the capture root:

- `<root>/<camera>-<kind>.bcmedia` — concatenated `BcMedia` packets in the wire format. Appends across runs; never truncates.
- `<root>/<camera>-<kind>.meta.json` — sidecar with `camera`, `stream_kind` (`main` / `sub` / `extern`), `capture_started_at` (ISO-8601 UTC), `bairelay_version`. Rewritten on every process start.

### Argus codec mix

Argus emits **H.265 on main, H.264 on sub.** The replay test asserts the codec is one of the two with matching parameter sets (SPS+PPS for H.264, VPS+SPS+PPS for H.265), not a specific codec.

### Reader pattern

```rust
use bytes::BytesMut;
use bairelay_neolink_core::bcmedia::model::BcMedia;
use bairelay_neolink_core::Error;

let raw = std::fs::read("tests/fixtures/cam1-main.bcmedia")?;
let mut buf = BytesMut::from(raw.as_slice());
loop {
    match BcMedia::deserialize(&mut buf) {
        Ok(pkt) => handle(pkt),
        Err(Error::NomIncomplete(_)) => break,    // clean EOF
        Err(e) => return Err(e.into()),           // corrupt / truncated
    }
}
```

`FakeStreamProvider` in `tests/fixture_replay.rs` does exactly this.

### Privacy

Treat fixtures like the camera stream itself.

- **`.bcmedia` files contain raw camera bytes.** The repo's `.gitignore` excludes `tests/fixtures/*.bcmedia` and `*.meta.json`; confirm before committing.
- **Sidecar JSON contains camera names verbatim.** Sanitise camera names in `config.toml` before sharing a fixture file.
- **`RUST_LOG=bairelay=debug` prints camera IPs and UIDs.** Strip them before sharing logs. Keep `=info` for sharable runs.
- **Fixtures are not encrypted at rest.** Put `tests/fixtures/` on an encrypted volume on shared / backed-up machines.

### Replay usage

Drop `.bcmedia` (and `.meta.json`) files into `tests/fixtures/`. Naming must match `<camera>-<kind>.bcmedia` (what `--dump-bcmedia` produces) — the loader splits on the last `-` to recover the stream kind.

```
cargo test --test fixture_replay fake_provider_replays_real
```

With no fixtures, the test exits early with a stderr message and PASSes as a no-op. With at least one file, it drives `FakeStreamProvider` through the real `apply_bcmedia_packet` translator, subscribes, and asserts codec detection + keyframe-first delivery.

For ad-hoc inspection:

```rust
let provider = FakeStreamProvider::from_dir(Path::new("tests/fixtures"))?;
let sub = provider.subscribe("cam1", StreamKind::Main, None).await?;
// sub.sdp_params.video is populated before the first frame.recv().
```

`FakeStreamProvider::from_dir` skips malformed filenames with a `tracing::warn!` rather than failing the scan.

### Wire-format caveat: ADPCM ser/de

`BcMedia::Adpcm` does NOT round-trip byte-exact through `serialize` → `deserialize`. The capture pipeline writes raw camera bytes through a single `serialize` per packet, so on-disk `.bcmedia` files parse cleanly. **Do not** re-serialise captured packets to "normalise" them — the first ADPCM packet desyncs the loop. See `docs/implementation.md` § Wire-format gotchas.

### Troubleshooting

**Empty `.bcmedia` file.**
- Camera never came online during the startup-wake window. Check bairelay's log for warnings; capture starts as soon as a later event wakes the camera.
- Camera never responded to `start_video`. Check for `start_video failed` / `start_video timed out` warnings.

**Huge `.bcmedia` file (> 256 MiB).** The replay harness caps fixture size (`tests/fixture_replay.rs::MAX_FIXTURE_BYTES`). Larger files reject on subscribe.
- Re-capture for a shorter duration.
- If you must truncate, use `head -c <bytes>`. **Do NOT use `dd`** — its default 512-byte block splits packets across the boundary.

**Test says "no fixture files" though I just captured some.**
- Confirm the file ends in `.bcmedia` (not `.bcmedia.bak`).
- Confirm the stem is `<cam>-<kind>` with `kind` exactly `main` / `sub` / `extern` (case-insensitive).
- Run with `RUST_LOG=info cargo test --test fixture_replay` — malformed- filename skips log at `warn`.

---

## Home Assistant compatibility rig

Verifies HA can ingest bairelay's RTSP streams via the Generic Camera integration. Local-only — no auth, no TLS, no reverse proxy. The HA instance is **dedicated to bairelay testing**: camera entries are persisted across runs.

### Prerequisites

The rig ships pre-loaded. The committed state under `tests/scripts/ha-config/` includes `configuration.yaml`, the lovelace `Bairelay Test` dashboard, the `bairelay-verify` admin user, and the matching long-lived token (`tests/ha-token`). HA loads all of this from the bind mount at first start — there is no manual onboarding, no token-generation, no dashboard-building.

**One-time install (Linux):**

```
# Debian / Ubuntu — substitute your distro's package manager.
sudo apt install docker.io mosquitto-clients
sudo usermod -aG docker "$USER"   # log out / back in to take effect

docker pull ghcr.io/home-assistant/home-assistant:stable
docker pull eclipse-mosquitto:2

docker run -d --name homeassistant \
    --restart=unless-stopped \
    -v "$(pwd)/tests/scripts/ha-config:/config" \
    --network=host \
    --add-host=host.docker.internal:host-gateway \
    ghcr.io/home-assistant/home-assistant:stable
```

**One-time install (macOS):**

```
brew install colima docker mosquitto
colima start

tests/scripts/colima-vm-setup/install.sh     # see "Colima VM networking" below

docker pull ghcr.io/home-assistant/home-assistant:stable
docker pull eclipse-mosquitto:2

docker run -d --name homeassistant \
    --restart=unless-stopped \
    -v "$(pwd)/tests/scripts/ha-config:/config" \
    --network=host \
    ghcr.io/home-assistant/home-assistant:stable
```

`--network=host` shares the host network namespace with the container. On Linux that is the host directly; on macOS it is colima's Linux VM, which forwards `localhost:8123` to macOS, so either way HA reaches `http://localhost:8123` on the host. From inside the container, the host is `host.docker.internal` — colima resolves this natively, Linux's Docker needs the explicit `--add-host=host.docker.internal:host-gateway`. bairelay binds `0.0.0.0:8554` by default, so HA reaches it at `host.docker.internal:8554` on either OS.

The `mosquitto` container is created automatically by `ha-up.sh` on first run; no manual `docker run` is needed for it.

### Colima VM networking

Colima's default QEMU profile leaves the VM with two flaws that surface as random `network is unreachable` / `server misbehaving` errors on `docker pull`:

1. **Two default routes.** `col0` (metric 100, via the macOS host) works; `eth0` (metric 200, via QEMU's slirp at `192.168.5.2`) doesn't, and the Docker daemon's outbound source-IP routing picks it.
2. **Flaky DNS proxy.** The slirp gateway at `192.168.5.1` answers the VM shell reliably but drops daemon image-pull queries.

`tests/scripts/colima-vm-setup/install.sh` copies four idempotent systemd units + a watcher script into the Colima VM:

- `colima-fix-network.service` (oneshot at boot) — deletes the eth0 default route + writes `/etc/gai.conf` to prefer IPv4 in `getaddrinfo` (the VM has no IPv6 transit).
- `colima-fix-resolv.service` (oneshot, guarded) — rewrites `/etc/resolv.conf` to `1.1.1.1` + `8.8.8.8`.
- `colima-fix-resolv.path` — re-fires the resolv service whenever Colima's host-side agent resets resolv.conf, which it does ~3 s into every boot.
- `colima-route-watcher.service` (Restart=always daemon) — runs `ip monitor route` and deletes the eth0 default route every time Colima's agent re-adds it after the boot oneshot has already finished.

Run the installer once after `brew install colima` (and re-run after any `colima delete` + recreate). Survives `colima stop` / `colima start` because the VM disk persists.

### Regenerating the access token

If you ever invalidate the pre-loaded token (deleting it from the HA UI, recreating the rig from scratch):

1. User icon (bottom-left) → "Security" tab.
2. "Long-lived access tokens" → "Create Token". Name it `bairelay-verify`, copy.
3. Write to `tests/ha-token`:

```
printf '%s\n' '<paste token>' > tests/ha-token
```

### Start / stop

`ha-up.sh` is idempotent. On macOS it starts colima if not already running and writes `tests/logs/colima.started-by-us` so `ha-down.sh` / `ha-verify.sh` can tell later that they (not you) started it; a pre-existing colima for other projects is never touched. On Linux there is no colima step — the script proceeds straight to the HA + mosquitto containers.

```
tests/scripts/ha-up.sh                 # colima (macOS) + HA + test mosquitto + HA MQTT integration
tests/scripts/ha-up.sh --config-only   # HA + mosquitto only (skip colima even on macOS)
tests/scripts/ha-up.sh --wait 60       # override API-ready timeout

tests/scripts/ha-down.sh               # stop HA + mosquitto; stop colima if marker present (macOS)
tests/scripts/ha-down.sh --ha-only     # stop HA + mosquitto only
tests/scripts/ha-down.sh --colima-only # colima only (macOS; no-op on Linux)
```

`ha-up.sh` also: launches a `bairelay-mosquitto` docker container (image `eclipse-mosquitto:2`) alongside HA, sharing the same `--network=host` namespace so HA reaches it at `127.0.0.1:1884`; registers HA's MQTT integration pointing at the same address if not already present. Both steps are idempotent and `ha-down.sh` stops the container alongside HA. Test creds: `bairelay-test` / `bairelay-test-password`. The image must already be available locally — `docker pull eclipse-mosquitto:2` once on a machine with working outbound (on macOS, that means inside colima — `docker save | docker load` to sideload if your colima can't reach the registry). The bairelay-mosquitto container's lifecycle policy is `--restart=unless-stopped`, mirroring HA.

### Test-rig bairelay config

All HA-related testing uses `tests/bairelay-test.toml` (gitignored) instead of the live `config.toml`. Seed it once from `tests/bairelay-test.toml.example` and fill in real camera UIDs / passwords. The MQTT block points at the local test broker (`127.0.0.1:1884`); `[mqtt.discovery]` is at the top level so HA discovery actually fires (per-camera `discovery.topic` keys are silently ignored — `MqttConfig` lacks `deny_unknown_fields`). `[cameras.mqtt]` enables every per-camera feature flag so the dashboard sees the full set.

`ha-verify.sh` defaults to `tests/bairelay-test.toml`. Pass `-c <other.toml>` to override.

### Bairelay Test dashboard

Dashboard `bairelay-test` (sidebar entry "Bairelay Test") is provisioned in `.storage/lovelace.bairelay_test` and committed with the rig. One section per camera: live picture-glance overlay (battery / motion / floodlight badges on top of the stream), a sub-stream tile, sensor + control entity rows, and a 4-button PT pad. Discovery features `enable_floodlight` / `enable_pir` differ per camera in the seed config, so a camera section can drop entity cards for hardware it lacks. To rebuild from scratch, drive HA's WebSocket API (`lovelace/dashboards/{create,delete}` + `lovelace/config/save`) — the `.storage` files are the canonical source of truth and travel with the repo.

### End-to-end verification

```
tests/scripts/ha-verify.sh
```

Steps:

1. Ensures HA is up via `ha-up.sh`.
2. Builds (release) and starts `bairelay mqtt-rtsp`.
3. Waits for the RTSP listener + startup-wake cycle.
4. Issues `<topic_prefix>/<cam>/control/wakeup 3` via MQTT so each camera holds a wake lock across the full check.
5. For each `(camera, stream_kind)`:
   - Looks for an existing Generic Camera config entry with a matching `stream_source`.
   - If absent, creates one via `POST /api/config/config_entries/flow`.
   - **Never deletes entries on exit.**
6. Resolves `entry_id → entity_id` via HA's `/api/template`. Drives HA's WebSocket API (inside the HA container via `docker exec`) to rename the entry title, device `name_by_user`, and entity registry + `entity_id`. Idempotent — reruns are no-ops.
7. Pulls a snapshot two ways per stream:
   - `ha` — `GET /api/camera_proxy/<entity>` (Generic Camera wraps the RTSP URL with `ffmpeg:`).
   - `g2r` — adds a temporary raw-RTSP stream to HA's bundled go2rtc via its unix socket, fetches `GET /api/frame.jpeg?src=<tmp>`, deletes the temporary stream.
8. Asserts each JPEG is ≥ 1024 bytes and starts with `ff d8 ff`.
9. Stops bairelay. If `ha-up.sh` started colima this run, stops colima and removes the marker. HA container and entries stay.

Flags: `-c <file>`, `--no-build`, `--keep-colima`.

Expected output:

```
PASS  ha/camera_proxy/Cam1/main   size=...
PASS  ha/camera_proxy/Cam1/sub    size=...
PASS  ha/camera_proxy/Cam2/main   size=...
PASS  ha/camera_proxy/Cam2/sub    size=...
PASS  g2r-native/Cam1/main        size=...
PASS  g2r-native/Cam1/sub         size=...
PASS  g2r-native/Cam2/main        size=...
PASS  g2r-native/Cam2/sub         size=...

passed:       8 / 8
failed:       0
```

Exit code is zero when `failed == 0` and `passed > 0`.

### HA MQTT discovery

Bairelay publishes HA MQTT discovery payloads when opted in. Add `[mqtt.discovery]` to `config.toml`:

```toml
[mqtt]
broker_addr = "127.0.0.1"
# topic_prefix = "bairelay"   # default; "neolink" for legacy migration

[mqtt.discovery]
topic = "homeassistant"        # HA's own discovery prefix (required)
# features = ["floodlight", "camera", "motion", "led", "ir",
#             "reboot", "pt", "battery", "siren"]   # default: all 9
```

Per camera, bairelay publishes a single HA device named after the camera (underscores → spaces, title-cased: `front_door` → `Front Door`). Entities per feature:

| Feature    | HA entity                         | Notes                                        |
|------------|-----------------------------------|----------------------------------------------|
| Floodlight | `light` + `switch` (tasks)        | State payload JSON `{"state":"on"}`          |
| Camera     | `camera`                          | Base64 JPEG preview from `status/preview`    |
| Motion     | `binary_sensor`                   | `unique_id` suffix `_md`                     |
| LED        | `switch`                          | Fire-and-forget; no state feedback           |
| IR         | `select`                          | Options: on / off / auto                     |
| Reboot     | `button`                          |                                              |
| PT         | 4 × `button`                      | Only when camera reports PTZ support         |
| Battery    | `sensor`                          | HA long-term-statistics fields               |
| Siren      | `button`                          |                                              |

PT buttons are gated on live capability detection via `BcCamera::get_support()` — no model-class guessing. If `get_support()` fails at connect (transient protocol error, mid-wake), bairelay leaves the cache empty and re-probes on the next reconnect.

### Verifying entries directly

```
TOKEN=$(cat tests/ha-token)
curl -s -H "Authorization: Bearer $TOKEN" \
     http://localhost:8123/api/states \
| python3 -c 'import json,sys; [print(s["entity_id"], s["state"]) \
              for s in json.load(sys.stdin) \
              if s["entity_id"].startswith("camera.")]'
```

UI:

- `http://localhost:8123/config/integrations/integration/generic`
- `http://localhost:8123/config/devices/dashboard?domain=generic`

### Troubleshooting

**`token file 'tests/ha-token' missing or empty`** — create it (above).

**`HA API auth check failed (HTTP 401)`** — token expired / revoked. Regenerate.

**`RTSP listener did not open on :8554`** — check `tests/logs/ha-verify/bairelay.log`. Common cause: another process bound to 8554.

**`cannot reach host.docker.internal from HA container`** — verify with `docker exec homeassistant getent hosts host.docker.internal`. If empty: on macOS, `colima restart`; on Linux, recreate the HA container with `--add-host=host.docker.internal:host-gateway` (the create-once command in `ha-up.sh`'s error path shows the correct flags).

**colima gets stopped when I didn't want it to** — pass `--keep-colima`. The script only stops colima if `ha-up.sh` started it in the same run.

**Snapshot returns `exit status NNN`** — body is an ffmpeg exit code propagated by go2rtc:

```
docker exec homeassistant tail -100 /config/home-assistant.log
```

**Delete HA camera entries.** Use the HA UI (Settings → Devices & Services → Generic Camera → click each → Delete) or:

```
TOKEN=$(cat tests/ha-token)
for eid in $(curl -s -H "Authorization: Bearer $TOKEN" \
    'http://localhost:8123/api/config/config_entries/entry?domain=generic' \
    | python3 -c 'import json,sys; [print(e["entry_id"]) for e in json.load(sys.stdin)]'); do
    curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
        "http://localhost:8123/api/config/config_entries/entry/$eid"
done
```

The next `ha-verify.sh` run recreates them.

---

## HA Add-on verification

The HA Add-on at `hassio/bairelay/` has two distinct test surfaces, only one of which the Colima HA Container rig covers.

### Surface 1: container parity (Colima rig)

The HA Container in Colima has no Supervisor and cannot install add-ons. It can, however, run the add-on image as a plain Docker container and observe that the MQTT-side behaviour is identical to `cargo run`. This validates the entrypoint shim, the `render-hassio-config` subcommand, the merge logic, and bairelay end-to-end — everything below the Supervisor integration layer.

Prerequisites:

- The add-on image built locally:

    ```
    docker build hassio/bairelay/ \
        --build-arg BAIRELAY_VERSION=<x.y.z> \
        --build-arg BAIRELAY_SHA256_AMD64=$(sha256sum /path/to/bairelay-vX.Y.Z-x86_64-linux.tar.gz | cut -d' ' -f1) \
        --build-arg BAIRELAY_SHA256_AARCH64=0000 \
        --build-arg TARGETARCH=$(uname -m | sed s/x86_64/amd64/) \
        -t bairelay-hassio-test:<x.y.z>
    ```

    For local tests, the cross-arch SHA can be zeroed out — only the host's arch is actually verified.
- `tests/logs/addon-test/data/options.json` — hand-written Supervisor options for the test cameras:

    ```json
    {
      "topic_prefix": "bairelay",
      "log_level": "info",
      "cameras": [
        {
          "name": "TestCamera",
          "address": "192.168.1.50",
          "username": "admin",
          "password": "<password>",
          "idle_disconnect": true
        }
      ]
    }
    ```
- `tests/logs/addon-test/config/bairelay/config.toml` — the TOML overlay. Should mirror the existing on-host test config shape so the merged result matches what `tests/bairelay-test.toml` produces. The overlay must include `[mqtt]` (since the container has no Supervisor injecting it) and any per-camera knobs not exposed by the form (channel, discovery method, etc.). `username` is supplied by the form so doesn't need to be restated.

Run:

```
tests/scripts/ha-verify.sh --bairelay-as-container
```

The rest of the test rig (HA Container ingest, MQTT entity verification, snapshot probes) runs unchanged. `--bairelay-as-container` implies `--no-build`.

`ha-verify.sh` 8/8 indicates the add-on packaging is functionally equivalent to on-host bairelay for the MQTT path.

### Surface 2: Supervisor integration

Manifest correctness, the options form rendering, `mqtt:want` service injection, `host_network: true` activation, `/config` + `/ssl` mounts, repository discovery in the HA UI — only testable inside a real Supervisor.

Path: HA OS in QEMU, or HA Supervised on a spare Debian host. Not a CI gate; a manual release-prerequisite run before each release tag.

Steps:

1. Boot HA OS in QEMU (see Home Assistant's "Developer install on QEMU" documentation). Alternatively, install HA Supervised on a spare Debian box. HA 2026.2+ uses the "Apps" terminology (formerly "Add-ons"); the steps below assume the current UI.
2. Settings → Apps → Install app (bottom-right) → ⋮ (top-right) → Repositories → paste `https://github.com/mgc8/bairelay` → Add.
3. The Bairelay app appears in the list (from the custom repo just added). Click it → Install.
4. Configuration tab → fill in `topic_prefix`, `log_level`, and at least one camera entry (`name`, one of `address` / `uid`, `username`, `password`). Save.
5. Optional: drop a `/config/bairelay/config.toml` overlay via the File editor app or SSH.
6. Start. Verify the app stays running (Log tab shows `bairelay starting version=X.Y.Z` and no immediate exit).
7. Confirm MQTT entities appear in HA exactly as `ha-verify.sh` expects (same `homeassistant/.../config` discovery payloads, same entity IDs).
8. Stop the app. Uninstall. Verify clean removal (retained MQTT discovery topics are unpublished).

Issues at this surface are usually:

- `services: ["mqtt:want"]` not picking up the broker — the HA MQTT integration must be installed and started before the add-on.
- `host_network: true` failing to bind — another process on the host is already on 8554/8555/9999/58200.
- Options form rejecting input — the regex `^[A-Za-z0-9_-]+$` on camera names doesn't allow dots or slashes; the password field type is `password` (masked) but accepts any string.

---

## Manual live-camera harness

`tests/scripts/manual-verify.sh` exercises the RTSP server + MQTT bridge against real cameras. On-demand only — not wired into `cargo test`. Discovers installed client tools, parses non-secret bits of the real `config.toml` (cameras, MQTT broker, credentials), spawns bairelay with `RUST_LOG=bairelay=debug,bairelay_rtsp=debug`, runs the probe matrix, tears down. Logs go to `tests/logs/manual-verify/` (gitignored).

### Prerequisites

Required:

- A working `config.toml` pointing at real Reolink battery cameras on your LAN.
- A built bairelay binary (`cargo build --release`).
- `ffmpeg` (which also provides `ffprobe`).
- GNU coreutils' `timeout` (the script falls back to `gtimeout` on macOS; install from Homebrew if missing).

Optional — the harness exercises whichever tools it finds installed and skips the rest:

- `mpv`
- `vlc` (on macOS the script also looks at `/Applications/VLC.app/Contents/MacOS/VLC` if `vlc` isn't on `PATH`).
- `mosquitto-clients` (for `mosquitto_pub` / `mosquitto_sub` — drives the MQTT round-trip stage; set `NO_MQTT=1` to skip).
- `go2rtc` — download the release binary for your platform from <https://github.com/AlexxIT/go2rtc/releases> and place it at `tests/scripts/go2rtc/go2rtc` (`chmod +x` it). The script feeds bairelay's RTSP into go2rtc and re-pulls it back; set `NO_GO2RTC=1` to skip.

**Install (Debian / Ubuntu):**

```
sudo apt install ffmpeg mpv vlc mosquitto-clients coreutils
```

**Install (macOS, Homebrew):**

```
brew install ffmpeg mpv mosquitto coreutils
brew install --cask vlc
```

### Running

```
tests/scripts/manual-verify.sh
tests/scripts/manual-verify.sh --duration 10
tests/scripts/manual-verify.sh --tls           # rtsps:// path; auto-runs gen-test-certs.sh
```

### Probe matrix

Per camera, per stream, per transport:

- `ffprobe` / `ffmpeg` / `mpv` / `vlc` over TCP-interleaved.
- `ffprobe` / `ffmpeg` over UDP.
- `2× ffmpeg` fanout (concurrent broadcast subscribers).
- MQTT round-trip (`query/battery` → `status/battery`).
- go2rtc relay (RTSP-in / RTSP-out via the local go2rtc binary).
- Battery sleep + grace-period observation (skipped via `SKIP_BATTERY_SLEEP=1`).

---

## Live-verify discipline

Every change passes through:

1. Unit tests for pure logic — `cargo test`.
2. Integration tests via mock providers / fixture replay against real RTSP clients.
3. **Live-verify on real battery-camera hardware before merging anything that touches the RTSP path, MQTT bridge, wake server, or push listener.** Mocks pre-populate state that real cameras only fill in over time; race conditions in connect / wake / disconnect cycles only surface on real hardware.

The standard live-verify rig is:

- A working `config.toml` pointing at one or more battery cameras on your LAN.
- `cargo build --release`.
- `tests/scripts/manual-verify.sh` and/or `tests/scripts/ha-verify.sh`.

If you don't have hardware, say so on the PR — a maintainer with cameras will run the rig before merge.

## Local wake server live-verify

Pre-requisite: a real Argus on the same LAN as bairelay, plus a LAN DNS resolver (Pi-hole, dnsmasq, unbound, the gateway's resolver, or whatever the cameras query) that returns the bairelay box's IP for `p2p.reolink.com` and `p2p1.reolink.com` … `p2p11.reolink.com`. **The cameras' DNS resolution is the load-bearing piece** — `/etc/hosts` on the bairelay box is irrelevant because cameras don't query it; they query their configured DNS (typically DHCP-provided, which points at your LAN resolver). Bairelay's outgoing discovery uses the same DNS, so once the resolver is configured both directions land on the wake server.

After flipping the resolver, sleeping cameras may take up to one heartbeat cycle (~20 s) to send their first `D2R_HB` here — but only after their internal DNS cache expires. Some Argus firmwares cache DNS for the lifetime of the connection; if no heartbeats arrive within a few minutes, force a re-resolution by power-cycling the camera or issuing `bairelay reboot <camera>`.

```
1. Configure your LAN resolver (Pi-hole / dnsmasq / unbound / ...) so that
   p2p.reolink.com and p2p1.reolink.com ... p2p11.reolink.com resolve to
   the bairelay box's LAN IP. Restart / reload the resolver as needed.
2. config.toml gains:
     [wake_server]
     enable = true
3. Start bairelay; tail logs for `D2R_HB src=<camera-ip>:<port> uid=<UID>`
   within ~25 s (sleeping cameras) or after the first sleep cycle (awake
   cameras). If still nothing after a few minutes, the camera is likely
   serving a stale DNS cache — `bairelay reboot <camera>` clears it.
4. Trigger wake via MQTT: mosquitto_pub -t bairelay/<cam>/control/wakeup -m 5
5. Tail logs for `C2R_C src=` then 10 `R2D_C ->` lines; confirm the camera
   connects on TCP 9000 immediately after.
6. ha-verify.sh — must remain 8/8.
7. manual-verify.sh — should remain at its current pass count.
```

In-process correctness coverage lives in `crates/wake-server/tests/udp_loopback.rs`; the live-verify above is the integration check, not a CI gate.