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:
Thaddeus Hughes
2026-06-24 17:51:05 -05:00
parent 46f9bada4f
commit 1846fa7b36
20 changed files with 2206 additions and 2325 deletions

View File

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

View File

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

View File

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

View File

@@ -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");

View File

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

View File

@@ -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();

View File

@@ -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. */

View File

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

View File

@@ -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 */

View File

@@ -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];
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -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...");

View File

@@ -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()

View File

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

View File

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