|
| 1 | +# ESP-NOW client requirements (for the controller/"server") |
| 2 | + |
| 3 | +This describes what an ESP-NOW **client** (subscriber, e.g. a Waveshare touchscreen) |
| 4 | +must do to pair with and receive data from the spa **controller** (the "server"). |
| 5 | +The reference working implementation is `display/ESPNowUtils.cpp` in this repo — mirror |
| 6 | +its behavior. Shared wire structs are in `hot_tub_types.h` (must match byte-for-byte). |
| 7 | + |
| 8 | +## The one rule that trips everyone up: channel |
| 9 | + |
| 10 | +The controller runs `WIFI_AP_STA` and is **joined to the home WiFi router**, so its radio |
| 11 | +is **hard-locked to the router's channel** (e.g. channel 6). A single-radio ESP32 cannot |
| 12 | +run ESP-NOW on any other channel. Therefore: |
| 13 | + |
| 14 | +- The client **must be pure `WIFI_STA` and NOT connected to any AP** |
| 15 | + (`WiFi.mode(WIFI_STA); WiFi.disconnect();`). If the client joins WiFi, its channel is |
| 16 | + pinned and can never match the server → every send is `Delivery Fail`. |
| 17 | +- The client **finds the server's channel by scanning**, then **parks on it and stops**. |
| 18 | + While it is still hopping it is almost never on the server's channel, so the server's |
| 19 | + replies/broadcasts get no MAC ACK → `Delivery Fail`. "Stuck sending pairing requests" |
| 20 | + is the classic symptom of a client that never parks. |
| 21 | +- **Disable modem sleep** (`WiFi.setSleep(false);` or `esp_wifi_set_ps(WIFI_PS_NONE);`), |
| 22 | + or the client sleeps between beacons and drops packets. |
| 23 | + |
| 24 | +## Message types (`hot_tub_types.h`) |
| 25 | + |
| 26 | +First byte of every packet is `msgType`. Enum order is fixed: |
| 27 | + |
| 28 | +``` |
| 29 | +enum MessageType { PAIRING=0, COMMAND=1, CONTROL_STATUS=2, SERVER_STATUS=3, METRICS_STATUS=4 }; |
| 30 | +``` |
| 31 | + |
| 32 | +- `struct_pairing` — handshake, both directions |
| 33 | +- `struct_command` — client → server (user actions / overrides) |
| 34 | +- `struct_status_control` (CONTROL_STATUS) — server → client, one per control |
| 35 | +- `struct_status_server` (SERVER_STATUS) — server → client, water temp / time / flags |
| 36 | + |
| 37 | +When receiving, **bound the `memcpy` to the received `len`** (`min(len, sizeof(target))`) |
| 38 | +into a zero-initialized struct, so an older/newer controller struct layout can't over-read |
| 39 | +and missing trailing fields default to 0. See `display/ESPNowUtils.cpp` `OnDataRecv`. |
| 40 | + |
| 41 | +## Pairing handshake |
| 42 | + |
| 43 | +**Client scan loop** (see `autoPairing()` in `display/ESPNowUtils.cpp`): |
| 44 | + |
| 45 | +1. Pick a channel; `esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE)`. |
| 46 | +2. (Re)init ESP-NOW, register send/recv callbacks. |
| 47 | +3. Add a peer for the **broadcast address** on that channel and send a `struct_pairing`: |
| 48 | + - `msgType = PAIRING` |
| 49 | + - `board_id = <your non-zero client id>` (the server ignores `board_id == 0` = itself) |
| 50 | + - `channel = channel` (the channel you're currently probing) |
| 51 | +4. Wait ~250 ms for a response. If none, step to the next channel (1..13) and repeat. |
| 52 | + |
| 53 | +**Server response** (`struct_pairing` with `board_id == 0`): |
| 54 | + |
| 55 | +- `macAddr` = the server's **soft-AP MAC** — the address the client must send commands to. |
| 56 | +- `channel` = the server's **actual** channel (not the one you guessed). |
| 57 | + |
| 58 | +**Client on receiving the response — this is the step that must not be skipped:** |
| 59 | + |
| 60 | +1. `esp_wifi_set_channel(resp.channel, WIFI_SECOND_CHAN_NONE)` — lock onto the server's channel. |
| 61 | +2. `esp_now_del_peer` + `esp_now_add_peer` for `resp.macAddr` with `peer.channel = resp.channel`. |
| 62 | +3. Mark state PAIRED and **stop channel-hopping**. Store `serverAddress = resp.macAddr`. |
| 63 | +4. (Optional) persist the channel in NVS/EEPROM to pair faster next boot. |
| 64 | + |
| 65 | +## Interface / MAC direction (why replies fail even on the right channel) |
| 66 | + |
| 67 | +- The server replies and **broadcasts to the client's STA MAC** (it uses `info->src_addr` |
| 68 | + from the client's request). So the client must **send its pairing request from, and |
| 69 | + listen on, its STA interface** — the default in `WIFI_STA`. Don't send from a soft-AP |
| 70 | + interface. |
| 71 | +- The client sends **commands to the server's soft-AP MAC** (`resp.macAddr` above). |
| 72 | +- The client does **not** need the server registered as a peer to *receive* broadcasts; |
| 73 | + it only needs to be on the server's channel with its STA MAC listening. |
| 74 | + |
| 75 | +## After pairing |
| 76 | + |
| 77 | +- The server broadcasts `SERVER_STATUS` + one `CONTROL_STATUS` per control roughly every |
| 78 | + second. Just handle them in the recv callback. |
| 79 | +- **Re-pair if the link goes quiet**: if no message has arrived within a timeout |
| 80 | + (the reference uses `MESSAGE_RECEIVED_MAX_AGE`), drop back to the scan loop — this |
| 81 | + recovers automatically when the server reboots and its channel possibly changes. |
| 82 | +- Commands: fill a `struct_command` and `esp_now_send(serverAddress, ...)`. |
| 83 | + |
| 84 | +## Reading `water_temp` |
| 85 | + |
| 86 | +`struct_status_server.water_temp` is **Fahrenheit** (native unit). `temp_unit` |
| 87 | +(0 = F, 1 = C) tells you which unit to *display*; convert at presentation time only. |
| 88 | +A value of **exactly 0 is the sensor-fault sentinel** (the controller sends 0 when the |
| 89 | +water-temp reading is stale/invalid and the heater is locked out) — show a fault |
| 90 | +indicator instead of "0°", don't treat it as a real temperature. |
| 91 | + |
| 92 | +## Quick checklist |
| 93 | + |
| 94 | +- [ ] `WIFI_STA` only, `WiFi.disconnect()`, never joined to an AP |
| 95 | +- [ ] Modem sleep disabled |
| 96 | +- [ ] Channel-hop to find the server, then **lock to `resp.channel` and stop** |
| 97 | +- [ ] Peer = server soft-AP MAC (`resp.macAddr`) on the server's channel |
| 98 | +- [ ] Send from / listen on the **STA** interface MAC |
| 99 | +- [ ] `memcpy` bounded by received `len` into zero-initialized structs |
| 100 | +- [ ] Re-enter pairing when the link is silent past a timeout |
| 101 | +- [ ] Treat `water_temp == 0` as a sensor fault |
0 commit comments