Timing report: I (52322) LOG_TEST: === WRITE TIMING REPORT === I (52322) LOG_TEST: Iterations: 200 I (52322) LOG_TEST: Payload size: 39 bytes I (52322) LOG_TEST: Min: 49960 us I (52332) LOG_TEST: Max: 54476 us I (52332) LOG_TEST: Avg: 50005 us I (52342) LOG_TEST: Sector crossings: 2 (max 49983 us) I (52342) LOG_TEST: WDT margin: 4.9s (WDT=5s, worst=54476us) I (52352) LOG_TEST: =========================== so a write takes up to 54ms - not negligible!
6.0 KiB
SC-F001 Firmware — CLAUDE.md
See README.md for full project documentation (hardware, architecture, protocols, algorithms).
Workflow
- Minimize shell commands. Every Bash call requires user approval. Prefer Read/Edit/Write/Glob/Grep tools. Only use Bash when a shell command is genuinely needed (e.g.,
idf.py build, git operations). - Webpage build step: After editing
webpage.html, runwebpage_compile.pyto regeneratewebpage_gzip.hbefore building. - Don't touch git.
sdkconfig Management
Two files, different roles:
sdkconfig.defaults— checked into git. Contains only intentional project overrides with comments explaining why. Applied byidf.py reconfigureon top of IDF defaults.sdkconfig— generated/modified byidf.py menuconfigorreconfigure. Contains every resolved setting. Also checked in for reproducibility, but treatsdkconfig.defaultsas the source of truth for project-specific choices.
Rules:
- When changing a setting, add it to
sdkconfig.defaultswith a comment, then also apply it tosdkconfigso the next build picks it up without requiringidf.py reconfigure. - Never hand-edit
sdkconfigwithout also updatingsdkconfig.defaultsfor the same setting — otherwise the change will be lost on the nextreconfigure. - Keep
sdkconfig.defaultssmall and well-commented. Don't dump the full config into it.
Current project-specific overrides (sdkconfig.defaults):
| Setting | Value | Why |
|---|---|---|
CONFIG_ESP_TASK_WDT_PANIC |
y | WDT timeout → panic → reboot (feeds OTA rollback counter) |
Already correct at IDF defaults (verified, no override needed):
| Setting | Value | Status |
|---|---|---|
CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY |
y | Stack overflow detection via canary (method 2) |
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT |
y | Print backtrace then reboot on panic |
CONFIG_BROWNOUT_DET_LVL_SEL_0 |
y | ~2.43V brownout on ESP32 3.3V rail (appropriate — battery low-V is handled by LOW_PROTECTION_V in FSM) |
CONFIG_PARTITION_TABLE_CUSTOM |
y | Custom partitions.csv with ota_0 + ota_1 |
Managed Components
Only mdns is used. The TCA9555 is driven by a custom raw I2C driver in i2c.c (not the esp-idf-lib/tca95x5 library). LittleFS is not used.
idf_component.yml pins mdns to ~1.9.1 (compatible patch updates only). If adding a new component, pin it with ~ (e.g. "~1.2.0") to allow patches but not breaking changes.
After changing idf_component.yml, run idf.py reconfigure to update managed_components/.
Conventions
- Naming:
snake_casefunctions with module prefix (fsm_init,i2c_poll_buttons);UPPER_SNAKE_CASEconstants/enums - Module pattern:
.c/.hpairs; headers expose only public API - Concurrency: FSM commands via
xQueueSend; log writes via async queue; GPIO ISR → minimal work → sensor queue - State machine pattern: transitions in one
switch, relay outputs in a secondswitch(separated) - Watchdog:
esp_task_wdt_add/resetin each task, 10s timeout - Logging:
ESP_LOGI(TAG, ...)per module; flash circular log for telemetry - No dynamic allocation in ISR or high-priority paths
Webpage (main/webpage.html)
Single-file SPA. Compiled to a gzip binary embedded in firmware. All JS is inline.
Key globals:
const ge = (id) => document.getElementById(id)— shorthand used everywherelet data = {}— full/getJSON response, updated every poll cyclelet paramTableCreated = false— tracks whether the DANGER ZONE param table has been built yetlet pollInterval— handle for the 2-secondfetchStatus()interval
Endpoints used by JS (all relative):
./get— GET, returns full system status JSON; polled every 2 s byfetchStatus()./post— POSTapplication/json, handles commands + parameter updates./log— GET/POST, binary log download./ota— POST, firmware upload
POST body format (./post):
{ "cmd": "start", "parameters": { "KEY": value, ... }, "time": 1234567 }
All fields optional. parameters is a flat object of param key → value.
Input / parameter binding convention:
- Any
<input id="PARAM_<KEY>">anywhere in the page is automatically updated byupdateParamTable()on every poll (skipped if the input has classchangedor is focused) onchange="markChanged(this)"— adds classchanged(green), enablescommit_btn/cancel_btncommitParams()(Save Changes button) collects all.changedinputs whoseidstarts withPARAM_, POSTs{parameters: {...}}, clearschangedclasscancel_btncallslocation.reload()
Sections (top to bottom):
- Status display (voltage, state, distance, etc.) — auto-updated from
data - Schedule settings (
<details>) — MOVE_START / MOVE_END / NUM_MOVES - Remote Control (
<details>) — jog buttons + RF programming - WiFi Settings (
<details open>) — NET_SSID, NET_PASS, WIFI_SSID, WIFI_PASS with dedicatedapplyWifiSettings()button - DANGER ZONE (
<details>) — calibration, version, OTA upload, log download, auto-generated parameter table, REBOOT/SLEEP
updateParamTable():
- On first call: builds a
<table id="table">row per parameter, sorted alphabetically, skippingWIFI_PARAM_KEYS = {NET_SSID, NET_PASS, WIFI_SSID, WIFI_PASS}(those live in the dedicated WiFi section) - On subsequent calls: updates existing input values (skips changed/focused inputs); if a new key appears, rebuilds
Modal helpers (all return Promises):
modalAlert(message, title?)— OK onlymodalConfirm(message, title?)— OK / Cancel → resolvestrue/falsemodalPrompt(message, title?, defaultValue?)→ resolves string ornullon cancel
Adding a new dedicated UI section:
- Add
<input id="PARAM_<KEY>" onchange="markChanged(this)"/>in HTML - Add key to
WIFI_PARAM_KEYS(or equivalent filter set) inupdateParamTable()so it isn't duplicated in the raw table - Optionally add a dedicated apply function following
applyWifiSettings()pattern