diff --git a/DEVICES.md b/DEVICES.md
index d0ac4ff..30d5b2c 100644
--- a/DEVICES.md
+++ b/DEVICES.md
@@ -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
diff --git a/NOTICE b/NOTICE
index 711abdc..a62b524 100644
--- a/NOTICE
+++ b/NOTICE
@@ -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.
diff --git a/README.md b/README.md
index e0a6663..29cca65 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
A real touchscreen UI for your mesh radio. · open source · GPL-3.0
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 /
diff --git a/boards/crowpanel_35.json b/boards/crowpanel_35.json
new file mode 100644
index 0000000..26959be
--- /dev/null
+++ b/boards/crowpanel_35.json
@@ -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"
+}
diff --git a/deploy/flasher/index.html b/deploy/flasher/index.html
index 19f7ce5..c38dbbf 100644
--- a/deploy/flasher/index.html
+++ b/deploy/flasher/index.html
@@ -104,6 +104,13 @@
Use Chrome or Edge on desktop (Web Serial).
+
+
Elecrow CrowPanel Advance 3.5 Beta
+
3.5" touch display, microSD, and an SX1262 module in the LoRa expansion slot.
+
For the Advance 3.5 only. Hardware validation is pending; the SX1262 module must be attached.
+
+ Use Chrome or Edge on desktop (Web Serial).
+
How it works: plug the device in over USB, click Connect, pick the serial port,
diff --git a/deploy/site/index.html b/deploy/site/index.html
index 5e16e72..4385ad7 100644
--- a/deploy/site/index.html
+++ b/deploy/site/index.html
@@ -517,6 +517,11 @@
Download .bin
New board: beta channel only until the next stable.
+
CrowPanel Advance 3.5 (experimental)
3.5" 480×320 touch display; requires an SX1262 module in the LoRa expansion slot.
+ Partially supportedHardware validation pendingThis 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.
+
+ Download .bin
+
Beta only. Attach the SX1262 before booting; flash with care and report what works.
diff --git a/merge-bin.py b/merge-bin.py
index 6837b00..01abfbc 100644
--- a/merge-bin.py
+++ b/merge-bin.py
@@ -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)
diff --git a/platformio.ini b/platformio.ini
index 5708b75..922cc2f 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -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>
+ +
+ +
+ +
+ -
+ +
+ +
+ +
+ +<../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
diff --git a/scripts/build-upload-monitor.sh b/scripts/build-upload-monitor.sh
index d785b15..69a01c6 100755
--- a/scripts/build-upload-monitor.sh
+++ b/scripts/build-upload-monitor.sh
@@ -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" ;;
diff --git a/scripts/build/gen-flasher-meta.py b/scripts/build/gen-flasher-meta.py
index 2d18f28..4fab77b 100644
--- a/scripts/build/gen-flasher-meta.py
+++ b/scripts/build/gen-flasher-meta.py
@@ -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"),
diff --git a/scripts/release.sh b/scripts/release.sh
index c7d00b3..7341066 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -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 —
diff --git a/src/DataStore.cpp b/src/DataStore.cpp
index 95b8ac5..c6a5f32 100644
--- a/src/DataStore.cpp
+++ b/src/DataStore.cpp
@@ -3,7 +3,11 @@
#include "DataStore.h"
#include // ADV_TYPE_NONE (blank-record skip in loadContacts)
#if defined(ESP32)
+#if defined(HAS_CROWPANEL_35)
+#include
+#else
#include
+#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;
diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp
index 997b179..2b11358 100644
--- a/src/helpers/esp32/SdNvsPrefs.cpp
+++ b/src/helpers/esp32/SdNvsPrefs.cpp
@@ -2,7 +2,11 @@
#if defined(ESP32)
#include
+#if defined(HAS_CROWPANEL_35)
+#include
+#else
#include
+#endif
#include
#include
#include
diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp
index ea3173e..0eaabec 100644
--- a/src/helpers/esp32/TouchPrefsStore.cpp
+++ b/src/helpers/esp32/TouchPrefsStore.cpp
@@ -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() {
diff --git a/src/helpers/esp32/WebFileTransferConfig.h b/src/helpers/esp32/WebFileTransferConfig.h
index 5352265..8b3fa15 100644
--- a/src/helpers/esp32/WebFileTransferConfig.h
+++ b/src/helpers/esp32/WebFileTransferConfig.h
@@ -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
diff --git a/variants/wio_tracker_l2/WioTrackerL2Touch.cpp b/src/helpers/input/LgfxGt911Touch.cpp
similarity index 72%
rename from variants/wio_tracker_l2/WioTrackerL2Touch.cpp
rename to src/helpers/input/LgfxGt911Touch.cpp
index b543e37..0b9081d 100644
--- a/variants/wio_tracker_l2/WioTrackerL2Touch.cpp
+++ b/src/helpers/input/LgfxGt911Touch.cpp
@@ -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
+#include
#include
#include
#include
#include
-#include "WioTrackerL2Display.h"
-
-extern WioTrackerL2Display display;
+#if defined(HAS_CROWPANEL_35)
+ #include
+ extern CrowPanel35Display display;
+#else
+ #include
+ 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
\ No newline at end of file
diff --git a/src/main.cpp b/src/main.cpp
index 9d0cd3e..6645330 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -56,9 +56,13 @@ static uint32_t _atoi(const char* sp) {
#include
#include
#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
+ #else
#include
#include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards)
+ #endif
#include
#if defined(TLORA_PAGER)
#include
@@ -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
diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp
index c3ec86b..62a8efe 100644
--- a/src/ui-touch/UITask.cpp
+++ b/src/ui-touch/UITask.cpp
@@ -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
+#elif defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#include // 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
#include
+ #elif defined(HAS_CROWPANEL_35)
+ #include
#elif defined(HAS_RAK_TAP_V2)
#include // 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
diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h
index d480cae..0b4a7f1 100644
--- a/src/ui-touch/device_caps.h
+++ b/src/ui-touch/device_caps.h
@@ -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
diff --git a/test/test_crowpanel_sd_codec.cpp b/test/test_crowpanel_sd_codec.cpp
new file mode 100644
index 0000000..1e08dfa
--- /dev/null
+++ b/test/test_crowpanel_sd_codec.cpp
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include
+#include
+#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));
+}
diff --git a/test/test_merge_bin.py b/test/test_merge_bin.py
new file mode 100644
index 0000000..a590bbc
--- /dev/null
+++ b/test/test_merge_bin.py
@@ -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()
diff --git a/variants/crowpanel_35/CrowPanel35Board.h b/variants/crowpanel_35/CrowPanel35Board.h
new file mode 100644
index 0000000..1c8840a
--- /dev/null
+++ b/variants/crowpanel_35/CrowPanel35Board.h
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+#include
+
+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"; }
+};
diff --git a/variants/crowpanel_35/CrowPanel35Display.cpp b/variants/crowpanel_35/CrowPanel35Display.cpp
new file mode 100644
index 0000000..a4098ba
--- /dev/null
+++ b/variants/crowpanel_35/CrowPanel35Display.cpp
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "CrowPanel35Display.h"
+
+#include
+#include
+
+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(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;
+}
diff --git a/variants/crowpanel_35/CrowPanel35Display.h b/variants/crowpanel_35/CrowPanel35Display.h
new file mode 100644
index 0000000..2865322
--- /dev/null
+++ b/variants/crowpanel_35/CrowPanel35Display.h
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+
+#define LGFX_USE_V1
+#include
+
+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;
+};
diff --git a/variants/crowpanel_35/CrowPanel35SD.cpp b/variants/crowpanel_35/CrowPanel35SD.cpp
new file mode 100644
index 0000000..22a828f
--- /dev/null
+++ b/variants/crowpanel_35/CrowPanel35SD.cpp
@@ -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
+#include
+#include
+#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;
+}
diff --git a/variants/crowpanel_35/CrowPanel35SD.h b/variants/crowpanel_35/CrowPanel35SD.h
new file mode 100644
index 0000000..35563d9
--- /dev/null
+++ b/variants/crowpanel_35/CrowPanel35SD.h
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2025-2026 Michael A. Cojocari and contributors
+#pragma once
+
+#include
+#include
+
+// 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;
diff --git a/variants/crowpanel_35/CrowPanel35SdCodec.h b/variants/crowpanel_35/CrowPanel35SdCodec.h
new file mode 100644
index 0000000..a47912c
--- /dev/null
+++ b/variants/crowpanel_35/CrowPanel35SdCodec.h
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2025-2026 Michael A. Cojocari and contributors
+#pragma once
+
+#include
+#include
+
+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
diff --git a/variants/crowpanel_35/target.cpp b/variants/crowpanel_35/target.cpp
new file mode 100644
index 0000000..706e18a
--- /dev/null
+++ b/variants/crowpanel_35/target.cpp
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include
+#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);
+}
diff --git a/variants/crowpanel_35/target.h b/variants/crowpanel_35/target.h
new file mode 100644
index 0000000..1839705
--- /dev/null
+++ b/variants/crowpanel_35/target.h
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#define RADIOLIB_STATIC_ONLY 1
+#include
+#include
+#include
+#include
+#include
+#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();