# T1 ACTIVE Subsystem — Integration Notes

**Batch: ACTIVE** | Date: 2026-04-24 | Status: all 4 modules + 8 native tests

---

## New MSG_TYPEs

| Hex   | Name                    | Direction    | Size        | Description |
|-------|-------------------------|--------------|-------------|-------------|
| 0x14  | MSG_TYPE_PAYLOAD_CHUNK  | T2 → T1      | 24B hdr + ≤200B data | LoRa payload chunk for PSRAM staging |
| 0x15  | MSG_TYPE_NETWORK_LIST   | T1 → T2      | 6B hdr + N×16B (N≤4) | Network scan results |
| 0x1D  | MSG_TYPE_AUTH_TOKEN     | T2 → T1      | 84B fixed   | Ed25519 auth token preceding DELIVER_PAYLOAD |

Add to `config.h`:
```c
#define MSG_TYPE_PAYLOAD_CHUNK  0x14
#define MSG_TYPE_NETWORK_LIST   0x15
#define MSG_TYPE_AUTH_TOKEN     0x1D
```

---

## New SensorCmdTypes

| Value | Name                | Params (20B)                                                                 | Notes |
|-------|---------------------|------------------------------------------------------------------------------|-------|
| **17**| DELIVER_PAYLOAD     | `payload_id_le(4) \| target_bssid(6) \| target_channel(1) \| mode(1) \| repeat_count_le(2) \| dwell_ms_le(2) \| auth_token_offset(4)` | Check command_handler.h; if 17 is taken, use 20 |
| **21**| NETWORK_SCAN_DUMP   | `u8 force (0/1)` in params[0]; rest reserved | Triggers `network_id_publish(sensor_id)` on demand |

**IMPORTANT:** Check `command_handler.h` before using value 17. The file currently ends at `WFX_DUMP_LATEST = 16`. Use 17 unless another Batch has claimed it. If taken, use 20. NETWORK_SCAN_DUMP should always be 21 (gap intentional for future 18–20 commands).

Add to `SensorCmdType` enum in `command_handler.h`:
```cpp
DELIVER_PAYLOAD    = 17,   // or 20 if 17 is claimed by another batch
NETWORK_SCAN_DUMP  = 21,
```

---

## Module Summary

### Module 1 — `src/network_id.{h,cpp}`

Replaces RSSI-only sniffer with 802.11 management frame parser.

**Integration steps:**
1. Call `network_id_init()` from `setup()` before `esp_wifi_set_promiscuous_rx_cb()`.
2. Replace:
   ```cpp
   esp_wifi_set_promiscuous_rx_cb(wifi_sniffer_cb);
   ```
   with:
   ```cpp
   esp_wifi_set_promiscuous_rx_cb(network_id_sniffer_cb);
   ```
3. `network_id_sniffer_cb` internally calls `legacy_rssi_observe(channel, rssi)` for non-management frames. Define `legacy_rssi_observe()` in `rf_classifier.cpp` or wherever the old per-channel RSSI accounting lives (currently a `__attribute__((weak))` no-op).
4. Emit scans periodically: call `network_id_publish(enrollment_get_sensor_id())` every 30 seconds from the main loop (or on `NETWORK_SCAN_DUMP` command).

**Hash table:** 32 entries, LRU eviction. Each entry: `{bssid[6], ssid[33], channel, last_rssi, last_seen_ms, cipher_id}`.

**Cipher IDs:** `CIPHER_OPEN(0x08)`, `CIPHER_TKIP(0x02)`, `CIPHER_CCMP(0x04)` — can be ORed.

---

### Module 2 — `src/payload_buffer.{h,cpp}`

Multi-chunk LoRa→PSRAM staging for delivery payloads.

**Integration steps:**
1. Call `payload_buffer_init()` from `setup()`.
2. In the LoRa RX path, when `data[0] == MSG_TYPE_PAYLOAD_CHUNK (0x14)`, call:
   ```cpp
   PayloadBufStatus st = payload_buffer_handle_chunk(data, len);
   if (st == PB_COMPLETE) {
       // payload_id is ready; wait for MSG_TYPE_AUTH_TOKEN then DELIVER_PAYLOAD
   }
   ```
3. On `DELIVER_PAYLOAD` dispatch (after auth_gate passes):
   ```cpp
   const uint8_t* payload; size_t plen;
   if (payload_buffer_get(payload_id, &payload, &plen)) {
       wifi_inject_send((uint8_t*)payload, plen, mode, repeat);
       payload_buffer_release(payload_id);
   }
   ```

**Per-chunk CRC protocol:** sender sends per-chunk CRC32/BZIP2 in `chunk_crc32` for chunks 0..N-2. For the **final chunk** (chunk_idx == total_chunks-1), `chunk_crc32` carries the **full-payload CRC32** (entire assembled buffer). T2 `sensor_commander.py` must implement this dual-use field on the sending side.

**Limits:** max 2 concurrent payloads, 256 KB each, PSRAM via `heap_caps_malloc(MALLOC_CAP_SPIRAM)`.

---

### Module 3 — `src/wifi_inject.{h,cpp}`

802.11 frame injection with save/restore of promiscuous channel-scan state.

**Integration steps:**
1. Call `wifi_inject_init()` from `setup()`.
2. Never call `wifi_inject_send()` without a preceding `authgate_verify()` pass — the auth gate is the only policy boundary.
3. Save/restore is automatic inside `wifi_inject_send()`. Manual `save_state()`/`restore_state()` are available for burst sequences.

**Mode table (data-driven — add new modes without firmware changes):**

| Mode | Hex  | Frame input | Behavior |
|------|------|-------------|----------|
| INJECT_RAW | 0x00 | Full 802.11 frame | Transmit as-is N times |
| INJECT_DEAUTH | 0x01 | frame[0..5]=DA, frame[6..11]=BSSID | Build deauth targeting DA/BSSID |
| INJECT_BEACON_FLOOD | 0x02 | Beacon body (post-24B header) | Random TA/BSSID beacon flood |
| INJECT_TEMPLATE_PARAMETERIZED | 0x03 | `num_subst(2) + SubstDescriptors + template` | Table-driven field substitution |

**SubstSrc values for INJECT_TEMPLATE_PARAMETERIZED:**
- `SUBST_LITERAL(0)`, `SUBST_RANDOM_BYTES(1)`, `SUBST_SENSOR_ID(2)`, `SUBST_BSSID(3)`, `SUBST_SEQ_NUM(4)`.

Modes 0x04–0xFF reserved for ground/maritime templates (1-byte field = 256 modes).

---

### Module 4 — `src/auth_gate.{h,cpp}`

Ed25519 token verification gate. **ALL delivery functions must be conditioned on `authgate_verify()` returning true.**

**Integration steps:**
1. Receive `MSG_TYPE_AUTH_TOKEN (0x1D)` packet (84 bytes) before the `DELIVER_PAYLOAD` command. Store the 84-byte token in a local buffer indexed by `payload_id`.
2. On `DELIVER_PAYLOAD` dispatch, call:
   ```cpp
   bool ok = authgate_verify(
       enrollment_get_sensor_id(),
       target_bssid,      // from command params
       payload_id,        // from command params
       (uint64_t)millis(),
       stored_token,      // the 84-byte MSG_TYPE_AUTH_TOKEN payload
       84
   );
   if (!ok) { /* reject: ack with CmdAckStatus::REJECTED */ return; }
   ```
3. Public key is loaded lazily from NVS (`pdl_pki` / `ed25519_pk`) — provisioned by Batch B's `ota_signed_set_pubkey()`. If the key is absent, `authgate_verify()` returns false with `AUDIT_RESULT_FAIL_NOPK`.
4. Audit ring is written to NVS every call. Dump it with `authgate_audit_dump(buf, max_len)` for health report telemetry.

**Token wire format (84 bytes):**
```
[0..3]    sensor_id_le       uint32_t
[4..9]    target_bssid       6 bytes
[10..13]  payload_id_le      uint32_t
[14..21]  expiry_ms_le       uint64_t  (must be > now_ms)
[22..23]  reserved           2 bytes
[24..87]  Ed25519 sig        64 bytes  (over bytes [0..23])
```

**Audit entry format (32 bytes per entry, ring of 32):**
```
[0..7]   ts_ms     uint64_t LE
[8..11]  sensor_id uint32_t LE
[12..17] bssid     6 bytes
[18..21] payload_id uint32_t LE
[22]     result    0=pass 1=expired 2=badsig 3=badid 4=nopk
[23..30] token_hash_first8
[31]     reserved
```

---

## DELIVER_PAYLOAD Command Dispatch Pseudocode

```cpp
// In dispatchCommand() — add to the switch in command_handler.cpp
case (uint8_t)SensorCmdType::DELIVER_PAYLOAD: {
    uint32_t payload_id;    memcpy(&payload_id, pkt.params + 0, 4);
    uint8_t  target_bssid[6]; memcpy(target_bssid, pkt.params + 4, 6);
    uint8_t  target_channel = pkt.params[10];
    uint8_t  mode           = pkt.params[11];
    uint16_t repeat;        memcpy(&repeat, pkt.params + 12, 2);
    // uint32_t auth_token_offset in params[16..19] — unused on-wire; token
    // was delivered as a preceding MSG_TYPE_AUTH_TOKEN packet.

    // 1. Retrieve staged token (stored by MSG_TYPE_AUTH_TOKEN handler)
    const uint8_t* token = get_pending_auth_token(payload_id);  // integrator-provided
    if (!token) { ack_out.status = (uint8_t)CmdAckStatus::REJECTED; break; }

    // 2. Auth gate
    bool authorized = authgate_verify(
        enrollment_get_sensor_id(), target_bssid, payload_id,
        (uint64_t)millis(), token, AUTHGATE_TOKEN_LEN);
    if (!authorized) { ack_out.status = (uint8_t)CmdAckStatus::REJECTED; break; }

    // 3. Retrieve payload
    const uint8_t* pdata; size_t plen;
    if (!payload_buffer_get(payload_id, &pdata, &plen)) {
        ack_out.status = (uint8_t)CmdAckStatus::FAILED; break;
    }

    // 4. Set channel and inject
    wifi_inject_set_channel(target_channel);
    int sent = wifi_inject_send((uint8_t*)pdata, plen, mode, repeat);
    payload_buffer_release(payload_id);
    clear_pending_auth_token(payload_id);  // integrator-provided

    ack_out.result_code = (sent >= 0) ? (uint8_t)CmdAckStatus::OK
                                      : (uint8_t)CmdAckStatus::FAILED;
    ack_out.status = ack_out.result_code;
    break;
}

case (uint8_t)SensorCmdType::NETWORK_SCAN_DUMP: {
    bool ok = network_id_publish(enrollment_get_sensor_id());
    ack_out.status = ok ? (uint8_t)CmdAckStatus::OK : (uint8_t)CmdAckStatus::FAILED;
    break;
}
```

---

## Native Test Suite

`test/test_active/test_active.cpp` — 8 tests, `pio test -e native -f test_active`

| # | Test name | Module | What it verifies |
|---|-----------|--------|-----------------|
| 1 | `test_network_id_parses_beacon` | network_id | SSID/BSSID/channel/cipher extracted from synthetic beacon hex |
| 2 | `test_payload_chunk_reassembly` | payload_buffer | 5×200B chunks → 1 KB, PB_COMPLETE, CRC verified, buffer matches |
| 3 | `test_payload_chunk_bad_crc_rejected` | payload_buffer | Flipped CRC byte → PB_BAD_CHUNK_CRC |
| 4 | `test_authgate_valid_token_passes` | auth_gate | Valid sensor_id + bssid + payload_id + future expiry + stub sig → true |
| 5 | `test_authgate_expired_token_fails` | auth_gate | expiry = now−1 ms → false |
| 6 | `test_authgate_wrong_pubkey_fails` | auth_gate | Ed25519 stub returns false → authgate returns false |
| 7 | `test_wifi_inject_state_save_restore_idempotent` | wifi_inject | save/restore idempotency, tx_calls incremented, channel restored |
| 8 | `test_network_id_publish_emits_packet` | network_id | network_id_publish() → mesh_publish_raw() with correct MSG_TYPE_NETWORK_LIST header |

---

## Dependency Map

```
network_id.cpp       → mesh_publish_raw (mesh_publish.h)
                     → legacy_rssi_observe (weak; rf_classifier.cpp or no-op)
payload_buffer.cpp   → heap_caps_malloc/free (esp_heap_caps.h)
wifi_inject.cpp      → esp_wifi_set_channel, esp_wifi_80211_tx (esp_wifi.h)
auth_gate.cpp        → ota_signed_get_pubkey, ota_signed_verify_chunk (ota_signed.h — Batch B)
                     → mbedtls/sha256.h (for token_hash_first8 on device)
                     → nvs_flash.h / nvs.h (for audit ring)
```

All dependencies are forward-declared; if Batch B is not yet linked, the `extern` in `auth_gate.cpp` is resolved at Batch B merge time.

---

## Security Contract

**Without a valid auth token, every delivery path returns a refusal code.** The chain is:

1. `MSG_TYPE_AUTH_TOKEN (0x1D)` must arrive before `DELIVER_PAYLOAD`.
2. `authgate_verify()` checks: size → sensor_id → bssid → payload_id → expiry → Ed25519 sig.
3. Only on `true` does the dispatcher call `payload_buffer_get()` + `wifi_inject_send()`.
4. Every call (pass or fail) is written to the NVS audit ring.

The firmware has **no offensive default** — `wifi_inject_send()` is never called autonomously; it is only reachable through the command dispatcher gated by the auth token.
