Add watchdog, serial file download, and bootloader-mode flash workflow

Watchdog: 30s ESP32 task watchdog auto-resets on lockup. Fed every
main loop iteration. Disabled during deep sleep (CPU off).

File download: 'D' debug command streams file contents by index over
serial. screenshot.py 'dl <index>' downloads to local file.

Flash workflow no longer needs BOOT+RST:
  screenshot.py z  →  esptool --before no_reset write_flash
This commit is contained in:
GlassOnTin 2026-03-31 14:32:51 +01:00
commit a45986f7a4
3 changed files with 103 additions and 2 deletions

17
Gui.h
View file

@ -159,9 +159,11 @@ extern volatile uint32_t imu_step_count;
// Sensor logger toggle — set by .ino after IMULogger.h is included
typedef bool (*gui_log_toggle_fn_t)();
static gui_log_toggle_fn_t gui_log_toggle_fn = NULL;
// SD file listing — set by .ino after SD is available
// SD file listing and download — set by .ino after SD is available
typedef void (*gui_list_files_fn_t)();
static gui_list_files_fn_t gui_list_files_fn = NULL;
typedef void (*gui_download_file_fn_t)(uint8_t index);
static gui_download_file_fn_t gui_download_file_fn = NULL;
static bool gui_imu_logging = false;
// Forward declarations for IMULogger.h variables (defined later in compilation)
extern bool imu_logging;
@ -1003,7 +1005,8 @@ void gui_process_serial_byte(uint8_t b) {
switch (b) {
case 'T': gui_cmd_payload_len = 5; break; // x(2) + y(2) + duration(1)
case 'N': gui_cmd_payload_len = 2; break; // col(1) + row(1)
default: gui_cmd_payload_len = 0; break; // S, M, I, F, L — no payload
case 'D': gui_cmd_payload_len = 1; break; // file index(1)
default: gui_cmd_payload_len = 0; break; // S, M, I, F, L, X, Z — no payload
}
gui_cmd_state++;
if (gui_cmd_payload_len == 0) {
@ -1336,6 +1339,16 @@ static void gui_cmd_execute() {
Serial.flush();
break;
}
case 'D': { // Download file by index
Serial.write(hdr, 4);
if (gui_download_file_fn) {
gui_download_file_fn(gui_cmd_payload[0]);
} else {
Serial.println("{\"error\":\"no_sd\"}");
}
Serial.flush();
break;
}
case 'X': { // Hard reset
Serial.write(hdr, 4);
Serial.println("{\"reset\":true}");

View file

@ -18,6 +18,7 @@
#include "Utilities.h"
#if BOARD_MODEL == BOARD_TWATCH_ULT
#include "esp_task_wdt.h"
#include "XL9555.h"
#include "CO5300.h"
#include "DRV2605.h"
@ -117,6 +118,13 @@ void setup() {
#if MCU_VARIANT == MCU_ESP32
boot_seq();
// Hardware watchdog — auto-resets on lockup (30s timeout covers
// BHI260AP firmware upload which takes ~10s at boot)
#if BOARD_MODEL == BOARD_TWATCH_ULT
esp_task_wdt_init(30, true); // 30s timeout, panic on expire
esp_task_wdt_add(NULL); // subscribe current task (loopTask)
#endif
// Init shared SPI bus mutex before any SPI users
#if BOARD_MODEL == BOARD_TWATCH_ULT
shared_spi_init();
@ -2008,6 +2016,7 @@ void work_while_waiting() { loop(); }
void loop() {
#if BOARD_MODEL == BOARD_TWATCH_ULT
esp_task_wdt_reset(); // Feed watchdog
uint32_t _prof_t0 = micros(), _prof_t1;
#endif
@ -2258,6 +2267,38 @@ void loop() {
}
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
};
gui_download_file_fn = [](uint8_t index) {
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
SPI.begin(SD_CLK, SD_MISO, SD_MOSI, SD_CS);
if (SD.begin(SD_CS, SPI, 4000000, "/sd", 5)) {
File root = SD.open("/");
File f;
uint8_t i = 0;
while ((f = root.openNextFile())) {
if (i == index) {
Serial.printf("{\"name\":\"%s\",\"size\":%lu}\n", f.name(), (unsigned long)f.size());
uint8_t buf[512];
while (f.available()) {
int n = f.read(buf, sizeof(buf));
Serial.write(buf, n);
}
f.close();
root.close();
SD.end();
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
return;
}
f.close();
i++;
}
root.close();
SD.end();
Serial.printf("{\"error\":\"index %d not found\"}\n", index);
} else {
Serial.println("{\"error\":\"sd_init_failed\"}");
}
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
};
#endif
// Enable step counter (low power, always-on)

View file

@ -241,6 +241,46 @@ def cmd_files(s):
print(f"Timeout ({len(buf)} bytes)")
def cmd_download(s, index, output):
"""Download file from SD card by index"""
s.write(PREFIX + b'D' + bytes([index]))
s.flush()
buf = b""
# Wait for header line with filename and size
deadline = time.time() + 10
while time.time() < deadline:
chunk = s.read(max(1, s.in_waiting or 1))
if chunk:
buf += chunk
magic = PREFIX + b"D"
idx = buf.find(magic)
if idx >= 0:
# Find the JSON header line
hdr_start = idx + 4
nl = buf.find(b"\n", hdr_start)
if nl < 0:
continue
import json
info = json.loads(buf[hdr_start:nl])
if "error" in info:
print(f"Error: {info['error']}")
return
fname = info["name"]
fsize = info["size"]
# Read the file data
data = buf[nl + 1:]
while len(data) < fsize and time.time() < deadline + 30:
chunk = s.read(min(4096, fsize - len(data)))
if chunk:
data += chunk
outname = output or fname.lstrip("/")
with open(outname, "wb") as f:
f.write(data[:fsize])
print(f"Downloaded {fname} ({fsize} bytes) → {outname}")
return
print(f"Timeout ({len(buf)} bytes)")
def cmd_simple(s, cmd_char, label):
"""Send a command, print response, don't wait long"""
send_cmd(s, ord(cmd_char))
@ -309,6 +349,11 @@ def main():
sub.add_parser("files", aliases=["f"],
help="List files on SD card")
dl = sub.add_parser("download", aliases=["dl"],
help="Download file from SD card by index")
dl.add_argument("index", type=int, help="File index from 'files' listing")
dl.add_argument("-o", "--output", help="Output filename (default: use SD name)")
sub.add_parser("reset", aliases=["x"],
help="Hard reset the device")
@ -342,6 +387,8 @@ def main():
cmd_log(s)
elif args.command in ("files", "f"):
cmd_files(s)
elif args.command in ("download", "dl"):
cmd_download(s, args.index, args.output)
elif args.command in ("reset", "x"):
cmd_simple(s, 'X', "Reset sent")
elif args.command in ("bootloader", "z"):