i think we're basically done

This commit is contained in:
Thaddeus Hughes
2026-04-27 17:22:34 -05:00
parent 9f4362b5fd
commit f47a29205e
35 changed files with 14893 additions and 1687 deletions

View File

@@ -80,15 +80,29 @@ static float elapsed_s(void)
/* -------- output helpers -------- */
/* Build the line in a stack buffer and emit with a single write so concurrent
* ESP_LOGx output (notably the wifi driver during BU.WIFI.START) cannot slice
* into the middle of it. Leading '\n' protects against partial lines that
* another task may have written without a terminator. */
__attribute__((format(printf, 2, 3)))
static void emit(const char *kind, const char *fmt, ...)
{
printf("BU.%s ", kind);
char buf[256];
/* Reserve one byte at the end for the trailing '\n' so a long line is
* truncated within the body rather than dropping the newline. Without
* this, a body that filled the buffer would produce a line glued to
* whatever came next on the wire. */
const int cap = (int)sizeof(buf) - 1; // room for '\n'
int n = snprintf(buf, cap, "\nBU.%s ", kind);
if (n < 0) n = 0;
if (n > cap) n = cap;
va_list ap;
va_start(ap, fmt);
vprintf(fmt, ap);
int m = vsnprintf(buf + n, cap - n, fmt, ap);
va_end(ap);
printf("\n");
if (m > 0) n += (m < cap - n) ? m : cap - n;
buf[n++] = '\n';
fwrite(buf, 1, n, stdout);
fflush(stdout);
}
@@ -295,7 +309,12 @@ static void cmd_led_watch(char *args)
static void cmd_adc_once(void)
{
int bat_mv = get_bat_raw_mv();
float bat_V = get_battery_V();
/* Bypass the EMA — process_battery_voltage() runs in the FSM task,
* which is paused while bring-up is active, so get_battery_V() returns
* a stale value that never reflects V_SENS_K / V_SENS_OFFSET writes
* issued during calibration. Compute fresh from raw mV + current params. */
float bat_V = bat_mv * get_param_value_t(PARAM_V_SENS_K).f32
+ get_param_value_t(PARAM_V_SENS_OFFSET).f32;
#ifdef BOARD_V5
/* VOC and FAULT pins are unusable on V5 (input-only ESP32 GPIOs
* without external pulls — see README "V5 hardware caveats"); skip. */
@@ -345,6 +364,9 @@ static void cmd_sensors_watch(char *args)
* on UART0 (operator hit Enter on the host side).
* sec > 0 → watch for that many seconds, then return.
*/
/* Force the sensor rail (P10) up before we observe — covers cases where
* the FSM or sensor task drove it low between boot and BU.BEGIN. */
i2c_relays_idle();
char *s = args;
char *t = next_tok(&s);
int sec = t ? atoi(t) : 0;
@@ -462,7 +484,12 @@ static void cmd_relay(char *args)
if (ms < 10) ms = 10;
if (ms > 2000) ms = 2000;
if (!get_is_safe()) { SKIP("relay reason=\"safety open\""); return; }
/* Read SAFETY directly: sensors_check() runs in the FSM task, which is
* paused while bring-up is active, so is_safe / get_is_safe() are stale.
* Safety pin is active-LOW. */
extern uint8_t sensor_pins[N_SENSORS];
#define _BU_SAFETY_OPEN() (gpio_get_level(sensor_pins[SENSOR_SAFETY]) != 0)
if (_BU_SAFETY_OPEN()) { SKIP("relay reason=\"safety open\""); return; }
/* P10 / sensor power rail. Default is ON; pulse it OFF to prove the line
* can be driven, then restore. */
@@ -511,11 +538,44 @@ static void cmd_relay(char *args)
last_relay_state = rs;
i2c_set_relays(rs);
vTaskDelay(pdMS_TO_TICKS(ms / 2));
fsm_now = esp_timer_get_time();
process_bridge_current(b);
float I_mid = get_bridge_A(b);
vTaskDelay(pdMS_TO_TICKS(ms - ms / 2));
/* JACK DOWN should stop as soon as the JACK sensor goes active (LOW) so
* the bring-up pulse can't drive the actuator into its mechanical limit.
* Other directions/bridges run for the full requested duration. SAFETY
* is checked every iteration regardless of bridge — multi-second pulses
* during bring-up must still kill the motor on a safety break. */
bool jack_down = (b == BRIDGE_JACK && dir == BRIDGE_REV);
bool stopped_by_sensor = false;
bool stopped_by_safety = false;
int64_t pulse_start_us = esp_timer_get_time();
int64_t mid_us = pulse_start_us + (int64_t)(ms / 2) * 1000;
int64_t end_us = pulse_start_us + (int64_t)ms * 1000;
float I_mid = NAN;
while (esp_timer_get_time() < end_us) {
if (_BU_SAFETY_OPEN()) {
stopped_by_safety = true;
break;
}
if (jack_down && gpio_get_level(sensor_pins[SENSOR_JACK]) == 0) {
stopped_by_sensor = true;
break;
}
if (isnan(I_mid) && esp_timer_get_time() >= mid_us) {
fsm_now = esp_timer_get_time();
process_bridge_current(b);
I_mid = get_bridge_A(b);
}
esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(10));
}
if (isnan(I_mid)) {
/* Sensor tripped before we hit the midpoint; sample current now so
* the response still has a meaningful I_mid. */
fsm_now = esp_timer_get_time();
process_bridge_current(b);
I_mid = get_bridge_A(b);
}
relay_port_t idle = {.bridges = {.SENSORS = 1}};
last_relay_state = idle;
@@ -530,8 +590,15 @@ static void cmd_relay(char *args)
? get_sensor_isr_edges(which_sensor) : 0;
uint32_t edges = edges_after - edges_before;
OK("relay bridge=%s dir=%s ms=%d I_before=%+.2f I_mid=%+.2f I_after=%+.2f heat=%.3f tripped=%d edges=%u",
t_bridge, t_dir, ms, I_before, I_mid, I_after, heat, tripped, (unsigned)edges);
int actual_ms = (int)((esp_timer_get_time() - pulse_start_us) / 1000);
const char *stop_reason =
stopped_by_safety ? "safety" :
stopped_by_sensor ? "sensor" : "time";
OK("relay bridge=%s dir=%s ms=%d actual_ms=%d stop=%s "
"I_before=%+.2f I_mid=%+.2f I_after=%+.2f heat=%.3f tripped=%d edges=%u",
t_bridge, t_dir, ms, actual_ms, stop_reason,
I_before, I_mid, I_after, heat, tripped, (unsigned)edges);
#undef _BU_SAFETY_OPEN
}
static void cmd_rf_watch(char *args)
@@ -588,7 +655,24 @@ static void cmd_wifi_wait(char *args)
(void)args; /* no timeout — BRINGUP.md §4 Stage 6. Operator aborts via Ctrl+C. */
wifi_sta_list_t sta = {0};
int last_n = 0;
bool aborted = false;
while (1) {
/* Abort on any UART input — the host sends a stray byte to break out
* of the wait so that a follow-up BU.END is actually dispatched
* (otherwise the dispatcher stays blocked here forever). */
size_t available = 0;
if (uart_get_buffered_data_len(UART_NUM_0, &available) == ESP_OK
&& available > 0) {
uint8_t drain[64];
while (available > 0) {
int n = uart_read_bytes(UART_NUM_0, drain, sizeof(drain), 0);
if (n <= 0) break;
available = (size_t)n < available ? available - (size_t)n : 0;
}
aborted = true;
break;
}
if (esp_wifi_ap_get_sta_list(&sta) == ESP_OK && sta.num > last_n) {
EVT("wifi.assoc n=%d t=%.2f", sta.num, elapsed_s());
last_n = sta.num;
@@ -599,7 +683,8 @@ static void cmd_wifi_wait(char *args)
esp_task_wdt_reset();
vTaskDelay(pdMS_TO_TICKS(200));
}
OK("wifi.wait clients=%d http_reqs=%d", last_n, s_http_reqs);
OK("wifi.wait clients=%d http_reqs=%d aborted=%d",
last_n, s_http_reqs, aborted ? 1 : 0);
}
static void cmd_fsm(char *args)
@@ -652,6 +737,11 @@ static void cmd_param(char *args)
}
if (strcmp(op, "SET") == 0) {
/* SET writes flash. Require BU.BEGIN to prevent accidental persistence
* from stray BU.PARAM lines outside of an active bring-up session. */
if (!s_active) {
ERR("param reason=\"BU.BEGIN required first\""); return;
}
char *val = next_tok(&s);
if (!val) { ERR("param reason=\"missing value\""); return; }
esp_err_t e = ESP_OK;
@@ -673,6 +763,12 @@ static void cmd_param(char *args)
if (e != ESP_OK) { ERR("param reason=\"set failed\" err=%s", esp_err_to_name(e)); return; }
e = commit_params();
if (e != ESP_OK) { ERR("param reason=\"commit failed\" err=%s", esp_err_to_name(e)); return; }
/* If the conversion params changed, refresh the battery EMA so
* get_battery_V() returns a value consistent with the new K/OFFSET
* immediately rather than decaying through the EMA. */
if (idx == PARAM_V_SENS_K || idx == PARAM_V_SENS_OFFSET) {
reset_battery_ema();
}
OK("param key=%s set=ok committed=yes", key);
return;
}
@@ -680,6 +776,33 @@ static void cmd_param(char *args)
ERR("param reason=\"unknown op\" op=%s", op);
}
/* BU.FACTORY_RESET — wipe all params back to defaults, erase log partition,
* then reboot. Equivalent to the cold-boot button-hold path in main.c, but
* reachable from a host without physical access. Destructive — operator
* must explicitly invoke it. */
static void cmd_factory_reset(char *args)
{
(void)args;
/* Refuse without an explicit BU.BEGIN. Without this guard, any party
* that can write to UART0 can wipe params/log just by sending the
* command — and uart_comms.c forwards bare BU.* lines to the dispatcher
* even when bring-up mode is off. */
if (!s_active) {
ERR("factory_reset reason=\"BU.BEGIN required first\"");
return;
}
OK("factory_reset stage=start");
esp_err_t e = factory_reset();
if (e != ESP_OK) {
ERR("factory_reset stage=apply err=%s", esp_err_to_name(e));
return;
}
OK("factory_reset stage=done reboot=2s");
fflush(stdout);
vTaskDelay(pdMS_TO_TICKS(2000));
esp_restart();
}
/* -------- dispatcher -------- */
typedef void (*cmd_fn)(char *args);
@@ -707,6 +830,7 @@ static const struct cmd_entry CMDS[] = {
{ "FSM", cmd_fsm },
{ "SOLAR.TICK", cmd_solar_tick },
{ "PARAM", cmd_param },
{ "FACTORY_RESET", cmd_factory_reset},
};
void bringup_handle_line(char *line)

View File

@@ -168,6 +168,7 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
bool reboot_requested = false;
bool wifi_params_changed = false;
bool wifi_restart_requested = false;
bool refresh_battery_ema = false;
const char *error_msg = NULL;
int params_updated = 0;
int params_failed = 0;
@@ -183,8 +184,8 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
// Process remaining_dist if present
cJSON *remaining_dist = cJSON_GetObjectItem(root, "remaining_dist");
if (cJSON_IsNumber(remaining_dist)) {
int64_t new_dist = (int64_t)cJSON_GetNumberValue(remaining_dist);
ESP_LOGI(TAG, "Setting remaining_dist to %lld", new_dist);
float new_dist = (float)cJSON_GetNumberValue(remaining_dist);
ESP_LOGI(TAG, "Setting remaining_dist to %.3f", new_dist);
fsm_set_remaining_distance(new_dist);
}
@@ -362,6 +363,12 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
param_idx == PARAM_NET_PASS) {
wifi_params_changed = true;
}
/* If the battery conversion params change, refresh the EMA
* so get_battery_V() reflects the new K/OFFSET immediately. */
if (param_idx == PARAM_V_SENS_K || param_idx == PARAM_V_SENS_OFFSET) {
refresh_battery_ema = true;
}
cJSON *value_json = cJSON_GetObjectItem(parameters, key);
@@ -440,6 +447,9 @@ esp_err_t comms_handle_post(cJSON *root, cJSON **response_json) {
if (params_updated > 0) {
rtc_schedule_next_alarm();
commit_params();
if (refresh_battery_ema) {
reset_battery_ema();
}
if (wifi_params_changed) {
ESP_LOGI(TAG, "WiFi params changed — restarting WiFi AP");
wifi_restart_requested = true;

View File

@@ -40,8 +40,14 @@ esp_err_t fsm_get_error() { return fsm_error; }
void fsm_clear_error() { fsm_error = ESP_OK; }
/* override_time + override_cmd are written from RF/BT/comms tasks and read
* from the control task. int64_t isn't atomic on a 32-bit MCU, so we wrap
* read/write in a critical section to prevent torn reads (which could land
* override_time far in the future and run a motor for seconds longer than
* RF_PULSE_LENGTH). */
static portMUX_TYPE override_spin = portMUX_INITIALIZER_UNLOCKED;
int64_t override_time = -1;
fsm_override_t override_cmd;
fsm_override_t override_cmd = FSM_OVERRIDE_DRIVE_FWD;
bool enabled = false;
float this_move_dist = 0.0f;
@@ -82,11 +88,22 @@ void pulse_override(fsm_override_t cmd) {
if (soft_idle_is_active()) return;
if (current_state == STATE_IDLE) {
rtc_reset_shutdown_timer();
int64_t deadline = fsm_now + (int64_t)get_param_value_t(PARAM_RF_PULSE_LENGTH).u32;
portENTER_CRITICAL(&override_spin);
override_cmd = cmd;
override_time = fsm_now + get_param_value_t(PARAM_RF_PULSE_LENGTH).u32;
override_time = deadline;
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);
*time_out = override_time;
*cmd_out = override_cmd;
portEXIT_CRITICAL(&override_spin);
}
int64_t fsm_cal_t, fsm_cal_e;
int64_t fsm_get_cal_t(){return fsm_cal_t;}
int64_t fsm_get_cal_e(){return fsm_cal_e;}
@@ -130,49 +147,69 @@ 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_DOWN_TIME (jack_finish_us - jack_start_us) * 105/100
/* 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,
* or a transition that skipped the normal path) the delta is unsafe — fall
* back to the parameter-derived JACK_TIME as a floor so we don't either
* (a) cut the jack-down to ~0 and leave the actuator extended, or (b) run
* forever. */
static inline int64_t _jack_down_time_us(void) {
int64_t delta = jack_finish_us - jack_start_us;
int64_t floor_us = (int64_t)JACK_TIME;
if (delta < floor_us) delta = floor_us;
return delta * 105 / 100;
}
#define JACK_DOWN_TIME _jack_down_time_us()
#define DRIVE_TIME get_param_value_t(PARAM_DRIVE_KT).f32 * this_move_dist
#define DRIVE_DIST get_param_value_t(PARAM_DRIVE_KE).f32 * this_move_dist
int64_t last_log_time = 0;
#define LOGSIZE 39
/* FSM log payload (single current channel — V5 has one shared ACS sensor; V4
* had three but the per-bridge values are redundant since only one bridge is
* active at a time). Layout:
* [0:8] ts_ms u64
* [8:12] bat_V f32
* [12:16] current_A f32 — sum of bridge currents (mutually exclusive)
* [16:18] counter i16
* [18:19] sensors u8
* [19:23] heat f32 — max bridge heat
* [23:25] i2c_out u16 — last 16-bit TCA9555 output state
* (high byte = OUTPUT0 / LEDs, low = OUTPUT1 / relays) */
#define LOGSIZE 25
esp_err_t send_fsm_log() {
if(!rtc_is_set()) return ESP_OK;
uint8_t entry[LOGSIZE] = {};
// Pack 64-bit timestamp into bytes 1-8
uint64_t be_timestamp = rtc_get_ms();
memcpy(&entry[0], &be_timestamp, 8);
// Pack 32-bit voltages/currents into bytes 9-24
float be_voltage = get_battery_V();
memcpy(&entry[8], &be_voltage, 4);
float be_current1 = get_bridge_raw_A(BRIDGE_DRIVE);
memcpy(&entry[12], &be_current1, 4);
float be_current2 = get_bridge_raw_A(BRIDGE_JACK);
memcpy(&entry[16], &be_current2, 4);
float be_current3 = get_bridge_raw_A(BRIDGE_AUX);
memcpy(&entry[20], &be_current3, 4);
float current_A = get_bridge_raw_A(BRIDGE_DRIVE)
+ get_bridge_raw_A(BRIDGE_JACK)
+ get_bridge_raw_A(BRIDGE_AUX);
memcpy(&entry[12], &current_A, 4);
int16_t be_counter = get_sensor_counter(SENSOR_DRIVE);
memcpy(&entry[24], &be_counter, 2);
entry[26] = pack_sensors();
float heat1 = efuse_get_heat(BRIDGE_DRIVE);
memcpy(&entry[27], &heat1, 4);
float heat2 = efuse_get_heat(BRIDGE_JACK);
memcpy(&entry[31], &heat2, 4);
float heat3 = efuse_get_heat(BRIDGE_AUX);
memcpy(&entry[35], &heat3, 4);
memcpy(&entry[16], &be_counter, 2);
entry[18] = pack_sensors();
float heat = efuse_get_heat(BRIDGE_DRIVE);
float h2 = efuse_get_heat(BRIDGE_JACK);
float h3 = efuse_get_heat(BRIDGE_AUX);
if (h2 > heat) heat = h2;
if (h3 > heat) heat = h3;
memcpy(&entry[19], &heat, 4);
uint16_t i2c_out = i2c_get_outputs();
memcpy(&entry[23], &i2c_out, 2);
last_log_time = esp_timer_get_time();
log_write(entry, LOGSIZE, fsm_get_state());
//ESP_LOGI(TAG, "WROTE LOG; %lld / %ld/%ld; %5.2f %5.2f %5.2f", (long long)rtc_get_ms(), (unsigned long)log_get_tail(), (unsigned long)log_get_head(), heat1, heat2, heat3);
@@ -257,6 +294,11 @@ void control_task(void *param) {
ESP_LOGI(TAG, "STARTING");
fsm_error = ESP_OK; // if everything is OK now, we're OK.
/* Zero jack timestamps so JACK_DOWN_TIME on this cycle
* never inherits a stale value from a prior run. */
jack_start_us = 0;
jack_trans_us = 0;
jack_finish_us = 0;
current_state = STATE_MOVE_START_DELAY;
log = true;
set_timer(TRANSITION_DELAY_US);
@@ -454,12 +496,25 @@ void control_task(void *param) {
current_state = STATE_UNDO_JACK_START;
set_timer(JACK_DOWN_TIME);
log = true;
} else {
} else if (efuse_get(BRIDGE_DRIVE)) {
// Fault — deduct actual distance traveled (may be partial).
// Checked before the normal-completion branch so a tick
// that satisfies both conditions doesn't double-deduct
// remaining_distance.
int32_t current_encoder = get_sensor_counter(SENSOR_DRIVE);
int32_t ticks_traveled = current_encoder - move_start_encoder;
float ke = get_param_value_t(PARAM_DRIVE_KE).f32;
float distance_traveled = ticks_traveled / ke;
remaining_distance -= distance_traveled;
if (remaining_distance < 0.0f) remaining_distance = 0.0f;
fsm_error = SC_ERR_EFUSE_TRIP_1;
current_state = STATE_UNDO_JACK_START;
set_timer(JACK_DOWN_TIME);
log = true;
} else {
int32_t current_encoder = get_sensor_counter(SENSOR_DRIVE);
if (timer_done() || current_encoder > 0) {
// Normal completion — deduct planned distance from leash
remaining_distance -= this_move_dist;
@@ -468,28 +523,23 @@ void control_task(void *param) {
log = true;
set_timer(TRANSITION_DELAY_US);
}
if (efuse_get(BRIDGE_DRIVE)) {
// Fault — deduct actual distance traveled (may be partial)
remaining_distance -= distance_traveled;
if (remaining_distance < 0.0f) remaining_distance = 0.0f;
fsm_error = SC_ERR_EFUSE_TRIP_1;
current_state = STATE_UNDO_JACK_START;
set_timer(JACK_DOWN_TIME);
log = true;
}
}
break;
case STATE_DRIVE_END_DELAY:
// 1s pause after drive — then lower jack
// 1s pause after drive — then lower jack normally.
// Goes straight to STATE_JACK_DOWN so the LED/comms message
// reads "MOVING…" rather than "CANCELLING MOVE" on a normal
// cycle. STATE_UNDO_JACK_START remains the path for explicit
// undo / safety-break / efuse-trip recovery.
if (!get_is_safe()) {
fsm_error = SC_ERR_SAFETY_TRIP;
current_state = STATE_UNDO_JACK_START;
set_timer(JACK_DOWN_TIME);
log = true;
} else if (timer_done()) {
current_state = STATE_UNDO_JACK_START;
current_state = STATE_JACK_DOWN;
set_timer(JACK_DOWN_TIME);
log = true;
}
break;
@@ -551,10 +601,14 @@ void control_task(void *param) {
/**** SET OUTPUTS ****/
switch (current_state) {
case STATE_IDLE:
// In idle we still accept override commands
if (override_time > fsm_now) {
switch(override_cmd) {
case STATE_IDLE: {
// In idle we still accept override commands. Snapshot both fields
// atomically to defend against the int64 torn read on writers.
int64_t local_time;
fsm_override_t local_cmd;
override_snapshot(&local_time, &local_cmd);
if (local_time > fsm_now) {
switch(local_cmd) {
case FSM_OVERRIDE_DRIVE_FWD:
if (efuse_get(BRIDGE_DRIVE)){
drive_relays((relay_port_t){.bridges = {
@@ -653,6 +707,7 @@ void control_task(void *param) {
}});
}
break;
} /* close STATE_IDLE block scope */
case STATE_CALIBRATE_JACK_MOVE:
case STATE_JACK_UP_START:
case STATE_JACK_UP:

View File

@@ -12,6 +12,11 @@
static bool i2c_initted = false;
//static bool safety_ok = false; // Safety interlock
static uint8_t last_relay_request = 0; // Track last relay request
/* Cached last-written values for the two TCA9555 output ports. Used by
* i2c_get_outputs() so the FSM log can record the full 16-bit output state
* without paying for an extra I2C read each tick. */
static uint8_t last_output0 = 0;
static uint8_t last_output1 = 0;
// === I2C LOW-LEVEL ===
static esp_err_t tca_write_word_8(uint8_t reg, uint8_t value) {
@@ -65,6 +70,7 @@ esp_err_t i2c_post(void) {
}
esp_err_t i2c_set_relays(relay_port_t states) {
last_output1 = states.raw;
return tca_write_word_8(TCA_REG_OUTPUT1, states.raw);
}
@@ -77,12 +83,27 @@ esp_err_t i2c_relays_sleep(void) {
}
esp_err_t i2c_set_led1(uint8_t state) {
// push 3 LSB to top
return tca_write_word_8(TCA_REG_OUTPUT0, state<<5);
/* P05-P07 are LEDs (outputs); P00-P04 are buttons / unused INPUTS
* (CONFIG0 = 0b00000011 sets P00/P01 as inputs; P02-P04 are unused
* but also configured as inputs). Writing the whole OUTPUT0 register
* is therefore safe — the input-bit slots in OUTPUT0 are don't-cares
* because the pin direction prevents the value from driving the line.
* If P02-P04 ever become outputs, switch this to read-modify-write. */
uint8_t v = state << 5;
last_output0 = v;
return tca_write_word_8(TCA_REG_OUTPUT0, v);
}
uint16_t i2c_get_outputs(void) {
/* OUTPUT0 in the high byte (P00..P07), OUTPUT1 in the low byte
* (P10..P17). Reflects the last value written by this driver. */
return ((uint16_t)last_output0 << 8) | last_output1;
}
esp_err_t i2c_stop() {
if (!i2c_initted) return ESP_OK;
last_output0 = 0;
last_output1 = 0;
tca_write_word_8(TCA_REG_OUTPUT0, 0);
tca_write_word_8(TCA_REG_OUTPUT1, 0);
return ESP_OK;
@@ -147,25 +168,26 @@ bool i2c_get_button_repeat(uint8_t btn) {
}
int8_t i2c_get_button_repeats(uint8_t btn) {
if (!i2c_get_button_state(btn))
return 0;
if (btn >= N_BTNS || !debounced_state[btn]) return false;
/* Returns -1 on out-of-range button index (was previously `false` = 0,
* which conflated error with "no repeat"). 0 means button not pressed
* or no new repeat this poll. >=1 is a valid repeat count. */
if (btn >= N_BTNS) return -1;
if (!i2c_get_button_state(btn)) return 0;
uint64_t now = esp_timer_get_time() / 1000;
if (now + DEBOUNCE_MS < last_change_time[btn]) return false;
if (now + DEBOUNCE_MS < last_change_time[btn]) return 0;
if ((now - last_change_time[btn]) > (REPEAT_START_MS + REPEAT_MS * claimed_repeats[btn])) {
claimed_repeats[btn]++;
if (claimed_repeats[btn] > 100)
claimed_repeats[btn] = 100;
ESP_LOGI("BTN", "RPT %d", (uint8_t)claimed_repeats[btn]+2);
ESP_LOGI("BTN", "RPT %d", (uint8_t)(claimed_repeats[btn]+1));
return claimed_repeats[btn]+1;
}
if (debounced_state[btn] && !last_known_state[btn]) {
ESP_LOGI("BTN", "FST %d", 1);
return 1;
}
return 0;
}

View File

@@ -55,6 +55,11 @@ esp_err_t i2c_stop(void);
esp_err_t i2c_set_relays(relay_port_t states);
esp_err_t i2c_set_led1(uint8_t state);
/* Returns the last-written 16-bit TCA9555 output state.
* High byte: OUTPUT0 (P00..P07, LEDs in P05..P07).
* Low byte: OUTPUT1 (P10..P17, relay_port_t.raw). */
uint16_t i2c_get_outputs(void);
/* Normal run state: all bridges off, but P10 (sensor rail) held high.
* Use whenever "everything off" is really "all motors off, system is still on". */
esp_err_t i2c_relays_idle(void);

View File

@@ -150,7 +150,8 @@ void drive_leds(led_mode_t mode) {
}
}
void app_main(void) {esp_task_wdt_add(NULL);
void app_main(void) {
esp_task_wdt_add(NULL);
ESP_LOGI(TAG, "Firmware: %s", FIRMWARE_STRING);
ESP_LOGI(TAG, "Version: %s", FIRMWARE_VERSION);

View File

@@ -143,16 +143,11 @@ esp_err_t drive_relays(relay_port_t relay_state) {
BRIDGE_TRANSITION_LOGIC(JACK)
BRIDGE_TRANSITION_LOGIC(AUX)
/* Sensor rail (P10) powered only when FSM is actively doing something.
* In STATE_IDLE we cut it to save power. Overrides energize a motor
* while FSM is technically IDLE — keep the rail up in that case too
* so overrides that consult sensor state (e.g. JACK_DOWN, which checks
* SENSOR_JACK) still work. */
bool motor_requested = (relay_state.bridges.DRIVE != BRIDGE_OFF ||
relay_state.bridges.JACK != BRIDGE_OFF ||
relay_state.bridges.AUX != BRIDGE_OFF);
relay_state.bridges.SENSORS =
(fsm_get_state() != STATE_IDLE || motor_requested) ? 1 : 0;
/* Sensor rail (P10) is on whenever the device is awake — including
* STATE_IDLE — so the SAFETY input can be observed continuously.
* It is dropped only in soft_idle_enter() (sleep) via i2c_relays_sleep,
* and toggled explicitly by the bring-up tool's BU.RELAY SENSORS cmd. */
relay_state.bridges.SENSORS = 1;
if (!get_is_safe())
relay_state.bridges.DRIVE = 0;
@@ -287,6 +282,20 @@ float get_raw_battery_voltage(void) {
return voltage_mv * get_param_value_t(PARAM_V_SENS_K).f32 + get_param_value_t(PARAM_V_SENS_OFFSET).f32; // same as / 1000.0 * 1150.0 / 150.0;
}
void reset_battery_ema(void)
{
/* Next process_battery_voltage() call re-seeds from raw. Also refresh
* immediately from a fresh raw read so callers that read get_battery_V()
* before the FSM ticks again (e.g. during bringup) see the new value. */
float raw = get_raw_battery_voltage();
if (!isnan(raw)) {
ema_battery = raw;
ema_battery_init = true;
} else {
ema_battery_init = false;
}
}
esp_err_t process_battery_voltage(void)
{
float raw = get_raw_battery_voltage();
@@ -364,6 +373,20 @@ static bool v5_any_bridge_active(void) {
v5_bridge_is_active(BRIDGE_JACK) ||
v5_bridge_is_active(BRIDGE_AUX);
}
/* True if any currently-active bridge is still inside its INRUSH_US window.
* The shared ACS reading is unattributable per-bridge during a co-active
* inrush — the full combined current is attributed to each active bridge in
* process_bridge_current(), so a quieter bridge (e.g. AUX during DRIVE start)
* sees an inflated I_norm and would spuriously instant-trip on KINST. */
static bool v5_any_bridge_in_inrush(void) {
int64_t inrush_us = (int64_t)get_param_value_t(PARAM_EFUSE_INRUSH_US).u32;
for (bridge_t b = 0; b < N_BRIDGES; b++) {
if (!v5_bridge_is_active(b)) continue;
if (fsm_now < isens[b].on_us + inrush_us) return true;
}
return false;
}
#endif
esp_err_t process_bridge_current(bridge_t bridge) {
@@ -440,10 +463,11 @@ esp_err_t process_bridge_current(bridge_t bridge) {
} else {
float alpha = get_param_value_t(PARAM_ADC_ALPHA_ISENS).f32;
if (isnan(channel->raw_current)) {
//ESP_LOGI(TAG, "RAW BATTERY IS NAN");
channel->ema_current = NAN;
} else {
if (isnan(ema_battery) || isnan(alpha)) {
/* Reset the per-bridge EMA if it (or alpha) is NaN — using
* the per-bridge value, not the unrelated battery EMA. */
if (isnan(channel->ema_current) || isnan(alpha)) {
channel->ema_current = channel->raw_current;
} else {
channel->ema_current = alpha * channel->raw_current + (1.0f - alpha) * channel->ema_current;
@@ -470,9 +494,10 @@ esp_err_t process_bridge_current(bridge_t bridge) {
} else {
float alpha = get_param_value_t(PARAM_ADC_ALPHA_IAZ).f32;
if (isnan(channel->raw_current)) {
//ESP_LOGI(TAG, "RAW BATTERY IS NAN");
/* skip — no fresh sample */
} else {
if (isnan(ema_battery) || isnan(alpha)) {
/* Reset the autozero offset if it (or alpha) is NaN. */
if (isnan(channel->az_offset) || isnan(alpha)) {
channel->az_offset = channel->ema_current;
} else {
channel->az_offset = alpha * channel->ema_current +
@@ -512,9 +537,17 @@ esp_err_t process_bridge_current(bridge_t bridge) {
// Normalize the current as a fraction of rated current
float I_norm = fabsf(channel->current / I_nominal);
// Instant trip on extreme overcurrent
// Instant trip on extreme overcurrent. On V5, also require that no
// *other* active bridge is still in its inrush window — during a
// co-active inrush the shared ACS reading is attributed to each
// active bridge, which inflates the quieter bridge's I_norm and
// would otherwise cause a spurious instant-trip there.
if (fsm_now > channel->on_us + get_param_value_t(PARAM_EFUSE_INRUSH_US).u32
&& I_norm >= get_param_value_t(PARAM_EFUSE_KINST).f32) {
&& I_norm >= get_param_value_t(PARAM_EFUSE_KINST).f32
#ifdef BOARD_V5
&& !v5_any_bridge_in_inrush()
#endif
) {
// Check if overcurrent has persisted long enough
channel->tripped = true;
channel->trip_time = fsm_now;

View File

@@ -46,6 +46,13 @@ bool get_bridge_spike(bridge_t bridge, float threshold);
esp_err_t process_bridge_current(bridge_t bridge);
esp_err_t process_battery_voltage();
/* Force the battery EMA to re-seed from the next raw read. Call after a
* V_SENS_K / V_SENS_OFFSET change so get_battery_V() reflects the new
* calibration immediately instead of decaying through the EMA — which
* otherwise stays stale across bringup mode (FSM is paused, so
* process_battery_voltage doesn't tick) until alpha catches up. */
void reset_battery_ema(void);
esp_err_t adc_init();
esp_err_t adc_post(void);
esp_err_t power_init();

View File

@@ -37,8 +37,13 @@ typedef struct {
size_t num_symbols;
} rf_code_t;
int learn_flag = -1;
bool controls_enabled = true;
/* These are written by the comms task (HTTP/UART) and read by the RF
* receiver task. `volatile` forces the RF task to re-read each iteration
* rather than caching in a register; on a dual-core ESP32 the writer's
* memory is also flushed by the FreeRTOS task switch / queue ops the
* comms task does around updates. */
volatile int learn_flag = -1;
volatile bool controls_enabled = true;
// Temporary storage for learned keycodes (not committed to params yet)
static int64_t temp_keycodes[NUM_RF_BUTTONS] = {0};

View File

@@ -140,28 +140,36 @@ esp_err_t sensors_init() {
}
void sensors_check() {
sensor_event_t evt;
uint8_t i = 0;
if (xQueueReceive(sensor_event_queue, &evt, pdMS_TO_TICKS(10)) == pdTRUE) {
i = evt.sensor_id;
ESP_LOGI("SENS", "EVENT %d", i);
bool current_raw = !gpio_get_level(sensor_pins[i]);
/* Drain ALL queued events with a non-blocking receive. Previously this
* was a single blocking receive with a 10 ms timeout, which (a) chewed up
* half the FSM tick window waiting on quiet sensors, and (b) consumed
* only one edge per tick — encoder bursts above 50 Hz overflowed the
* 16-entry queue and undercounted distance. Now each tick consumes the
* full queue contents and applies a per-sensor debounce. */
sensor_event_t evt;
static uint64_t last_counted_us[N_SENSORS] = {0};
while (xQueueReceive(sensor_event_queue, &evt, 0) == pdTRUE) {
uint8_t i = evt.sensor_id;
if (i >= N_SENSORS) continue;
/* Use the ISR-captured level (snapshot at edge time) rather than a
* fresh GPIO read here — by the time the FSM tick drains the queue,
* the line may have toggled again and a re-read would miss the
* transition we're processing. */
bool current_raw = evt.level;
/* Software debounce on non-safety sensors. The safety sensor has its
* own asymmetric debouncer below, so we don't double-count there. */
uint64_t now = esp_timer_get_time();
if (i != SENSOR_SAFETY) {
if (now - last_counted_us[i] < DEBOUNCE_TIME_US) continue;
}
last_counted_us[i] = now;
sensor_stable_state[i] = current_raw;
if (current_raw && !last_raw_state[i]){
ESP_LOGI("SENS", "FALLING");
if (current_raw != last_raw_state[i]) {
sensor_count[i]++;
}
if (!current_raw && last_raw_state[i]){
ESP_LOGI("SENS", "RISING");
sensor_count[i]++;
}
last_raw_state[i] = current_raw;
}
@@ -214,8 +222,7 @@ bool get_sensor(sensor_t i) {
}
bool get_is_safe(void) {
return true;
//return is_safe;
return is_safe;
}
int16_t get_sensor_counter(sensor_t i) {

View File

@@ -34,40 +34,43 @@ esp_err_t init_solar_gpio() {
esp_err_t solar_run_fsm() {
init_solar_gpio();
int64_t now = rtc_get_ms();
//ESP_LOGI("BAT", "FSM STATE %d", current_charge_state);
/* `now` is in milliseconds; CHG_BULK_S / CHG_LOW_S are in seconds.
* Scale the parameter to ms before comparing. Initialize the timer on
* first run rather than leaving it at -1 (which would otherwise make
* `now > timer + threshold` true after just `threshold` ms). */
if (timer < 0) timer = now;
float vbat = get_battery_V();
/*
The state machine is simple.
- After a period of time when battery is low, switch to bulk
- After a period of time in bulk, switch to float
*/
//if (rtc_is_set()) {
switch(current_charge_state) {
case CHG_STATE_BULK:
if (now > timer+get_param_value_t(PARAM_CHG_BULK_S).u32) {
current_charge_state = CHG_STATE_FLOAT;
}
break;
case CHG_STATE_FLOAT:
// if we have sufficient voltage, reset the timer
if (vbat > get_param_value_t(PARAM_CHG_LOW_V).f32) {
timer = now;
}
if (now > timer+get_param_value_t(PARAM_CHG_LOW_S).u32) {
timer = now;
current_charge_state = CHG_STATE_BULK;
}
break;
}
switch(current_charge_state) {
case CHG_STATE_BULK:
if (now > timer + (int64_t)get_param_value_t(PARAM_CHG_BULK_S).u32 * 1000) {
current_charge_state = CHG_STATE_FLOAT;
}
break;
case CHG_STATE_FLOAT:
// if we have sufficient voltage, reset the timer
if (vbat > get_param_value_t(PARAM_CHG_LOW_V).f32) {
timer = now;
}
if (now > timer + (int64_t)get_param_value_t(PARAM_CHG_LOW_S).u32 * 1000) {
timer = now;
current_charge_state = CHG_STATE_BULK;
}
break;
}
/*} else {
reset_solar_fsm();
ESP_LOGI(TAG, "RESET SOLAR FSM");

View File

@@ -37,20 +37,9 @@ typedef struct {
uint8_t type;
} log_queue_entry_t;
// ============================================================================
// LOG TYPE DEFINITIONS (Magic values 0xC0-0xCF)
// ============================================================================
#define LOG_TYPE_DATA 0xC0 // Generic data log
#define LOG_TYPE_EVENT 0xC1 // Event marker
#define LOG_TYPE_ERROR 0xC2 // Error log
#define LOG_TYPE_DEBUG 0xC3 // Debug message
#define LOG_TYPE_SENSOR 0xC4 // Sensor reading
#define LOG_TYPE_COMMAND 0xC5 // Command executed
#define LOG_TYPE_STATUS 0xC6 // Status update
#define LOG_TYPE_CUSTOM 0xCF // Custom/user-defined
// Helper macro to check if a byte is a valid log type
#define IS_VALID_LOG_TYPE(x) ((x) >= 0xC0 && (x) <= 0xCF)
/* LOG_TYPE_* + IS_VALID_LOG_TYPE moved to storage.h as the single source
* of truth. The duplicate set previously here disagreed with the header
* (different LOG_TYPE_CUSTOM* names). */
// ============================================================================
// PARAMETER TABLE GENERATION
@@ -272,6 +261,10 @@ esp_err_t set_param_value_t(param_idx_t id, param_value_t val) {
return ESP_ERR_INVALID_ARG;
}
parameter_table[id] = val;
/* Run bounds/NaN validation immediately so callers that read between
* set and the next commit_params() see a clamped value rather than a
* raw out-of-range one. */
validate_param(id);
ESP_LOGI(TAG, "Parameter %d (%s) set (not committed)", id, parameter_names[id]);
return ESP_OK;
}
@@ -518,6 +511,20 @@ esp_err_t commit_params(void) {
esp_err_t factory_reset(void) {
ESP_LOGI(TAG, "Performing factory reset...");
/* Stop the log writer task before erasing the log partition so an
* in-flight write doesn't race against esp_partition_erase_range. We
* also take log_mutex for the rest of this function so any pre-existing
* writer that's mid-write completes before we erase, and any new writer
* (if log_task_running observation lagged) blocks. The caller is about
* to esp_restart, so we never give the mutex back. */
if (log_task_running) {
log_task_running = false;
/* Give the writer task time to observe the flag (its queue receive
* has a 100 ms timeout) and finish any in-flight write. */
vTaskDelay(pdMS_TO_TICKS(150));
}
if (log_mutex) xSemaphoreTake(log_mutex, portMAX_DELAY);
// Reset all parameters to defaults
for (int i = 0; i < NUM_PARAMS; i++) {
memcpy(&parameter_table[i], &parameter_defaults[i], sizeof(param_value_t));
@@ -753,11 +760,6 @@ static bool is_sector_full(uint32_t x) {
return (buf != 0xFF);
}
static inline void find_head_tail(int32_t num_sectors, int32_t *head, int32_t *tail) {
}
// Replace log_write with this non-blocking version:
esp_err_t log_write(const uint8_t* buf, uint8_t len, uint8_t type) {
if (!log_initialized || log_partition == NULL) {
@@ -811,11 +813,28 @@ static esp_err_t log_write_blocking(const uint8_t* buf, uint8_t len, uint8_t typ
// check if we will overrun the sector
if (log_head_offset + len+2 >= log_sector_end(log_head_offset)) {
// zero the rest of sector
char zeros[256] = {0};
esp_partition_write(log_partition,
log_head_offset, &zeros,
log_sector_end(log_head_offset)-log_head_offset);
/* Zero the remainder of the current sector. The gap can be up to
* one full sector (FLASH_SECTOR_SIZE = 4096); a single 256-byte
* stack buffer would have caused the partition driver to read past
* the buffer and write arbitrary stack contents to flash. Use a
* static-zero buffer (`.bss` is implicitly zero-initialized) and
* chunk the write so an oversize buffer is never required. */
static const uint8_t zeros[256] = {0};
size_t gap = log_sector_end(log_head_offset) - log_head_offset;
size_t written = 0;
while (written < gap) {
size_t chunk = MIN(sizeof(zeros), gap - written);
esp_err_t we = esp_partition_write(log_partition,
log_head_offset + written,
zeros, chunk);
if (we != ESP_OK) {
ESP_LOGW(TAG, "Failed zero-pad write: %s", esp_err_to_name(we));
/* Bump head to the next sector regardless — readers tolerate
* 0xFF or 0x00 padding between entries. */
break;
}
written += chunk;
}
// set head to next sector, and check for wrap
log_head_offset = log_sector_end(log_head_offset);
@@ -851,15 +870,30 @@ static esp_err_t log_write_blocking(const uint8_t* buf, uint8_t len, uint8_t typ
}
len++; // account for type bit
esp_partition_write(log_partition, log_head_offset, &len, 1);
esp_partition_write(log_partition, log_head_offset+1, buf, len-1);
esp_partition_write(log_partition, log_head_offset+len, &type, 1);
/* Check each write — a partial failure here leaves a corrupt entry that
* the recovery scan has to skip past. On any failure, bump head to the
* next sector so the next write starts clean. */
esp_err_t we;
we = esp_partition_write(log_partition, log_head_offset, &len, 1);
if (we == ESP_OK) {
we = esp_partition_write(log_partition, log_head_offset+1, buf, len-1);
}
if (we == ESP_OK) {
we = esp_partition_write(log_partition, log_head_offset+len, &type, 1);
}
if (we != ESP_OK) {
ESP_LOGE(TAG, "log_write_blocking partial-write fail: %s",
esp_err_to_name(we));
log_head_offset = log_sector_end(log_head_offset);
if (log_head_offset >= log_partition->size) log_head_offset = 0;
if (log_mutex) xSemaphoreGive(log_mutex);
return we;
}
log_head_offset+=len+1;
ESP_LOGI(TAG, "Wrote; Tail/Head are now %lu/%lu",
ESP_LOGI(TAG, "Wrote; Tail/Head are now %lu/%lu",
(unsigned long)log_tail_offset, (unsigned long)log_head_offset);
if (log_mutex) xSemaphoreGive(log_mutex);
return ESP_OK;
}

View File

@@ -11,26 +11,39 @@
// ============================================================================
#define FLASH_SECTOR_SIZE 4096
// ============================================================================
// LOG ENTRY TYPE DEFINITIONS (Magic values 0xC0-0xCF)
// ============================================================================
#define LOG_TYPE_DATA 0xC0 // Generic data log
#define LOG_TYPE_EVENT 0xC1 // Event marker
#define LOG_TYPE_ERROR 0xC2 // Error log
#define LOG_TYPE_DEBUG 0xC3 // Debug message
#define LOG_TYPE_SENSOR 0xC4 // Sensor reading
#define LOG_TYPE_COMMAND 0xC5 // Command executed
#define LOG_TYPE_STATUS 0xC6 // Status update
#define LOG_TYPE_CUSTOM_1 0xC7 // Custom type 1
#define LOG_TYPE_CUSTOM_2 0xC8 // Custom type 2
#define LOG_TYPE_CUSTOM_3 0xC9 // Custom type 3
/* LOG ENTRY TYPES.
*
* Two ranges are emitted by the firmware in practice:
* 0..13 — FSM state-tagged data entries (see LOG_FSM_NAMES in
* webpage.html and send_fsm_log() in control_fsm.c).
* 100..103 — System events: BAT, CRASH, BOOT, TIME_SET (see
* control_fsm.h LOG_TYPE_BAT/CRASH/BOOT/TIME_SET).
*
* Constants in the 0xC0-0xCF range are reserved for legacy/future use; the
* webpage parser does not currently understand them.
*/
#define LOG_TYPE_DATA 0xC0 // reserved
#define LOG_TYPE_EVENT 0xC1
#define LOG_TYPE_ERROR 0xC2
#define LOG_TYPE_DEBUG 0xC3
#define LOG_TYPE_SENSOR 0xC4
#define LOG_TYPE_COMMAND 0xC5
#define LOG_TYPE_STATUS 0xC6
#define LOG_TYPE_CUSTOM_1 0xC7
#define LOG_TYPE_CUSTOM_2 0xC8
#define LOG_TYPE_CUSTOM_3 0xC9
// 0xCA-0xCF reserved for future use
// Maximum payload size per log entry (255 max due to 1-byte size field)
#define LOG_MAX_PAYLOAD 200
// Helper macro to check if a byte is a valid log type
#define IS_VALID_LOG_TYPE(x) ((x) >= 0xC0 && (x) <= 0xCF)
/* Helper macro to check if a byte is a valid log type. Includes the
* 0..13 FSM-state range, the 100..103 system-event range, and the legacy
* 0xC0-0xCF magic range. Used as a soft sanity check during log_read —
* unknown types are still surfaced to callers, just with a warning. */
#define IS_VALID_LOG_TYPE(x) (((x) <= 13) || \
((x) >= 100 && (x) <= 103) || \
((x) >= 0xC0 && (x) <= 0xCF))
// ============================================================================
// LOG ENTRY STRUCTURE
@@ -61,11 +74,11 @@ typedef struct {
PARAM_DEF(NUM_MOVES, u32, 0, "", 0, 1000) \
PARAM_DEF(MOVE_START, u32, 0, "s", 0, 86400) \
PARAM_DEF(MOVE_END, u32, 0, "s", 0, 86400) \
PARAM_DEF(DRIVE_DIST, f32, 10, "ft", 0.0, 100.0) \
PARAM_DEF(JACK_DIST, f32, 5, "in", 0.0, 10.0) \
PARAM_DEF(DRIVE_DIST, f32, 4, "ft", 0.0, 100.0) \
PARAM_DEF(JACK_DIST, f32, 1.5, "in", 0.0, 10.0) \
PARAM_DEF(DRIVE_KE, f32, 29.2, "n/ft", 1.0, 1e9) \
PARAM_DEF(DRIVE_KT, f32, 1440000, "us/ft", 1.0, 1e9) /* div-critical */ \
PARAM_DEF(JACK_KT, f32, 1428571, "ms/in", 1.0, 1e9) /* div-critical */ \
PARAM_DEF(JACK_KT, f32, 1725698, "ms/in", 1.0, 1e9) /* div-critical */ \
PARAM_DEF(KEYCODE_0, u32, 0, "", 0, 0) /* skip */ \
PARAM_DEF(KEYCODE_1, u32, 0, "", 0, 0) \
PARAM_DEF(KEYCODE_2, u32, 0, "", 0, 0) \

File diff suppressed because one or more lines are too long

View File

@@ -1,55 +1,136 @@
<!DOCTYPE html>
<html>
<!--
tan: #ba965b
light tan: #efede9
white: #ffffff
green: #2a493d
black: #2f2f2f
-->
<head>
<meta charset="utf-8">
<title>Control Panel</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* === COLOR TOKENS ===
* Single source of truth for every color used in the UI. Item 14:
* green/red/yellow each appear in exactly one place here and are
* referenced everywhere else via var(--…). */
:root {
--green: #38B000;
--red: #C1121F;
--yellow: #FFDD00;
/* Dark theme surfaces (item 15) */
--bg: #1a1a1a; /* page background */
--surface: #262626; /* cards, inputs, popups */
--surface-2: #333333; /* hover / readonly tint */
--text: #f0f0f0; /* primary text */
--text-dim: #a0a0a0; /* secondary text */
--border: #444444;
--accent: #ba965b; /* brand tan kept as accent */
}
html, body { background-color: var(--bg); color: var(--text); }
body { text-align: center; margin: 0; padding: 0; }
#wrapper { text-align: center; box-sizing: border-box; }
#content { max-width: 500px; margin: auto; padding: 0 10px; }
body { text-align: center; margin: 0; padding: 0; }
* {
font-size: 1.2rem;
background-color: #ffffff;
color: #2f2f2f;
font-family: "Noto Sans", "Verdana", sans-serif;
font-size: 1.2rem;
color: var(--text);
font-family: system-ui, sans-serif;
background-color: transparent;
}
input, button { width: 100%; }
input, button { background-color: #efede9; text-align: right; box-sizing: border-box; border: 1px; border-radius: 5px; margin: 5px;}
input[type="text"], input[type="number"],input[type="time"] { font-family: monospace;border: 1px solid #ba965b; border-radius: 5px; }
input[readonly] { background-color: #e4e4e4; border-radius: 5px;}
button { text-align: center; border: 1px solid #ba965b; border-radius: 5px; }
.changed, #commit_btn { background-color: #2a493d !important; color: #ffffff !important; }
#cancel_btn { background-color: #723 !important; color: #ffffff !important; }
#commit_btn, #cancel_btn { width: 45%; margin-top: 10px; padding: 10px; cursor: pointer; border: none; font-weight: bold; }
#commit_btn[disabled], #cancel_btn[disabled] { background-color: #444 !important; color: #888; cursor: not-allowed; }
/* === FORM CONTROLS === */
input, button {
width: 100%;
background-color: var(--surface);
color: var(--text);
text-align: right;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 5px;
margin: 5px;
}
input[type="text"], input[type="number"], input[type="time"] {
/* Numerical / freeform-text inputs keep monospace per CHANGELIST. */
font-family: monospace;
border: 1px solid var(--accent);
}
input[readonly] {
background-color: var(--surface-2);
color: var(--text-dim);
}
button {
text-align: center;
border: 1px solid var(--accent);
border-radius: 5px;
cursor: pointer;
}
button:hover { background-color: var(--surface-2); }
/* Save / Discard footer buttons. */
.changed, #commit_btn { background-color: var(--green) !important; color: #ffffff !important; border-color: var(--green) !important; }
#cancel_btn { background-color: var(--red) !important; color: #ffffff !important; border-color: var(--red) !important; }
#commit_btn, #cancel_btn { width: 45%; margin-top: 10px; padding: 10px; font-weight: bold; }
#commit_btn[disabled], #cancel_btn[disabled] {
background-color: var(--surface-2) !important;
color: var(--text-dim) !important;
border-color: var(--border) !important;
cursor: not-allowed;
}
table { width: 100%; border-collapse: collapse; text-align: left; }
td { padding: 8px; border-bottom: 1px solid #efede9; }
td { padding: 8px; border-bottom: 1px solid var(--border); }
#log_viewer_table td { padding: 2px 6px; font-size: 0.65rem; }
#log_viewer_table td:first-child { white-space: nowrap; }
summary { border-radius: 5px; font-weight: bold; text-align: left; color: #fff; background-color: #723; padding: 0.3rem;}
.cmd { font-size: 1.5rem; border: none;}
#msg {text-align: center;}
/* Status box: state label plus a flex row of per-error pills. */
summary {
border-radius: 5px;
font-weight: bold;
text-align: left;
color: #ffffff;
background-color: var(--red);
padding: 0.3rem;
cursor: pointer;
}
/* === ACTION BUTTON ROW (item 12) ===
* Flex layout instead of a table: each button gets exactly 50% of
* the row width with a 10px gap, regardless of label width. The
* `.cmd` text wraps when "START MOVE" doesn't fit on one line. */
.action_row {
display: flex;
gap: 10px;
padding: 5px 0;
align-items: stretch;
}
.action_row > button {
flex: 1 1 0; /* equal width — both buttons take 50% */
margin: 0;
min-height: 64px;
}
.cmd {
font-size: 1.5rem;
font-weight: bold;
white-space: normal;
word-break: break-word;
border: none;
line-height: 1.15;
}
.cmd_action { background-color: var(--green); color: #ffffff; }
.cmd_action.moving { background-color: var(--yellow); color: #1a1a1a; }
.cmd_estop { background-color: var(--red); color: #ffffff; }
#msg { text-align: center; }
/* === STATUS BOX === */
#status_box { text-align: center; padding: 6px 0; }
#status_state { font-weight: bold; font-size: 1.3rem; padding: 4px 0; color: #2a493d; }
#status_state.error { color: #c33; }
/* Item 13: status text always uses the default theme color — no
* error-class override that flips it red. The pill row below does
* the error signaling. */
#status_state {
font-weight: bold;
font-size: 1.3rem;
padding: 4px 0;
color: var(--text);
}
#status_indicators { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 4px; }
#status_indicators:empty { display: none; }
.status_pill {
@@ -58,18 +139,19 @@ black: #2f2f2f
font-size: 0.85rem;
font-weight: bold;
color: #ffffff;
background-color: #888;
background-color: var(--surface-2);
white-space: nowrap;
}
.status_pill.error { background-color: #c33; }
.status_pill.warn { background-color: #c90; }
.status_pill.info { background-color: #479; }
.status_pill.error { background-color: var(--red); }
.status_pill.warn { background-color: var(--yellow); color: #1a1a1a; }
.status_pill.info { background-color: var(--accent); }
h1 {
font-size: 2.5rem;
font-size: 2.5rem;
color: var(--text);
}
/* Popup modal styles */
/* === POPUP MODAL === */
#popup-overlay {
display: none;
position: fixed;
@@ -77,101 +159,87 @@ black: #2f2f2f
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
background-color: rgba(0, 0, 0, 0.75);
z-index: 1000;
justify-content: center;
align-items: center;
}
#popup-content {
background-color: #2a493d;
color: #ffffff;
background-color: var(--surface);
color: var(--text);
padding: 30px;
border-radius: 10px;
border: 1px solid var(--border);
text-align: center;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
#popup-content h2 {
margin-top: 0;
color: #ffffff;
background-color: #2a493d;
}
#popup-content h2,
#popup-content p {
background-color: #2a493d;
color: #ffffff;
font-size: 1.1rem;
background-color: var(--surface);
color: var(--text);
}
#popup-content h2 { margin-top: 0; }
#popup-content p { font-size: 1.1rem; }
@media screen and (max-width: 350px) {
#content { max-width: 100%; padding: 0 5px; }
/* Stack table cells vertically on mobile for better usability,
* but exempt the log viewer — it stays as a wide table with
* horizontal scroll provided by its wrapping div. */
/* Stack table cells vertically on mobile, but exempt the log
* viewer — it stays as a wide table with horizontal scroll. */
table:not(#log_viewer_table) tr td { display: block; width: 100%; box-sizing: border-box; }
table:not(#log_viewer_table) tr { display: block; margin-bottom: 10px; }
}
#popup-buttons {
background-color: #2a493d;
background-color: var(--surface);
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
#popup-buttons button {
background-color: #efede9;
color: #2f2f2f;
border: 1px solid #ba965b;
background-color: var(--surface-2);
color: var(--text);
border: 1px solid var(--accent);
padding: 10px 20px;
cursor: pointer;
border-radius: 5px;
font-weight: bold;
min-width: 100px;
}
#popup-buttons button:hover {
background-color: #ba965b;
color: #ffffff;
background-color: var(--accent);
color: #1a1a1a;
}
#popup-buttons button.primary {
background-color: #ba965b;
color: #ffffff;
background-color: var(--accent);
color: #1a1a1a;
}
#popup-buttons button.primary:hover {
background-color: #8a7045;
background-color: #d4b27e;
}
#popup-input-container {
background-color: #2a493d;
background-color: var(--surface);
margin: 20px 0;
}
#popup-input {
width: 100%;
padding: 10px;
border: 1px solid #ba965b;
border: 1px solid var(--accent);
border-radius: 5px;
background-color: #efede9;
color: #2f2f2f;
background-color: var(--surface-2);
color: var(--text);
text-align: center;
font-size: 1.1rem;
}
.sqbtn {
width: 35%;
padding: 30px;
font-weight: bold;
}
.sqbtn {
width: 35%;
padding: 30px;
font-weight: bold;
}
</style>
</head>
<body>
@@ -181,14 +249,11 @@ black: #2f2f2f
<h1>CluckCommand</h1>
<table>
<tr>
<td><button class="cmd" onclick="sendCommand('start')">START</button></td>
<td><button class="cmd" onclick="sendCommand('stop')" style="background-color:#723; color: #fff">STOP</button></td>
<td><button class="cmd" onclick="sendCommand('undo')">UNDO</button></td>
</tr>
</table>
<div class="action_row">
<button class="cmd cmd_action" id="action_btn" onclick="actionBtnClick()">START MOVE</button>
<button class="cmd cmd_estop" onclick="sendCommand('stop')">E-STOP</button>
</div>
<table>
@@ -325,7 +390,7 @@ black: #2f2f2f
<br/>
<details id="log_viewer_details">
<summary style="background-color:#2a493d;">EVENT LOG</summary>
<summary style="background-color:var(--surface-2);">EVENT LOG</summary>
<!-- Body breaks out of the 500px #content column so the table has
room to breathe. The summary above stays inside the column. -->
<div id="log_viewer_body" style="position:relative;width:100vw;left:50%;margin-left:-50vw;padding:0 8px;box-sizing:border-box;">
@@ -365,8 +430,8 @@ black: #2f2f2f
</table>
<table id="table"></table>
<button class="cmd" onclick="sendCommand('reboot')" style="background-color:#723; color: #fff">REBOOT</button>
<button class="cmd" onclick="sendCommand('sleep')" style="background-color:#237; color: #fff">SLEEP</button>
<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>
</details>
</div>
</div>
@@ -665,6 +730,14 @@ black: #2f2f2f
}
}
// Single action-button dispatcher. STATE_IDLE (0) → start, anything else
// → undo. Uses last polled FSM state so the click matches what the
// operator sees on the button label.
function actionBtnClick() {
const isIdle = !data.state || data.state === 0;
sendCommand(isIdle ? 'start' : 'undo');
}
async function sendCommand(cmdName) {
if (cmdName === 'start') {
if (!await modalConfirm("Will begin moving - please confirm."))
@@ -930,6 +1003,16 @@ black: #2f2f2f
stateEl.textContent = state || '—';
stateEl.classList.toggle('error', activePills.length > 0);
// Action button: STATE_IDLE (0) → "START MOVE" (green);
// any other state → "UNDO" (yellow). Single button replaces
// the old separate START/UNDO controls.
const actionBtn = ge('action_btn');
if (actionBtn) {
const isIdle = !data.state || data.state === 0;
actionBtn.textContent = isIdle ? 'START MOVE' : 'UNDO';
actionBtn.classList.toggle('moving', !isIdle);
}
const box = ge('status_indicators');
box.innerHTML = '';
for (const [, label, kind] of activePills) {
@@ -1557,15 +1640,22 @@ black: #2f2f2f
const payloadStart = i + 1;
const entry = { type };
try {
if (type >= 0 && type <= 13 && payloadSize >= 27) {
entry.ts_ms = Number(dv.getBigUint64(payloadStart + 0, true));
entry.bat_V = dv.getFloat32(payloadStart + 8, true);
entry.drive_A = dv.getFloat32(payloadStart + 12, true);
entry.jack_A = dv.getFloat32(payloadStart + 16, true);
entry.aux_A = dv.getFloat32(payloadStart + 20, true);
entry.counter = dv.getInt16(payloadStart + 24, true);
entry.sensors = bytes[payloadStart + 26];
entry.name = LOG_FSM_NAMES[type] || `STATE_${type}`;
if (type >= 0 && type <= 13 && payloadSize >= 19) {
// Single-current FSM payload (25 bytes):
// ts(8) bat(4) current(4) counter(2) sensors(1)
// heat(4) i2c_out(2)
entry.ts_ms = Number(dv.getBigUint64(payloadStart + 0, true));
entry.bat_V = dv.getFloat32(payloadStart + 8, true);
entry.current_A = dv.getFloat32(payloadStart + 12, true);
entry.counter = dv.getInt16(payloadStart + 16, true);
entry.sensors = bytes[payloadStart + 18];
if (payloadSize >= 23) {
entry.heat = dv.getFloat32(payloadStart + 19, true);
}
if (payloadSize >= 25) {
entry.i2c_out = dv.getUint16(payloadStart + 23, true);
}
entry.name = LOG_FSM_NAMES[type] || `STATE_${type}`;
} else if (type === LOG_TYPE_BAT && payloadSize >= 12) {
entry.ts_ms = Number(dv.getBigUint64(payloadStart, true));
entry.bat_V = dv.getFloat32(payloadStart + 8, true);
@@ -1596,16 +1686,22 @@ black: #2f2f2f
const sensorHex = e.sensors !== undefined
? '0x' + e.sensors.toString(16).padStart(2,'0')
: '';
const i2cHex = e.i2c_out !== undefined
? '0x' + e.i2c_out.toString(16).padStart(4,'0')
: '';
return [
{ text: _logFormatTs(e.ts_ms) },
{ text: e.name || '' },
{ text: e.bat_V !== undefined ? e.bat_V.toFixed(2) : '' },
{ text: e.drive_A !== undefined ? e.drive_A.toFixed(2) : '' },
{ text: e.jack_A !== undefined ? e.jack_A.toFixed(2) : '' },
{ text: e.aux_A !== undefined ? e.aux_A.toFixed(2) : '' },
{ text: e.counter !== undefined ? String(e.counter) : '' },
{ text: e.bat_V !== undefined ? e.bat_V.toFixed(2) : '' },
{ text: e.current_A !== undefined ? e.current_A.toFixed(2) : '' },
{ text: e.counter !== undefined ? String(e.counter) : '' },
{ text: sensorHex,
title: e.sensors !== undefined ? _decodeSensors(e.sensors) : undefined },
{ text: e.heat !== undefined ? e.heat.toFixed(2) : '' },
{ text: i2cHex,
title: e.i2c_out !== undefined
? '0b' + e.i2c_out.toString(2).padStart(16,'0')
: undefined },
{ text: e.reason || '' },
];
}
@@ -1635,7 +1731,7 @@ black: #2f2f2f
function _renderLogEntries(entries) {
const table = ge('log_viewer_table');
table.innerHTML = '';
const cols = ['Time','Type','Battery V','Drive A','Jack A','Aux A','Counter','Sensors','Extra'];
const cols = ['Time','Type','Battery V','Current A','Counter','Sensors','Heat','I2C Out','Extra'];
_appendRow(table, cols.map(h => ({ text: h, bold: true })));
// Walk newest-first, group contiguous entries sharing the same
@@ -1663,10 +1759,11 @@ black: #2f2f2f
const header = _appendRow(table, [{
text: `[+] ${name} × ${group.length} ${firstTs}${lastTs}`,
colSpan: cols.length,
style: { cursor: 'pointer', background: '#efede9',
style: { cursor: 'pointer', background: 'var(--surface-2)',
color: 'var(--text)',
fontWeight: 'bold', fontSize: '0.7rem' },
}], {
style: { background: '#efede9' },
style: { background: 'var(--surface-2)' },
});
// Defer wiring the onclick until we've appended the child rows.
const childRows = group.map(e =>

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

@@ -21,6 +21,7 @@
#include "string.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_task_wdt.h"
#include "esp_wifi.h"
#include "esp_system.h"
@@ -61,7 +62,32 @@ extern const uint8_t prvtkey_pem_end[] asm("_binary_prvtkey_pem_end");
static httpd_handle_t http_server_instance = NULL;
/* Shared scratch buffer used by log/post/ota handlers. The httpd default
* config uses a single worker task, so handlers don't normally race — but
* with config.lru_purge_enable + 7 sockets some IDF configurations allow
* concurrent invocations. The mutex below is defense in depth: each
* handler that touches http_buffer takes it on entry via the wrap_*
* dispatcher below and releases on exit. */
char http_buffer[4096];
static SemaphoreHandle_t http_buffer_mutex = NULL;
static esp_err_t with_http_buffer(httpd_req_t *req,
esp_err_t (*body)(httpd_req_t *)) {
if (http_buffer_mutex != NULL) {
if (xSemaphoreTake(http_buffer_mutex, pdMS_TO_TICKS(2000)) != pdTRUE) {
/* esp_http_server's httpd_err_code_t enum doesn't include 503,
* so emit it via the lower-level set_status + send pair. */
ESP_LOGW(TAG, "http_buffer busy — rejecting request");
httpd_resp_set_status(req, "503 Service Unavailable");
httpd_resp_set_type(req, "text/plain");
httpd_resp_send(req, "busy", HTTPD_RESP_USE_STRLEN);
return ESP_FAIL;
}
}
esp_err_t ret = body(req);
if (http_buffer_mutex != NULL) xSemaphoreGive(http_buffer_mutex);
return ret;
}
/* Handler to serve the HTML page */
static esp_err_t root_get_handler(httpd_req_t *req) {
@@ -107,7 +133,11 @@ static const esp_partition_t *cached_log_partition = NULL;
// In webserver.c - Replace the log_handler function
static esp_err_t log_handler_locked(httpd_req_t *req);
static esp_err_t log_handler(httpd_req_t *req) {
return with_http_buffer(req, log_handler_locked);
}
static esp_err_t log_handler_locked(httpd_req_t *req) {
if (req == NULL) {
ESP_LOGE(TAG, "Null request pointer");
return ESP_FAIL;
@@ -443,7 +473,11 @@ static void webserver_restart_wifi_cb(void *arg) { webserver_restart_wifi(); }
/**
* Unified POST handler - handles commands, parameter updates, time updates
*/
static esp_err_t post_handler_locked(httpd_req_t *req);
static esp_err_t post_handler(httpd_req_t *req) {
return with_http_buffer(req, post_handler_locked);
}
static esp_err_t post_handler_locked(httpd_req_t *req) {
ESP_LOGI(TAG, "post_handler");
if (req == NULL) {
@@ -575,7 +609,11 @@ static esp_err_t post_handler(httpd_req_t *req) {
return err;
}
static esp_err_t ota_post_handler_locked(httpd_req_t *req);
static esp_err_t ota_post_handler(httpd_req_t *req) {
return with_http_buffer(req, ota_post_handler_locked);
}
static esp_err_t ota_post_handler_locked(httpd_req_t *req) {
ESP_LOGI(TAG, "OTA POST request received");
if (req == NULL) {
@@ -670,8 +708,10 @@ static esp_err_t ota_post_handler(httpd_req_t *req) {
remaining -= recv_len;
received += recv_len;
// Log progress every 10%
if (total_len > 0 && (received % (total_len / 10)) < recv_len) {
// Log progress every 10%. Guard against total_len < 10 (would
// otherwise divide by zero in the modulo) and total_len == 0
// (chunked transfer with unknown length).
if (total_len >= 10 && (received % (total_len / 10)) < recv_len) {
ESP_LOGI(TAG, "OTA progress: %d%%", (received * 100) / total_len);
}
}
@@ -834,6 +874,9 @@ static bool s_wifi_initted = false;
static esp_err_t start_http_server(void) {
if (server_running) return ESP_OK;
ESP_LOGI(TAG, "STARTING HTTP");
if (http_buffer_mutex == NULL) {
http_buffer_mutex = xSemaphoreCreateMutex();
}
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = 80;
config.max_open_sockets = 7;
@@ -891,20 +934,33 @@ static esp_err_t stop_http_server(void) {
int n_connected = 0;
/* Signaled by the WiFi event task when the AP fully stops. Used by
* webserver_restart_wifi() to deterministically wait for teardown to
* complete instead of racing on a fixed delay. */
static SemaphoreHandle_t s_ap_stopped_sem = NULL;
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data) {
if (event_base == WIFI_EVENT) {
if (event_id == WIFI_EVENT_AP_STOP) {
ESP_LOGI(TAG, "WIFI_EVENT_AP_STOP");
if (s_ap_stopped_sem) xSemaphoreGive(s_ap_stopped_sem);
return;
}
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();
n_connected++;
if (n_connected > 0) start_http_server();
/* HTTP lifecycle is no longer tied to client count — the server
* is started once in webserver_init() and stays up. Tying
* start/stop to events created races where rapid connect/
* disconnect bursts could call httpd_start twice or stop on
* the wrong tick. */
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
wifi_event_ap_stadisconnected_t* event = (wifi_event_ap_stadisconnected_t*) event_data;
ESP_LOGI(TAG, "Station disconnected, AID=%d", event->aid);
n_connected--;
if (n_connected <= 0) stop_http_server();
if (n_connected > 0) n_connected--;
}
}
}
@@ -939,11 +995,24 @@ static esp_err_t wifi_common_init(void) {
return err;
}
if (s_ap_stopped_sem == NULL) {
s_ap_stopped_sem = xSemaphoreCreateBinary();
}
s_wifi_initted = true;
return ESP_OK;
}
static esp_err_t launch_soft_ap(void) {
/* If WiFi is already up, don't re-issue set_mode/set_config/start —
* those modify driver state on a running AP and can de-associate
* existing clients. The caller (typically webserver_init or
* BU.WIFI.START) just gets ESP_OK as if a fresh launch succeeded. */
if (s_wifi_running) {
ESP_LOGI(TAG, "AP already running — launch_soft_ap is a no-op");
return ESP_OK;
}
ESP_LOGI(TAG, "AP LAUNCHING");
if (s_ap_netif == NULL) {
@@ -987,7 +1056,15 @@ static esp_err_t launch_soft_ap(void) {
wifi_config.ap.channel = 6;
}
wifi_config.ap.ssid_len = strlen(ssid_str);
/* Clamp explicitly: the param store currently caps SSIDs at 16 bytes so
* the value is always within `wifi_config.ap.ssid` (32-byte) bounds, but
* if the param size ever grows the WiFi driver would read past the
* buffer. */
{
size_t len = strlen(ssid_str);
if (len > sizeof(wifi_config.ap.ssid)) len = sizeof(wifi_config.ap.ssid);
wifi_config.ap.ssid_len = (uint8_t)len;
}
err = esp_wifi_set_mode(WIFI_MODE_AP);
if (err != ESP_OK) { ESP_LOGE(TAG, "set_mode AP: %s", esp_err_to_name(err)); return err; }
@@ -1049,11 +1126,20 @@ esp_err_t webserver_restart_wifi(void) {
stop_http_server();
if (s_wifi_running) {
/* Drain any pre-existing token so xSemaphoreTake below blocks for a
* fresh AP_STOP event rather than returning immediately on stale
* state. */
if (s_ap_stopped_sem) xSemaphoreTake(s_ap_stopped_sem, 0);
esp_wifi_stop();
s_wifi_running = false;
/* Allow the event loop to drain the AP-stop event that
* esp_wifi_stop() queues asynchronously before we relaunch. */
vTaskDelay(pdMS_TO_TICKS(200));
/* Wait for the WiFi driver to finish tearing the AP down. The
* 1-second deadline is generous; on healthy hardware the event
* arrives within a few tens of ms. */
if (s_ap_stopped_sem) {
xSemaphoreTake(s_ap_stopped_sem, pdMS_TO_TICKS(1000));
} else {
vTaskDelay(pdMS_TO_TICKS(200));
}
}
esp_err_t err = start_wifi(false); // called from esp_timer task, not subscribed to WDT