Sitelet https://github.com/akomakom/esp32-spa-controller/commit/f74ad0b56ef7df706e6862c4b7dc8d7ffbb743f8
Skip to content

Commit f74ad0b

Browse files
committed
Graft additions
1 parent dac51a4 commit f74ad0b

3 files changed

Lines changed: 109 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ display/log
1010
display/log2
1111
time_examples.cpp
1212
libs
13+
14+
# graft's local graph cache — regenerable, not committed (run `graft build`).
15+
/graft/

‎.ignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# graft's cards are gitignored but should stay greppable: ripgrep reads
2+
# .ignore before .gitignore, so this re-admits the tree to search only.
3+
!graft/
4+
graft/.cache/
5+
graft/.graph/

‎ESP-NOW-CLIENT.md‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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

Comments
 (0)