Root cause found via loop profiling: two functions called every loop
iteration on unprovisioned devices consumed 950ms:
1. beacon_update() called startRadio() every loop when GPS had fix
but device wasn't provisioned (hw_ready=false). startRadio() does
full radio init with SPI commands and delays (~600ms). Fix: gate
beacon_update() on hw_ready.
2. stopRadio() called LoRa->end() (SPI sleep + SPI.end()) every loop
in the !hw_ready path (~99ms). Fix: only call when radio_online
is true (stop once, not repeatedly).
Result: loop time 950ms → 0.3ms (3200x improvement).
Also added:
- Main loop profiling (radio/serial/display/pmu/gps/bt/imu timing)
- Build timestamp in metrics command for version verification
- Visible-tile-only label updates (GPS float formatting skipped
when GPS screen not shown)
- Reverted to hwcdc USB mode (TinyUSB adds ~900ms/loop overhead)
- USB MSC SD card code preserved but inactive (needs TinyUSB)
SharedSPI.h: FreeRTOS mutex protecting the shared SPI bus (pins
33/34/35) used by LoRa (SX1262), SD card, and future NFC.
sx126x.cpp: All 6 SPI transaction blocks wrapped with
shared_spi_mutex acquire/release. LoRa uses portMAX_DELAY
(always gets access, just delayed by SD if needed).
USBSD.h: USB MSC SD card access uses shared_spi_mutex with
200ms timeout. Deferred init (3s after boot) for SPI bus
readiness. Metrics: sd_ready, sd_reads, sd_fails counters.
IMULogger.h, Gui.h: SD card writes wrapped in shared_spi_mutex.
Status: SD card correctly reports 29.7GB via USB MSC. Reads
work intermittently (~50% success). Root cause: main loop
takes ~700ms per iteration, causing mutex timeout for MSC
callbacks. Needs loop time investigation to achieve reliable
USB mass storage.
The CO5300 display uses a separate SPI3 bus — not affected.
Streams BHI260AP accelerometer (50Hz), gyroscope (50Hz), and
magnetometer (25Hz) as timestamped CSV to SD card. Ring buffer
in PSRAM (512 samples) flushed to SD from the main loop.
Remote debug command 'L' toggles logging. Python tool:
./scripts/screenshot.py log
CSV format: ms,ax,ay,az,gx,gy,gz,mx,my,mz (raw int16 units)
Measured throughput: ~43Hz actual sample rate.
Also fixed: BHI260AP init no longer gated on hw_ready (radio
provisioning) — IMU sensors should work regardless of radio state.
Foundation for compass calibration (PCA on magnetometer data),
gesture recognition training, and activity classification.
Wrist tilt gesture (sensor ID 67): waking the display by raising
the wrist toward you, with a light haptic click. Replaces the need
to touch the screen or press a button for the most common interaction.
Step counter (sensor ID 52): always-on pedometer displayed below
the complications on the watch face. Shows count once walking starts.
Both sensors configured after the deferred BHI260AP firmware upload
(~5s after boot). IMU events processed in the main loop via
bhi260->update(). Wrist tilt triggers display_unblank() directly.
Also added BoschSensorDataHelper.hpp for typed sensor data parsing
and imu_step_count/imu_wrist_tilt globals for cross-module access.
GPS screen: coordinates shown in teal when HDOP<10 (good fix),
grey when poor. Speed suppressed when HDOP>=10 (meaningless
noise from position drift). HDOP text color-coded: green (<2),
teal (<5), amber (<10), red (>=10).
Watch face GPS complication: satellite count color reflects fix
quality — teal (HDOP<5), amber (HDOP<15), grey (poor).
Addresses phantom speed readings during high-HDOP conditions
where position noise causes false velocity reports from the
GPS module's Kalman filter.
GPS screen: centered title and coordinate text to clear the
display's rounded corners. Previously left-aligned text was
clipped at the top-left corner radius.
Screenshot command: advance LVGL tick by LV_DEF_REFR_PERIOD+1
before calling lv_timer_handler() to ensure the refresh timer
fires. Previously the 1ms tick advance wasn't enough to trigger
a render cycle, resulting in black screenshots.
GPIO 0 (BOOT button) now serves as the home/wake button:
- Short press when blanked: wake display
- Short press when on sub-screen: animate back to watch face
- Long press (2s): screenshot to SD (unchanged)
Addresses spurious navigation from USB disconnect noise on
the capacitive touch panel.
Watch face: replaced Steps complication with battery voltage and
charge state (green when charging/full, red when <15%, white
otherwise). Shows actual voltage (e.g., 4.17V).
Radio status screen: added BATTERY row (voltage + percentage)
and TEMPERATURE row (ESP32-S3 internal temp via PMU sensor).
Status bar continues to show percentage + charging indicator.
Scroll throw friction set to 2% (momentum carries into snap naturally).
Disabled elastic overscroll at tile boundaries.
gui_update_data() skipped during scroll animations to free CPU for
rendering. Added gui_is_scrolling() detection.
Loop timing metrics added: loop_us (interval between gui_update calls),
loop_max_us (worst case). Metrics show 84ms/frame during scroll
(37ms SPI flush + 44ms LVGL render). Release-to-snap latency is
~2 frames (~168ms).
SPI chunk size remains at 16384 pixels (32768 bytes) — larger chunks
cause display failure (DMA/PSRAM bandwidth limits on ESP32-S3).
Async DMA and larger chunks investigated but not viable with current
SPI driver constraints.
Switched from full-frame to partial rendering — only dirty
rectangles are rendered and flushed. Static watch face flushes
in ~750us vs ~18ms (24x faster). PSRAM usage drops from 824KB
to 197KB for draw buffers.
Remote debug protocol over serial (prefix "RWS" + command byte):
S — Screenshot (wakes display, forces full redraw, captures)
T — Touch injection (x, y, duration)
N — Navigate to tile by column/row
M — Frame metrics (JSON: flush/render times, memory)
I — Invalidate (force full redraw)
Python tool (scripts/screenshot.py) supports all commands:
screenshot, metrics, touch, swipe, navigate, invalidate
Screenshot now works correctly with partial rendering by
keeping the capture flag active across all flush strips and
forcing a full invalidation before capture.
Frame timing instrumentation added to flush callback and
render loop for performance profiling.
Switched all SPI operations from spi_device_polling_transmit (CPU
busy-wait) to spi_device_transmit (DMA with FreeRTOS yield). The
CPU blocks on a semaphore instead of spinning, allowing ISRs to
run during the ~18ms frame transfer.
Removed per-frame 411KB screenshot buffer copy — now only copies
when a screenshot is actually requested via serial trigger.
Shortened tileview scroll snap animation from default ~300ms to
150ms for snappier transitions with fewer intermediate frames.
Async DMA (queue_trans with deferred flush_ready) was investigated
but spi_device_queue_trans conflicts with spi_device_transmit on
the same device — needs further investigation with a fully queued
pipeline (no mixed blocking/queued calls).
Both custom fonts (96px time, 28px date/complications/sub-screens)
now render correctly using C++ namespace isolation to avoid symbol
collisions when included in the same translation unit.
Switched to LV_DISPLAY_RENDER_MODE_FULL with two 412KB PSRAM
frame buffers for tear-free scrolling. Combined with
LV_COLOR_FORMAT_RGB565_SWAPPED to eliminate byte-swap overhead
in the flush callback entirely.
Custom 96px font was not rendering because lv_font_conv defaults
to compressed bitmap format (bitmap_format=1) which requires
LV_USE_FONT_COMPRESSED. Regenerated with --no-compress.
Eliminated scroll tearing by switching from partial-strip rendering
to full-frame double buffering (LV_DISPLAY_RENDER_MODE_FULL).
Each buffer is 412KB in PSRAM (824KB total). Removes the need for
a separate byte-swap buffer — using LV_COLOR_FORMAT_RGB565_SWAPPED
so LVGL renders directly in the display's native byte order.
Also: hidden tileview scrollbar, enabled PSRAM in build flags.
LVGL 9.5.0 integration replacing direct framebuffer rendering.
Watch face with time, date, status bar (mode/battery), and three
complications (LoRa/GPS/Steps). Tileview swipe navigation between
five screens: watch face, radio status, GPS, messages, settings.
Haptic feedback on screen transitions via DRV2605.
Radio status screen shows frequency, LoRa params, RSSI bar gauge,
channel utilization, BLE state, and packet counts. GPS screen shows
coordinates, fix quality, altitude/speed, and beacon status.
Serial screenshot tool (scripts/screenshot.py) captures display
contents over USB CDC by sending trigger bytes and receiving the
shadow framebuffer as raw RGB565.
Build changes:
- FlashSize=16M in FQBN (bootloader embeds flash size, defaults 4MB)
- PSRAM=enabled for LVGL draw buffers and screenshot shadow buffer
- Custom 8MB app partition (partition_twatch.csv) for 16MB flash
- flash-twatch_ultra-full make target for full bootloader+partition+app flash
Architecture notes in code:
- display_init() must run BEFORE xl9555_init() (display power gate
defaults high at power-on, reordering causes black screen)
- LVGL draw buffers use separate swap buffer for RGB565 byte-order
conversion to avoid corrupting LVGL's internal buffer state
- Touch input registered via function pointer to decouple Gui.h from
touch library include order
Microphone on I2S_NUM_0 (PDM hardware constraint), speaker moved to
I2S_NUM_1. Both init at boot, shut down before deep sleep.
Mic provides raw audio read and RMS level measurement.
Boot beep disabled (speaker confirmed working).
I2S audio output on GPIO 9/10/11 (BCLK/WCLK/DOUT). Sine wave tone
generator with predefined alert sounds: beep, alert, success, error.
Boot beep confirms speaker is working. Speaker shut down before deep
sleep to release I2S driver.
Touch driver via SensorLib TouchDrvCST92xx on I2C 0x1A with interrupt
on GPIO 12. Touch events unblank the display and reset the blanking
timer. Display blanks after 10 seconds of inactivity.
XL9555 TOUCH_RST now explicitly released at boot. Touch init runs
with explicit I2C_SDA/I2C_SCL pins (same fix as XPowersLib).
BHI260AP firmware upload (~260KB at 1MHz I2C) takes ~10 seconds and
blocks the main loop. Moved init from setup() to a deferred check in
the main loop that runs once after 5 seconds when hw_ready is true.
This allows the radio and serial to come up immediately while the
sensor firmware uploads in the background.
Also restores XL9555 DRV_EN and DISP_EN enables at boot (lost during
earlier git checkout/stash operations).
Minimal I2C driver for the DRV2605 ERM vibration motor. 117 built-in
effects via the ERM effect library. Named constants for watch use
cases (click, bump, alert, buzz, tick, etc.).
Boot: sharp click on startup. Sleep: soft bump before entering deep
sleep. XL9555 DRV_EN and DISP_EN now explicitly enabled at boot.
Updated Makefile upload-twatch_ultra to set firmware hash after JTAG flash.
Three critical fixes:
- Remove PMU->enableSleep() which bricked AXP2101 I2C across resets
- Remove I2C pins (GPIO 2/3) from OPEN_DRAIN list — gpio_reset_pin()
already sets INPUT mode, and OPEN_DRAIN persists in battery-backed
RTC domain, corrupting the I2C bus
- Use esp_reset_reason() == ESP_RST_DEEPSLEEP for reliable cold boot
vs timer wake discrimination
Extracted twatch_enter_deep_sleep() shared helper used by both
sleep_now() and beacon_wake_cycle(). Adds co5300_sleep() call
before SPI.end() to properly shut down display controller.
Tested: sleep via long-press, wake via button, radio reconnects,
display re-initialises, I2C bus intact across multiple cycles.
Display now shows time (00:00 from reset RTC) and status line with
radio state, battery percentage, GPS satellites, and uptime counter.
Display blanking disabled until button input is implemented.
QSPI display driver confirmed working after I2C pin fix.
Covers: I2C pin default mismatch (XPowersLib), flash workflow
(esptool vs JTAG), deep sleep PMU caution, display power gate,
GPS/RTC differences from T-Beam Supreme, and I2C bus architecture.
Root cause: XPowersAXP2101(Wire) constructor defaults to SDA=8, SCL=9
(from generic esp32s3 variant pins_arduino.h). Its begin() method then
calls Wire.begin(8, 9), overriding our Wire.begin(3, 2). All I2C
traffic went to the wrong GPIO pins.
Fix: pass explicit I2C_SDA, I2C_SCL to the XPowersAXP2101 constructor
so it uses GPIO 3/2 (the T-Watch Ultra's actual I2C pins).
Also removed I2C diagnostic/recovery code that was debugging the
wrong problem.
Three photos from disassembly session:
- PCB overview with back cover removed
- PCB closeup showing component labels
- Battery connector detail
Factory firmware reflashed to verify hardware integrity. I2C bus and
display confirmed working under LilyGo firmware. RNode firmware I2C
init needs debugging — Wire.begin succeeds but no slaves respond.
Key finding: deep sleep GPIO OPEN_DRAIN configuration persists in the
battery-backed RTC domain, corrupting the I2C bus across reboots.
Wire.begin() returns false after deep sleep has been entered once.
I2C bus recovery attempt added (SCL clock-out + STOP condition) but
Wire (I2C0) peripheral remains stuck. Wire1 (I2C1) works on same pins
but no slave devices respond — bus lines may be held by stuck slaves.
Found from schematic: XL9555 DOES control VC_EN (display VCI power).
Display QSPI pins confirmed correct from schematic (sheet 4).
BHI260AP SensorLib removed (was causing boot failure due to firmware blob size).
Beacon wake cycle still disabled pending I2C fix.
Critical TODO: fix deep sleep to not set I2C pins to OPEN_DRAIN,
or add robust I2C bus recovery in early boot before PMU init.
Added SensorLib dependency (v0.3.1) with BHI260AP GPIO firmware
for controlling display power gate, haptic driver, and touch reset
via the sensor's auxiliary GPIO pins.
Fixed beacon wake cycle triggering on cold boot — disabled pending
proper deep sleep vs cold boot discrimination.
Display status: BHI260AP begin() returns false (firmware upload
failing). CO5300 QSPI driver still cannot reach display controller.
Both issues under investigation.
Standalone GPS beacon mode: when no KISS host is connected for 15s,
the RNode transmits position and battery telemetry over LoRa.
Two beacon paths:
- LXMF (recommended): encrypted per-packet messages with announces,
compatible with Sideband and any LXMF application. Supports IFAC
network authentication.
- Legacy JSON: plaintext or encrypted raw packets for simple collectors.
Key changes:
- GPS support for T-Beam Supreme S3 (L76K) and Heltec V4 (external)
- SX1262 radio fixes: IQ polarity, DCD preamble lockup, RX reliability
- LXMF identity management with NVS-backed Ed25519/X25519 keys
- IFAC authentication (CMD_IFAC_KEY 0x89) for private networks
- Per-channel serial isolation (USB, BLE, WiFi)
- GPS status page in OLED display rotation
- Provisioning via rnlog: provision-lxmf, provision-ifac
- Documentation in Documentation/BEACON.md