Skip to content

Commit 3d8211a

Browse files
skialpineclaude
andauthored
docs: add CLAUDE.md with project gotchas
* docs: add CLAUDE.md with project gotchas Captures project-specific knowledge that isn't obvious from the code: * Build / flash / serial commands (including the pyserial workaround when pio device monitor can't get a PTY). * Code layout: explains the unusual "headers contain implementations" pattern, where setup() / loop() live, what's in config.h. * Threading model: WS event callback runs on the AsyncTCP task on a different core; what's safe / unsafe to share with the main loop; the wsPendingMask deferral pattern; why cross-task globals need to be marked volatile. * WiFi/BLE coexistence: do NOT call WiFi.setSleep(false); the BT coex layer needs WiFi's sleep windows for BLE slots. * ESPAsyncWebServer ordering rules (server.begin() last, server.end() doesn't clear handlers, single WS client cap). * macOS curl AAAA workaround (curl -4 or --resolve) — saves 5s per request when debugging. * AP-mode fallback credentials (DecentScale / 12345678 / 192.168.1.1). * "When X is broken, check Y" troubleshooting table. * Naming conventions (b_, i_, t_, f_ Hungarian-ish prefixes). * .gitattributes LF-normalization note for bisect across that commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: tighten u8g2 and StopWatch thread-safety wording Fact-check on PR #55 caught two loose phrasings: * "races the OLED draws in loop()" understated the issue. The races are against any draws issued from the main-loop task, which includes button callbacks and menu/display helpers, not just code literally inside loop(). * "just bool + uint32_t writes" mis-described StopWatch (it has an enum + multiple uint32_t fields plus a function pointer). The actual safety argument is that each individual field write is atomic on ESP32, so start/stop/reset can't tear a reader on the main-loop task. No semantic change to the threading model the doc is describing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: add "Keeping this file fresh" section to CLAUDE.md CLAUDE.md is a living document; future sessions should update it when they hit a new footgun or workflow shift. Add explicit guidance on what to add, what to skip, and when to prune, so it doesn't decay into stale advice. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: refresh CLAUDE.md for the merged #54 WebSocket work #54 changed substantially during review after this doc was drafted; update to match merged main: - Code layout: add include/websocket.h (WS protocol extracted out of hds.ino); fix hds.ino size (~1880) and refs (setup ~395, loop ~1265), drop the stale "WS handlers ~670-930". - Threading model: stopWatch is NO LONGER safe to mutate from the AsyncTCP task — it's multi-field and shared with loop/BLE/USB, so timer ops are now deferred via WSP_TIMER_*. Corrected the row (it documented the bug #54 fixed). - ESPAsyncWebServer: the single-client middleware was removed; multiple clients are supported now. Added the getClients()/printfAll mutex/UAF gotcha. - Deferral pattern now lives in websocket.h; added timer to the mutually- exclusive pairs. - Noted the DNS-SD _decentscale._tcp advert and tools/ws_feature_test.py. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ae48700 commit 3d8211a

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

‎CLAUDE.md‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# CLAUDE.md — Half Decent Scale firmware
2+
3+
Espresso-scale firmware for the HDS hardware: ESP32-S3 + load-cell amplifier (ADS1232 or HX711) + 128x64 OLED + BLE + WiFi. Built with PlatformIO + Arduino framework. The on-device web app at `/` talks to the firmware over a single `/snapshot` WebSocket.
4+
5+
## Quick reference
6+
7+
```sh
8+
# All commands run from the repo root.
9+
pio run -e esp32s3 # build
10+
pio run -e esp32s3 -t upload --upload-port /dev/cu.wchusbserial110 # flash firmware
11+
pio run -e esp32s3 -t uploadfs --upload-port /dev/cu.wchusbserial110 # flash LittleFS (web_apps/)
12+
```
13+
14+
```sh
15+
# Serial monitor — pio device monitor needs a PTY. From a non-tty harness use
16+
# pyserial directly. Path is the PlatformIO-bundled python so pyserial is on
17+
# its sys.path; system python3 typically isn't.
18+
/opt/homebrew/Cellar/platformio/6.1.19_1/libexec/bin/python3 -u -c "
19+
import serial, sys
20+
s = serial.Serial('/dev/cu.wchusbserial110', 115200, timeout=1)
21+
while True:
22+
line = s.readline()
23+
if line:
24+
sys.stdout.write(line.decode('utf-8', errors='replace'))
25+
sys.stdout.flush()
26+
"
27+
```
28+
29+
The scale advertises mDNS `hds.local` plus a DNS-SD service `_decentscale._tcp` (TXT `path=/snapshot proto=ws model=hds fw=…`) for app discovery. On macOS use `curl -4` or `--resolve` — plain `curl http://hds.local/` blocks for ~5s on the AAAA lookup before falling back to A.
30+
31+
If no WiFi credentials are stored, the device falls back to AP mode: SSID `DecentScale` / password `12345678` / IP `192.168.1.1`. Browse there and POST credentials to `/setup/wifi` (the on-device UI provides a form).
32+
33+
WebSocket protocol regression check (WiFi enabled on the scale): `python3 tools/ws_feature_test.py` (needs `pip install websocket-client`; exits non-zero on failure).
34+
35+
## Code layout
36+
37+
This codebase is unusual: **most logic lives in `include/*.h` as full implementations**, not just declarations. There's only one `.ino` and two `.cpp` files. Don't include the same header from two translation units; treat them as a unity build.
38+
39+
| Location | Contents | Key line refs |
40+
| --- | --- | --- |
41+
| `src/hds.ino` | `setup()`, `loop()`, button callbacks, scale/UI helpers — ~1880 lines | `setup()` ~395, `loop()` ~1265 |
42+
| `src/wifi_setup.cpp` | STA/AP bring-up; credentials in NVS preferences | `connectToWifi`, `setupAP` |
43+
| `src/ADS1232_ADC.cpp` | Load-cell driver | |
44+
| `include/config.h` | **Per-board hardware config.** Selects `BUZZER`, `ACC_MPU6050` / `ACC_BMA400`, `ESPNOW`, `FIRMWARE_VER`, etc. Active board is chosen by `#if defined(BOARD_X)` branches. | |
45+
| `include/parameter.h` | All global state declarations — every `b_*`, `i_*`, `t_*`, `f_*` global lives here | |
46+
| `include/declare.h` | Object instances (`u8g2`, `stopWatch`, `scale`, `mpu`, …) | |
47+
| `include/ble.h` | BLE GATT server + decentespresso wire-protocol parser | |
48+
| `include/webserver.h` | `AsyncWebServer` + the `websocket` object; `startWebServer`/`stopWebServer` (handler registration + init ordering) | |
49+
| `include/websocket.h` | **`/snapshot` WebSocket protocol** — control + event + status/error frames, command dispatch, the `wsPendingMask` deferral machinery, per-client broadcast, `setupWebsocketEvents()`. Extracted from `hds.ino` in #54 | `processWsPendingCmds` ~120 |
50+
| `include/usbcomm.h` | USB binary protocol (decentespresso-compatible) | |
51+
| `include/power.h` | Battery monitoring, `shut_down_*`, `esp32_sleep()` — **centralized teardown chain** | `esp32_sleep()` ~166 |
52+
| `include/finger_detection.h` | Touch-button press classifier | |
53+
| `include/menu.h`, `display.h` | OLED UI | |
54+
| `web_apps/` | Static files served via LittleFS at `/` (Quality_Control_Assistant, Weigh_Save, dosing_assistant, index.html) | |
55+
56+
## Threading model — the #1 footgun
57+
58+
ESP32-S3 has two cores. The Arduino sketch runs on the main loop task. **`websocket.onEvent(…)` runs on the AsyncTCP task** — a different task, possibly a different core. Anything touched from both must be safe to share.
59+
60+
| Resource | Safe to touch from AsyncTCP task? | Reason |
61+
| --- | --- | --- |
62+
| `u8g2.*` | **No** — I²C bus, races OLED draws issued from the main-loop task (`loop()`, button callbacks, menu/display helpers) | Defer via `wsQueuePending` |
63+
| `digitalWrite(PWR_CTRL, …)`, `digitalWrite(ACC_PWR_CTRL, …)` | **No** — power-gating must be sequenced with the `u8g2` ops in the SLEEP_ON / SLEEP_OFF paths | Defer |
64+
| `stopWatch.*` | **No** — multi-field (running flag + start ts + accumulator) and also mutated from `loop()`, BLE and USB; a status-frame read can tear a write across tasks | Defer via `wsReplacePending(WSP_TIMER_*)`; `loop()` applies start/stop/zero in `processWsPendingCmds` |
65+
| Single `bool` / aligned `uint32_t` flags shared with `loop()` | Yes — **but mark them `volatile`** | See `include/parameter.h` |
66+
| `Serial.print*`, `websocket.printfAll`, `client->printf` | Yes — library serializes internally | |
67+
68+
The deferral pattern (`include/websocket.h`, drained from `loop()` in `src/hds.ino`):
69+
70+
```c
71+
// AsyncTCP task (WS event callback):
72+
b_u8g2Sleep = true; // visible-state update, atomic
73+
wsReplacePending(WSP_DISPLAY_OFF, WSP_DISPLAY_ON); // queue hw op, supersede opposite
74+
75+
// loop() — drained at the very top, before the b_softSleep guard, so
76+
// SLEEP_OFF / POWER_OFF queued on the AsyncTCP task can still wake/shut.
77+
processWsPendingCmds();
78+
```
79+
80+
Use `wsQueuePending(bits)` to queue a single action; use `wsReplacePending(set, clear)` for mutually-exclusive pairs (display, low_power, sleep, timer) so a stale opposing bit doesn't survive into the drain. Without this, a fast `off; on` burst can leave the hardware in the opposite state of what `b_u8g2Sleep` reports.
81+
82+
New cross-task globals belong in `include/parameter.h` with `volatile`.
83+
84+
## WiFi / BLE coexistence — the #2 footgun
85+
86+
WiFi and BLE share the same 2.4 GHz radio. The Arduino-ESP32 default is `WIFI_PS_MIN_MODEM` (WiFi sleeps between DTIM beacons), and the BT coexistence layer needs those sleep windows for BLE slots.
87+
88+
**Do not call `WiFi.setSleep(false)`.** Measured impact with BLE connected: ~8% packet loss, multi-second HTTP stalls, the AP retransmitting (`ping` shows `(DUP!)`). The Arduino default is correct.
89+
90+
## ESPAsyncWebServer notes
91+
92+
- `server.begin()` must be called **after** handlers are registered.
93+
- `server.end()` does **not** clear the handler list — guard repeat-init with `static bool handlersRegistered` (see `include/webserver.h`).
94+
- Don't `addHandler(&websocket)` twice. The library silently keeps both.
95+
- `LittleFS.begin()` is idempotent.
96+
- Multiple concurrent WS clients are supported (on-device web UI + a separate app). `cleanupClients()` caps at `DEFAULT_MAX_WS_CLIENTS` (8 on ESP32); shared session state (rate, events) resets only when the last client disconnects (`server->count() == 0`).
97+
- Broadcast with `websocket.printfAll(...)`, not a hand-rolled `getClients()` loop: `getClients()` doesn't take the library's client-list mutex, so iterating it on the loop task races a client disconnect on the AsyncTCP task (use-after-free). `printfAll` holds the lock and sends to each client.
98+
99+
WS frame parsing: only act on complete unfragmented text frames:
100+
101+
```c
102+
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
103+
String msg((const char *)data, len); // NOT += char-by-char (O(N²))
104+
...
105+
}
106+
```
107+
108+
## Style conventions
109+
110+
| Prefix | Meaning |
111+
| --- | --- |
112+
| `b_` | bool |
113+
| `i_` | int / count / GPIO pin |
114+
| `t_` | timestamp (millis) |
115+
| `f_` | float |
116+
| `WSP_` | WebSocket pending-action bit |
117+
118+
Functions and locals are camelCase. Some legacy snake_case remains; don't churn it. Timer deltas use `millis() - lastUpdate` with **`unsigned long`** for both — a signed `lastUpdate` wraps incorrectly across the 49.7-day rollover.
119+
120+
## Build / OTA notes
121+
122+
- `git_rev_macro.py` (referenced in `platformio.ini` `build_flags`) injects the current git rev as a compile-time macro.
123+
- `ELEGANTOTA_USE_ASYNC_WEBSERVER=1` is set; `ElegantOTA.loop()` is called in `loop()` and the OTA UI is available when `b_ota` is set.
124+
- `CONFIG_ASYNC_TCP_RUNNING_CORE=1` pins AsyncTCP's worker task to core 1.
125+
- `.gitattributes` enforces LF line-endings repo-wide. A 2026-Q2 commit normalized all files; bisecting across that point will show enormous diffs even for one-line changes.
126+
127+
## When something is broken
128+
129+
| Symptom | First place to look |
130+
| --- | --- |
131+
| Device pingable, HTTP times out mid-body | AsyncTCP task starvation from main-loop work — see `processWsPendingCmds` and the WS event callback. Don't move hardware ops back into the callback. |
132+
| HTTP fast, but `curl` reports ~5s constant delay | macOS resolver waiting on AAAA. Use `curl -4` or `--resolve`. Not a device problem. |
133+
| `ping` shows `(DUP!)` or 8%+ loss with BLE active | Someone re-introduced `WiFi.setSleep(false)`. Revert. |
134+
| Device unreachable after flash, USB still enumerates | WiFi failed to associate on this boot. Reset via RTS toggle (see Quick reference) and retry. Not deterministic; the router can rate-limit fast reconnects. |
135+
| Boot logs show `LittleFS mount failed` | Run `pio run -t uploadfs` to write the filesystem image — firmware-only flashes don't touch it. |
136+
| `pio device monitor` hangs in a non-PTY shell | Use the pyserial snippet in Quick reference. |
137+
| `pio` flash takes >60s instead of ~15s | Bad firmware is choking the bootloader handshake. Symptom of a serious bug on the device (WiFi coex, OLED stuck, etc.), not a hardware fault. |
138+
139+
## Keeping this file fresh
140+
141+
This document is meant to evolve with the codebase. During a session, if you (Claude or human) discover something that would have saved time to know at the start — a new footgun, a thread-safety constraint, a workflow that changed, a file layout shift — update this file as part of the same change, or open a small `docs: CLAUDE.md` follow-up PR if it isn't tied to a code change.
142+
143+
- **Add** lessons earned through debugging, new conventions, recurring symptoms with non-obvious root causes, and cross-file dependencies that aren't visible in `#include` graphs.
144+
- **Don't add** per-feature implementation details (those belong next to the code), transient information, or anything a quick `grep` would surface.
145+
- **Prune** entries that no longer match the code — stale docs mislead worse than missing docs do. Refresh line refs when a file grows or shrinks past obvious anchors.
146+
- When in doubt: prefer fewer, sharper claims over more, vague ones.
147+
148+
If you fix a bug whose symptom is documented in the "When something is broken" table, leave the entry in place — it's still the right "first place to look" for the next person.
149+
150+
## Don't
151+
152+
- Don't call I²C / SPI / blocking IO from the AsyncTCP task.
153+
- Don't force WiFi to never sleep while BLE is active.
154+
- Don't accumulate `String` byte-by-byte from a known-length buffer — use `String((const char*)buf, len)`.
155+
- Don't add new global state outside `include/parameter.h`.
156+
- Don't break the single-line `pio run` and `pio run -t upload` flows by adding required interactive steps.
157+
- Don't `--amend` or `git push --force` on `main`. The PR flow is trunk-based with squash merges via GitHub.

0 commit comments

Comments
 (0)