|
| 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