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();