wifi fixes and vetted changes
- wifi consistently comes up and brings web interface up - switch to websockets for remote control etc - jack extension is limited in its capacity - schedule is now a table, not a range
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -27,6 +27,7 @@ See `README.md` for full project documentation (hardware, architecture, protocol
|
||||
|---------|-------|-----|
|
||||
| `CONFIG_ESP_TASK_WDT_PANIC` | y | WDT timeout → panic → reboot (feeds OTA rollback counter) |
|
||||
| `CONFIG_RTC_CLK_SRC_INT_RC` | y | Use internal 150kHz RC oscillator — no external 32kHz crystal. Avoids failed XTAL probe that corrupts RTC slow memory. |
|
||||
| `CONFIG_HTTPD_WS_SUPPORT` | y | WebSocket support in esp_http_server — `/ws` real-time control + 1 Hz status push. |
|
||||
|
||||
**Already correct at IDF defaults (verified, no override needed):**
|
||||
| Setting | Value | Status |
|
||||
@@ -66,13 +67,17 @@ Single-file SPA. Compiled to a gzip binary embedded in firmware. All JS is inlin
|
||||
|
||||
**Key globals:**
|
||||
- `const ge = (id) => document.getElementById(id)` — shorthand used everywhere
|
||||
- `let data = {}` — full `/get` JSON response, updated every poll cycle
|
||||
- `let data = {}` — full status JSON; refreshed by the WebSocket push (or the poll fallback)
|
||||
- `let paramTableCreated = false` — tracks whether the DANGER ZONE param table has been built yet
|
||||
- `let pollInterval` — handle for the 2-second `fetchStatus()` interval
|
||||
- `let ws` — the `/ws` WebSocket; `wsConnected()` is the live check. `connectWS()` opens it, auto-reconnects (3 s backoff), and `stopPolling()` once open
|
||||
- `let pollInterval` — handle for the `fetchStatus()` poll **fallback** (only fetches while the WS is down)
|
||||
|
||||
**Real-time channel (`./ws` WebSocket):** primary transport. Server pushes status JSON ~1 Hz (drives `updateUI()` exactly like a poll); client sends remote-control commands via `sendCmd()` (`ws.send()`, falling back to `./post`). Tab-hidden closes the WS so the device can soft-idle; tab-visible reconnects. See README "WebSocket" section for the server side + stop-on-disconnect safety.
|
||||
|
||||
**Endpoints used by JS (all relative):**
|
||||
- `./get` — GET, returns full system status JSON; polled every 2 s by `fetchStatus()`
|
||||
- `./post` — POST `application/json`, handles commands + parameter updates
|
||||
- `./ws` — WebSocket, real-time status push + remote-control commands (primary)
|
||||
- `./get` — GET, full system status JSON; polled by `fetchStatus()` only as a fallback when the WS is down
|
||||
- `./post` — POST `application/json`, handles commands + parameter updates (also the remote-control fallback)
|
||||
- `./log` — GET/POST, binary log download
|
||||
- `./ota` — POST, firmware upload
|
||||
|
||||
@@ -90,13 +95,13 @@ All fields optional. `parameters` is a flat object of param key → value.
|
||||
|
||||
**Sections (top to bottom):**
|
||||
1. Status display (voltage, state, distance, error flags) — auto-updated from `data`
|
||||
2. Schedule settings (`<details>`) — MOVE_START / MOVE_END / NUM_MOVES
|
||||
2. Schedule settings (`<details>`) — daily `MOVE_TIME_NN` slots (HH:MM); `startRemote`/`stopRemote` jog via `sendCmd()`, releasing sends `stop_override`
|
||||
3. Remote Control (`<details open>`) — jog buttons + RF programming
|
||||
4. **WiFi Settings** (`<details>`) — WIFI_SSID, WIFI_PASS (STA mode disabled: NET_SSID/NET_PASS inputs commented out)
|
||||
5. **DANGER ZONE** (`<details>`) — calibration, version, OTA upload, log download, auto-generated parameter table, REBOOT/SLEEP
|
||||
5. **DANGER ZONE** (`<details>`) — calibration, version, OTA upload, log download, auto-generated parameter table, jack-position + heap (free / min) readouts, REBOOT / SLEEP / RESTART WIFI / FACTORY RESET
|
||||
|
||||
**`updateParamTable()`:**
|
||||
- On first call: builds a `<table id="table">` row per parameter, sorted alphabetically, skipping `WIFI_PARAM_KEYS = {NET_SSID, NET_PASS, WIFI_SSID, WIFI_PASS}` (those live in the dedicated WiFi section)
|
||||
- On first call: builds a `<table id="table">` row per parameter, sorted alphabetically, skipping keys for which `paramSkipped(key)` is true — i.e. members of `PARAM_TABLE_SKIP = {NET_SSID, NET_PASS, WIFI_SSID, WIFI_PASS, MOVE_START, MOVE_END, NUM_MOVES}` (WiFi keys live in the dedicated WiFi section; the MOVE_* trio is deprecated/superseded by the `MOVE_TIME_NN` schedule)
|
||||
- On subsequent calls: updates existing input values (skips changed/focused inputs); if a new key appears, rebuilds
|
||||
|
||||
**Modal helpers** (all return Promises):
|
||||
@@ -106,5 +111,5 @@ All fields optional. `parameters` is a flat object of param key → value.
|
||||
|
||||
**Adding a new dedicated UI section:**
|
||||
1. Add `<input id="PARAM_<KEY>" onchange="markChanged(this)"/>` in HTML
|
||||
2. Add key to `WIFI_PARAM_KEYS` (or equivalent filter set) in `updateParamTable()` so it isn't duplicated in the raw table
|
||||
2. Add the key to `PARAM_TABLE_SKIP` so it isn't duplicated in the auto-generated raw table. (Note: keep dedicated inputs out of the raw table — a stray `PARAM_<KEY>` input that's *also* in the table makes `updateParamTable()` rebuild every poll.)
|
||||
3. Optionally add a dedicated apply function following `applyWifiSettings()` pattern
|
||||
|
||||
21
README.md
21
README.md
@@ -64,7 +64,7 @@ app_main()
|
||||
├── fsm_init() Control FSM task (priority 10, 20ms tick)
|
||||
├── rf_433_init() 433MHz RMT receiver task
|
||||
├── bt_hid_init() BLE HID host scanner task
|
||||
└── webserver_init() WiFi softAP + HTTP + mDNS + DNS
|
||||
└── webserver_init() WiFi softAP + HTTP + WebSocket + mDNS + DNS
|
||||
|
||||
Main loop (50ms):
|
||||
soft-idle check
|
||||
@@ -76,7 +76,7 @@ Main loop (50ms):
|
||||
fsm_request() based on button events
|
||||
solar_run_fsm()
|
||||
drive_leds() status animation
|
||||
rtc_check_shutdown_timer() → soft idle on inactivity (180s)
|
||||
rtc_check_shutdown_timer() → soft idle after INACTIVITY_TIMEOUT_S (default 300s)
|
||||
esp_task_wdt_reset()
|
||||
```
|
||||
|
||||
@@ -181,16 +181,27 @@ Safety break → immediate `STATE_UNDO_JACK_START`.
|
||||
- mDNS hostname: `sc.local`
|
||||
- Captive portal DNS: all queries → 192.168.4.1
|
||||
- HTTP port 80
|
||||
- The softAP and HTTP server stay up during soft idle so a client can always associate and revive the device (see Power Management).
|
||||
|
||||
### HTTP API (port 80)
|
||||
| Endpoint | Method | Description |
|
||||
|------------|--------|----------------------------------------------------------------------|
|
||||
| `/` | GET | Embedded gzip HTML webpage |
|
||||
| `/get` | GET | JSON system status |
|
||||
| `/get` | GET | JSON system status (polling fallback when the WebSocket is down) |
|
||||
| `/post` | POST | JSON commands + parameter updates |
|
||||
| `/ws` | GET | WebSocket: real-time control channel (see below) |
|
||||
| `/log` | ANY | Binary log download (4B JSON len + JSON + 8B offsets + log data) |
|
||||
| `/ota` | POST | Firmware update upload |
|
||||
|
||||
### WebSocket (`/ws`) — real-time channel
|
||||
Requires `CONFIG_HTTPD_WS_SUPPORT=y` (set in `sdkconfig.defaults`). The web UI opens a WebSocket on load and uses it for:
|
||||
- **client → server:** low-latency remote-control commands (`fwd`/`rev`/`extend`/`retract`/`aux`/`stop_override`) as small JSON text frames, routed through `comms_handle_post()` so they share the POST command vocabulary.
|
||||
- **server → client:** a 1 Hz status push (same JSON as `/get`), replacing the old 2 s HTTP poll. Built only when ≥1 client is connected (no heap churn when idle).
|
||||
|
||||
**Safety:** any WS socket close (tab closed, WiFi dropped, crash) fires `stop_override()` via the httpd `close_fn`, halting jogged motion without relying on the `RF_PULSE_LENGTH` timeout. Held jog also re-sends every 150 ms, re-arming that timeout as a backstop.
|
||||
|
||||
**Robustness:** a vanished client leaves a stale TCP socket (no FIN). The broadcast pre-checks writability with a zero-timeout `select()` and sets a 2 s `SO_SNDTIMEO` on WS sockets, so a dead client is reclaimed (`httpd_sess_trigger_close`) instead of blocking the shared httpd task — which previously wedged the server and broke reconnects. The client falls back to `/get` polling + `/post` if the WS won't connect.
|
||||
|
||||
### UART (115200 8N1)
|
||||
- `GET` → same as HTTP GET /get
|
||||
- `POST: {json}` → same as HTTP POST /post
|
||||
@@ -276,7 +287,7 @@ Single physical button (button 0 via TCA9555 I2C expander) controls all interact
|
||||
|
||||
**Calibration states** — tap advances through calibration steps (unchanged)
|
||||
|
||||
**Factory reset** — power cycle with GPIO13 held for 10 seconds. Resets all params and erases log/post_test partitions. Preserves NVS (board_rev, BT pairing, RTC time). Only triggers on `ESP_RST_POWERON` or `ESP_RST_EXT`.
|
||||
**Factory reset** — two ways, both run `factory_reset()`: (1) power cycle with GPIO13 held for 10 seconds (only triggers on `ESP_RST_POWERON` or `ESP_RST_EXT`), or (2) the **FACTORY RESET** button in the DANGER ZONE (web UI → `cmd: "factory_reset"` → reset + reboot). Resets all params and erases log/post_test partitions. Preserves NVS (board_rev, BT pairing, RTC time).
|
||||
|
||||
### LED Status Indicators
|
||||
|
||||
@@ -324,7 +335,7 @@ Error codes are also shown on the web interface status field with individual fla
|
||||
|
||||
- **Battery voltage:** GPIO35, thru divider → `V = raw × V_SENS_K + V_SENS_OFFSET` (defaults: K=0.00766̄, offset=0.4)
|
||||
- **Solar charger:** GPIO26 (RTC hold) — FLOAT/BULK FSM, bulk for 20s when V < 5V for 5s
|
||||
- **Inactivity shutdown:** 180s → **soft idle** (WiFi/BT off, LEDs off — not deep sleep). Button press exits soft idle.
|
||||
- **Inactivity shutdown:** after `INACTIVITY_TIMEOUT_S` (default 300s) → **soft idle** (BT off, LEDs off, sensor rail off — not deep sleep). **WiFi softAP + HTTP server stay up.** Any incoming request (page load, `/get`, WS connect, command) or a button press calls `rtc_reset_shutdown_timer()`, which wakes from soft idle — so an already-associated client can revive the device just by reconnecting, without re-associating.
|
||||
- **RTC_DATA_ATTR:** Sync timestamps, alarm times, charge state — survive software resets (panics, WDT)
|
||||
|
||||
---
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
* Usage codes sent by generic BLE media remotes (HID Consumer Control page).
|
||||
* Only the four we care about are mapped; everything else is ignored.
|
||||
*/
|
||||
#define USAGE_VOL_UP 0x00E9u /* -> jack up */
|
||||
#define USAGE_VOL_DOWN 0x00EAu /* -> jack down */
|
||||
#define USAGE_VOL_UP 0x00E9u /* -> jack extend */
|
||||
#define USAGE_VOL_DOWN 0x00EAu /* -> jack retract */
|
||||
#define USAGE_PREV 0x00B6u /* -> reverse */
|
||||
#define USAGE_NEXT 0x00B5u /* -> forward */
|
||||
#define USAGE_NONE 0x0000u
|
||||
|
||||
45
main/comms.c
45
main/comms.c
@@ -42,13 +42,11 @@ static bool set_param_from_json(param_idx_t idx, cJSON *value_json) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSON object containing complete system status
|
||||
* Build the full system-status JSON object WITHOUT touching the shutdown
|
||||
* timer. Used by the 1 Hz WebSocket push, which must not keep the device
|
||||
* awake on its own — only genuine client activity (commands) should.
|
||||
*/
|
||||
cJSON* comms_handle_get(void) {
|
||||
//ESP_LOGI(TAG, "GET request");
|
||||
|
||||
rtc_reset_shutdown_timer();
|
||||
|
||||
cJSON* comms_build_status(void) {
|
||||
// Create root JSON object
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
if (root == NULL) {
|
||||
@@ -67,6 +65,9 @@ cJSON* comms_handle_get(void) {
|
||||
cJSON_AddNumberToObject(root, "next_alarm", (double)rtc_get_next_alarm_s());
|
||||
cJSON_AddNumberToObject(root, "board_rev", hw_get_board_rev());
|
||||
cJSON_AddNumberToObject(root, "fsm_error", fsm_get_error());
|
||||
cJSON_AddNumberToObject(root, "jack_pos_us", (double)fsm_get_jack_pos_us());
|
||||
cJSON_AddNumberToObject(root, "free_heap", (double)esp_get_free_heap_size());
|
||||
cJSON_AddNumberToObject(root, "min_free_heap", (double)esp_get_minimum_free_heap_size());
|
||||
|
||||
// Structured error flags (match LED error code bits)
|
||||
cJSON *errors = cJSON_CreateObject();
|
||||
@@ -156,10 +157,19 @@ cJSON* comms_handle_get(void) {
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "parameters", parameters);
|
||||
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSON object containing complete system status (GET request).
|
||||
* Resets the shutdown timer because an HTTP GET is a genuine client poll.
|
||||
*/
|
||||
cJSON* comms_handle_get(void) {
|
||||
rtc_reset_shutdown_timer();
|
||||
return comms_build_status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a POST request with JSON data
|
||||
*/
|
||||
@@ -177,6 +187,7 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
|
||||
bool sleep_requested = false;
|
||||
bool hibernate_requested = false;
|
||||
bool reboot_requested = false;
|
||||
bool factory_reset_requested = false;
|
||||
bool wifi_params_changed = false;
|
||||
bool wifi_restart_requested = false;
|
||||
bool refresh_battery_ema = false;
|
||||
@@ -226,11 +237,11 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
|
||||
pulse_override(FSM_OVERRIDE_DRIVE_REV);
|
||||
cmd_executed = true;
|
||||
}
|
||||
else if (strcmp(cmd_str, "up") == 0) {
|
||||
else if (strcmp(cmd_str, "extend") == 0) {
|
||||
pulse_override(FSM_OVERRIDE_JACK_UP);
|
||||
cmd_executed = true;
|
||||
}
|
||||
else if (strcmp(cmd_str, "down") == 0) {
|
||||
else if (strcmp(cmd_str, "retract") == 0) {
|
||||
pulse_override(FSM_OVERRIDE_JACK_DOWN);
|
||||
cmd_executed = true;
|
||||
}
|
||||
@@ -238,10 +249,18 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
|
||||
pulse_override(FSM_OVERRIDE_AUX);
|
||||
cmd_executed = true;
|
||||
}
|
||||
else if (strcmp(cmd_str, "stop_override") == 0) {
|
||||
stop_override();
|
||||
cmd_executed = true;
|
||||
}
|
||||
else if (strcmp(cmd_str, "reboot") == 0) {
|
||||
reboot_requested = true;
|
||||
cmd_executed = true;
|
||||
}
|
||||
else if (strcmp(cmd_str, "factory_reset") == 0) {
|
||||
factory_reset_requested = true;
|
||||
cmd_executed = true;
|
||||
}
|
||||
else if (strcmp(cmd_str, "sleep") == 0) {
|
||||
sleep_requested = true;
|
||||
cmd_executed = true;
|
||||
@@ -430,6 +449,14 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
|
||||
*response_json = response;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (factory_reset_requested) {
|
||||
cJSON_AddStringToObject(response, "status", "ok");
|
||||
cJSON_AddStringToObject(response, "message", "Factory reset — erasing params and rebooting...");
|
||||
cJSON_AddBoolToObject(response, "factory_reset", true);
|
||||
*response_json = response;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (sleep_requested) {
|
||||
cJSON_AddStringToObject(response, "status", "ok");
|
||||
|
||||
15
main/comms.h
15
main/comms.h
@@ -13,13 +13,24 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Process a GET request - returns complete system status as JSON
|
||||
*
|
||||
* Process a GET request - returns complete system status as JSON.
|
||||
* Resets the inactivity/shutdown timer (a GET is a real client poll).
|
||||
*
|
||||
* @return cJSON object containing system status, or NULL on error
|
||||
* Caller is responsible for deleting the returned object with cJSON_Delete()
|
||||
*/
|
||||
cJSON* comms_handle_get(void);
|
||||
|
||||
/**
|
||||
* Build the same system-status JSON as comms_handle_get() but WITHOUT
|
||||
* resetting the shutdown timer. Used by the periodic WebSocket status push
|
||||
* so an open-but-idle browser tab doesn't prevent the device from sleeping.
|
||||
*
|
||||
* @return cJSON object containing system status, or NULL on error
|
||||
* Caller is responsible for deleting the returned object with cJSON_Delete()
|
||||
*/
|
||||
cJSON* comms_build_status(void);
|
||||
|
||||
/**
|
||||
* Process a POST request - handles commands, parameter updates, time updates
|
||||
*
|
||||
|
||||
@@ -63,6 +63,11 @@ static int64_t jack_start_us = 0;
|
||||
static int64_t jack_trans_us = 0;
|
||||
static int64_t jack_finish_us = 0;
|
||||
|
||||
/* Cumulative jack extension estimate in microseconds (0 = fully retracted).
|
||||
* Reset to 0 whenever SENSOR_JACK trips (home position). Persists across
|
||||
* panics/WDT resets so the guard survives a mid-extension reboot. */
|
||||
RTC_DATA_ATTR static int64_t jack_pos_us = 0;
|
||||
|
||||
volatile fsm_state_t current_state = STATE_IDLE;
|
||||
volatile int64_t fsm_now = 0;
|
||||
volatile bool start_running_request = false;
|
||||
@@ -76,6 +81,10 @@ bool fsm_is_idle(void) {
|
||||
return current_state == STATE_IDLE;
|
||||
}
|
||||
|
||||
int64_t fsm_get_jack_pos_us(void) {
|
||||
return jack_pos_us;
|
||||
}
|
||||
|
||||
static int64_t timer_end = 0;
|
||||
static int64_t timer_start = 0;
|
||||
static inline void set_timer(uint64_t us) {
|
||||
@@ -96,6 +105,12 @@ void pulse_override(fsm_override_t cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
void stop_override(void) {
|
||||
portENTER_CRITICAL(&override_spin);
|
||||
override_time = 0;
|
||||
portEXIT_CRITICAL(&override_spin);
|
||||
}
|
||||
|
||||
/* Atomic snapshot of override_time + override_cmd for the control task. */
|
||||
static inline void override_snapshot(int64_t *time_out, fsm_override_t *cmd_out) {
|
||||
portENTER_CRITICAL(&override_spin);
|
||||
@@ -210,7 +225,8 @@ int8_t fsm_get_current_progress(int8_t denominator) {
|
||||
}
|
||||
|
||||
|
||||
#define JACK_TIME get_param_value_t(PARAM_JACK_KT).f32 * get_param_value_t(PARAM_JACK_DIST ).f32
|
||||
#define JACK_TIME get_param_value_t(PARAM_JACK_KT).f32 * get_param_value_t(PARAM_JACK_DIST).f32
|
||||
#define JACK_MAX_TIME get_param_value_t(PARAM_JACK_KT).f32 * get_param_value_t(PARAM_JACK_MAX ).f32
|
||||
|
||||
/* Symmetric jack-down duration: how long jack-up actually ran, plus 5%.
|
||||
* If jack_start_us / jack_finish_us are zero or negative (panic recovery,
|
||||
@@ -678,7 +694,7 @@ void control_task(void *param) {
|
||||
}
|
||||
break;
|
||||
case FSM_OVERRIDE_JACK_UP:
|
||||
if (efuse_get(BRIDGE_JACK)){
|
||||
if (efuse_get(BRIDGE_JACK) || jack_pos_us >= (int64_t)JACK_MAX_TIME) {
|
||||
drive_relays((relay_port_t){.bridges = {
|
||||
.DRIVE=BRIDGE_OFF,
|
||||
.JACK=BRIDGE_OFF,
|
||||
@@ -813,6 +829,48 @@ void control_task(void *param) {
|
||||
}
|
||||
|
||||
|
||||
/**** JACK POSITION TRACKING ****/
|
||||
/* Update jack_pos_us each tick based on what the relay outputs just did.
|
||||
* SENSOR_JACK tripping is the definitive home reset (overrides everything). */
|
||||
{
|
||||
const int64_t TICK_US = 20000LL;
|
||||
bridge_dir_t jack_dir = BRIDGE_OFF;
|
||||
|
||||
switch (current_state) {
|
||||
case STATE_JACK_UP_START:
|
||||
case STATE_JACK_UP:
|
||||
case STATE_CALIBRATE_JACK_MOVE:
|
||||
jack_dir = BRIDGE_FWD;
|
||||
break;
|
||||
case STATE_JACK_DOWN:
|
||||
jack_dir = BRIDGE_REV;
|
||||
break;
|
||||
case STATE_IDLE: {
|
||||
int64_t local_time;
|
||||
fsm_override_t local_cmd;
|
||||
override_snapshot(&local_time, &local_cmd);
|
||||
if (local_time > fsm_now && !efuse_get(BRIDGE_JACK)) {
|
||||
if (local_cmd == FSM_OVERRIDE_JACK_UP && jack_pos_us < (int64_t)JACK_MAX_TIME)
|
||||
jack_dir = BRIDGE_FWD;
|
||||
else if (local_cmd == FSM_OVERRIDE_JACK_DOWN)
|
||||
jack_dir = BRIDGE_REV;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
|
||||
if (jack_dir == BRIDGE_FWD)
|
||||
jack_pos_us += TICK_US;
|
||||
else if (jack_dir == BRIDGE_REV) {
|
||||
jack_pos_us -= TICK_US;
|
||||
if (jack_pos_us < 0LL) jack_pos_us = 0LL;
|
||||
}
|
||||
|
||||
if (get_sensor(SENSOR_JACK))
|
||||
jack_pos_us = 0LL;
|
||||
}
|
||||
|
||||
/**** LOGGING ****/
|
||||
if (log) send_fsm_log();
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ typedef enum {
|
||||
#define N_BRIDGES 3
|
||||
|
||||
void pulse_override(fsm_override_t cmd);
|
||||
void stop_override(void);
|
||||
|
||||
esp_err_t fsm_init();
|
||||
esp_err_t fsm_stop();
|
||||
@@ -111,6 +112,7 @@ int8_t fsm_get_current_progress(int8_t remainder);
|
||||
|
||||
fsm_state_t fsm_get_state();
|
||||
bool fsm_is_idle(void);
|
||||
int64_t fsm_get_jack_pos_us(void);
|
||||
|
||||
/* Human-readable name for a fsm_state_t value — used by logs and any web
|
||||
* UI surface that wants to render a friendly state name. Returns a literal. */
|
||||
|
||||
27
main/rtc.c
27
main/rtc.c
@@ -77,6 +77,13 @@ esp_err_t rtc_xtal_init(void) {
|
||||
|
||||
void rtc_reset_shutdown_timer(void)
|
||||
{
|
||||
/* Any genuine activity (HTTP/WS request, command, new association) flows
|
||||
* through here. If we're soft-idle, that activity is the wake trigger —
|
||||
* this is what lets an already-associated client reconnect and revive the
|
||||
* device WITHOUT having to re-associate (which would be the only other
|
||||
* wake signal). The HTTP server stays up during soft idle precisely so it
|
||||
* can receive that reconnect and call us. */
|
||||
if (in_soft_idle) soft_idle_exit();
|
||||
last_activity_tick = xTaskGetTickCount();
|
||||
rtc_wdt_feed();
|
||||
}
|
||||
@@ -85,8 +92,7 @@ void soft_idle_enter(void)
|
||||
{
|
||||
if (in_soft_idle) return;
|
||||
in_soft_idle = true;
|
||||
ESP_LOGI("RTC", "Entering soft idle (WiFi/BT off, LEDs off, sensors off)");
|
||||
webserver_stop();
|
||||
ESP_LOGI("RTC", "Entering soft idle (BT off, LEDs off, sensors off; WiFi AP + HTTP stay up for wake-on-request)");
|
||||
bt_hid_stop();
|
||||
i2c_set_led1(0);
|
||||
/* Drop P10 to kill sensor rail power while we're asleep. */
|
||||
@@ -103,9 +109,12 @@ void soft_idle_exit(void)
|
||||
ESP_LOGI("RTC", "Exiting soft idle");
|
||||
/* Bring sensor rail back before anything else tries to read sensors. */
|
||||
i2c_relays_idle();
|
||||
webserver_restart_wifi();
|
||||
bt_hid_resume();
|
||||
rtc_reset_shutdown_timer();
|
||||
/* Reset the timer directly rather than via rtc_reset_shutdown_timer() —
|
||||
* in_soft_idle is already false so it wouldn't re-enter, but being explicit
|
||||
* keeps the wake path obvious and avoids any future recursion footgun. */
|
||||
last_activity_tick = xTaskGetTickCount();
|
||||
rtc_wdt_feed();
|
||||
}
|
||||
|
||||
void hibernate_enter(void)
|
||||
@@ -273,9 +282,11 @@ void rtc_check_shutdown_timer(void)
|
||||
{
|
||||
// Unsigned subtraction handles TickType_t (uint32_t) wraparound correctly:
|
||||
// e.g. if tick wrapped from 0xFFFFFFFE to 5, elapsed = 5 - 0xFFFFFFFE = 7.
|
||||
// At 1ms/tick, uint32_t wraps after ~49.7 days — well beyond the 180s timeout.
|
||||
// At 1ms/tick, uint32_t wraps after ~49.7 days — well beyond any reasonable timeout.
|
||||
TickType_t elapsed = xTaskGetTickCount() - last_activity_tick;
|
||||
if (elapsed * portTICK_PERIOD_MS >= POWER_INACTIVITY_TIMEOUT_MS)
|
||||
uint32_t timeout_ms = get_param_value_t(PARAM_INACTIVITY_TIMEOUT_S).u32 * 1000u;
|
||||
if (timeout_ms == 0) timeout_ms = POWER_INACTIVITY_TIMEOUT_MS; // guard against zero
|
||||
if (elapsed * portTICK_PERIOD_MS >= timeout_ms)
|
||||
soft_idle_enter();
|
||||
}
|
||||
|
||||
@@ -308,7 +319,7 @@ void adjust_rtc_min(char *key, int8_t dir)
|
||||
|
||||
|
||||
void rtc_schedule_next_alarm(void) {
|
||||
/* Walk MOVE_TIME_0..MOVE_TIME_(NUM_MOVE_TIMES-1). Each slot is either
|
||||
/* Walk MOVE_TIME_00..MOVE_TIME_(NUM_MOVE_TIMES-1). Each slot is either
|
||||
* -1 (disabled) or a 0..86399 seconds-into-day offset. For each enabled
|
||||
* slot we compute its absolute Unix time for today and tomorrow, keep
|
||||
* whichever is the soonest still-future timestamp, and take the minimum
|
||||
@@ -333,7 +344,7 @@ void rtc_schedule_next_alarm(void) {
|
||||
|
||||
time_t best = -1;
|
||||
for (int i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
int32_t slot = get_param_value_t(PARAM_MOVE_TIME_0 + i).i32;
|
||||
int32_t slot = get_param_value_t(PARAM_MOVE_TIME_00 + i).i32;
|
||||
if (slot < 0) continue; // disabled
|
||||
|
||||
/* Candidate is today's occurrence if still in the future, else
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
#include <time.h>
|
||||
|
||||
|
||||
#define POWER_INACTIVITY_TIMEOUT_MS 180000
|
||||
/* Fallback only — runtime value comes from PARAM_INACTIVITY_TIMEOUT_S in storage. */
|
||||
#define POWER_INACTIVITY_TIMEOUT_MS 300000
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Public API */
|
||||
|
||||
@@ -403,12 +403,12 @@ double param_to_double(param_idx_t id) {
|
||||
// — N=12 makes algorithmic complexity irrelevant. Treating -1 as +infinity
|
||||
// keeps both the "rank by time" and "disabled goes last" behavior in a
|
||||
// single comparator. Called by commit_params() so on-disk layout is always
|
||||
// the canonical sorted order; UI can read MOVE_TIME_0..N back in order.
|
||||
// the canonical sorted order; UI can read MOVE_TIME_00..N back in order.
|
||||
// ============================================================================
|
||||
void sort_move_schedule(void) {
|
||||
int32_t v[NUM_MOVE_TIMES];
|
||||
for (int i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
v[i] = parameter_table[PARAM_MOVE_TIME_0 + i].i32;
|
||||
v[i] = parameter_table[PARAM_MOVE_TIME_00 + i].i32;
|
||||
}
|
||||
for (int i = 1; i < NUM_MOVE_TIMES; i++) {
|
||||
int32_t key = v[i];
|
||||
@@ -423,7 +423,7 @@ void sort_move_schedule(void) {
|
||||
v[j + 1] = key;
|
||||
}
|
||||
for (int i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
parameter_table[PARAM_MOVE_TIME_0 + i].i32 = v[i];
|
||||
parameter_table[PARAM_MOVE_TIME_00 + i].i32 = v[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ typedef struct {
|
||||
// min == max → skip bounds validation (used for keycodes, strings, informational params)
|
||||
// Division-critical params have min > 0 to prevent div-by-zero
|
||||
// NaN/Inf floats are always reset to default regardless of bounds
|
||||
//
|
||||
// *** FLASH LAYOUT WARNING ***
|
||||
// Parameters are stored in flash indexed by their enum position (order in this list).
|
||||
// NEVER insert a new parameter in the middle — it shifts all subsequent indices and
|
||||
// corrupts every stored value after the insertion point.
|
||||
// ALWAYS append new parameters at the very end of PARAM_LIST.
|
||||
|
||||
#define PARAM_LIST \
|
||||
PARAM_DEF(BOOT_TIME, i32, 0, "us", 0, 0) /* informational, skip */ \
|
||||
@@ -117,6 +123,7 @@ typedef struct {
|
||||
PARAM_DEF(SAFETY_MAKE_US, u32, 1000000, "", 0, 10000000) \
|
||||
PARAM_DEF(JACK_IS_DOWN, f32, 8.0, "A", 0.0, 200.0) /* deprecated: may duplicate JACK_I_DOWN */ \
|
||||
PARAM_DEF(FLUFF_PREDRIVE_MS, u32, 2000, "ms", 0, 60000) \
|
||||
PARAM_DEF(INACTIVITY_TIMEOUT_S, u32, 300, "s", 10, 86400) \
|
||||
/* Tabular schedule: up to 12 daily move times (seconds since local midnight). \
|
||||
* A value of -1 marks the slot as disabled — the default. Slots are sorted \
|
||||
* by commit_params() so non-negative values appear first in ascending order, \
|
||||
@@ -125,21 +132,23 @@ typedef struct {
|
||||
* end of PARAM_LIST so existing flash layout / CRC offsets stay stable. \
|
||||
* MOVE_START / MOVE_END / NUM_MOVES above are deprecated; the scheduler \
|
||||
* no longer reads them and the web UI no longer surfaces them. */ \
|
||||
PARAM_DEF(MOVE_TIME_0, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_1, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_2, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_3, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_4, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_5, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_6, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_7, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_8, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_9, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_00, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_01, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_02, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_03, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_04, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_05, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_06, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_07, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_08, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_09, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_10, i32, -1, "s", -1, 86399) \
|
||||
PARAM_DEF(MOVE_TIME_11, i32, -1, "s", -1, 86399)
|
||||
PARAM_DEF(MOVE_TIME_11, i32, -1, "s", -1, 86399) \
|
||||
/* Appended at end to avoid shifting existing flash layout: */ \
|
||||
PARAM_DEF(JACK_MAX, f32, 5.0, "in", 0.0, 10.0)
|
||||
|
||||
/* Tabular schedule width. The enum entries above must remain contiguous so
|
||||
* PARAM_MOVE_TIME_0 + i indexes slot i. */
|
||||
* PARAM_MOVE_TIME_00 + i indexes slot i. */
|
||||
#define NUM_MOVE_TIMES 12
|
||||
|
||||
// Generate enum for parameter indices
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -106,6 +106,8 @@
|
||||
margin: 0;
|
||||
min-height: 64px;
|
||||
}
|
||||
#schedule_rows div { display: flex; align-items: center; gap: 4px; }
|
||||
#schedule_rows input, #schedule_rows button { width: auto; }
|
||||
.cmd {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
@@ -271,23 +273,22 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Schedule</td>
|
||||
<td>
|
||||
<!-- Visible rows are rendered by renderSchedule() from the
|
||||
12 hidden PARAM_MOVE_TIME_N inputs below. -->
|
||||
<div id="schedule_rows"></div>
|
||||
<button id="schedule_add_btn" onclick="addScheduleSlot()">+ Add move</button>
|
||||
<!-- Hidden inputs hold the seconds-of-day value for each of
|
||||
the 12 schedule slots (-1 = disabled). They participate
|
||||
in the regular PARAM_* save flow: when the user edits
|
||||
a row we mark the corresponding hidden input .changed,
|
||||
and commitParams() picks it up like any other param. -->
|
||||
<div style="display:none" id="schedule_hidden_inputs"></div>
|
||||
<td id="schedule_rows">
|
||||
<div id="UX_SCHEDULE_ROW_0" ><input type="time" id="UX_MOVE_TIME_0" onchange="scheduleTimeChanged(0, this.value)"> <button onclick="removeScheduleSlot(0)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_1" style="display:none"><input type="time" id="UX_MOVE_TIME_1" onchange="scheduleTimeChanged(1, this.value)"> <button onclick="removeScheduleSlot(1)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_2" style="display:none"><input type="time" id="UX_MOVE_TIME_2" onchange="scheduleTimeChanged(2, this.value)"> <button onclick="removeScheduleSlot(2)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_3" style="display:none"><input type="time" id="UX_MOVE_TIME_3" onchange="scheduleTimeChanged(3, this.value)"> <button onclick="removeScheduleSlot(3)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_4" style="display:none"><input type="time" id="UX_MOVE_TIME_4" onchange="scheduleTimeChanged(4, this.value)"> <button onclick="removeScheduleSlot(4)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_5" style="display:none"><input type="time" id="UX_MOVE_TIME_5" onchange="scheduleTimeChanged(5, this.value)"> <button onclick="removeScheduleSlot(5)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_6" style="display:none"><input type="time" id="UX_MOVE_TIME_6" onchange="scheduleTimeChanged(6, this.value)"> <button onclick="removeScheduleSlot(6)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_7" style="display:none"><input type="time" id="UX_MOVE_TIME_7" onchange="scheduleTimeChanged(7, this.value)"> <button onclick="removeScheduleSlot(7)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_8" style="display:none"><input type="time" id="UX_MOVE_TIME_8" onchange="scheduleTimeChanged(8, this.value)"> <button onclick="removeScheduleSlot(8)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_9" style="display:none"><input type="time" id="UX_MOVE_TIME_9" onchange="scheduleTimeChanged(9, this.value)"> <button onclick="removeScheduleSlot(9)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_10" style="display:none"><input type="time" id="UX_MOVE_TIME_10" onchange="scheduleTimeChanged(10, this.value)"> <button onclick="removeScheduleSlot(10)">✕</button></div>
|
||||
<div id="UX_SCHEDULE_ROW_11" style="display:none"><input type="time" id="UX_MOVE_TIME_11" onchange="scheduleTimeChanged(11, this.value)"> <button onclick="removeScheduleSlot(11)">✕</button></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Next Move At</td>
|
||||
<td><input type="datetime-local" id="UX_NEXT_ALARM" readonly=""/></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Leash (ft)</td>
|
||||
<td><input type="number" id="UX_REM_DIST" onchange="handleRemainingDistChange(this)"/></td>
|
||||
@@ -343,20 +344,20 @@
|
||||
REV
|
||||
</button>
|
||||
<button class="sqbtn"
|
||||
onmousedown="startRemote('up', event)"
|
||||
onmouseup="stopRemote()"
|
||||
onmousedown="startRemote('extend', event)"
|
||||
onmouseup="stopRemote()"
|
||||
onmouseleave="stopRemote()"
|
||||
ontouchstart="startRemote('up', event)"
|
||||
ontouchstart="startRemote('extend', event)"
|
||||
ontouchend="stopRemote()">
|
||||
UP
|
||||
EXTEND
|
||||
</button>
|
||||
<button class="sqbtn"
|
||||
onmousedown="startRemote('down', event)"
|
||||
onmouseup="stopRemote()"
|
||||
onmousedown="startRemote('retract', event)"
|
||||
onmouseup="stopRemote()"
|
||||
onmouseleave="stopRemote()"
|
||||
ontouchstart="startRemote('down', event)"
|
||||
ontouchstart="startRemote('retract', event)"
|
||||
ontouchend="stopRemote()">
|
||||
DOWN
|
||||
RETRACT
|
||||
</button>
|
||||
<button class="sqbtn"
|
||||
onmousedown="startRemote('aux', event)"
|
||||
@@ -420,6 +421,14 @@
|
||||
<td><button onclick="calibrate('jack')">Jack Calibration</button>
|
||||
<button onclick="calibrate('drive')">Drive Calibration</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Jack Position</td>
|
||||
<td><span id="jack_pos_readout">—</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Heap (free / low watermark)</td>
|
||||
<td><span id="heap_readout">—</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Current Version</td>
|
||||
<td><input readonly="" id="version"/></td>
|
||||
@@ -441,6 +450,8 @@
|
||||
<table id="table"></table>
|
||||
<button class="cmd" onclick="sendCommand('reboot')" style="background-color:var(--red); color:#fff">REBOOT</button>
|
||||
<button class="cmd" onclick="sendCommand('sleep')" style="background-color:var(--surface-2); color:var(--text)">SLEEP</button>
|
||||
<button class="cmd" onclick="restartWifi()" style="background-color:var(--surface-2); color:var(--text)">RESTART WIFI</button>
|
||||
<button class="cmd" onclick="factoryReset()" style="background-color:var(--red); color:#fff">FACTORY RESET</button>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,6 +492,78 @@
|
||||
let paramTableCreated = false; // Track if param table has been created
|
||||
let pollInterval = null; // Store interval ID for polling
|
||||
let modalResolve = null;
|
||||
|
||||
// Real-time channel: a WebSocket carries low-latency remote-control
|
||||
// commands (client->server) and a ~1 Hz status push (server->client).
|
||||
// When the WS is down we fall back to HTTP polling + POST.
|
||||
let ws = null;
|
||||
let wsReconnectTimer = null;
|
||||
let wsSuppressed = false; // true while tab hidden — don't auto-reconnect
|
||||
const wsConnected = () => ws && ws.readyState === WebSocket.OPEN;
|
||||
|
||||
// Lightweight tagged logger so WS/remote traffic is easy to filter in
|
||||
// the browser console (filter on "[WS]").
|
||||
let wsMsgCount = 0;
|
||||
const wlog = (...a) => console.log('[WS]', ...a);
|
||||
|
||||
function connectWS() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN ||
|
||||
ws.readyState === WebSocket.CONNECTING)) {
|
||||
wlog('connectWS skipped, readyState=', ws.readyState);
|
||||
return;
|
||||
}
|
||||
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://')
|
||||
+ location.host + '/ws';
|
||||
wlog('connecting to', url);
|
||||
try { ws = new WebSocket(url); }
|
||||
catch (e) { wlog('constructor threw', e); scheduleWSReconnect(); return; }
|
||||
|
||||
ws.onopen = () => {
|
||||
wlog('OPEN — status now via push, commands via WS');
|
||||
stopPolling(); // status now arrives via push
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
wsMsgCount++;
|
||||
if (wsMsgCount <= 3 || wsMsgCount % 30 === 0)
|
||||
wlog('status push #' + wsMsgCount, '(' + ev.data.length + ' bytes)');
|
||||
try { data = JSON.parse(ev.data); updateUI(); }
|
||||
catch (e) { console.error('[WS] parse error', e); }
|
||||
};
|
||||
ws.onerror = (e) => { wlog('ERROR event', e); try { ws.close(); } catch (e2) {} };
|
||||
ws.onclose = (e) => {
|
||||
wlog('CLOSE code=' + e.code + ' reason="' + e.reason + '" clean=' + e.wasClean);
|
||||
ws = null;
|
||||
if (wsSuppressed) { wlog('suppressed (tab hidden), not reconnecting'); return; }
|
||||
startPolling(); // fall back to HTTP polling
|
||||
scheduleWSReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleWSReconnect() {
|
||||
if (wsReconnectTimer || wsSuppressed) return;
|
||||
wlog('reconnect scheduled in 3s');
|
||||
wsReconnectTimer = setTimeout(() => {
|
||||
wsReconnectTimer = null;
|
||||
connectWS();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Send a command, preferring the WebSocket; fall back to fire-and-forget
|
||||
// POST so controls still work if the socket is momentarily down.
|
||||
function sendCmd(cmd) {
|
||||
if (wsConnected()) {
|
||||
try { ws.send(JSON.stringify({cmd})); wlog('sent via WS:', cmd); return; }
|
||||
catch (e) { wlog('WS send threw, falling back to POST', e); }
|
||||
} else {
|
||||
wlog('WS not open (readyState=' + (ws ? ws.readyState : 'null') + '), POST:', cmd);
|
||||
}
|
||||
fetch('./post', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({cmd})
|
||||
}).then(r => wlog('POST', cmd, '->', r.status))
|
||||
.catch(e => wlog('POST', cmd, 'failed', e));
|
||||
}
|
||||
|
||||
const ge = (id) => document.getElementById(id);
|
||||
|
||||
@@ -701,20 +784,18 @@
|
||||
let intervalId = null;
|
||||
|
||||
function remote(command) {
|
||||
// Fire-and-forget POST. Held-button jog sends this every 150 ms —
|
||||
// transient NetworkErrors must not pop a modal (would spam the UI
|
||||
// and race with subsequent sends). Any error is swallowed.
|
||||
fetch('./post', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({cmd: command})
|
||||
}).catch(() => {});
|
||||
// Held-button jog calls this every 150 ms. Over the WebSocket each
|
||||
// send re-arms the firmware's pulse timeout (safety net if a stop
|
||||
// is ever lost); sendCmd falls back to a fire-and-forget POST when
|
||||
// the socket is down.
|
||||
sendCmd(command);
|
||||
try {
|
||||
navigator.vibrate(200);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
function startRemote(command, event) {
|
||||
wlog('startRemote(' + command + ') ' + (event ? event.type : 'no-event'));
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -722,21 +803,23 @@
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
|
||||
// Send immediately
|
||||
remote(command);
|
||||
|
||||
|
||||
// Then send while held
|
||||
intervalId = setInterval(() => {
|
||||
remote(command);
|
||||
}, 150); //ms
|
||||
}
|
||||
|
||||
|
||||
function stopRemote() {
|
||||
wlog('stopRemote');
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
sendCmd('stop_override'); // explicit halt on button release
|
||||
}
|
||||
|
||||
// Single action-button dispatcher. STATE_IDLE (0) → start, anything else
|
||||
@@ -790,6 +873,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function factoryReset() {
|
||||
if (!await modalConfirm("FACTORY RESET: all parameters will be erased and reset to defaults. The device will reboot. Are you sure?", "Factory Reset")) return;
|
||||
if (!await modalConfirm("This cannot be undone. Confirm factory reset?", "Confirm")) return;
|
||||
try {
|
||||
await fetch('./post', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({cmd: 'factory_reset'})
|
||||
});
|
||||
} catch(e) { /* expected — device reboots */ }
|
||||
showRebootModal();
|
||||
}
|
||||
|
||||
async function restartWifi() {
|
||||
if (!await modalConfirm("Restart WiFi? You will lose the connection briefly.")) return;
|
||||
try {
|
||||
await fetch('./post', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({wifi_restart: true})
|
||||
});
|
||||
} catch(e) { /* expected — connection drops during restart */ }
|
||||
setTimeout(fetchStatus, 3000);
|
||||
}
|
||||
|
||||
async function calibrate(type) {
|
||||
const cmdName = type === 'jack' ? 'cal_jack' : 'cal_drive';
|
||||
|
||||
@@ -844,44 +952,10 @@
|
||||
|
||||
const scheduleInputs = ['DRIVE_DIST', 'JACK_DIST'];
|
||||
|
||||
// ============================================================
|
||||
// Tabular schedule (up to 12 daily move times)
|
||||
// ============================================================
|
||||
// The user thinks in "list of times-of-day". The firmware stores
|
||||
// 12 i32 params (MOVE_TIME_0..MOVE_TIME_11), each holding either
|
||||
// seconds-since-local-midnight (0..86399) or -1 = disabled. The
|
||||
// device sorts the array ascending after every commit so on every
|
||||
// poll the enabled slots arrive packed at the front in time order.
|
||||
//
|
||||
// We back the UI with 12 hidden `<input id="PARAM_MOVE_TIME_N">`
|
||||
// elements so the values plug into the existing PARAM_* save flow:
|
||||
// editing a row marks its hidden input .changed, commitParams()
|
||||
// picks it up like any other param.
|
||||
// 12 daily move-time slots. Firmware stores seconds-since-local-midnight
|
||||
// (0..86399) or -1 = disabled. Server key names are zero-padded: MOVE_TIME_00..11.
|
||||
const NUM_MOVE_TIMES = 12;
|
||||
|
||||
(function initScheduleHiddenInputs() {
|
||||
const host = ge('schedule_hidden_inputs');
|
||||
if (!host) return;
|
||||
for (let i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.id = `PARAM_MOVE_TIME_${i}`;
|
||||
inp.value = -1;
|
||||
host.appendChild(inp);
|
||||
}
|
||||
})();
|
||||
|
||||
function readScheduleSlot(i) {
|
||||
const inp = ge(`PARAM_MOVE_TIME_${i}`);
|
||||
return inp ? parseInt(inp.value, 10) : -1;
|
||||
}
|
||||
|
||||
function writeScheduleSlot(i, value) {
|
||||
const inp = ge(`PARAM_MOVE_TIME_${i}`);
|
||||
if (!inp) return;
|
||||
inp.value = value;
|
||||
markChanged(inp);
|
||||
}
|
||||
const mtKey = i => `MOVE_TIME_${String(i).padStart(2,'0')}`;
|
||||
|
||||
// Format seconds-of-day as HH:MM for an <input type="time">.
|
||||
function secondsToHM(seconds) {
|
||||
@@ -890,91 +964,58 @@
|
||||
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function renderSchedule() {
|
||||
const host = ge('schedule_rows');
|
||||
if (!host) return;
|
||||
|
||||
// If any time input inside the schedule is currently focused,
|
||||
// the user is mid-edit; re-rendering would steal focus on every
|
||||
// 2-second poll. Skip — the next poll after blur picks it up.
|
||||
if (host.contains(document.activeElement)
|
||||
&& document.activeElement.type === 'time') return;
|
||||
|
||||
host.innerHTML = '';
|
||||
let enabledCount = 0;
|
||||
// Show only filled slots plus one trailing empty slot (for entering the next time).
|
||||
// Server always sorts non-negative values to the front, so filled slots are contiguous.
|
||||
function renderScheduleVisibility() {
|
||||
let showCount = 1;
|
||||
for (let i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
const v = readScheduleSlot(i);
|
||||
if (v < 0) continue;
|
||||
enabledCount++;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '4px';
|
||||
|
||||
const t = document.createElement('input');
|
||||
t.type = 'time';
|
||||
t.value = secondsToHM(v);
|
||||
// Capture the slot index — at render time `i` is the slot,
|
||||
// which is also where the hidden input lives.
|
||||
t.oninput = ((slot) => () => onScheduleTimeEdit(slot, t.value))(i);
|
||||
row.appendChild(t);
|
||||
|
||||
const x = document.createElement('button');
|
||||
x.textContent = '✕'; // ✕
|
||||
x.style.width = '40px';
|
||||
x.style.flexShrink = '0';
|
||||
x.onclick = ((slot) => () => onScheduleRemove(slot))(i);
|
||||
row.appendChild(x);
|
||||
|
||||
host.appendChild(row);
|
||||
const inp = ge(`UX_MOVE_TIME_${i}`);
|
||||
if (inp && inp.value !== '') showCount = i + 2;
|
||||
}
|
||||
|
||||
// The + Add button is disabled when all 12 slots are in use.
|
||||
const addBtn = ge('schedule_add_btn');
|
||||
if (addBtn) addBtn.disabled = enabledCount >= NUM_MOVE_TIMES;
|
||||
}
|
||||
|
||||
function onScheduleTimeEdit(slot, hhmm) {
|
||||
if (!hhmm) return; // empty edit — leave previous value
|
||||
const parts = hhmm.split(':').map(Number);
|
||||
if (parts.length < 2 || Number.isNaN(parts[0]) || Number.isNaN(parts[1])) return;
|
||||
writeScheduleSlot(slot, parts[0] * 3600 + parts[1] * 60);
|
||||
// Don't re-render: the visible <input> already shows the new
|
||||
// value and a rebuild would steal focus mid-edit.
|
||||
}
|
||||
|
||||
function onScheduleRemove(slot) {
|
||||
writeScheduleSlot(slot, -1);
|
||||
renderSchedule();
|
||||
}
|
||||
|
||||
function addScheduleSlot() {
|
||||
// Find the first disabled slot and arm it with a default of
|
||||
// 12:00. Visible rows are sorted alphabetically by storage
|
||||
// index so the new row appears at whatever position holds the
|
||||
// first -1 — the device will re-sort canonically on commit.
|
||||
showCount = Math.min(showCount, NUM_MOVE_TIMES);
|
||||
for (let i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
if (readScheduleSlot(i) < 0) {
|
||||
writeScheduleSlot(i, 12 * 3600);
|
||||
renderSchedule();
|
||||
return;
|
||||
const row = ge(`UX_SCHEDULE_ROW_${i}`);
|
||||
if (!row) continue;
|
||||
if (i < showCount) {
|
||||
row.style.display = '';
|
||||
const inp = ge(`UX_MOVE_TIME_${i}`);
|
||||
const btn = row.querySelector('button');
|
||||
if (btn) btn.style.display = (inp && inp.value !== '') ? '' : 'none';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called once per /get poll from updateUI(). Syncs the 12 hidden
|
||||
// inputs from the server's `parameters` object using _safeSet so
|
||||
// any slot the user has edited (marked .changed) survives the
|
||||
// sync. renderSchedule() then redraws the visible rows.
|
||||
function removeScheduleSlot(i) {
|
||||
const inp = ge(`UX_MOVE_TIME_${i}`);
|
||||
if (inp) { inp.value = ''; scheduleTimeChanged(i, ''); }
|
||||
}
|
||||
|
||||
// Called when a UX_MOVE_TIME_N time input changes. Converts HH:MM to
|
||||
// seconds (or blank → -1) and pushes the value into the corresponding
|
||||
// PARAM_MOVE_TIME_NN raw-table input so the normal save flow picks it up.
|
||||
function scheduleTimeChanged(i, val) {
|
||||
const seconds = val === '' ? -1
|
||||
: (() => { const p = val.split(':').map(Number); return p[0]*3600 + p[1]*60; })();
|
||||
const raw = ge(`PARAM_${mtKey(i)}`);
|
||||
if (raw) { raw.value = seconds; markChanged(raw); }
|
||||
markChanged(ge(`UX_MOVE_TIME_${i}`)); // protect from _safeSet until saved
|
||||
renderScheduleVisibility();
|
||||
}
|
||||
|
||||
// Sync the 12 visible time inputs from the server's parameters object.
|
||||
// Uses UX_MOVE_TIME_N (not PARAM_) so there's no ID conflict with the
|
||||
// raw parameter table. _safeSet skips inputs the user has pending edits on.
|
||||
function updateScheduleFromServer() {
|
||||
if (!data.parameters) return;
|
||||
for (let i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
const inp = ge(`PARAM_MOVE_TIME_${i}`);
|
||||
const v = data.parameters[`MOVE_TIME_${i}`];
|
||||
if (typeof v === 'number') _safeSet(inp, v);
|
||||
const inp = ge(`UX_MOVE_TIME_${i}`);
|
||||
const v = data.parameters[mtKey(i)];
|
||||
if (inp && typeof v === 'number')
|
||||
_safeSet(inp, v < 0 ? '' : secondsToHM(v));
|
||||
}
|
||||
renderSchedule();
|
||||
renderScheduleVisibility();
|
||||
}
|
||||
|
||||
|
||||
@@ -1048,11 +1089,11 @@
|
||||
} else if (id.startsWith('PARAM_')) {
|
||||
// Parameter table inputs
|
||||
const paramName = id.substring(6);
|
||||
|
||||
|
||||
let value = input.value;
|
||||
if (input.type === 'number')
|
||||
value = parseFloat(input.value) || 0;
|
||||
|
||||
|
||||
// Add to parameters object
|
||||
if (!payload.parameters) {
|
||||
payload.parameters = {};
|
||||
@@ -1061,6 +1102,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// If any schedule times changed, send all 12 slots pre-sorted so
|
||||
// the displayed order matches what the server will store.
|
||||
if (payload.parameters &&
|
||||
Object.keys(payload.parameters).some(k => k.startsWith('MOVE_TIME_'))) {
|
||||
const times = [];
|
||||
for (let i = 0; i < NUM_MOVE_TIMES; i++) {
|
||||
const raw = ge(`PARAM_${mtKey(i)}`);
|
||||
const v = raw ? parseInt(raw.value, 10) : NaN;
|
||||
times.push(isNaN(v) ? -1 : v);
|
||||
}
|
||||
times.sort((a, b) => {
|
||||
if (a < 0 && b < 0) return 0;
|
||||
if (a < 0) return 1;
|
||||
if (b < 0) return -1;
|
||||
return a - b;
|
||||
});
|
||||
for (let i = 0; i < NUM_MOVE_TIMES; i++)
|
||||
payload.parameters[mtKey(i)] = times[i];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Sending data:', payload);
|
||||
|
||||
@@ -1193,30 +1254,6 @@
|
||||
_safeSet(timeInput, `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
|
||||
}
|
||||
|
||||
const timeOutput = ge('UX_NEXT_ALARM');
|
||||
if (data.next_alarm !== undefined && document.activeElement !== timeOutput) {
|
||||
if (data.next_alarm > 0) {
|
||||
// Flip the element back to a datetime-local input so
|
||||
// the value parses — the "disabled" branch swaps it to
|
||||
// a plain text input to display a message.
|
||||
if (timeOutput.type !== 'datetime-local') timeOutput.type = 'datetime-local';
|
||||
// Treat incoming timestamp as local time (no timezone conversion)
|
||||
const dt = new Date(data.next_alarm * 1000);
|
||||
const year = dt.getUTCFullYear();
|
||||
const month = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(dt.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(dt.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(dt.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(dt.getUTCSeconds()).padStart(2, '0');
|
||||
timeOutput.value = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||||
} else {
|
||||
// <=0 means firmware has no alarm scheduled — either every
|
||||
// MOVE_TIME_* slot is -1, or the RTC isn't set yet.
|
||||
if (timeOutput.type !== 'text') timeOutput.type = 'text';
|
||||
timeOutput.value = 'DISABLED';
|
||||
}
|
||||
}
|
||||
|
||||
// Update parameters
|
||||
if (data.parameters) {
|
||||
updateParamTable();
|
||||
@@ -1224,6 +1261,33 @@
|
||||
updateScheduleFromServer();
|
||||
}
|
||||
|
||||
// Jack position accumulator readout (DANGER ZONE)
|
||||
{
|
||||
const el = ge('jack_pos_readout');
|
||||
const pos = data.jack_pos_us;
|
||||
if (el && typeof pos === 'number' && data.parameters) {
|
||||
const kt = data.parameters.JACK_KT;
|
||||
const max = data.parameters.JACK_MAX;
|
||||
if (kt > 0 && max > 0) {
|
||||
const pos_in = pos / kt;
|
||||
const pct = Math.round(pos_in / max * 100);
|
||||
el.textContent = `${pct}% (${pos_in.toFixed(2)} in / ${max.toFixed(2)} in)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heap readout (DANGER ZONE) — low watermark < 20 KB is danger territory
|
||||
{
|
||||
const el = ge('heap_readout');
|
||||
if (el && typeof data.free_heap === 'number') {
|
||||
const freeKB = (data.free_heap / 1024).toFixed(1);
|
||||
const minKB = (data.min_free_heap / 1024).toFixed(1);
|
||||
const warn = data.min_free_heap < 20480;
|
||||
el.textContent = `${freeKB} KB free / ${minKB} KB min`;
|
||||
el.style.color = warn ? 'var(--red)' : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Update remaining distance (special parameter)
|
||||
if (data.remaining_dist !== undefined) {
|
||||
_safeSet(ge('UX_REM_DIST'), data.remaining_dist.toFixed(1));
|
||||
@@ -1245,11 +1309,8 @@
|
||||
'MOVE_START', 'MOVE_END', 'NUM_MOVES',
|
||||
]);
|
||||
|
||||
// MOVE_TIME_0..11 are rendered in the dedicated Schedule section;
|
||||
// exclude them from the DANGER ZONE table the same way (kept as a
|
||||
// prefix check so we don't have to maintain a 12-entry list).
|
||||
function paramSkipped(key) {
|
||||
return PARAM_TABLE_SKIP.has(key) || key.startsWith('MOVE_TIME_');
|
||||
return PARAM_TABLE_SKIP.has(key);
|
||||
}
|
||||
|
||||
function updateParamTable() {
|
||||
@@ -1422,7 +1483,7 @@
|
||||
}
|
||||
|
||||
async function programRFSequence() {
|
||||
const buttonNames = ["Forward", "Reverse", "Up", "Down"];
|
||||
const buttonNames = ["Forward", "Reverse", "Extend", "Retract"];
|
||||
const learnedCodes = [null, null, null, null];
|
||||
|
||||
if (!await modalConfirm("This will program all 4 RF remote buttons in sequence.\n\nPress OK to begin, then follow the prompts.")) {
|
||||
@@ -2003,39 +2064,47 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Start automatic polling
|
||||
// HTTP-polling fallback for when the WebSocket isn't connected. The
|
||||
// interval itself no-ops while the WS is up, so it's safe to leave
|
||||
// running; we still stop it on WS open to avoid wasted timers.
|
||||
function startPolling() {
|
||||
// Initial fetch
|
||||
fetchStatus();
|
||||
|
||||
// Set up interval for polling in ms
|
||||
pollInterval = setInterval(fetchStatus, 3000);
|
||||
if (pollInterval) return; // already polling
|
||||
fetchStatus(); // immediate populate
|
||||
pollInterval = setInterval(() => {
|
||||
if (!wsConnected()) fetchStatus();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Stop polling (if needed in the future)
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
// Tab is hidden - stop polling
|
||||
// Tab hidden: drop the WS so status pushes stop and the device
|
||||
// can reach its inactivity timeout, and stop polling.
|
||||
wsSuppressed = true;
|
||||
stopPolling();
|
||||
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
|
||||
if (ws) { try { ws.close(); } catch (e) {} }
|
||||
} else {
|
||||
// Tab is visible again - resume polling
|
||||
fetchStatus(); // Immediate fetch when returning
|
||||
pollInterval = setInterval(fetchStatus, 3000);
|
||||
// Visible again: reconnect WS and resume the polling fallback.
|
||||
wsSuppressed = false;
|
||||
connectWS();
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial Load with polling
|
||||
// Initial load: open the real-time WebSocket and start the polling
|
||||
// fallback (which also does the first immediate status fetch).
|
||||
window.onload = function() {
|
||||
ge('commit_btn').disabled = true;
|
||||
ge('cancel_btn').disabled = true;
|
||||
_attachLogViewerToggle();
|
||||
connectWS();
|
||||
startPolling();
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2184
main/webpage_gzip.h
2184
main/webpage_gzip.h
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
300
main/webserver.c
300
main/webserver.c
@@ -30,6 +30,7 @@
|
||||
#include "nvs_flash.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_netif.h"
|
||||
#include "lwip/sockets.h" // close() for the WS disconnect callback
|
||||
#include <math.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/param.h>
|
||||
@@ -161,12 +162,13 @@ static esp_err_t root_get_handler(httpd_req_t *req) {
|
||||
}
|
||||
|
||||
bringup_notify_http_request();
|
||||
rtc_reset_shutdown_timer(); // serving the page is real activity → wake from soft idle
|
||||
|
||||
HTTPD_RET_ON_ERR(httpd_resp_set_type(req, "text/html"), "set response type");
|
||||
HTTPD_RET_ON_ERR(httpd_resp_set_hdr(req, "Content-Encoding", "gzip"), "set content encoding header");
|
||||
HTTPD_RET_ON_ERR(httpd_resp_set_hdr(req, "Connection", "close"), "set connection header");
|
||||
HTTPD_RET_ON_ERR(httpd_resp_send(req, (const char *)html_content, html_content_len),
|
||||
"send HTML response");
|
||||
HTTPD_WARN_ON_ERR(httpd_resp_set_hdr(req, "Connection", "close"), "set connection header");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -306,6 +308,8 @@ static esp_err_t log_handler_locked(httpd_req_t *req) {
|
||||
return err;
|
||||
}
|
||||
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
|
||||
// Send JSON length (4 bytes, big-endian)
|
||||
uint32_t json_len_be = htobe32(json_len);
|
||||
memcpy(&http_buffer[0], &json_len_be, 4);
|
||||
@@ -352,7 +356,6 @@ static esp_err_t log_handler_locked(httpd_req_t *req) {
|
||||
|
||||
// Send empty chunk to signal end
|
||||
HTTPD_RET_ON_ERR(httpd_resp_send_chunk(req, NULL, 0), "send final chunk");
|
||||
HTTPD_WARN_ON_ERR(httpd_resp_set_hdr(req, "Connection", "close"), "set connection header");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -410,20 +413,16 @@ static esp_err_t get_handler(httpd_req_t *req) {
|
||||
free(json_str);
|
||||
return err;
|
||||
}
|
||||
|
||||
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
err = httpd_resp_send(req, json_str, strlen(json_str));
|
||||
free(json_str);
|
||||
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to send response: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = httpd_resp_set_hdr(req, "Connection", "close");
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to set connection header: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -502,14 +501,16 @@ static esp_err_t post_handler_locked(httpd_req_t *req) {
|
||||
}
|
||||
|
||||
// Check for special response flags
|
||||
cJSON *reboot_flag = cJSON_GetObjectItem(response, "reboot");
|
||||
cJSON *sleep_flag = cJSON_GetObjectItem(response, "sleep");
|
||||
cJSON *hibernate_flag = cJSON_GetObjectItem(response, "hibernate");
|
||||
cJSON *wifi_restart_flag = cJSON_GetObjectItem(response, "wifi_restart");
|
||||
bool should_reboot = cJSON_IsTrue(reboot_flag);
|
||||
bool should_sleep = cJSON_IsTrue(sleep_flag);
|
||||
bool should_hibernate = cJSON_IsTrue(hibernate_flag);
|
||||
bool should_restart_wifi = cJSON_IsTrue(wifi_restart_flag);
|
||||
cJSON *reboot_flag = cJSON_GetObjectItem(response, "reboot");
|
||||
cJSON *factory_reset_flag = cJSON_GetObjectItem(response, "factory_reset");
|
||||
cJSON *sleep_flag = cJSON_GetObjectItem(response, "sleep");
|
||||
cJSON *hibernate_flag = cJSON_GetObjectItem(response, "hibernate");
|
||||
cJSON *wifi_restart_flag = cJSON_GetObjectItem(response, "wifi_restart");
|
||||
bool should_reboot = cJSON_IsTrue(reboot_flag);
|
||||
bool should_factory_reset = cJSON_IsTrue(factory_reset_flag);
|
||||
bool should_sleep = cJSON_IsTrue(sleep_flag);
|
||||
bool should_hibernate = cJSON_IsTrue(hibernate_flag);
|
||||
bool should_restart_wifi = cJSON_IsTrue(wifi_restart_flag);
|
||||
|
||||
// Convert response to string
|
||||
char *json_str = cJSON_PrintUnformatted(response);
|
||||
@@ -521,18 +522,18 @@ static esp_err_t post_handler_locked(httpd_req_t *req) {
|
||||
"Failed to serialize response");
|
||||
}
|
||||
|
||||
// Send response
|
||||
// Send response — keep-alive intentional; held-button remote pulses reuse this connection
|
||||
err = httpd_resp_set_type(req, "application/json");
|
||||
if (err == ESP_OK) {
|
||||
err = httpd_resp_send(req, json_str, strlen(json_str));
|
||||
}
|
||||
free(json_str);
|
||||
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to send response: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
|
||||
// Handle special actions after response is sent
|
||||
if (should_reboot) {
|
||||
ESP_LOGI(TAG, "Rebooting in 2 seconds...");
|
||||
@@ -541,6 +542,14 @@ static esp_err_t post_handler_locked(httpd_req_t *req) {
|
||||
return ESP_OK; // Never reached
|
||||
}
|
||||
|
||||
if (should_factory_reset) {
|
||||
ESP_LOGW(TAG, "Factory reset in 2 seconds...");
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
factory_reset();
|
||||
esp_restart();
|
||||
return ESP_OK; // Never reached
|
||||
}
|
||||
|
||||
/* All three of these have the same deadlock concern: calling
|
||||
* httpd_stop() (directly or via webserver_stop / soft_idle_enter /
|
||||
* hibernate_enter) from inside a handler blocks because httpd_stop()
|
||||
@@ -564,7 +573,6 @@ static esp_err_t post_handler_locked(httpd_req_t *req) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
HTTPD_WARN_ON_ERR(httpd_resp_set_hdr(req, "Connection", "close"), "set connection header");
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -738,46 +746,45 @@ static esp_err_t catchall_handler(httpd_req_t *req) {
|
||||
// Windows NCSI
|
||||
if (strcmp(uri, "/connecttest.txt") == 0) {
|
||||
httpd_resp_set_type(req, "text/plain");
|
||||
httpd_resp_sendstr(req, "Microsoft Connect Test");
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
httpd_resp_sendstr(req, "Microsoft Connect Test");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
if (strncmp(uri, "/success.txt", 12) == 0) { // Handles query params too
|
||||
httpd_resp_set_type(req, "text/plain");
|
||||
httpd_resp_sendstr(req, "Success");
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
httpd_resp_sendstr(req, "Success");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
if (strcmp(uri, "/canonical.html") == 0) {
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
httpd_resp_sendstr(req, "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
httpd_resp_sendstr(req, "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
// Android
|
||||
if (strcmp(uri, "/generate_204") == 0 || strcmp(uri, "/gen_204") == 0) {
|
||||
httpd_resp_set_status(req, "204 No Content");
|
||||
httpd_resp_send(req, NULL, 0);
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
httpd_resp_send(req, NULL, 0);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// iOS/macOS
|
||||
if (strcmp(uri, "/hotspot-detect.html") == 0 ||
|
||||
|
||||
// iOS/macOS — return 200 Success so the OS stops probing
|
||||
if (strcmp(uri, "/hotspot-detect.html") == 0 ||
|
||||
strcmp(uri, "/library/test/success.html") == 0) {
|
||||
httpd_resp_set_status(req, "302 Found");
|
||||
httpd_resp_set_hdr(req, "Location", "/");
|
||||
httpd_resp_send(req, NULL, 0);
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
httpd_resp_sendstr(req, "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
// Default 404
|
||||
httpd_resp_send_404(req);
|
||||
httpd_resp_set_hdr(req, "Connection", "close");
|
||||
httpd_resp_send_404(req);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -788,6 +795,10 @@ static esp_err_t catchall_handler(httpd_req_t *req) {
|
||||
**************** URI HANDLER MAP ****************
|
||||
************************************************/
|
||||
|
||||
/* Real-time control + status channel. Defined further down (needs the
|
||||
* server_running / http_server_instance globals declared below). */
|
||||
static esp_err_t ws_handler(httpd_req_t *req);
|
||||
|
||||
httpd_uri_t uris[] = {{
|
||||
.uri = "/",
|
||||
.method = HTTP_GET,
|
||||
@@ -813,6 +824,17 @@ httpd_uri_t uris[] = {{
|
||||
.method = HTTP_POST,
|
||||
.handler = ota_post_handler,
|
||||
.user_ctx = NULL
|
||||
},{
|
||||
/* WebSocket: must be registered before the wildcard catchall so the
|
||||
* handshake GET to /ws is matched here, not by catchall_handler. */
|
||||
.uri = "/ws",
|
||||
.method = HTTP_GET,
|
||||
.handler = ws_handler,
|
||||
.user_ctx = NULL,
|
||||
.is_websocket = true,
|
||||
/* Let httpd handle PING/PONG/CLOSE internally. A clean CLOSE still closes
|
||||
* the socket, which fires ws_close_fn -> stop_override(). */
|
||||
.handle_ws_control_frames = false
|
||||
},{
|
||||
.uri = "/*",
|
||||
.method = HTTP_GET,
|
||||
@@ -830,6 +852,185 @@ static bool s_wifi_running = false;
|
||||
static esp_netif_t *s_ap_netif = NULL;
|
||||
static bool s_wifi_initted = false;
|
||||
|
||||
/**********************************************************
|
||||
**************** WEBSOCKET (REAL-TIME) ********************
|
||||
**********************************************************/
|
||||
/* The page opens a WebSocket to /ws for two things:
|
||||
* - client -> server: low-latency remote-control commands (extend, retract,
|
||||
* fwd, rev, aux, stop_override) sent as small JSON text frames. These are
|
||||
* routed through comms_handle_post() so they share the POST command
|
||||
* vocabulary exactly.
|
||||
* - server -> client: a ~1 Hz status push (same JSON as /get) so the page
|
||||
* no longer has to poll over HTTP.
|
||||
*
|
||||
* Safety: any WS socket close (tab closed, WiFi drop, crash) invokes
|
||||
* stop_override() via the httpd close callback, halting remote motion with no
|
||||
* dependence on the 350 ms pulse timeout. */
|
||||
|
||||
#define WS_MAX_CLIENTS 7 /* mirrors config.max_open_sockets */
|
||||
#define WS_RX_MAX 256 /* remote-control JSON frames are tiny */
|
||||
|
||||
/* Non-blocking writability poll. A live browser drains a ~1 KB status frame
|
||||
* instantly, so a WS socket that isn't writable here is effectively dead — the
|
||||
* classic case being a phone that dropped off WiFi without sending a TCP FIN.
|
||||
* Sending to such a socket would block the httpd task for up to
|
||||
* send_wait_timeout (30 s), wedging the server so a reconnect can't even load
|
||||
* the page. We skip + reclaim those instead. */
|
||||
static bool ws_sock_writable(int fd) {
|
||||
fd_set wfds;
|
||||
FD_ZERO(&wfds);
|
||||
FD_SET(fd, &wfds);
|
||||
struct timeval tv = { .tv_sec = 0, .tv_usec = 0 }; // poll, never block
|
||||
int r = select(fd + 1, NULL, &wfds, NULL, &tv);
|
||||
return r > 0 && FD_ISSET(fd, &wfds);
|
||||
}
|
||||
|
||||
/* Build the status JSON and push it to every connected WS client. Runs on the
|
||||
* httpd task (queued via httpd_queue_work) so all WS sends happen in the
|
||||
* server's own context. Dead/stuck sockets are reclaimed rather than sent to,
|
||||
* so one vanished client can't block status (or new connections) for everyone. */
|
||||
static void ws_broadcast_work(void *arg) {
|
||||
httpd_handle_t hd = http_server_instance;
|
||||
if (hd == NULL) return;
|
||||
|
||||
size_t fds = WS_MAX_CLIENTS;
|
||||
int client_fds[WS_MAX_CLIENTS];
|
||||
if (httpd_get_client_list(hd, &fds, client_fds) != ESP_OK) return;
|
||||
|
||||
/* Collect just the WS clients first so we skip building the (large) JSON
|
||||
* payload entirely when no browser is connected — zero heap churn idle. */
|
||||
int ws_fds[WS_MAX_CLIENTS];
|
||||
int n_ws = 0;
|
||||
for (size_t i = 0; i < fds; i++) {
|
||||
if (httpd_ws_get_fd_info(hd, client_fds[i]) == HTTPD_WS_CLIENT_WEBSOCKET)
|
||||
ws_fds[n_ws++] = client_fds[i];
|
||||
}
|
||||
if (n_ws == 0) return;
|
||||
|
||||
cJSON *root = comms_build_status(); /* does NOT reset shutdown timer */
|
||||
if (root == NULL) return;
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
if (json == NULL) return;
|
||||
|
||||
httpd_ws_frame_t frame = {
|
||||
.type = HTTPD_WS_TYPE_TEXT,
|
||||
.payload = (uint8_t *)json,
|
||||
.len = strlen(json),
|
||||
};
|
||||
int sent = 0;
|
||||
for (int i = 0; i < n_ws; i++) {
|
||||
int fd = ws_fds[i];
|
||||
if (!ws_sock_writable(fd)) {
|
||||
ESP_LOGW(TAG, "WS fd=%d not writable — reclaiming stale client", fd);
|
||||
httpd_sess_trigger_close(hd, fd);
|
||||
continue;
|
||||
}
|
||||
esp_err_t e = httpd_ws_send_frame_async(hd, fd, &frame);
|
||||
if (e != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WS send fd=%d failed (%s) — reclaiming", fd, esp_err_to_name(e));
|
||||
httpd_sess_trigger_close(hd, fd);
|
||||
} else {
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
free(json);
|
||||
|
||||
/* A live, connected browser keeps the device awake — same intent as the
|
||||
* old 2 s poll resetting the shutdown timer. Only count genuinely-reachable
|
||||
* clients so a pile of stale sockets can't pin the device awake. */
|
||||
if (sent > 0) rtc_reset_shutdown_timer();
|
||||
}
|
||||
|
||||
/* 1 Hz timer that queues a broadcast onto the httpd task. */
|
||||
static esp_timer_handle_t s_ws_push_timer = NULL;
|
||||
static void ws_push_timer_cb(void *arg) {
|
||||
if (server_running && http_server_instance)
|
||||
httpd_queue_work(http_server_instance, ws_broadcast_work, NULL);
|
||||
}
|
||||
static void ws_push_timer_start(void) {
|
||||
if (s_ws_push_timer == NULL) {
|
||||
esp_timer_create_args_t ta = { .callback = ws_push_timer_cb, .name = "ws_push" };
|
||||
if (esp_timer_create(&ta, &s_ws_push_timer) != ESP_OK) return;
|
||||
}
|
||||
esp_timer_start_periodic(s_ws_push_timer, 1000 * 1000); // 1 Hz
|
||||
}
|
||||
static void ws_push_timer_stop(void) {
|
||||
if (s_ws_push_timer) esp_timer_stop(s_ws_push_timer);
|
||||
}
|
||||
|
||||
/* WebSocket handler. The handshake arrives as HTTP_GET; subsequent frames
|
||||
* carry remote-control commands. */
|
||||
static esp_err_t ws_handler(httpd_req_t *req) {
|
||||
if (req->method == HTTP_GET) {
|
||||
int fd = httpd_req_to_sockfd(req);
|
||||
/* Bound how long a status push can block on this socket. httpd defaults
|
||||
* WS sockets to send_wait_timeout (30 s); if this client vanishes, a
|
||||
* push must not hang the shared httpd task that long. 2 s is ample for
|
||||
* a live client to drain ~1 KB. */
|
||||
struct timeval tv = { .tv_sec = 2, .tv_usec = 0 };
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
ESP_LOGI(TAG, "WS connect, fd=%d", fd);
|
||||
rtc_reset_shutdown_timer();
|
||||
return ESP_OK; // httpd completed the handshake
|
||||
}
|
||||
|
||||
/* First recv with max_len=0 fills in frame.len/type without copying. */
|
||||
httpd_ws_frame_t frame = { .type = HTTPD_WS_TYPE_TEXT };
|
||||
esp_err_t ret = httpd_ws_recv_frame(req, &frame, 0);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WS recv (len query) failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
ESP_LOGD(TAG, "WS frame type=%d len=%u fd=%d",
|
||||
frame.type, (unsigned)frame.len, httpd_req_to_sockfd(req));
|
||||
|
||||
if (frame.type == HTTPD_WS_TYPE_CLOSE) {
|
||||
stop_override(); // explicit close → halt remote motion
|
||||
return ESP_OK;
|
||||
}
|
||||
if (frame.type != HTTPD_WS_TYPE_TEXT || frame.len == 0) return ESP_OK;
|
||||
if (frame.len >= WS_RX_MAX) {
|
||||
ESP_LOGW(TAG, "WS frame too large (%u bytes), dropping", (unsigned)frame.len);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint8_t buf[WS_RX_MAX];
|
||||
frame.payload = buf;
|
||||
ret = httpd_ws_recv_frame(req, &frame, WS_RX_MAX);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WS recv (payload) failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
buf[frame.len] = '\0';
|
||||
ESP_LOGD(TAG, "WS cmd: %s", (char *)buf);
|
||||
|
||||
/* Route through the shared command dispatcher. Remote-control commands act
|
||||
* immediately (pulse_override); request/response commands like reboot just
|
||||
* set response flags we don't act on here — those stay on the HTTP path. */
|
||||
cJSON *root = cJSON_Parse((char *)buf);
|
||||
if (root) {
|
||||
cJSON *resp = NULL;
|
||||
comms_handle_post(root, &resp);
|
||||
cJSON_Delete(root);
|
||||
if (resp) cJSON_Delete(resp);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "WS cmd parse failed");
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Global socket-close callback. Fires for every socket the server closes; we
|
||||
* only act on WS sockets so a stray HTTP socket close can't stop an active
|
||||
* remote hold. Must close the socket ourselves (we replaced the default). */
|
||||
static void ws_close_fn(httpd_handle_t hd, int sockfd) {
|
||||
if (httpd_ws_get_fd_info(hd, sockfd) == HTTPD_WS_CLIENT_WEBSOCKET) {
|
||||
ESP_LOGI(TAG, "WS disconnect, fd=%d — stop_override()", sockfd);
|
||||
stop_override();
|
||||
}
|
||||
close(sockfd);
|
||||
}
|
||||
|
||||
static esp_err_t start_http_server(void) {
|
||||
if (server_running) return ESP_OK;
|
||||
ESP_LOGI(TAG, "STARTING HTTP");
|
||||
@@ -843,7 +1044,8 @@ static esp_err_t start_http_server(void) {
|
||||
config.recv_wait_timeout = 10; // seconds (default 5)
|
||||
config.send_wait_timeout = 30; // seconds (default 5) — log download needs headroom in STA mode
|
||||
config.uri_match_fn = httpd_uri_match_wildcard; // enable wildcarding
|
||||
|
||||
config.close_fn = ws_close_fn; // halt remote motion on any WS disconnect
|
||||
|
||||
esp_err_t err = httpd_start(&http_server_instance, &config);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start HTTP server: %s", esp_err_to_name(err));
|
||||
@@ -865,7 +1067,9 @@ static esp_err_t start_http_server(void) {
|
||||
}
|
||||
|
||||
server_running = true;
|
||||
|
||||
|
||||
ws_push_timer_start(); // begin 1 Hz status push to WS clients
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -876,7 +1080,9 @@ static esp_err_t stop_http_server(void) {
|
||||
ESP_LOGW(TAG, "HTTP server not running");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
|
||||
ws_push_timer_stop();
|
||||
|
||||
esp_err_t err = httpd_stop(http_server_instance);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to stop HTTP server: %s", esp_err_to_name(err));
|
||||
@@ -909,7 +1115,7 @@ static void wifi_event_handler(void* arg, esp_event_base_t event_base,
|
||||
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
wifi_event_ap_staconnected_t* event = (wifi_event_ap_staconnected_t*) event_data;
|
||||
ESP_LOGI(TAG, "Station connected, AID=%d", event->aid);
|
||||
rtc_reset_shutdown_timer();
|
||||
rtc_reset_shutdown_timer(); // also wakes from soft idle if needed
|
||||
n_connected++;
|
||||
/* HTTP lifecycle is no longer tied to client count — the server
|
||||
* is started once in webserver_init() and stays up. Tying
|
||||
@@ -1080,6 +1286,18 @@ esp_err_t webserver_stop(void) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Soft-idle entry: stop HTTP server but leave WiFi AP running so clients
|
||||
* can still associate and trigger auto-wake via WIFI_EVENT_AP_STACONNECTED. */
|
||||
esp_err_t webserver_sleep(void) {
|
||||
stop_http_server();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Soft-idle exit: restart HTTP server (WiFi AP is already up). */
|
||||
esp_err_t webserver_wake(void) {
|
||||
return start_http_server();
|
||||
}
|
||||
|
||||
esp_err_t webserver_restart_wifi(void) {
|
||||
ESP_LOGI(TAG, "Restarting WiFi with updated params...");
|
||||
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
esp_err_t webserver_init(void);
|
||||
esp_err_t webserver_restart_wifi(void); // Reconfigure and restart AP with current params
|
||||
esp_err_t webserver_stop(void); // Stop HTTP server and WiFi (for soft idle)
|
||||
esp_err_t webserver_stop(void); // Stop HTTP server AND WiFi (hibernate/shutdown only)
|
||||
esp_err_t webserver_sleep(void); // Stop HTTP server, keep WiFi AP up (soft idle)
|
||||
esp_err_t webserver_wake(void); // Restart HTTP server after webserver_sleep()
|
||||
@@ -1000,7 +1000,7 @@ CONFIG_HTTPD_MAX_URI_LEN=512
|
||||
CONFIG_HTTPD_ERR_RESP_NO_DELAY=y
|
||||
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
||||
# CONFIG_HTTPD_LOG_PURGE_DATA is not set
|
||||
# CONFIG_HTTPD_WS_SUPPORT is not set
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
||||
# end of HTTP Server
|
||||
|
||||
|
||||
@@ -14,3 +14,10 @@ CONFIG_RTC_CLK_SRC_INT_RC=y
|
||||
# WDT timeout triggers a panic (→ reboot + core dump) instead of just logging.
|
||||
# Required for OTA rollback: a hung task causes a reboot, incrementing the reset counter.
|
||||
CONFIG_ESP_TASK_WDT_PANIC=y
|
||||
|
||||
# --- Web server ---
|
||||
|
||||
# Enable WebSocket support in esp_http_server. The web UI opens a /ws socket for
|
||||
# low-latency remote-control commands (with stop-on-disconnect safety) and a 1 Hz
|
||||
# server-pushed status feed, replacing the old 2 s HTTP polling.
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
|
||||
Reference in New Issue
Block a user