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:
@@ -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()
|
||||
Reference in New Issue
Block a user