feat(crowpanel-35): add Elecrow CrowPanel 3.5" (ESP32-S3, ILI9488 + GT911) touch target

- New crowpanel_35 variant: display, SD, board and target files, plus board JSON
- Portrait mode is drawn rotated 180°; landscape stays at rotation 3
- Share the GT911 touch driver between Wio Tracker L2 and CrowPanel
  (WioTrackerL2Touch.cpp -> src/helpers/input/LgfxGt911Touch.cpp)
- platformio envs, flasher/site entries, release and merge-bin support
- Tests for the CrowPanel SD codec and merge-bin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132MkJ7RUV32mV9F7o4PJMm
This commit is contained in:
Michael A. Cojocari
2026-10-02 20:16:06 -04:00
co-authored by Claude Opus 5.5
parent f7402e628c
commit dbecef0b6f
29 changed files with 1461 additions and 118 deletions
+26
View File
@@ -14,6 +14,7 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases
| Heltec V4 + TFT | ESP32-S3, SX1262 | 2.4" 240x320 touch (CHSC6x) | Web flasher | Stable | Fully supported; Expansion Kit sensors, V4.3 high-gain RX toggle |
| Tanmatsu | ESP32-P4 + ESP32-C6, SX1262 | 4" 800x480, 69-key keyboard (no touch) | Tanmatsu app store on the device (runs under the badge.team launcher, not web-flashable) | Store tracks the test channel | Fully supported; LoRa + Wi-Fi + Bluetooth simultaneously, standalone and companion in one |
| Elecrow ThinkNode M9 | ESP32-S3, LR1110 | 2.4" 240x320 (no touch), I2C QWERTY + d-pad | Web flasher | Beta (new in beta_38) | Hardware-complete community port by ded (#138): GPS, microSD, buzzer, lock screen, d-pad navigation |
| Elecrow CrowPanel Advance 3.5 | ESP32-S3, external SX1262 | 3.5" ILI9488 480x320 touch (GT911) | Web flasher (beta) | Beta, hardware validation pending | Requires an SX1262 module in the LoRa expansion slot; not for other CrowPanel models |
| RAK WisMesh Tap V2 (RAK3312) | ESP32-S3, SX1262 | Touch display (LovyanGFX, 30+ fps) | Web flasher | Beta (new in beta_38) | Early community port by Ethac.chen (#136); core mesh, chat and map working |
| LilyGo T-Lora Pager | ESP32-S3, LR1121 or SX1262 | 2.33" IPS LCD, 480x222 (no touch), QWERTY + rotary encoder | Web flasher (pick the build matching your radio) | Beta (new in beta_45) | Fully supported; keyboard-first navigation, microSD, map tile packs |
| Heltec V4-R8 + Expansion Kit V2 | ESP32-S3 (8 MB octal PSRAM), SX1262 | 2.4" touch (CHSC6x) | Web flasher | Beta (new in beta_45) | As the V4 plus microSD and the Expansion Kit sensors; buzzer supported |
@@ -98,6 +99,31 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases
- **Seeed Wio Tracker L2**: pre-release touch target under active bring-up. Builds are for
development and hardware validation only; no public release artifact is
promised until the port is verified.
- **CrowPanel Advance 3.5**: `crowpanel_35_companion_radio_touch` is a
16 MB flash / 8 MB OPI PSRAM build for the ILI9488/GT911 panel and an SX1262
in the expansion slot. It starts upright in 480x320 landscape; Display
settings can switch to 320x480 portrait, rotated 180 degrees. The selected
orientation persists across reboots; landscape is only the default when no
saved choice exists.
It uses the same compact font and control sizing as Wio Tracker L2.
Fit the radio before booting. GPIO0 is shared by BOOT and the radio's chip
select, so BOOT is not configured as a wake/unlock input on stock wiring.
Screen-off remains touch-wakeable; hard lock is disabled without a separate
unlock button. See the
[official schematic](https://github.com/Elecrow-RD/CrowPanel-Advance-HMI-ESP32-AI-Display/blob/master/3.5/schematic/ESP32%20Display%203.5%20inch%20V1.0.sch)
and [reference radio pin map](https://github.com/meshtastic/firmware/blob/master/variants/esp32s3/elecrow_panel/variant.h).
An optional external GPS can be connected to UART1 (CrowPanel RX GPIO18 /
TX GPIO17, 9600 baud). Data
can use a FAT32 microSD card for data, offline maps, File Manager and browser
Transfer. The card uses software SPI (SCLK 5, MISO 4, MOSI 6, CS 7), leaving
the display and radio SPI hosts untouched; missing or unreadable cards fall
back to SPIFFS. The dedicated LittleFS partition remains the internal tile
cache. Cards are never auto-formatted; format them on a computer. Serial
uses the board's USB-to-UART bridge. Battery voltage monitoring is not
supported. Display, touch, radio and SD still need validation on a physical
CrowPanel. Like Wio Tracker L2, it is included in the all-target builds and local beta release
scripts, not the two-board stable CI workflow. Select it in the upload
helper with `scripts/build-upload-monitor.sh --crowpanel-35`.
## Requested boards
+6
View File
@@ -91,5 +91,11 @@ driver, variants/tdisplay_p4/P4Audio.cpp)
LovyanGFX (RAK WisMesh Tap V2 display backend)
Copyright (c) lovyan03 — FreeBSD License — https://github.com/lovyan03/LovyanGFX
Camillia-MT (CrowPanel Advance 3.5 software-SPI microSD backend)
Copyright (c) 2025-2026 Michael A. Cojocari and contributors
GNU General Public License v3.0 or later
Adapted from src/sd_soft_spi.cpp; integrated with WadaMesh's filesystem
API and concurrent storage consumers.
This list is not exhaustive and is expanded as dependencies are vendored or
added.
+4 -2
View File
@@ -8,7 +8,7 @@
<p align="center"><b>A real touchscreen UI for your mesh radio.</b> &middot; open source &middot; GPL-3.0</p>
Touch-UI [MeshCore](https://github.com/meshcore-dev/MeshCore) companion-radio
firmware for the **LilyGo T-Deck / T-Deck Plus**, **Heltec V4 + TFT** and eight other boards
firmware for the **LilyGo T-Deck / T-Deck Plus**, **Heltec V4 + TFT** and other boards
(ESP32-S3).
An LVGL touch UI — map, chat, contacts, channels, settings — split out of
@@ -29,6 +29,7 @@ per-board status.
- Heltec V4-R8 + Expansion Kit V2 — env `heltec_v4_r8_tft_companion_radio_usb_tcp_touch` (beta)
- LilyGo T-Display P4 — built from `tdisplay_p4/` (ESP-IDF); AMOLED + SX1262 by default, with `WADA_P4_LCD=1` for TFT-LCD and `WADA_P4_LR2021=1` for LR2021 (beta)
- Attaky Mesh Series — env `attaky_mesh_series_companion_radio_touch` (beta)
- Elecrow CrowPanel Advance 3.5 — env `crowpanel_35_companion_radio_touch` (beta, hardware validation pending; requires an SX1262 in the expansion slot)
## Architecture
@@ -98,7 +99,8 @@ automatically:
```bash
pio run -e heltec_v4_tft_companion_radio_usb_tcp_touch # Heltec V4 TFT
pio run -e LilyGo_TDeck_companion_radio_touch # LilyGo T-Deck
# or just `pio run` to build both
pio run -e crowpanel_35_companion_radio_touch # CrowPanel Advance 3.5
# or just `pio run` to build the configured default environments
```
Flash with the NVS-preserving 4-component chain (bootloader / partitions /
+45
View File
@@ -0,0 +1,45 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "default_16MB.csv",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "opi",
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": ["wifi", "bluetooth", "lora", "gps"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Elecrow CrowPanel Advance 3.5 (N16R8)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"use_1200bps_touch": false,
"wait_for_upload_port": false,
"require_upload_port": true,
"speed": 921600
},
"url": "https://www.elecrow.com/",
"vendor": "Elecrow"
}
+7
View File
@@ -104,6 +104,13 @@
<esp-web-install-button manifest="./manifest-tlora-pager-sx1262.json"></esp-web-install-button>
<span class="unsupported">Use Chrome or Edge on desktop (Web Serial).</span>
</div>
<div class="card">
<h2>Elecrow CrowPanel Advance 3.5 <span class="beta">Beta</span></h2>
<p>3.5&quot; touch display, microSD, and an SX1262 module in the LoRa expansion slot.</p>
<p class="warn">For the <b>Advance 3.5</b> only. Hardware validation is pending; the SX1262 module must be attached.</p>
<esp-web-install-button manifest="https://firmware.wadamesh.com/latest-beta/manifest-crowpanel-35.json"></esp-web-install-button>
<span class="unsupported">Use Chrome or Edge on desktop (Web Serial).</span>
</div>
</div>
<div class="note">
<b>How it works:</b> plug the device in over USB, click <b>Connect</b>, pick the serial port,
+5
View File
@@ -517,6 +517,11 @@
<esp-web-install-button id="inst-m9" manifest="https://firmware.wadamesh.com/latest-beta/manifest-thinknode-m9.json"></esp-web-install-button>
<a class="dl2" data-dl-board="thinknode-m9" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-thinknode-m9-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board: beta channel only until the next stable.</p></div>
<div class="board" data-search="elecrow crowpanel advance 3.5 esp32-s3 ili9488 gt911 sx1262 touchscreen lora"><h4>CrowPanel Advance 3.5 <span style="font-weight:600;color:var(--mut)">(experimental)</span></h4><p>3.5&quot; 480&times;320 touch display; requires an SX1262 module in the LoRa expansion slot.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Hardware validation pending</b>This build is for the Advance 3.5 with an external SX1262, not other CrowPanel models. Display, touch, radio and software-SPI microSD are ported from the hardware reference; behaviour on a physical unit has not been verified. Landscape is the default, with portrait available in Display settings. No battery monitoring.</span></span>
<esp-web-install-button id="inst-crowpanel-35" manifest="https://firmware.wadamesh.com/latest-beta/manifest-crowpanel-35.json"></esp-web-install-button>
<a class="dl2" data-dl-board="crowpanel-35" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-crowpanel-35-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">Beta only. Attach the SX1262 before booting; flash with care and report what works.</p></div>
</div>
</details>
+18 -19
View File
@@ -77,25 +77,24 @@ def merge_bin_action(source, target, env):
"$ESP32_APP_OFFSET",
source[0].get_abspath(),
]
merge_cmd = " ".join(
[
'"$PYTHONEXE"',
'"$OBJCOPY"',
"--chip",
board_config.get("build.mcu", "esp32"),
"merge_bin",
"-o",
merged_bin,
"--flash_mode",
board_config.get("build.flash_mode", "dio"),
"--flash_freq",
"${__get_board_f_flash(__env__)}",
"--flash_size",
board_config.get("upload.flash_size", "4MB"),
*flash_images,
]
)
env.Execute(merge_cmd)
merge_cmd = [
"$PYTHONEXE",
"$OBJCOPY",
"--chip",
board_config.get("build.mcu", "esp32"),
"merge_bin",
"-o",
merged_bin,
"--flash_mode",
board_config.get("build.flash_mode", "dio"),
"--flash_freq",
"${__get_board_f_flash(__env__)}",
"--flash_size",
board_config.get("upload.flash_size", "4MB"),
*flash_images,
]
merge_args = [env.subst(str(arg)) for arg in merge_cmd]
return subprocess.run(merge_args, env=env["ENV"]).returncode
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", copy_app_bin_to_out)
+151
View File
@@ -1299,6 +1299,157 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
knolleary/PubSubClient @ ^2.8.0
; Elecrow CrowPanel Advance 3.5 — ESP32-S3, ILI9488/GT911 and SX1262 expansion.
; ======================================================================
[env:crowpanel_35_companion_radio_touch]
platform = platformio/espressif32@6.11.0
framework = arduino
board = crowpanel_35
monitor_speed = 115200
upload_speed = 921600
board_build.arduino.memory_type = qio_opi
board_build.flash_size = 16MB
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
board_build.partitions = partitions_tft_touch.csv
extra_scripts = pre:scripts/build/pre_gen_baked.py, pre:scripts/inject_wifi_env.py, pre:scripts/build/patch_lvgl_anim_uaf.py, pre:scripts/build/patch_lgfx_dmadesc.py, merge-bin.py
build_unflags =
-std=gnu++11
build_flags =
-I"${platformio.libdeps_dir}/${this.__env__}/MicroNMEA/src"
-I"${platformio.libdeps_dir}/${this.__env__}/LovyanGFX/src"
-I variants/crowpanel_35
-I include
-I src
-I src/ui-touch
-Wno-deprecated-declarations
-Wno-unused-parameter
-std=gnu++17
-DNDEBUG
-DRADIOLIB_STATIC_ONLY=1
-DRADIOLIB_GODMODE=1
-D MC_VENDORED_TOUCH_APP
-D ESP32_PLATFORM
-D HAS_CROWPANEL_35=1
-D BOARD_HAS_PSRAM=1
-D ESP32_CPU_FREQ=240
-D CORE_DEBUG_LEVEL=0
-D LORA_FREQ=869.618
-D LORA_BW=62.5
-D LORA_SF=8
-D USE_SX1262
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
; GPIO0 is shared by BOOT and LoRa chip select; leave the wake input disabled.
-D P_LORA_NSS=0
-D P_LORA_RESET=2
-D P_LORA_DIO_1=1
-D P_LORA_BUSY=46
-D P_LORA_SCLK=10
-D P_LORA_MISO=9
-D P_LORA_MOSI=3
-D SX126X_DIO2_AS_RF_SWITCH=true
-D SX126X_DIO3_TCXO_VOLTAGE=3.3
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D RADIOLIB_EXCLUDE_CC1101=1
-D RADIOLIB_EXCLUDE_RF69=1
-D RADIOLIB_EXCLUDE_SX1231=1
-D RADIOLIB_EXCLUDE_SI443X=1
-D RADIOLIB_EXCLUDE_RFM2X=1
-D RADIOLIB_EXCLUDE_SX128X=1
-D RADIOLIB_EXCLUDE_AFSK=1
-D RADIOLIB_EXCLUDE_AX25=1
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
-D RADIOLIB_EXCLUDE_MORSE=1
-D RADIOLIB_EXCLUDE_APRS=1
-D RADIOLIB_EXCLUDE_BELL=1
-D RADIOLIB_EXCLUDE_RTTY=1
-D RADIOLIB_EXCLUDE_SSTV=1
-D ENV_INCLUDE_GPS=1
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=0
-D ENV_INCLUDE_BMP280=0
-D ENV_INCLUDE_SHTC3=0
-D ENV_INCLUDE_SHT4X=0
-D ENV_INCLUDE_LPS22HB=0
-D ENV_INCLUDE_INA3221=0
-D ENV_INCLUDE_INA219=0
-D ENV_INCLUDE_INA226=0
-D ENV_INCLUDE_INA260=0
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=0
-D ENV_INCLUDE_BME680=0
-D ENV_INCLUDE_BMP085=0
; Core calls Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX), i.e. (ESP RX, ESP TX).
-D PIN_GPS_TX=18
-D PIN_GPS_RX=17
-D GPS_BAUD_RATE=9600
-D PIN_BOARD_SDA=15
-D PIN_BOARD_SCL=16
; Software-SPI SD: the radio and display already occupy both SPI hosts.
-D PIN_SD_CS=7
-D PIN_SD_SCLK=5
-D PIN_SD_MISO=4
-D PIN_SD_MOSI=6
-D DISPLAY_CLASS=CrowPanel35Display
-D DISPLAY_ROTATION=3
-D DISPLAY_SCALE_X=1.0
-D DISPLAY_SCALE_Y=1.0
-D UI_LVGL=1
-D HAS_TOUCH_UI=1
-D FIRMWARE_OTA_ENV='"crowpanel_35_companion_radio_touch"'
-D ENABLE_ADVERT_ON_BOOT=0
-D LV_CONF_PATH=lv_conf.h
-D LV_CONF_INCLUDE_SIMPLE=1
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000
-D WS_PORT=8765
-D BLE_PIN_CODE=123456
-D ENABLE_PRIVATE_KEY_IMPORT=1
-D ENABLE_PRIVATE_KEY_EXPORT=1
-D CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=1
-D CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384
-D CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
build_src_filter =
+<*.cpp>
+<*.c>
+<helpers/*.cpp>
+<helpers/esp32/*.cpp>
+<helpers/input/*.cpp>
-<helpers/input/TDeckTouch.cpp>
+<helpers/ui/UIColorPalette.cpp>
+<ui-touch/*.cpp>
+<ui-touch/*.c>
+<../variants/crowpanel_35/*.cpp>
lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.17.4
SPI
Wire
SD
jgromes/RadioLib @ ^7.6.0
rweather/Crypto @ ^0.4.0
adafruit/RTClib @ ^2.1.3
melopero/Melopero RV3028 @ ^1.1.0
electroniccats/CayenneLPP @ 1.6.1
h2zero/NimBLE-Arduino @ 1.4.3
stevemarple/MicroNMEA @ ^2.0.6
ESP32Async/ESPAsyncWebServer @ 3.10.3
file://arch/esp32/AsyncElegantOTA
lvgl/lvgl @ ^8.3.11
lovyan03/LovyanGFX @ 1.2.27
densaugeo/base64 @ ~1.4.0
knolleary/PubSubClient @ ^2.8.0
[env:tlora_pager_lr1121_companion_radio_touch]
platform = platformio/espressif32@6.11.0
framework = arduino
+5
View File
@@ -14,6 +14,7 @@ HELTEC_ENV="heltec_v4_tft_companion_radio_usb_tcp_touch"
HELTEC_R8_ENV="heltec_v4_r8_tft_companion_radio_usb_tcp_touch"
ATTAKY_ENV="attaky_mesh_series_companion_radio_touch"
WIO_ENV="wio_tracker_l2_companion_radio_touch"
CROWPANEL_ENV="crowpanel_35_companion_radio_touch"
PAGER_LR1121_ENV="tlora_pager_lr1121_companion_radio_touch"
PAGER_SX1262_ENV="tlora_pager_sx1262_companion_radio_touch"
M9_ENV="ThinkNode_M9_companion_radio_touch"
@@ -92,6 +93,7 @@ env_label() {
"$HELTEC_R8_ENV") echo "Heltec V4-R8" ;;
"$ATTAKY_ENV") echo "Attaky Mesh Series" ;;
"$WIO_ENV") echo "Seeed Wio Tracker L2" ;;
"$CROWPANEL_ENV") echo "Elecrow CrowPanel Advance 3.5" ;;
"$PAGER_LR1121_ENV") echo "LilyGo T-LoRa Pager LR1121" ;;
"$PAGER_SX1262_ENV") echo "LilyGo T-LoRa Pager SX1262" ;;
"$M9_ENV") echo "ThinkNode M9" ;;
@@ -117,6 +119,7 @@ Devices:
--heltec-r8 Heltec V4-R8
--attaky Attaky Mesh Series
--wio Seeed Wio Tracker L2
--crowpanel-35 Elecrow CrowPanel Advance 3.5
--pager-lr1121 LilyGo T-LoRa Pager LR1121
--pager-sx1262 LilyGo T-LoRa Pager SX1262
--m9 ThinkNode M9
@@ -167,6 +170,7 @@ prompt_for_device() {
"$HELTEC_R8_ENV"
"$ATTAKY_ENV"
"$WIO_ENV"
"$CROWPANEL_ENV"
"$PAGER_LR1121_ENV"
"$PAGER_SX1262_ENV"
"$RAK_ENV"
@@ -245,6 +249,7 @@ while [ $# -gt 0 ]; do
--heltec-r8) select_env "$HELTEC_R8_ENV" ;;
--attaky) select_env "$ATTAKY_ENV" ;;
--wio) select_env "$WIO_ENV" ;;
--crowpanel-35) select_env "$CROWPANEL_ENV" ;;
--pager-lr1121) select_env "$PAGER_LR1121_ENV" ;;
--pager-sx1262) select_env "$PAGER_SX1262_ENV" ;;
--m9) select_env "$M9_ENV" ;;
+1
View File
@@ -44,6 +44,7 @@ BOARDS = {
"manifest-tlora-pager-sx1262.json": ("wadamesh - LilyGo T-LoRa Pager (SX1262)", "wadamesh-tlora-pager-sx1262-merged.bin", "ESP32-S3"),
"manifest-attaky.json": ("wadamesh - Attaky Core (experimental)", "wadamesh-attaky-merged.bin", "ESP32-S3"),
"manifest-wio-tracker-l2.json": ("wadamesh - Seeed Wio Tracker L2 (experimental)", "wadamesh-wio-tracker-l2-merged.bin", "ESP32-S3"),
"manifest-crowpanel-35.json": ("wadamesh - Elecrow CrowPanel Advance 3.5 (experimental)", "wadamesh-crowpanel-35-merged.bin", "ESP32-S3"),
"manifest-tdeck-pro-v1-0.json": ("wadamesh - LilyGo T-Deck Pro V1.0 (experimental)", "wadamesh-tdeck-pro-v1-0-merged.bin", "ESP32-S3"),
"manifest-tdeck-pro-v1-1.json": ("wadamesh - LilyGo T-Deck Pro V1.1 (experimental)", "wadamesh-tdeck-pro-v1-1-merged.bin", "ESP32-S3"),
"manifest-tdeck-max.json": ("wadamesh - LilyGo T-Deck Max (experimental)", "wadamesh-tdeck-max-merged.bin", "ESP32-S3"),
+2 -2
View File
@@ -29,7 +29,7 @@ DEST="${WADAMESH_VPS:-}"; DEST_PATH="${WADAMESH_VPS_PATH:-/srv/wadamesh/firmware
# env:binname pairs — plain string form (works on macOS's bash 3.2; no associative arrays).
# All S3/PIO boards. The T-Display P4 is an ESP32-P4 IDF build (tdisplay_p4/) handled OUT of band
# — see the release skill / the P4 build+merge step — because this loop is PlatformIO-only.
ENVS="heltec_v4_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-tft LilyGo_TDeck_companion_radio_touch:wadamesh-tdeck ThinkNode_M9_companion_radio_touch:wadamesh-thinknode-m9 rak_tap_v2_companion_radio_touch:wadamesh-rak-tap-v2 heltec_v4_r8_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-r8-tft tlora_pager_lr1121_companion_radio_touch:wadamesh-tlora-pager-lr1121 tlora_pager_sx1262_companion_radio_touch:wadamesh-tlora-pager-sx1262 attaky_mesh_series_companion_radio_touch:wadamesh-attaky wio_tracker_l2_companion_radio_touch:wadamesh-wio-tracker-l2 LilyGo_TDeck_Pro_v1_0_companion_radio_touch:wadamesh-tdeck-pro-v1-0 LilyGo_TDeck_Pro_v1_1_companion_radio_touch:wadamesh-tdeck-pro-v1-1 LilyGo_TDeck_Max_companion_radio_touch:wadamesh-tdeck-max"
ENVS="heltec_v4_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-tft LilyGo_TDeck_companion_radio_touch:wadamesh-tdeck ThinkNode_M9_companion_radio_touch:wadamesh-thinknode-m9 rak_tap_v2_companion_radio_touch:wadamesh-rak-tap-v2 heltec_v4_r8_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-r8-tft tlora_pager_lr1121_companion_radio_touch:wadamesh-tlora-pager-lr1121 tlora_pager_sx1262_companion_radio_touch:wadamesh-tlora-pager-sx1262 attaky_mesh_series_companion_radio_touch:wadamesh-attaky wio_tracker_l2_companion_radio_touch:wadamesh-wio-tracker-l2 crowpanel_35_companion_radio_touch:wadamesh-crowpanel-35 LilyGo_TDeck_Pro_v1_0_companion_radio_touch:wadamesh-tdeck-pro-v1-0 LilyGo_TDeck_Pro_v1_1_companion_radio_touch:wadamesh-tdeck-pro-v1-1 LilyGo_TDeck_Max_companion_radio_touch:wadamesh-tdeck-max"
# Per-channel destination paths.
if [ "$MODE" = "stable" ]; then
@@ -53,7 +53,7 @@ fi
mkdir -p "$ARCH/$TAG" "$FEED"
if [ "$MODE" = "beta" ]; then
# 2b. TEST build: compile both boards (tag + version embedded) into the beta archive + feed.
# 2b. TEST build: compile all PlatformIO boards (tag + version embedded) into the beta archive + feed.
# Firmware data (About screen + the app's device info) is derived here, never
# hand-maintained: the tag, the core version actually pinned in platformio.ini,
# and today's build date. Every staged binary is verified to carry them below —
+11 -3
View File
@@ -3,7 +3,11 @@
#include "DataStore.h"
#include <helpers/AdvertDataHelpers.h> // ADV_TYPE_NONE (blank-record skip in loadContacts)
#if defined(ESP32)
#if defined(HAS_CROWPANEL_35)
#include <CrowPanel35SD.h>
#else
#include <SD.h>
#endif
#include "helpers/esp32/WdtHeavyGuard.h" // suspend core-0 idle WDT during the (SPIFFS-GC-prone) contact write
#endif
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) || defined(HAS_WIO_TRACKER_L2)
@@ -1127,9 +1131,13 @@ bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) {
// Creates the folders, repoints _fs + the identity store, and sets the path
// prefix so every subsequent read/write/exists/remove lands under /meshcomod.
bool DataStore::useSdStorage() {
if (!SD.exists("/meshcomod")) SD.mkdir("/meshcomod");
if (!SD.exists("/meshcomod/bl")) SD.mkdir("/meshcomod/bl");
if (!SD.exists("/meshcomod/identity")) SD.mkdir("/meshcomod/identity");
static const char* const directories[] = { "/meshcomod", "/meshcomod/bl", "/meshcomod/identity" };
for (const char* path : directories) {
if (!SD.exists(path) && !SD.mkdir(path)) {
Serial.printf("[STORE] SD data directory unavailable: %s\n", path);
return false;
}
}
strncpy(_root, "/meshcomod", sizeof(_root) - 1);
_root[sizeof(_root) - 1] = '\0';
_fs = &SD;
+4
View File
@@ -2,7 +2,11 @@
#if defined(ESP32)
#include <FS.h>
#if defined(HAS_CROWPANEL_35)
#include <CrowPanel35SD.h>
#else
#include <SD.h>
#endif
#include <SPIFFS.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
+13 -5
View File
@@ -57,6 +57,11 @@ static const uint32_t DEFAULT_ACCENT = 0x15B6A6u; // brand teal
static const bool DEFAULT_DC_SHOW = true;
static const uint8_t DEFAULT_SIG_PROBE_EN = 1; // signal discover probe ON
static const uint16_t DEFAULT_SIG_POLL_MIN = 5; // minutes between probes
#if defined(HAS_CROWPANEL_35)
static const uint8_t DEFAULT_UI_ROTATION = 3; // upright ILI9488 landscape
#else
static const uint8_t DEFAULT_UI_ROTATION = 0;
#endif
using TouchCfg = TouchPrefsSchema::Config;
@@ -91,7 +96,7 @@ static void cfgSetDefaults(TouchCfg& c) {
c.kb_layout = DEFAULT_KB_LAYOUT;
c.kb_secondary = DEFAULT_KB_SECONDARY;
c.ui_lang = 0;
c.ui_rotation = 0;
c.ui_rotation = DEFAULT_UI_ROTATION;
c.dc_show = DEFAULT_DC_SHOW ? 1 : 0;
c.use_miles = 0;
c.tiles_from_sd = 0;
@@ -356,7 +361,7 @@ static void cfgLoadOrMigrate() {
if (s_prefs.isKey(LK_USE_MILES)) s_cfg.use_miles = s_prefs.getBool(LK_USE_MILES, false) ? 1 : 0;
if (s_prefs.isKey(LK_TILES_FROM_SD))s_cfg.tiles_from_sd= s_prefs.getBool(LK_TILES_FROM_SD, false) ? 1 : 0;
if (s_prefs.isKey(LK_UI_LANG)) s_cfg.ui_lang = s_prefs.getUChar(LK_UI_LANG, 0);
if (s_prefs.isKey(LK_UI_ROTATION)) s_cfg.ui_rotation = s_prefs.getUChar(LK_UI_ROTATION, 0);
if (s_prefs.isKey(LK_UI_ROTATION)) s_cfg.ui_rotation = s_prefs.getUChar(LK_UI_ROTATION, DEFAULT_UI_ROTATION);
if (s_prefs.isKey(LK_BATT_FULL)) s_cfg.batt_full_mv = s_prefs.getUShort(LK_BATT_FULL, 0);
if (s_prefs.isKey(LK_GPS_BAUD)) s_cfg.gps_baud = s_prefs.getUInt(LK_GPS_BAUD, 0);
// Enabled-layout mask: legacy used 0xFFFF as the "never written" sentinel and
@@ -1628,15 +1633,18 @@ bool touchPrefsSetLangFile(const char* code) {
uint8_t touchPrefsGetUiRotation() {
if (!s_begun) touchPrefsBegin();
uint8_t r = s_cfg.ui_rotation; // default = portrait
return (r <= 3) ? r : 0;
const uint8_t r = s_cfg.ui_rotation;
return (s_cfg_loaded && r <= 3) ? r : DEFAULT_UI_ROTATION;
}
bool touchPrefsSetUiRotation(uint8_t rot) {
if (rot > 3) rot = 0;
if (!s_begun) touchPrefsBegin();
const uint8_t previous = s_cfg.ui_rotation;
s_cfg.ui_rotation = rot;
return cfgFlush();
if (cfgFlush()) return true;
s_cfg.ui_rotation = previous;
return false;
}
uint16_t touchPrefsGetBattFullMv() {
+1 -1
View File
@@ -6,7 +6,7 @@
#if defined(MULTI_TRANSPORT_COMPANION) && \
(defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || \
defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) || \
defined(HAS_WIO_TRACKER_L2) || defined(HAS_TDISPLAY_P4) || \
defined(HAS_WIO_TRACKER_L2) || defined(HAS_CROWPANEL_35) || defined(HAS_TDISPLAY_P4) || \
defined(HAS_TANMATSU))
#define WADA_WEB_FILE_TRANSFER 1
#else
@@ -1,15 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#if defined(HAS_WIO_TRACKER_L2) && defined(ESP32)
#if (defined(HAS_WIO_TRACKER_L2) || defined(HAS_CROWPANEL_35)) && defined(ESP32)
#include <Arduino.h>
#include <Wire.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <helpers/input/HeltecV4CapTouch.h>
#include <helpers/ui/MomentaryButton.h>
#include "WioTrackerL2Display.h"
extern WioTrackerL2Display display;
#if defined(HAS_CROWPANEL_35)
#include <CrowPanel35Display.h>
extern CrowPanel35Display display;
#else
#include <WioTrackerL2Display.h>
extern WioTrackerL2Display display;
#endif
namespace {
bool s_ready = false;
@@ -56,7 +61,18 @@ void pollTask(void*) {
}
}
bool heltecV4CapTouchBegin() { s_ready = true; return true; }
bool heltecV4CapTouchBegin() {
#if defined(HAS_CROWPANEL_35)
static uint32_t nextProbeMs = 0;
const uint32_t now = millis();
if (nextProbeMs && (int32_t)(now - nextProbeMs) < 0) return false;
nextProbeMs = now + 1000;
Wire.beginTransmission(0x5D);
if (Wire.endTransmission() != 0) return false;
#endif
s_ready = true;
return true;
}
int heltecV4CapTouchCheck() { if (!s_ready) return BUTTON_EVENT_NONE; poll(); return BUTTON_EVENT_NONE; }
bool heltecV4CapTouchPopTap(uint16_t* x, uint16_t* y) {
if (!s_tapPending) return false; s_tapPending = false;
@@ -72,7 +88,12 @@ bool heltecV4CapTouchPopSwipe(int8_t* x, int8_t* y) {
bool heltecV4CapTouchStartBackgroundPoll(uint32_t periodMs) {
if (s_async || !s_ready) return false;
s_periodMs = periodMs < 4 ? 4 : (periodMs > 100 ? 100 : periodMs);
if (xTaskCreatePinnedToCore(pollTask, "wio_l2_touch", 3072, nullptr, 2, &s_task, 0) != pdPASS) return false;
#if defined(HAS_CROWPANEL_35)
const char* name = "crow_touch";
#else
const char* name = "wio_l2_touch";
#endif
if (xTaskCreatePinnedToCore(pollTask, name, 3072, nullptr, 2, &s_task, 0) != pdPASS) return false;
s_async = true; return true;
}
bool heltecV4CapTouchIsAsyncPolling() { return s_async; }
@@ -80,7 +101,13 @@ bool heltecV4CapTouchIsSwiping() { return s_swiping; }
void heltecV4CapTouchSetRotation(uint8_t) {}
void heltecV4CapTouchSetPointRotation(uint8_t) {}
void heltecV4CapTouchSetSlowPoll(bool slow) { s_periodMs = slow ? 50 : 8; }
const char* heltecV4CapTouchDebug() { return "Wio Tracker L2 GT911"; }
const char* heltecV4CapTouchDebug() {
#if defined(HAS_CROWPANEL_35)
return s_ready ? "CrowPanel Advance 3.5 GT911" : "CrowPanel GT911 not responding at 0x5D";
#else
return "Wio Tracker L2 GT911";
#endif
}
void heltecV4CapTouchGetRaw(uint16_t* x, uint16_t* y) { if (x) *x = s_x; if (y) *y = s_y; }
#endif
+27 -5
View File
@@ -56,9 +56,13 @@ static uint32_t _atoi(const char* sp) {
#include <SD_MMC.h>
#include <WioTrackerL2Io.h>
#endif
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
#if defined(HAS_CROWPANEL_35)
#include <CrowPanel35SD.h>
#else
#include <SD.h>
#include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards)
#endif
#include <Preferences.h>
#if defined(TLORA_PAGER)
#include <mbedtls/sha256.h>
@@ -270,7 +274,7 @@ extern volatile uint8_t g_wifi_last_disc_reason;
#include "esp_task_wdt.h" // task-watchdog reconfigure — see setup() (GH #56)
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
// ---- SPIFFS -> SD migration (fixes the beta_36 "lost my profile" upgrades) ----
// Users who flipped "Store data on SD" before beta_36 ran with the toggle IGNORED
// (the flag never survived a reboot), so their identity/prefs/contacts kept living
@@ -858,6 +862,9 @@ void setup() {
uint8_t r = touchPrefsGetUiRotation();
if (r == 1) display.setDisplayRotation(1);
else if (r == 3) display.setDisplayRotation(3);
#if defined(HAS_CROWPANEL_35)
else display.setDisplayRotation(0); // restore saved portrait
#endif
}
#endif
#if defined(HAS_TDECK_PRO)
@@ -1082,8 +1089,9 @@ void setup() {
Serial.println("[BOOT] wio-l2 SD_MMC unavailable; using SPIFFS");
}
#endif
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
{
#if !defined(HAS_CROWPANEL_35)
#if defined(TLORA_PAGER)
extern SPIClass* tloraPagerSharedSPI(); // display/radio/SD shared bus
#elif defined(HELTEC_LORA_V4_R8)
@@ -1092,6 +1100,7 @@ void setup() {
extern SPIClass* m9SharedSPI(); // radio/display/SD shared bus
#else
extern SPIClass* tdeckSharedSPI(); // LoRa SPI bus
#endif
#endif
bool setup_done = false;
{ Preferences _p; if (_p.begin("touch", true)) {
@@ -1115,6 +1124,7 @@ void setup() {
// device has no usable SPIFFS, the user opted in, or it's a brand-new device.
bool want_full_sd = !spiffs_ok || use_sd_pref || fresh_install;
#if !defined(HAS_CROWPANEL_35)
#if defined(TLORA_PAGER)
SPIClass* _spi = tloraPagerSharedSPI();
#elif defined(HELTEC_LORA_V4_R8)
@@ -1123,9 +1133,17 @@ void setup() {
SPIClass* _spi = m9SharedSPI();
#else
SPIClass* _spi = tdeckSharedSPI();
#endif
#endif
bool sd_mounted = false;
#if defined(TLORA_PAGER)
#if defined(HAS_CROWPANEL_35)
sdMountDiagBegin();
const bool begin_ok = SD.begin();
sd_mounted = begin_ok && SD.cardType() != CARD_NONE;
sdMountDiagAttempt(0, begin_ok, sd_mounted);
sdMountDiagSetMounted(sd_mounted, 0);
if (!sd_mounted) Serial.println("[BOOT] CrowPanel SD unavailable; using SPIFFS");
#elif defined(TLORA_PAGER)
sdMountDiagBegin();
if (!_spi) {
Serial.println("[BOOT] SD: shared SPI unavailable");
@@ -1317,6 +1335,10 @@ void setup() {
if (adopt) {
sd_storage = store.useSdStorage();
g_full_data_on_sd = sd_storage;
if (!sd_storage) {
g_contacts_on_sd = false;
Serial.println("[BOOT] SD data directory setup failed; using SPIFFS");
}
// On a genuine first run, persist the auto-pick so the "Store data on SD"
// toggle reflects it and the choice sticks on every later boot.
if (fresh_install && sd_storage && !use_sd_pref) {
@@ -1402,7 +1424,7 @@ void setup() {
#if defined(HAS_WIO_TRACKER_L2)
SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD_MMC : (fs::FS*)&SPIFFS,
sd_storage ? "/meshcomod" : "/prefs");
#elif defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#elif defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD : (fs::FS*)&SPIFFS,
sd_storage ? "/meshcomod" : "/prefs");
#else
+149 -71
View File
@@ -71,7 +71,9 @@ static void* wadaMp3Scratch() { return s_wada_mp3_scratch; }
static inline esp_err_t esp_core_dump_image_erase() { return ESP_FAIL; }
#endif
#endif
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#if defined(HAS_CROWPANEL_35)
#include <CrowPanel35SD.h>
#elif defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#include <SD.h> // microSD — T-Deck/M9 on the LoRa SPI, V4-R8 on the TFT SPI
#include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards)
#include "sd_diskio.h" // internal Arduino-SD drive helpers (sdcard_init / sd_*_raw)
@@ -186,6 +188,8 @@ static_assert(ChannelSenderSplit::kMaxWireName >= (size_t)UITask::MAX_SENDER_NAM
#elif defined(HAS_WIO_TRACKER_L2)
#include <WioTrackerL2Display.h>
#include <WioTrackerL2Io.h>
#elif defined(HAS_CROWPANEL_35)
#include <CrowPanel35Display.h>
#elif defined(HAS_RAK_TAP_V2)
#include <LGFXDisplay.h> // LovyanGFX FSPI on RAK Tap V2
#elif defined(HAS_TDISPLAY_P4)
@@ -252,6 +256,8 @@ static_assert(ChannelSenderSplit::kMaxWireName >= (size_t)UITask::MAX_SENDER_NAM
extern ST7796LCDDisplay display;
#elif defined(HAS_WIO_TRACKER_L2)
extern WioTrackerL2Display display;
#elif defined(HAS_CROWPANEL_35)
extern CrowPanel35Display display;
#elif defined(HAS_RAK_TAP_V2) || defined(HELTEC_LORA_V4_R8)
extern LGFXDisplay display;
#elif defined(HAS_TDISPLAY_P4)
@@ -1185,8 +1191,8 @@ static inline bool luaAudioStorageBusy() {
}
#endif
#if defined(HELTEC_LORA_V4_R8) || defined(HAS_THINKNODE_M9) || defined(HAS_TDECK_PRO)
static bool fmSdTryMount(); // non-audio SD targets — fwd decl for sdRestoreRun
#if CAP_SD || defined(TLORA_PAGER)
static bool fmSdTryMount(); // storage callers precede the mount implementation
#endif
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER)
static constexpr int kI2sSampleRate = 16000;
@@ -1195,9 +1201,6 @@ static constexpr i2s_port_t kI2sPort = I2S_NUM_0;
// beeping while tiles are downloading — the I2S DMA buffers + Wi-Fi RX DMA + a
// tile decode all contend for the scarce internal DMA RAM, and this build is
// already tight enough that tile downloads can OOM-reboot on their own.
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER)
static bool fmSdTryMount(); // defined far below (microSD mount)
#endif
#if defined(HAS_TDECK_GT911)
// I2S is installed ON DEMAND for the duration of a tone and uninstalled after.
// Holding the driver resident permanently kept ~2 KB of internal DMA RAM, which
@@ -2760,9 +2763,14 @@ void sdMountDiagSetMounted(bool mounted, uint32_t hz) {
}
static void sdDiagClockText(uint32_t hz, char* out, size_t cap) {
#if defined(HAS_CROWPANEL_35)
(void)hz;
snprintf(out, cap, "software SPI");
#else
if (hz >= 1000000) snprintf(out, cap, "%lu MHz", (unsigned long)(hz / 1000000));
else if (hz > 0) snprintf(out, cap, "%lu kHz", (unsigned long)(hz / 1000));
else snprintf(out, cap, "unknown");
#endif
}
static uint32_t g_draw_buf_px = 240 * LV_DRAW_BUF_LINES; // actual buffer size in px; shrinks if the full alloc fails at boot
#if CAP_LARGE_SCREEN
@@ -4049,6 +4057,9 @@ static void lvglFlush(lv_disp_drv_t* disp_drv, const lv_area_t* area, lv_color_t
static void applyHardwarePanelRotation(uint8_t lvgl_rot) {
if (lvgl_rot == LV_DISP_ROT_90) display.setDisplayRotation(1);
else if (lvgl_rot == LV_DISP_ROT_270) display.setDisplayRotation(3);
#if defined(HAS_CROWPANEL_35)
else display.setDisplayRotation(0);
#endif
}
// In-chat action openers, forward-declared here UNCONDITIONALLY — their click-callbacks
@@ -7449,13 +7460,12 @@ static void kbShowRotateArrows(bool show) {
}
}
// The Wio Tracker L2 panel is fixed 320x240 LANDSCAPE (CAP_ROTATABLE 0), so
// "rotate for landscape typing" has nothing to offer there — it would turn a
// landscape keyboard into a portrait one. The arrows are not created on that
// board and these, their only callers, go with them. Every other reference to
// The Wio panel is fixed landscape; CrowPanel defaults to landscape and rotates
// through Display settings. Neither needs a second keyboard-only orientation
// control. The arrows are not created on these boards; every other reference to
// the two pointers is null-guarded, so leaving them null is enough to remove
// the buttons everywhere — the same pattern the retired s_kb_alt_btn uses.
#if !defined(HAS_WIO_TRACKER_L2)
#if !defined(HAS_WIO_TRACKER_L2) && !defined(HAS_CROWPANEL_35)
static void kbRotLeftCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
// Tap the left arrow: rotate landscape that way; tap again returns to portrait.
@@ -13794,7 +13804,7 @@ static void useSdStorageToggleCb(lv_event_t* e) {
: TR("Data -> internal on reboot"), 1800);
}
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#if CAP_SD || defined(TLORA_PAGER)
// "Copy internal data to SD": recovery for the beta_36 upgrades where the live
// profile was orphaned on internal flash while the honored SD toggle adopted an
// empty card. Pager resumes only onto a card with no identity or the identical
@@ -14280,11 +14290,19 @@ static const char* uiRotationLabel() {
static void rotateScreenCycleCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
#if defined(ESP32)
// Simple toggle: portrait <-> landscape (90 degrees). One tap each way.
// Simple toggle: portrait <-> the board's upright landscape orientation.
const uint8_t cur = touchPrefsGetUiRotation();
#if defined(HAS_CROWPANEL_35)
const uint8_t next = (cur == LV_DISP_ROT_NONE) ? (uint8_t)LV_DISP_ROT_270
: (uint8_t)LV_DISP_ROT_NONE;
#else
const uint8_t next = (cur == LV_DISP_ROT_NONE) ? (uint8_t)LV_DISP_ROT_90
: (uint8_t)LV_DISP_ROT_NONE;
touchPrefsSetUiRotation(next);
#endif
if (!touchPrefsSetUiRotation(next)) {
if (g_lv.task) g_lv.task->showAlert(TR("Save failed"), 1200);
return;
}
#endif
// Same as the theme and language switches: reboot on a timer so the notice is
// actually painted first, rather than going dark under the user's finger.
@@ -15667,7 +15685,7 @@ static void buildDeviceSettings(int sec) {
lv_obj_add_event_cb(sw, useSdStorageToggleCb, LV_EVENT_VALUE_CHANGED, nullptr);
y += LV_MAX(40, h + 12);
}
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#if CAP_SD || defined(TLORA_PAGER)
/* Where contacts ACTUALLY live this boot. The toggle above is only an intent — if the
card failed to mount at boot (cold/slow card), contacts silently stay on internal flash
even with it ON. This line shows the truth and flags that mismatch. */
@@ -15707,7 +15725,7 @@ static void buildDeviceSettings(int sec) {
fresh-identity) card. This copies EVERYTHING from internal flash over the
card's copies and reboots into the restored profile. This is a SPIFFS->SD
recovery on T-Deck, V4-R8 and Pager; Tanmatsu uses SD_MMC with no SPIFFS. */
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#if CAP_SD || defined(TLORA_PAGER)
{
lv_obj_t* b = lv_btn_create(body);
lv_obj_set_size(b, lv_pct(96), SC(30));
@@ -22574,8 +22592,8 @@ static char s_fm_path[160] = {0}; // current dir within s_fm_fs (e.g.
// a generic fs::FS*; only &SD is real microSD I/O (Internal = SPIFFS). Browsing
// (fmRefresh) and the file open/save paths call this; mutations re-list via
// fmRefresh, so they blip the LED too.
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
static inline bool fmIsSd(fs::FS* fs) { return fs == &SD; } // Arduino SD (T-Deck/pager/M9 LoRa bus, V4-R8 TFT bus)
#if CAP_SD || defined(TLORA_PAGER)
static inline bool fmIsSd(fs::FS* fs) { return fs == &SD; } // hardware or software SPI SD
#elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) || defined(HAS_WIO_TRACKER_L2)
static inline bool fmIsSd(fs::FS* fs) { return fs == &SD_MMC; } // microSD on SDMMC slot 0
#else
@@ -24035,7 +24053,14 @@ static void fmFmtSize64(uint64_t bytes, char* out, size_t outsz) {
else snprintf(out, outsz, "%.1f GB", bytes / (1024.0 * 1024 * 1024));
}
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // microSD mount/format helpers — Arduino SD on the shared SPI bus
#if CAP_SD || defined(TLORA_PAGER)
#if defined(HAS_CROWPANEL_35)
static uint8_t sdDriveNumber() { return SD.driveNumber(); }
#else
struct SdDriveAccess : fs::SDFS {
static uint8_t of(const fs::SDFS& sd) { return sd.*(&SdDriveAccess::_pdrv); }
};
static uint8_t sdDriveNumber() { return SdDriveAccess::of(SD); }
// One shared-SPI accessor per board: the T-Deck/M9 expose their pre-begun SPIClass
// via tdeckSharedSPI()/m9SharedSPI(); the V4-R8's microSD shares its TFT FSPI bus
// (heltecV4R8SharedSPI()); the pager accessor returns the same TFT_eSPI SPIClass
@@ -24049,6 +24074,7 @@ static inline SPIClass* sdSharedSPI() { return m9SharedSPI(); }
#else
static inline SPIClass* sdSharedSPI() { return tdeckSharedSPI(); }
#endif
#endif // !HAS_CROWPANEL_35
// Mount the microSD on its shared SPI bus. Safe to call repeatedly (no-op
// once mounted). SD.begin's internal spi.begin() is a no-op because the bus is
// already initialised by the radio/display, so those pins are untouched.
@@ -24060,6 +24086,14 @@ static bool sdAdoptLiveMount() {
sdMountDiagSetMounted(true, g_sd_operating_hz);
return true;
}
#if defined(HAS_CROWPANEL_35)
static bool sdBeginTracked() {
const bool begin_ok = SD.begin();
const bool card_ready = begin_ok && SD.cardType() != CARD_NONE;
sdMountDiagAttempt(0, begin_ok, card_ready);
return card_ready;
}
#else
static bool sdBeginTracked(SPIClass* spi, uint32_t hz, bool* begin_ok_out = nullptr) {
const bool begin_ok = spi && SD.begin(PIN_SD_CS, *spi, hz, "/sd", 6);
const bool card_ready = begin_ok && SD.cardType() != CARD_NONE;
@@ -24067,6 +24101,7 @@ static bool sdBeginTracked(SPIClass* spi, uint32_t hz, bool* begin_ok_out = null
sdMountDiagAttempt(hz, begin_ok, card_ready);
return card_ready;
}
#endif
#if defined(TLORA_PAGER)
static bool sdPagerBeginWithPowerRecovery(SPIClass* spi, bool* begin_ok_out) {
bool begin_ok = false;
@@ -24108,10 +24143,17 @@ static bool fmSdTryMount() {
// the multi-second mount ladder while the card is out, freezing the UI.
// Explicit user retries clear the backoff first (fmSdMountOrFormatCb).
if (s_sd_retry_after_ms && millis() < s_sd_retry_after_ms) return false;
#if defined(TLORA_PAGER)
#if defined(TLORA_PAGER) || defined(HAS_CROWPANEL_35)
if (sdRuntimeLifecycleBusy()) return false;
#endif
#if defined(TLORA_PAGER)
if (!board.sdCardPresent()) return false;
#endif
#if defined(HAS_CROWPANEL_35)
sdMountDiagBegin();
const bool mounted = sdBeginTracked();
const uint32_t mounted_hz = 0;
#else
SPIClass* spi = sdSharedSPI();
if (!spi) return false;
sdMountDiagBegin();
@@ -24190,6 +24232,7 @@ static bool fmSdTryMount() {
if (mounted) g_sd_operating_hz = mounted_hz;
}
#endif
#endif // !HAS_CROWPANEL_35
if (mounted) {
s_sd_mounted = true;
s_sd_size = SD.cardSize();
@@ -24240,10 +24283,14 @@ static void fmSdUnmount() {
// (opendir fails -> operator bool false). Healthy-card cost is sub-ms; a dead
// card rides out the SPI timeouts once, so callers rate-limit the probe.
static bool sdProbeAlive() {
#if defined(HAS_CROWPANEL_35)
return SD.probeAlive(); // Explicit CMD13; a cached FAT root is not a card probe.
#else
File d = SD.open("/");
const bool ok = d && d.isDirectory();
if (d) d.close();
return ok;
#endif
}
// Discriminator for hot READ paths (map tile lookups run up to five SD opens
// per tile on the loop task): a missing FILE on a healthy card must stay cheap
@@ -24265,24 +24312,20 @@ static void fmSdClickCb(lv_event_t* e) {
if (s_sd_mounted) fmOpenStorage(&SD, "SD", "/");
}
#if defined(TLORA_PAGER)
// Recovery entry for a card the Pager can SEE (card-detect asserted) but cannot
// mount: exFAT/NTFS, or a damaged FAT. Without this the roots page renders no SD
// row at all (fmShowRoots below), so there is no way to tell "no card" from "card
// the firmware won't take", and no way to retry without a reboot.
//
// Deliberately NON-destructive. It clears the mount backoff and makes exactly the
// one vendor-compatible 4 MHz attempt fmSdTryMount() already makes — it does not
// add a retry ladder and never tears down the live shared display/radio bus. If
// that attempt fails the card needs formatting, and on this board that is a
// deliberate host-side task: see the format-helper guard below for why the in-app
// formatter stays off for the Pager.
static void fmSdPagerRetryMountCb(lv_event_t* e) {
#if defined(TLORA_PAGER) || defined(HAS_CROWPANEL_35)
// These ports offer only a non-destructive mount retry. Formatting stays on
// the computer, rather than using the hardware-SPI formatter on an untested bus.
static void fmSdRetryMountCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
s_sd_retry_after_ms = 0; // explicit user retry — bypass the backoff
if (fmSdTryMount() && s_sd_mounted) { fmShowRoots(); return; }
if (g_lv.task)
if (g_lv.task) {
#if defined(HAS_CROWPANEL_35)
g_lv.task->showAlert(TR("SD unavailable - insert a FAT32 card and retry"), 3600);
#else
g_lv.task->showAlert(TR("SD unreadable - format it as FAT32 on a computer"), 3600);
#endif
}
}
#endif
@@ -25727,6 +25770,19 @@ static void fmShowRoots() {
fmStyleRow(sd, COLOR_SUB);
lv_obj_add_event_cb(sd, fmSdMountOrFormatCb, LV_EVENT_CLICKED, nullptr);
}
#elif defined(HAS_CROWPANEL_35)
if ((s_sd_mounted || millis() >= s_sd_retry_after_ms) && fmSdTryMount()) {
char sdl[48], cs[16];
fmFmtSize64(s_sd_size, cs, sizeof cs);
snprintf(sdl, sizeof sdl, TR("SD card %s"), cs);
lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, sdl);
fmStyleRow(sd, COLOR_TEXT);
lv_obj_add_event_cb(sd, fmSdClickCb, LV_EVENT_SHORT_CLICKED, nullptr);
} else {
lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, TR("SD card (tap to mount)"));
fmStyleRow(sd, COLOR_SUB);
lv_obj_add_event_cb(sd, fmSdRetryMountCb, LV_EVENT_CLICKED, nullptr);
}
#elif defined(TLORA_PAGER) // microSD row — browse + mount recovery, no in-app format
// The card-detect line makes "not present" unambiguous, unlike the T-Deck (no detect
// pin at all) -- so unlike its always-shown "tap to mount/format" fallback row, a bare
@@ -25745,7 +25801,7 @@ static void fmShowRoots() {
// absent case above, and gives a retry that bypasses the mount backoff.
lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, TR("SD card (unreadable - tap to retry)"));
fmStyleRow(sd, COLOR_SUB);
lv_obj_add_event_cb(sd, fmSdPagerRetryMountCb, LV_EVENT_CLICKED, nullptr);
lv_obj_add_event_cb(sd, fmSdRetryMountCb, LV_EVENT_CLICKED, nullptr);
}
#endif
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) || defined(HAS_WIO_TRACKER_L2)
@@ -26386,7 +26442,7 @@ static ReaderLocalResult readerReadLocal(const char* url, uint8_t* raw, size_t c
s_reader_sd_busy = false;
storage_claimed = false;
};
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#if CAP_SD || defined(TLORA_PAGER)
s_reader_sd_busy = true;
s_reader_sd_owner = xTaskGetCurrentTaskHandle();
storage_claimed = true;
@@ -29496,7 +29552,7 @@ static void makeHome(lv_obj_t* tab) {
lv_obj_set_ext_click_area(s_home_chart_legend, 8);
lv_obj_add_event_cb(s_home_chart_legend, homeChartClickedCb, LV_EVENT_CLICKED, nullptr);
#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_RAK_TAP_V2) || defined(HAS_THINKNODE_M9) || defined(HAS_WIO_TRACKER_L2) || defined(ATTAKY_MESH_SERIES) || defined(HAS_TDISPLAY_P4)
#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_RAK_TAP_V2) || defined(HAS_THINKNODE_M9) || defined(HAS_WIO_TRACKER_L2) || defined(HAS_CROWPANEL_35) || defined(ATTAKY_MESH_SERIES) || defined(HAS_TDISPLAY_P4)
// Landscape boards keep the chart clear of the right-hand button strip.
const int chart_w = home_land ? (cw - RSTRIP) : cw;
#else
@@ -32116,6 +32172,8 @@ static int wifiScanWatchdogSafe(uint32_t cap_ms, uint16_t per_chan_ms = 300) {
#define WADA_BOARD_ID "attaky"
#elif defined(HAS_WIO_TRACKER_L2)
#define WADA_BOARD_ID "wio-tracker-l2"
#elif defined(HAS_CROWPANEL_35)
#define WADA_BOARD_ID "crowpanel-35"
#elif defined(HELTEC_LORA_V4_TFT)
#define WADA_BOARD_ID "heltec-v4-tft"
#else
@@ -33795,7 +33853,7 @@ static void renderMapTiles() {
} else
#endif
if (!s_tiles_fs_ready) {
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HAS_CROWPANEL_35)
// Pager included: under the launcher there's no "tiles" partition, so point the user at the
// microSD fallback rather than the (launcher-wrong) "reflash the tiles partition" advice.
// M9 included: its cache PREFERS the built-in 16 GB microSD (every unit ships with one), so
@@ -38196,6 +38254,9 @@ static void makeSettings(lv_obj_t* tab) {
const lv_coord_t card_h = landscape ? 54 : 46;
for (int c = 0; c < CAT_COUNT; ++c) {
#if !CAP_BATTERY
if (c == CAT_BATTERY) continue;
#endif
#if !CAP_LOCK_SCREEN
if (c == CAT_LOCK) continue; // lock screen is a T-Deck / Tanmatsu feature
#endif
@@ -47644,7 +47705,7 @@ static void openPowerMenu() {
// ROM force-download leaves a COM that esptool cannot open on HW CDC — hide the entry.
const int card_w = (sw - 40 > 240) ? 240 : (sw - 40);
const int p_bh = 34, p_y0 = 28, p_step = 40, card_h = p_y0 + 3 * p_step + 8;
#elif defined(HAS_THINKNODE_M9) || defined(HAS_WIO_TRACKER_L2)
#elif defined(HAS_THINKNODE_M9) || defined(HAS_WIO_TRACKER_L2) || defined(HAS_CROWPANEL_35)
// Power-off row hidden (see below) — 3 rows: Reboot / Download / Cancel.
const int card_w = (sw - 40 > 240) ? 240 : (sw - 40);
const int p_bh = 34, p_y0 = 28, p_step = 40, card_h = p_y0 + 3 * p_step + 8;
@@ -47977,7 +48038,7 @@ static void applyVolume(uint8_t pct) {
if (pct > 100) pct = 100;
s_volume_pct = pct; // play paths read the persisted pref; this keeps the slider live
}
#elif defined(HAS_WIO_TRACKER_L2)
#elif defined(HAS_WIO_TRACKER_L2) || defined(HAS_CROWPANEL_35)
#define HAS_CC_BRIGHTNESS 1
static uint8_t s_brightness_pct = 63;
static void applyBrightness(uint8_t pct) {
@@ -49716,14 +49777,6 @@ static lv_timer_t* s_usbfiles_timer = nullptr;
static bool s_usbfiles_map_dirty = false; // tiles or the card changed: re-check the map
static void closeUsbFilesPage();
#if CAP_SD
// FatFs drive of the Arduino SD mount, for the session's fast folder listings.
// Same pointer-to-member route as luaHostSdClearAttributes (further down).
struct UsbFilesSdDrive : fs::SDFS {
static uint8_t of(const fs::SDFS& sd) { return sd.*(&UsbFilesSdDrive::_pdrv); }
};
#endif
class UiUsbFilesHost : public UsbFilesHost {
public:
bool root(UsbFiles::Root r, UsbFilesRootInfo* out, bool mount) override {
@@ -49737,7 +49790,7 @@ class UiUsbFilesHost : public UsbFilesHost {
const bool up = mount ? fmSdTryMount() : s_sd_mounted;
if (up && SD.cardType() != CARD_NONE) {
out->fs = &SD;
snprintf(out->drive, sizeof out->drive, "%u:", (unsigned)UsbFilesSdDrive::of(SD));
snprintf(out->drive, sizeof out->drive, "%u:", (unsigned)sdDriveNumber());
}
return true;
}
@@ -51541,6 +51594,10 @@ static void buildGlobalStatusBar() {
// the bar root's NAV_SKIP into its children, so each indicator opts out individually.)
lv_obj_add_flag(g_statusbar.batt_icon, NAV_SKIP_FLAG);
lv_obj_add_flag(g_statusbar.batt_pct, NAV_SKIP_FLAG);
#if !CAP_BATTERY
lv_obj_add_flag(g_statusbar.batt_icon, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(g_statusbar.batt_pct, LV_OBJ_FLAG_HIDDEN);
#endif
g_statusbar.clock = lv_label_create(g_statusbar.root);
lv_label_set_text(g_statusbar.clock, "--:--");
@@ -55337,7 +55394,7 @@ static void buildUiTree() {
};
// Both buttons use the rotation/refresh glyph; left vs. right position
// tells the user which way the display will turn.
#if !defined(HAS_WIO_TRACKER_L2)
#if !defined(HAS_WIO_TRACKER_L2) && !defined(HAS_CROWPANEL_35)
s_kb_rot_left_btn = makeRotBtn(LV_SYMBOL_REFRESH, kbRotLeftCb);
s_kb_rot_right_btn = makeRotBtn(LV_SYMBOL_REFRESH, kbRotRightCb);
#endif
@@ -57657,13 +57714,10 @@ bool luaHostSdReadFailed() { return sdReadFailedCardDead(); }
// Declared by hand like f_mkfs above: ff.h drags FatFs' BYTE/WORD typedefs into
// this file. FRESULT is an int-sized enum and FR_OK is 0.
extern "C" int f_chmod(const char* path, uint8_t attr, uint8_t mask);
struct SdPdrvAccess : fs::SDFS {
static uint8_t of(const fs::SDFS& sd) { return sd.*(&SdPdrvAccess::_pdrv); }
};
bool luaHostSdClearAttributes(const char* path) {
if (!s_sd_mounted || !path || path[0] != '/') return false;
char ffpath[200];
const int n = snprintf(ffpath, sizeof ffpath, "%u:%s", (unsigned)SdPdrvAccess::of(SD), path);
const int n = snprintf(ffpath, sizeof ffpath, "%u:%s", (unsigned)sdDriveNumber(), path);
if (n <= 0 || n >= (int)sizeof ffpath) return false;
markSdIo();
constexpr uint8_t kAmRdo = 0x01, kAmHid = 0x02, kAmSys = 0x04; // FatFs AM_RDO/AM_HID/AM_SYS
@@ -58361,7 +58415,7 @@ static bool uiDataFsIsSdCard() {
if (!uiDataFsReady()) return false;
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) || defined(HAS_WIO_TRACKER_L2)
return s_ui_data_fs == &SD_MMC;
#elif defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#elif CAP_SD || defined(TLORA_PAGER)
return s_ui_data_fs == &SD;
#else
return false;
@@ -58378,7 +58432,7 @@ static File uiDataOpen(const char* name, const char* mode) {
if (!uiDataFsReady()) return File();
char p[80]; snprintf(p, sizeof p, "%s%s", s_ui_data_root, name);
File f = s_ui_data_fs->open(p, mode);
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#if CAP_SD || defined(TLORA_PAGER)
// A failed WRITE open on the SD-backed history store is the wedge tell (reads
// fail legitimately on first boot). Called from the loop task AND the core-0
// history worker — sdNoteIoFailure is a volatile stamp, safe from both.
@@ -59032,7 +59086,7 @@ static bool uiMsgsWriteResult(bool ok) {
s_msgs_write_fail_ms = m ? m : 1;
s_msgs_write_fail_epoch = ep;
if (s_msgs_write_fails < 0xFFFFu) s_msgs_write_fails = s_msgs_write_fails + 1;
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#if CAP_SD || defined(TLORA_PAGER)
if (s_ui_data_fs == &SD) sdNoteIoFailure();
#endif
}
@@ -60736,7 +60790,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
s_tile_fs = &s_tiles_fs;
s_tile_root[0] = '\0';
}
#if defined(HAS_TDECK_GT911)
#if defined(HAS_TDECK_GT911) || defined(HAS_CROWPANEL_35)
// Under Launcher there's no "tiles" partition; cache to the SD card instead.
// main.cpp already mounted the card for SD data storage (boot runs well before
// ui_task.begin()), so PREFER that live mount via SD.cardType(). Calling
@@ -61088,6 +61142,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
#else
#if defined(HAS_RAK_TAP_V2) || defined(HAS_WIO_TRACKER_L2)
const int draw_band_w = 320;
#elif defined(HAS_CROWPANEL_35)
const int draw_band_w = 480;
g_draw_buf_px = draw_band_w * LV_DRAW_BUF_LINES;
#else
const int draw_band_w = 240;
#endif
@@ -61304,6 +61361,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
#elif defined(HAS_WIO_TRACKER_L2)
g_lv.disp_drv.hor_res = 320;
g_lv.disp_drv.ver_res = 240;
#elif defined(HAS_CROWPANEL_35)
g_lv.disp_drv.hor_res = s_remote_mode ? (s_remote_landscape ? 400 : 240) : (ui_landscape ? 480 : 320);
g_lv.disp_drv.ver_res = s_remote_mode ? (s_remote_landscape ? 240 : 400) : (ui_landscape ? 320 : 480);
#else
// Landscape rotates the panel in HARDWARE (smooth — no per-pixel software
// rotation each flush), so tell LVGL the already-rotated resolution and let
@@ -61376,6 +61436,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// back to LVGL in the panel frame.
heltecV4CapTouchSetPointRotation(s_ui_rotation); // LVGL won't, so driver does
}
#if defined(HAS_CROWPANEL_35)
else if (!s_remote_mode) {
// A file-backed portrait preference may not have been loaded at the boot logo.
applyHardwarePanelRotation(LV_DISP_ROT_NONE);
}
#endif
// Swipe-axis transform always matches the visible orientation.
heltecV4CapTouchSetRotation(s_ui_rotation);
@@ -62589,9 +62655,8 @@ static inline void touchScreenBacklight(bool on) {
ledcWrite(kM9BlPwmChannel, 255); // inverted: 255 = 0% conduction = off
touchPanelSleep(true);
}
#elif defined(HAS_WIO_TRACKER_L2)
// L2: brightness is controlled by the display's I2C light controller, not a
// direct TFT_BL GPIO. Restore the saved level on wake and write zero on sleep.
#elif defined(HAS_WIO_TRACKER_L2) || defined(HAS_CROWPANEL_35)
// The display owns its backlight (I2C on Wio, PWM on CrowPanel).
if (on) applyBrightness(s_brightness_pct);
else display.setBrightness(0);
#elif defined(HAS_TDISPLAY_P4)
@@ -62653,6 +62718,11 @@ void UITask::wakeScreen() {
}
void UITask::lockScreen() {
#if defined(HAS_CROWPANEL_35)
// No physical unlock button: imported lock preferences must still allow touch wake.
sleepScreen();
return;
#endif
// Backlight off + manual lock so touch is ignored (noteUserInput()
// early-returns) until a deliberate unlock.
#if defined(HAS_TDECK_PRO)
@@ -63711,10 +63781,14 @@ static void sdHealthTick() {
#if defined(TLORA_PAGER)
if (!board.sdCardPresent()) return; // card-detect says the slot is empty
#endif
#if !defined(HAS_CROWPANEL_35)
SPIClass* spi = sdSharedSPI();
if (!spi) return;
#endif
sdMountDiagBegin();
#if defined(TLORA_PAGER)
#if defined(HAS_CROWPANEL_35)
const bool remounted = sdBeginTracked();
#elif defined(TLORA_PAGER)
// The Pager shares this SPIClass with display + radio: one 4 MHz attempt,
// with every other CS parked HIGH. A failed attempt gets one dedicated
// SD-rail power cycle before retrying; the display/radio bus stays live.
@@ -63739,7 +63813,7 @@ static void sdHealthTick() {
#endif
markSdIo();
if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800);
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#if CAP_SD || defined(TLORA_PAGER)
// Land the RAM ring on the card promptly, not up to 30+ s later: every
// message received while the card was out is only in RAM. Armed as an
// OFF-THREAD flush — a synchronous write here froze the UI for >30 s on
@@ -63880,7 +63954,7 @@ static void sdHealthTick() {
s_sd_data_warn_next_ms = 0;
#endif
if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800);
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#if CAP_SD || defined(TLORA_PAGER)
if (!s_ui_data_fs) uiDataFsReady();
if (s_ui_data_fs == &SD) {
SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root
@@ -63914,16 +63988,20 @@ void UITask::loop() {
// because LVGL is never initialised, so nothing ever started the poll task
// and the keyboard was never even begun. That is why keys did nothing.
//
// One call covers both: the background poll task owns the shared I2C bus
// and scans the touch panel AND (on the T-Deck) the keyboard, which is
// exactly why they must not be polled from two places at once.
// The background task scans touch and (on the T-Deck) the keyboard.
// CrowPanel keeps LovyanGFX and Wire transactions on this thread.
static bool s_con_input_up = false;
if (!s_con_input_up) {
if (heltecV4CapTouchBegin()) {
#if !defined(HAS_CROWPANEL_35)
heltecV4CapTouchStartBackgroundPoll(8);
#endif
s_con_input_up = true;
}
}
if (s_con_input_up && !heltecV4CapTouchIsAsyncPolling()) {
(void)heltecV4CapTouchCheck();
}
#endif
// The physical-keyboard drain lives further down this function, below this
// return, so console mode has to do its own. Same ring, filled by the task
@@ -64668,10 +64746,10 @@ void UITask::loop() {
}
if (g_lv.touch_inited) {
pushDiagLine("touch init ok");
#if defined(HAS_WIO_TRACKER_L2)
#if defined(HAS_WIO_TRACKER_L2) || defined(HAS_CROWPANEL_35)
// GT911 (LovyanGFX) and the TCA9535/ADS1115 (Wire) share I2C0 on
// GPIO47/48 but use different driver locks. Keep every transaction on
// loopTask so the touch poll cannot interrupt an expander/ADC transfer.
// Wio's GPIO47/48; the CrowPanel's GT911 shares I2C0 on GPIO15/16.
// Keep every transaction on loopTask so polling cannot interrupt Wire.
pushDiagLine("touch inline (shared I2C)");
#elif defined(HAS_TDECK_PRO)
// The e-paper BUSY callback keeps this inline driver polling while the
@@ -64779,7 +64857,7 @@ void UITask::loop() {
// unmountable card spikes current / churns the bus and can reset the board.
if (!sdRuntimeLifecycleBusy() && now >= s_sd_retry_after_ms && fmSdTryMount()) {
showAlert(TR("SD card inserted"), 1500);
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#if CAP_SD || defined(TLORA_PAGER)
if (!s_ui_data_fs) uiDataFsReady();
if (s_ui_data_fs == &SD) {
SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root
+19 -3
View File
@@ -25,7 +25,18 @@
// =============================================================================
// ---- Per-board structural capabilities (factored out of the device names) ----
#if defined(HAS_WIO_TRACKER_L2) // ===== Seeed Wio Tracker L2 (ESP32-S3) =====
#if defined(HAS_CROWPANEL_35) // ===== Elecrow CrowPanel Advance 3.5 (ESP32-S3) =====
#define CAP_TOUCH 1 // GT911 capacitive touch
#define CAP_ROTATABLE 1 // ILI9488 native portrait, upright landscape = rotation 3
#define CAP_LARGE_SCREEN 0 // native 480x320, no upscaling
#define CAP_SD 1 // microSD over software SPI, SD-compatible filesystem API
#define CAP_FILESYSTEM 1 // internal SPIFFS + tiles LittleFS
#define CAP_GPS 1 // optional external UART1 GPS
#define CAP_OTA 1
#define CAP_LOCK_SCREEN 0 // GPIO0 is LoRa NSS, no separate button to unlock
#define CAP_BATTERY 0 // no battery voltage sensing
#elif defined(HAS_WIO_TRACKER_L2) // ===== Seeed Wio Tracker L2 (ESP32-S3) =====
#define CAP_TOUCH 1
#define CAP_ROTATABLE 0
#define CAP_LARGE_SCREEN 0 // fixed 320x240 landscape
@@ -170,9 +181,14 @@
#define CAP_LOCK_SCREEN 0
#endif
// Boards without a battery ADC must not advertise battery history/calibration.
#ifndef CAP_BATTERY
#define CAP_BATTERY 1
#endif
// Physical removable microSD slot, independent of the filesystem API used to
// drive it. CAP_SD specifically means Arduino SD over SPI; these three boards
// use SD_MMC instead but still need the same user-facing card diagnostics.
// drive it. CAP_SD means the SD-compatible SPI API (hardware or software);
// SD_MMC boards still need the same user-facing card diagnostics.
#if CAP_SD || defined(HAS_WIO_TRACKER_L2) || defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
#define CAP_MICROSD 1
#else
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#include <string.h>
#include "CrowPanel35SdCodec.h"
int main() {
using namespace CrowPanel35SdCodec;
const uint8_t cmd0[] = {0x40, 0, 0, 0, 0};
const uint8_t cmd8[] = {0x48, 0, 0, 0x01, 0xAA};
assert(commandCrc(cmd0, sizeof cmd0) == 0x95);
assert(commandCrc(cmd8, sizeof cmd8) == 0x87);
uint16_t crc = 0;
for (const char* byte = "123456789"; *byte; ++byte) crc = dataCrc(crc, (uint8_t)*byte);
assert(crc == 0x31C3);
uint8_t csd[16] = {};
csd[0] = 0x40;
csd[8] = 0xFF;
csd[9] = 0xFF;
assert(sectorsFromCsd(csd) == 67108864); // 32 GiB, block-addressed.
csd[7] = 0x3F;
assert(sectorsFromCsd(csd) == 0); // 2 TiB overflows 32-bit sector count.
csd[0] = 0x80;
assert(sectorsFromCsd(csd) == 0); // Unsupported CSD structure.
memset(csd, 0, sizeof csd);
csd[5] = 9;
csd[6] = 3;
csd[7] = 0xFF;
csd[8] = 0xC0;
csd[9] = 3;
csd[10] = 0x80;
assert(sectorsFromCsd(csd) == 2097152); // 1 GiB, byte-addressed.
assert(validRange(100, 0, 100));
assert(validRange(100, 99, 1));
assert(!validRange(100, 100, 1));
assert(!validRange(100, 0, 0));
assert(!validRange(100, 99, 2));
assert(!validRange(UINT32_MAX, UINT32_MAX - 1, UINT32_MAX));
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from pathlib import Path
import runpy
import unittest
from unittest.mock import MagicMock, patch
SCRIPT = Path(__file__).resolve().parents[1] / "merge-bin.py"
class MergeBinTests(unittest.TestCase):
def load_action(self, output=None):
self.build_dir = "/project with spaces/board's build"
self.app_path = self.build_dir + "/firmware.bin"
self.images = [
("0x0", self.build_dir + "/bootloader.bin"),
("0x8000", self.build_dir + "/partitions.bin"),
("0xe000", "/framework with spaces/boot_app0.bin"),
]
self.process_env = {"PATH": "/tools with spaces"}
env = MagicMock()
env.BoardConfig.return_value = {
"build.mcu": "esp32s3",
"build.flash_mode": "qio",
"upload.flash_size": "16MB",
}
env.get.return_value = self.images
env.Flatten.side_effect = lambda images: [value for pair in images for value in pair]
substitutions = {
"$PYTHONEXE": "/python with spaces/python",
"$OBJCOPY": "/tools with spaces/esptool.py",
"${BUILD_DIR}/${PROGNAME}-merged.bin": self.build_dir + "/firmware-merged.bin",
"${__get_board_f_flash(__env__)}": "80m",
"$ESP32_APP_OFFSET": "0x10000",
}
env.subst.side_effect = lambda value: substitutions.get(value, value)
env.__getitem__.return_value = self.process_env
environ = {} if output is None else {"MERGED_BIN_PATH": output}
with patch.dict(os.environ, environ, clear=True):
script = runpy.run_path(str(SCRIPT), init_globals={
"Import": lambda *args: None,
"env": env,
"projenv": env,
})
source = MagicMock()
source.get_abspath.return_value = self.app_path
return script["merge_bin_action"], [source], env
@patch("subprocess.run")
def test_paths_and_flash_offsets_are_preserved(self, run):
action, source, env = self.load_action()
run.return_value.returncode = 0
self.assertEqual(action(source, [], env), 0)
run.assert_called_once_with([
"/python with spaces/python",
"/tools with spaces/esptool.py",
"--chip", "esp32s3",
"merge_bin",
"-o", self.build_dir + "/firmware-merged.bin",
"--flash_mode", "qio",
"--flash_freq", "80m",
"--flash_size", "16MB",
*[value for pair in self.images for value in pair],
"0x10000", self.app_path,
], env=self.process_env)
@patch("subprocess.run")
def test_output_override_is_preserved(self, run):
output = "/custom output/owner's merged image.bin"
action, source, env = self.load_action(output)
run.return_value.returncode = 0
self.assertEqual(action(source, [], env), 0)
args = run.call_args.args[0]
self.assertEqual(args[args.index("-o") + 1], output)
@patch("subprocess.run")
def test_merge_failure_is_returned(self, run):
action, source, env = self.load_action()
run.return_value.returncode = 2
self.assertEqual(action(source, [], env), 2)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <Arduino.h>
#include <helpers/ESP32Board.h>
class CrowPanel35Board : public ESP32Board {
public:
void begin() {
digitalWrite(PIN_SD_CS, HIGH);
pinMode(PIN_SD_CS, OUTPUT);
digitalWrite(45, LOW); // LOW routes the expansion slot to LoRa, not the microphone.
pinMode(45, OUTPUT);
ESP32Board::begin();
}
const char* getManufacturerName() const override { return "Elecrow CrowPanel Advance 3.5"; }
};
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "CrowPanel35Display.h"
#include <Arduino.h>
#include <Wire.h>
CrowPanel35Display::CrowPanel35Display() : DisplayDriver(480, 320) {
{
auto cfg = _bus.config();
cfg.spi_host = SPI3_HOST;
cfg.spi_mode = 0;
cfg.freq_write = 40000000;
cfg.freq_read = 16000000;
cfg.spi_3wire = false;
cfg.use_lock = true;
cfg.dma_channel = SPI_DMA_CH_AUTO;
cfg.pin_sclk = 42;
cfg.pin_mosi = 39;
cfg.pin_miso = -1;
cfg.pin_dc = 41;
_bus.config(cfg);
_panel.setBus(&_bus);
}
{
auto cfg = _panel.config();
cfg.pin_cs = 40;
cfg.pin_rst = -1;
cfg.pin_busy = -1;
cfg.panel_width = 320;
cfg.panel_height = 480;
cfg.memory_width = 320;
cfg.memory_height = 480;
cfg.offset_x = 0;
cfg.offset_y = 0;
cfg.readable = false;
cfg.invert = true;
cfg.rgb_order = false;
cfg.dlen_16bit = false;
cfg.bus_shared = false;
_panel.config(cfg);
}
{
auto cfg = _light.config();
cfg.pin_bl = 38;
cfg.invert = false;
cfg.freq = 44000;
cfg.pwm_channel = 7;
_light.config(cfg);
_panel.setLight(&_light);
}
{
auto cfg = _touch.config();
cfg.i2c_port = 0;
cfg.i2c_addr = 0x5D;
cfg.pin_sda = 15;
cfg.pin_scl = 16;
cfg.pin_int = 47;
cfg.pin_rst = 48;
cfg.bus_shared = false;
cfg.freq = 400000;
cfg.x_min = 0;
cfg.x_max = 319;
cfg.y_min = 0;
cfg.y_max = 479;
cfg.offset_rotation = 0;
_touch.config(cfg);
_panel.setTouch(&_touch);
}
_lcd.setPanel(&_panel);
}
bool CrowPanel35Display::begin() {
if (_isOn) return true;
if (!_lcd.init()) {
Serial.println("[crowpanel-35] ILI9488 display init failed");
return false;
}
_lcd.setSwapBytes(true);
setDisplayRotation(3);
_lcd.setBrightness(160);
_lcd.fillScreen(0);
Wire.end();
if (!Wire.begin(15, 16, 400000)) {
Serial.println("[crowpanel-35] GT911 I2C bus init failed");
return false;
}
_isOn = true;
Serial.printf("[crowpanel-35] display %dx%d\n", width(), height());
return true;
}
void CrowPanel35Display::turnOn() {
if (!_isOn) { _lcd.setBrightness(160); _isOn = true; }
}
void CrowPanel35Display::turnOff() {
if (_isOn) { _lcd.setBrightness(0); _isOn = false; }
}
void CrowPanel35Display::clear() { _lcd.fillScreen(0); }
void CrowPanel35Display::setDisplayRotation(uint8_t rotation) {
// Keep the saved portrait mode at 0; rotate the panel and touch together by 180 degrees.
_lcd.setRotation(rotation == 0 ? 2 : rotation);
setLogicalSize(_lcd.width(), _lcd.height());
}
void CrowPanel35Display::startFrame(ColorVal bkg) { _lcd.fillScreen(bkg); }
void CrowPanel35Display::setTextSize(int sz) { _lcd.setTextSize(sz); }
void CrowPanel35Display::setColor(ColorVal c) { _color = c; _lcd.setTextColor(c); }
void CrowPanel35Display::setCursor(int x, int y) { _lcd.setCursor(x, y); }
void CrowPanel35Display::print(const char* str) { _lcd.print(str); }
void CrowPanel35Display::fillRect(int x, int y, int w, int h) {
_lcd.fillRect(x, y, w, h, _color);
}
void CrowPanel35Display::drawRect(int x, int y, int w, int h) {
_lcd.drawRect(x, y, w, h, _color);
}
void CrowPanel35Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
_lcd.drawXBitmap(x, y, bits, w, h, _color);
}
uint16_t CrowPanel35Display::getTextWidth(const char* str) { return _lcd.textWidth(str); }
void CrowPanel35Display::endFrame() {}
void CrowPanel35Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) {
if (!_isOn || !pixels || w <= 0 || h <= 0) return;
_lcd.startWrite();
_lcd.setAddrWindow(x, y, w, h);
_lcd.writePixels(const_cast<uint16_t*>(pixels), (uint32_t)w * h);
_lcd.endWrite();
}
void CrowPanel35Display::setBrightness(uint8_t value) { _lcd.setBrightness(value); }
bool CrowPanel35Display::getTouchPoint(uint16_t& x, uint16_t& y) {
int32_t tx = 0, ty = 0;
if (!_lcd.getTouch(&tx, &ty)) return false;
x = (uint16_t)tx;
y = (uint16_t)ty;
return true;
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <helpers/ui/DisplayDriver.h>
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
class CrowPanel35Display : public DisplayDriver {
public:
CrowPanel35Display();
bool begin();
bool isOn() override { return _isOn; }
void turnOn() override;
void turnOff() override;
void clear() override;
void startFrame(ColorVal bkg = UIColor::window_bkg) override;
void setTextSize(int sz) override;
void setColor(ColorVal c) override;
void setCursor(int x, int y) override;
void print(const char* str) override;
void fillRect(int x, int y, int w, int h) override;
void drawRect(int x, int y, int w, int h) override;
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
uint16_t getTextWidth(const char* str) override;
void endFrame() override;
void setDisplayRotation(uint8_t rotation);
void setBrightness(uint8_t value);
bool getTouchPoint(uint16_t& x, uint16_t& y);
void writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels);
private:
lgfx::Bus_SPI _bus;
lgfx::Panel_ILI9488 _panel;
lgfx::Light_PWM _light;
lgfx::Touch_GT911 _touch;
lgfx::LGFX_Device _lcd;
bool _isOn = false;
uint16_t _color = 0xffff;
};
+478
View File
@@ -0,0 +1,478 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025-2026 Michael A. Cojocari and contributors
// Adapted from Camillia-MT's sd_soft_spi.cpp for WadaMesh's SD filesystem API.
#include "CrowPanel35SD.h"
#include "CrowPanel35SdCodec.h"
#include <Arduino.h>
#include <vfs_api.h>
#include <freertos/semphr.h>
#include "diskio_impl.h"
#include "esp_vfs_fat.h"
#include "ff.h"
#include "soc/gpio_reg.h"
#include "rom/ets_sys.h"
static_assert(PIN_SD_SCLK >= 0 && PIN_SD_SCLK < 32, "SD SCLK must be GPIO0-31");
static_assert(PIN_SD_MISO >= 0 && PIN_SD_MISO < 32, "SD MISO must be GPIO0-31");
static_assert(PIN_SD_MOSI >= 0 && PIN_SD_MOSI < 32, "SD MOSI must be GPIO0-31");
static_assert(PIN_SD_CS >= 0 && PIN_SD_CS < 32, "SD CS must be GPIO0-31");
namespace {
constexpr const char* kMountPoint = "/sd";
constexpr uint8_t kMaxOpenFiles = 8;
constexpr uint32_t kClockMask = 1UL << PIN_SD_SCLK;
constexpr uint32_t kMosiMask = 1UL << PIN_SD_MOSI;
constexpr uint32_t kCsMask = 1UL << PIN_SD_CS;
constexpr uint8_t kReady = 0x00, kIdle = 0x01, kIllegal = 0x04;
constexpr uint8_t kSingle = 0xFE, kMulti = 0xFC, kStop = 0xFD;
sdcard_type_t s_type = CARD_NONE;
uint32_t s_sectors = 0;
BYTE s_drive = FF_DRV_NOT_USED;
bool s_mounted = false, s_vfs_registered = false, s_slow = true;
SemaphoreHandle_t s_mutex = nullptr;
SemaphoreHandle_t s_lifecycle_mutex = nullptr;
// FatFs serializes filesystem operations, but the health probe also sends SD
// commands outside FatFs. Keep each command/data sequence indivisible.
class CardLock {
SemaphoreHandle_t _mutex;
public:
explicit CardLock(SemaphoreHandle_t mutex = s_mutex) : _mutex(mutex) {
xSemaphoreTakeRecursive(_mutex, portMAX_DELAY);
}
~CardLock() { xSemaphoreGiveRecursive(_mutex); }
CardLock(const CardLock&) = delete;
CardLock& operator=(const CardLock&) = delete;
};
uint8_t transfer(uint8_t out) {
uint8_t in = 0;
for (int bit = 7; bit >= 0; --bit) {
REG_WRITE((out >> bit) & 1 ? GPIO_OUT_W1TS_REG : GPIO_OUT_W1TC_REG, kMosiMask);
if (s_slow) ets_delay_us(2);
REG_WRITE(GPIO_OUT_W1TS_REG, kClockMask);
if (s_slow) ets_delay_us(2);
in = (uint8_t)((in << 1) | ((REG_READ(GPIO_IN_REG) >> PIN_SD_MISO) & 1));
REG_WRITE(GPIO_OUT_W1TC_REG, kClockMask);
}
return in;
}
uint8_t receive() { return transfer(0xFF); }
void select() { REG_WRITE(GPIO_OUT_W1TC_REG, kCsMask); }
void release() {
REG_WRITE(GPIO_OUT_W1TS_REG, kCsMask);
receive(); // Clock MISO free after deselecting.
}
bool waitReady(uint32_t timeout) {
const uint32_t start = millis();
do {
if (receive() == 0xFF) return true;
delay(1);
} while ((uint32_t)(millis() - start) < timeout);
return false;
}
uint8_t command(uint8_t cmd, uint32_t arg) {
select();
if (cmd != 0 && cmd != 12 && !waitReady(500)) return 0xFF;
const uint8_t frame[] = {
(uint8_t)(0x40 | cmd), (uint8_t)(arg >> 24), (uint8_t)(arg >> 16),
(uint8_t)(arg >> 8), (uint8_t)arg
};
for (uint8_t byte : frame) transfer(byte);
transfer(CrowPanel35SdCodec::commandCrc(frame, sizeof frame));
if (cmd == 12) receive(); // STOP_TRANSMISSION has a leading stuff byte.
uint8_t response = 0xFF;
for (int i = 0; i < 10; ++i) {
response = receive();
if (!(response & 0x80)) break;
}
return response;
}
uint8_t appCommand(uint8_t cmd, uint32_t arg) {
const uint8_t response = command(55, 0);
return response <= kIdle ? command(cmd, arg) : response;
}
bool readBlock(uint8_t* data, size_t length) {
const uint32_t start = millis();
uint8_t token;
do {
token = receive();
if (token == 0xFF) delay(1);
} while (token == 0xFF && (uint32_t)(millis() - start) < 300);
if (token != kSingle) return false;
uint16_t crc = 0;
for (size_t i = 0; i < length; ++i) {
data[i] = receive();
crc = CrowPanel35SdCodec::dataCrc(crc, data[i]);
}
const uint16_t expected = (uint16_t)receive() << 8;
return (expected | receive()) == crc;
}
bool writeBlock(const uint8_t* data, uint8_t token) {
if (!waitReady(1000)) return false;
transfer(token);
uint16_t crc = 0;
for (size_t i = 0; i < 512; ++i) {
transfer(data[i]);
crc = CrowPanel35SdCodec::dataCrc(crc, data[i]);
}
transfer((uint8_t)(crc >> 8));
transfer((uint8_t)crc);
const bool accepted = (receive() & 0x1F) == 0x05;
const bool ready = waitReady(1000);
return accepted && ready;
}
bool cardInit() {
digitalWrite(PIN_SD_CS, HIGH);
pinMode(PIN_SD_CS, OUTPUT);
pinMode(PIN_SD_SCLK, OUTPUT);
pinMode(PIN_SD_MOSI, OUTPUT);
pinMode(PIN_SD_MISO, INPUT_PULLUP);
REG_WRITE(GPIO_OUT_W1TC_REG, kClockMask);
REG_WRITE(GPIO_OUT_W1TS_REG, kMosiMask);
s_slow = true;
s_type = CARD_NONE;
s_sectors = 0;
for (int i = 0; i < 10; ++i) receive(); // At least 74 power-up clocks, CS high.
uint8_t response = 0xFF;
for (int i = 0; i < 10 && response != kIdle; ++i) {
response = command(0, 0);
release();
if (response != kIdle) delay(10);
}
if (response != kIdle) {
Serial.printf("[sd] soft SPI: CMD0 failed (R1=0x%02X)\n", response);
return false;
}
response = command(59, 1);
release();
if (response == (kIdle | kIllegal)) {
Serial.println("[sd] soft SPI: card does not support write CRC checks");
} else if (response != kIdle) {
Serial.printf("[sd] soft SPI: CRC enable failed (R1=0x%02X)\n", response);
return false;
}
sdcard_type_t type = CARD_NONE;
uint32_t start = millis();
response = command(8, 0x1AA);
if (response == kIdle) {
uint8_t echo[4];
for (auto& byte : echo) byte = receive();
release();
if (echo[2] != 0x01 || echo[3] != 0xAA) {
Serial.println("[sd] soft SPI: CMD8 voltage/check pattern mismatch");
return false;
}
do {
response = appCommand(41, 1UL << 30);
release();
if (response == kIdle) delay(1);
} while (response == kIdle && (uint32_t)(millis() - start) < 1000);
if (response != kReady) {
Serial.printf("[sd] soft SPI: ACMD41 failed (R1=0x%02X)\n", response);
return false;
}
response = command(58, 0);
if (response != kReady) {
release();
Serial.printf("[sd] soft SPI: CMD58 failed (R1=0x%02X)\n", response);
return false;
}
uint8_t ocr[4];
for (auto& byte : ocr) byte = receive();
release();
if (!(ocr[0] & 0x80) || !(ocr[1] & 0x10)) {
Serial.println("[sd] soft SPI: card not ready for 3.3 V operation");
return false;
}
type = (ocr[0] & 0x40) ? CARD_SDHC : CARD_SD;
} else {
release();
if (response != (kIdle | kIllegal)) {
Serial.printf("[sd] soft SPI: CMD8 failed (R1=0x%02X)\n", response);
return false;
}
response = appCommand(41, 0);
release();
if (response <= kIdle) {
type = CARD_SD;
while (response == kIdle && (uint32_t)(millis() - start) < 1000) {
delay(1);
response = appCommand(41, 0);
release();
}
} else {
type = CARD_MMC;
start = millis();
do {
response = command(1, 0);
release();
if (response == kIdle) delay(1);
} while (response == kIdle && (uint32_t)(millis() - start) < 1000);
}
if (response != kReady) {
Serial.printf("[sd] soft SPI: legacy init failed (R1=0x%02X)\n", response);
return false;
}
}
if (type != CARD_SDHC) {
response = command(16, 512);
release();
if (response != kReady) {
Serial.printf("[sd] soft SPI: CMD16 failed (R1=0x%02X)\n", response);
return false;
}
}
uint8_t csd[16];
const bool csd_ok = command(9, 0) == kReady && readBlock(csd, sizeof csd);
release();
if (!csd_ok) {
Serial.println("[sd] soft SPI: CSD read/CRC failed");
return false;
}
s_sectors = CrowPanel35SdCodec::sectorsFromCsd(csd);
if (!s_sectors || (type != CARD_SDHC && s_sectors > UINT32_MAX / 512UL + 1)) {
Serial.println("[sd] soft SPI: invalid or unsupported capacity");
s_sectors = 0;
return false;
}
s_type = type;
s_slow = false;
return true;
}
bool cardReady() {
if (s_type == CARD_NONE) return false;
const uint8_t response = command(13, 0);
const uint8_t status = response == kReady ? receive() : 0xFF;
release();
return response == kReady && status == 0;
}
bool readSectors(uint8_t* data, uint32_t sector, unsigned count) {
const uint32_t address = s_type == CARD_SDHC ? sector : sector * 512;
bool ok;
if (count == 1) {
ok = command(17, address) == kReady && readBlock(data, 512);
} else {
ok = command(18, address) == kReady;
if (ok) {
for (unsigned i = 0; ok && i < count; ++i) ok = readBlock(data + i * 512, 512);
const bool stopped = command(12, 0) == kReady;
const bool ready = waitReady(500);
ok = ok && stopped && ready;
}
}
release();
return ok;
}
bool writeSectors(const uint8_t* data, uint32_t sector, unsigned count) {
const uint32_t address = s_type == CARD_SDHC ? sector : sector * 512;
bool ok;
if (count == 1) {
ok = command(24, address) == kReady && writeBlock(data, kSingle);
} else {
ok = command(25, address) == kReady;
if (ok) {
for (unsigned i = 0; ok && i < count; ++i) ok = writeBlock(data + i * 512, kMulti);
if (waitReady(1000)) {
transfer(kStop); // Only an accepted CMD25 owes a stop token.
receive();
const bool ready = waitReady(1000);
ok = ok && ready;
} else {
ok = false;
}
}
}
release();
return ok;
}
DSTATUS diskStatus(unsigned char) {
CardLock lock;
return cardReady() ? 0 : STA_NOINIT;
}
DSTATUS diskInit(unsigned char drive) { return diskStatus(drive); }
DRESULT diskRead(unsigned char, unsigned char* data, uint32_t sector, unsigned count) {
CardLock lock;
if (s_type == CARD_NONE) return RES_NOTRDY;
if (!data || !CrowPanel35SdCodec::validRange(s_sectors, sector, count)) {
Serial.printf("[sd] soft SPI: invalid read sector=%lu count=%u\n", (unsigned long)sector, count);
return RES_PARERR;
}
if (readSectors(data, sector, count) || readSectors(data, sector, count)) return RES_OK;
Serial.printf("[sd] soft SPI: read failed sector=%lu count=%u\n", (unsigned long)sector, count);
return RES_ERROR;
}
DRESULT diskWrite(unsigned char, const unsigned char* data, uint32_t sector, unsigned count) {
CardLock lock;
if (s_type == CARD_NONE) return RES_NOTRDY;
if (!data || !CrowPanel35SdCodec::validRange(s_sectors, sector, count)) {
Serial.printf("[sd] soft SPI: invalid write sector=%lu count=%u\n", (unsigned long)sector, count);
return RES_PARERR;
}
if (writeSectors(data, sector, count) || writeSectors(data, sector, count)) return RES_OK;
Serial.printf("[sd] soft SPI: write failed sector=%lu count=%u\n", (unsigned long)sector, count);
return RES_ERROR;
}
DRESULT diskIoctl(unsigned char, unsigned char cmd, void* data) {
CardLock lock;
if (s_type == CARD_NONE) return RES_NOTRDY;
if (cmd != CTRL_SYNC && !data) {
Serial.printf("[sd] soft SPI: missing ioctl output for %u\n", (unsigned)cmd);
return RES_PARERR;
}
switch (cmd) {
case CTRL_SYNC: {
select();
const bool ready = waitReady(500);
release();
if (!ready) Serial.println("[sd] soft SPI: sync timed out");
return ready ? RES_OK : RES_ERROR;
}
case GET_SECTOR_COUNT: *(DWORD*)data = s_sectors; return RES_OK;
case GET_SECTOR_SIZE: *(WORD*)data = 512; return RES_OK;
case GET_BLOCK_SIZE: *(DWORD*)data = 1; return RES_OK;
default:
Serial.printf("[sd] soft SPI: unsupported ioctl %u\n", (unsigned)cmd);
return RES_PARERR;
}
}
const ff_diskio_impl_t kDiskImpl = { diskInit, diskStatus, diskRead, diskWrite, diskIoctl };
void driveName(char (&name)[8], uint8_t drive) { snprintf(name, sizeof name, "%u:", (unsigned)drive); }
bool space(uint64_t& total, uint64_t& used) {
const uint8_t drive = SD.driveNumber();
if (drive == FF_DRV_NOT_USED) return false;
char name[8];
driveName(name, drive);
FATFS* fat = nullptr;
DWORD free_clusters;
// Do not hold CardLock while acquiring FatFs' volume lock: disk callbacks
// acquire CardLock in the opposite order.
const FRESULT result = f_getfree(name, &free_clusters, &fat);
if (result != FR_OK || !fat || fat->n_fatent < 2 || free_clusters > fat->n_fatent - 2) {
Serial.printf("[sd] soft SPI: free-space read failed (%u)\n", (unsigned)result);
return false;
}
total = (uint64_t)(fat->n_fatent - 2) * fat->csize * 512ULL;
used = total - (uint64_t)free_clusters * fat->csize * 512ULL;
return true;
}
} // namespace
CrowPanel35SD SD;
CrowPanel35SD::CrowPanel35SD() : fs::FS(fs::FSImplPtr(new VFSImpl())) {}
bool CrowPanel35SD::begin() {
if (!s_mutex) s_mutex = xSemaphoreCreateRecursiveMutex();
if (!s_lifecycle_mutex) s_lifecycle_mutex = xSemaphoreCreateRecursiveMutex();
if (!s_mutex || !s_lifecycle_mutex) {
Serial.println("[sd] soft SPI: mutex allocation failed");
return false;
}
CardLock lifecycle(s_lifecycle_mutex);
{
CardLock lock;
if (s_mounted) return true;
}
{
CardLock lock;
if (!cardInit()) return false;
}
const esp_err_t drive_error = ff_diskio_get_drive(&s_drive);
if (drive_error != ESP_OK || s_drive == FF_DRV_NOT_USED) {
Serial.printf("[sd] soft SPI: no FatFs drive (%s)\n", esp_err_to_name(drive_error));
end();
return false;
}
ff_diskio_register(s_drive, &kDiskImpl);
char name[8];
driveName(name, s_drive);
FATFS* fat = nullptr;
const esp_err_t vfs_error = esp_vfs_fat_register(kMountPoint, name, kMaxOpenFiles, &fat);
if (vfs_error != ESP_OK) {
Serial.printf("[sd] soft SPI: VFS registration failed (%s)\n", esp_err_to_name(vfs_error));
end();
return false;
}
s_vfs_registered = true;
const FRESULT mount_error = f_mount(fat, name, 1);
if (mount_error != FR_OK) {
Serial.printf("[sd] soft SPI: FAT mount failed (%u); card left unchanged\n", (unsigned)mount_error);
end();
return false;
}
_impl->mountpoint(kMountPoint);
{
CardLock lock;
s_mounted = true;
}
Serial.printf("[sd] soft SPI mounted: %llu MB\n", (unsigned long long)(cardSize() / (1024ULL * 1024)));
return true;
}
void CrowPanel35SD::end() {
if (!s_lifecycle_mutex) return; // No begin() has created a mount.
CardLock lifecycle(s_lifecycle_mutex);
_impl->mountpoint(nullptr);
if (s_vfs_registered) {
char name[8];
driveName(name, s_drive);
const FRESULT result = f_mount(nullptr, name, 0);
if (result != FR_OK) Serial.printf("[sd] soft SPI: FAT unmount failed (%u)\n", (unsigned)result);
const esp_err_t vfs_error = esp_vfs_fat_unregister_path(kMountPoint);
if (vfs_error != ESP_OK)
Serial.printf("[sd] soft SPI: VFS unregister failed (%s)\n", esp_err_to_name(vfs_error));
}
if (s_drive != FF_DRV_NOT_USED) ff_diskio_unregister(s_drive);
if (!s_mutex) return;
CardLock lock;
s_drive = FF_DRV_NOT_USED;
s_type = CARD_NONE;
s_sectors = 0;
s_mounted = false;
s_vfs_registered = false;
}
sdcard_type_t CrowPanel35SD::cardType() const {
if (!s_mutex) return CARD_NONE;
CardLock lock;
return s_mounted ? s_type : CARD_NONE;
}
uint64_t CrowPanel35SD::cardSize() const {
if (!s_mutex) return 0;
CardLock lock;
return s_mounted ? (uint64_t)s_sectors * 512ULL : 0;
}
uint8_t CrowPanel35SD::driveNumber() const {
if (!s_mutex) return FF_DRV_NOT_USED;
CardLock lock;
return s_mounted ? s_drive : FF_DRV_NOT_USED;
}
bool CrowPanel35SD::probeAlive() const {
if (!s_mutex) return false;
CardLock lock;
return s_mounted && cardReady();
}
uint64_t CrowPanel35SD::totalBytes() const {
uint64_t total = 0, used = 0;
return space(total, used) ? total : 0;
}
uint64_t CrowPanel35SD::usedBytes() const {
uint64_t total = 0, used = 0;
return space(total, used) ? used : 0;
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025-2026 Michael A. Cojocari and contributors
#pragma once
#include <FS.h>
#include <sd_defines.h>
// SD-compatible filesystem facade for Camillia-MT's software-SPI card backend.
// Neither the display's SPI3 host nor the radio's SPI2 host is reconfigured.
class CrowPanel35SD : public fs::FS {
public:
CrowPanel35SD();
bool begin();
void end();
sdcard_type_t cardType() const;
uint64_t cardSize() const;
uint64_t totalBytes() const;
uint64_t usedBytes() const;
uint8_t driveNumber() const;
bool probeAlive() const;
};
extern CrowPanel35SD SD;
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025-2026 Michael A. Cojocari and contributors
#pragma once
#include <stddef.h>
#include <stdint.h>
namespace CrowPanel35SdCodec {
inline uint8_t commandCrc(const uint8_t* frame, size_t length) {
uint8_t crc = 0;
for (size_t i = 0; i < length; ++i) {
uint8_t byte = frame[i];
for (int bit = 0; bit < 8; ++bit) {
crc = (uint8_t)(crc << 1);
if ((byte ^ crc) & 0x80) crc ^= 0x09;
byte = (uint8_t)(byte << 1);
}
}
return (uint8_t)((crc << 1) | 1);
}
inline uint16_t dataCrc(uint16_t crc, uint8_t byte) {
crc ^= (uint16_t)byte << 8;
for (int bit = 0; bit < 8; ++bit)
crc = (uint16_t)((crc & 0x8000) ? (crc << 1) ^ 0x1021 : crc << 1);
return crc;
}
inline uint32_t sectorsFromCsd(const uint8_t (&csd)[16]) {
uint64_t sectors;
const uint8_t version = csd[0] >> 6;
if (version == 1) {
const uint32_t size = ((uint32_t)(csd[7] & 0x3F) << 16)
| ((uint32_t)csd[8] << 8) | csd[9];
sectors = ((uint64_t)size + 1) * 1024;
} else if (version == 0) {
const uint32_t blockLength = csd[5] & 0x0F;
const uint32_t size = ((uint32_t)(csd[6] & 0x03) << 10)
| ((uint32_t)csd[7] << 2) | (csd[8] >> 6);
const uint32_t multiplier = ((csd[9] & 0x03) << 1) | (csd[10] >> 7);
sectors = (((uint64_t)size + 1) << (multiplier + 2 + blockLength)) / 512;
} else {
return 0;
}
return sectors <= UINT32_MAX ? (uint32_t)sectors : 0;
}
inline bool validRange(uint32_t sectors, uint32_t sector, unsigned count) {
return count && sector < sectors && count <= sectors - sector;
}
} // namespace CrowPanel35SdCodec
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <Arduino.h>
#include "target.h"
CrowPanel35Board board;
static SPIClass radioSpi(FSPI);
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, radioSpi);
WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
ClockFloorRTC rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#else
EnvironmentSensorManager sensors;
#endif
CrowPanel35Display display;
bool radio_init() {
fallback_clock.begin();
rtc_clock.begin(Wire);
if (!radio.std_init(&radioSpi)) {
Serial.println("[crowpanel-35] SX1262 not found on the LoRa expansion slot");
return false;
}
return true;
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#include "../../src/helpers/ClockFloorRTC.h"
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
#include "CrowPanel35Board.h"
#include "CrowPanel35Display.h"
extern CrowPanel35Board board;
extern RADIO_CLASS radio;
extern WRAPPER_CLASS radio_driver;
extern ClockFloorRTC rtc_clock;
extern EnvironmentSensorManager sensors;
extern CrowPanel35Display display;
bool radio_init();
mesh::LocalIdentity radio_new_identity();