From d9ad270e509f56ae8d36f957d380ea7079dec499 Mon Sep 17 00:00:00 2001 From: Valleytech Custom Solutions Date: Sat, 7 Nov 2026 11:11:24 -0500 Subject: [PATCH] Add Heltec V4-R8 Expansion Kit V2 support Integrate touch, buttons, SD, GNSS, SX1262 front end, battery and sleep. Protect fixed wiring, package factory and Launcher images, and add host regression coverage. Fix serial formatting, binary file hashing and credential logging encountered during hardware validation. --- .github/workflows/PR_All_envs.yml | 6 +- .github/workflows/PR_check.yml | 18 +- .github/workflows/buil_parallel.yml | 3 + .github/workflows/manual_build_sel_env.yml | 1 + .gitignore | 2 + README.md | 3 + .../_boards_json/heltec-v4-expansion-v2.json | 37 ++ boards/heltec-v4-expansion-v2/NOTICE.md | 31 ++ boards/heltec-v4-expansion-v2/README.md | 151 ++++++++ boards/heltec-v4-expansion-v2/board_input.h | 108 ++++++ .../heltec-v4-expansion-v2.ini | 23 ++ boards/heltec-v4-expansion-v2/interface.cpp | 294 +++++++++++++++ .../heltec-v4-expansion-v2/package_release.py | 155 ++++++++ boards/heltec-v4-expansion-v2/pins_arduino.h | 111 ++++++ boards/heltec-v4-expansion-v2/post_build.py | 30 ++ boards/pinouts/pins_arduino.h | 4 +- include/interface.h | 2 + platformio.ini | 3 +- src/core/USBSerial/USBSerial.h | 2 +- src/core/config.cpp | 1 - src/core/configPins.cpp | 114 +++++- src/core/configPins.h | 10 +- src/core/file_hash_stream.h | 23 ++ src/core/menu_items/ConfigMenu.cpp | 13 + src/core/menu_items/GpsMenu.cpp | 4 + src/core/sd_functions.cpp | 9 +- src/core/serial_commands/power_commands.cpp | 4 + src/main.cpp | 4 + src/modules/NRF24/nrf_common.cpp | 16 + src/modules/lora/LoRaRF.cpp | 173 +++++++-- src/modules/lora/lora_board.h | 20 ++ src/modules/rf/emit.cpp | 2 +- src/modules/rf/record.cpp | 5 +- src/modules/rf/rf_send.cpp | 2 +- src/modules/rf/rf_utils.cpp | 14 + tests/heltec_packaging/README.md | 15 + .../heltec_packaging/test_package_release.py | 175 +++++++++ tests/heltec_pins/README.md | 13 + tests/heltec_pins/run.py | 53 +++ tests/heltec_pins/stubs/Arduino.h | 33 ++ tests/heltec_pins/stubs/esp_mac.h | 7 + tests/heltec_pins/stubs/globals.h | 1 + tests/heltec_pins/stubs/host_fs.h | 78 ++++ tests/heltec_pins/stubs/precompiler_flags.h | 2 + tests/heltec_pins/stubs/soc/soc_caps.h | 1 + tests/heltec_pins/test_config_pins.cpp | 118 ++++++ tests/heltec_v4_expansion_v2/README.md | 43 +++ .../file_hash_tests.cpp | 145 ++++++++ .../input_policy_tests.cpp | 338 ++++++++++++++++++ .../lora_band_tests.cpp | 116 ++++++ tests/heltec_v4_expansion_v2/run.py | 55 +++ tests/serial_adapter/README.md | 11 + tests/serial_adapter/run.py | 35 ++ tests/serial_adapter/stubs/Arduino.h | 62 ++++ tests/serial_adapter/test_serial_adapter.cpp | 39 ++ 55 files changed, 2685 insertions(+), 53 deletions(-) create mode 100644 boards/_boards_json/heltec-v4-expansion-v2.json create mode 100644 boards/heltec-v4-expansion-v2/NOTICE.md create mode 100644 boards/heltec-v4-expansion-v2/README.md create mode 100644 boards/heltec-v4-expansion-v2/board_input.h create mode 100644 boards/heltec-v4-expansion-v2/heltec-v4-expansion-v2.ini create mode 100644 boards/heltec-v4-expansion-v2/interface.cpp create mode 100644 boards/heltec-v4-expansion-v2/package_release.py create mode 100644 boards/heltec-v4-expansion-v2/pins_arduino.h create mode 100644 boards/heltec-v4-expansion-v2/post_build.py create mode 100644 src/core/file_hash_stream.h create mode 100644 src/modules/lora/lora_board.h create mode 100644 tests/heltec_packaging/README.md create mode 100644 tests/heltec_packaging/test_package_release.py create mode 100644 tests/heltec_pins/README.md create mode 100644 tests/heltec_pins/run.py create mode 100644 tests/heltec_pins/stubs/Arduino.h create mode 100644 tests/heltec_pins/stubs/esp_mac.h create mode 100644 tests/heltec_pins/stubs/globals.h create mode 100644 tests/heltec_pins/stubs/host_fs.h create mode 100644 tests/heltec_pins/stubs/precompiler_flags.h create mode 100644 tests/heltec_pins/stubs/soc/soc_caps.h create mode 100644 tests/heltec_pins/test_config_pins.cpp create mode 100644 tests/heltec_v4_expansion_v2/README.md create mode 100644 tests/heltec_v4_expansion_v2/file_hash_tests.cpp create mode 100644 tests/heltec_v4_expansion_v2/input_policy_tests.cpp create mode 100644 tests/heltec_v4_expansion_v2/lora_band_tests.cpp create mode 100644 tests/heltec_v4_expansion_v2/run.py create mode 100644 tests/serial_adapter/README.md create mode 100644 tests/serial_adapter/run.py create mode 100644 tests/serial_adapter/stubs/Arduino.h create mode 100644 tests/serial_adapter/test_serial_adapter.cpp diff --git a/.github/workflows/PR_All_envs.yml b/.github/workflows/PR_All_envs.yml index bffe623ebb..7f89584a9c 100644 --- a/.github/workflows/PR_All_envs.yml +++ b/.github/workflows/PR_All_envs.yml @@ -18,6 +18,7 @@ jobs: matrix: board: - { env: "m5stack-cardputer", family: "ESP32-S3",} + - { env: "heltec-v4-expansion-v2", family: "ESP32-S3",} - { env: "m5stack-cplus2", family: "ESP32",} - { env: "m5stack-cplus1_1", family: "ESP32",} - { env: "LAUNCHER_m5stack-cplus1_1", family: "ESP32",} @@ -143,6 +144,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: Bruce-${{ matrix.board.env }} - path: Bruce-*.bin + path: | + Bruce-*.bin + Bruce-*.sha256 + Bruce-*.json retention-days: 5 if-no-files-found: error diff --git a/.github/workflows/PR_check.yml b/.github/workflows/PR_check.yml index fa0a078a78..30f3717028 100644 --- a/.github/workflows/PR_check.yml +++ b/.github/workflows/PR_check.yml @@ -37,6 +37,11 @@ jobs: env: "m5stack-cardputer", partitions: { bootloader_addr: "0x0000" }, } + - { + name: "ESP32-S3 - Heltec V4-R8 Expansion Kit V2", + env: "heltec-v4-expansion-v2", + partitions: { bootloader_addr: "0x0000" }, + } - { name: "ESP32 - M5StickCPlus2", env: "m5stack-cplus2", @@ -100,10 +105,21 @@ jobs: run: | platformio run -e ${{ matrix.board.env }} + - name: Check Heltec board policies + if: matrix.board.env == 'heltec-v4-expansion-v2' + run: | + python tests/heltec_pins/run.py + python tests/heltec_v4_expansion_v2/run.py + python tests/heltec_packaging/test_package_release.py + python tests/serial_adapter/run.py + - name: Upload ${{ matrix.board.name }} uses: actions/upload-artifact@v4 with: name: Bruce-${{ matrix.board.env }} - path: Bruce-*.bin + path: | + Bruce-*.bin + Bruce-*.sha256 + Bruce-*.json retention-days: 5 if-no-files-found: error diff --git a/.github/workflows/buil_parallel.yml b/.github/workflows/buil_parallel.yml index dcca4f1b71..b218b37094 100644 --- a/.github/workflows/buil_parallel.yml +++ b/.github/workflows/buil_parallel.yml @@ -36,6 +36,7 @@ jobs: board: - { env: "arduino-nesso-n1", family: "ESP32-C6",} - { env: "m5stack-cardputer", family: "ESP32-S3",} + - { env: "heltec-v4-expansion-v2", family: "ESP32-S3",} - { env: "m5stack-sticks3", family: "ESP32-S3",} - { env: "m5stack-cplus2", family: "ESP32",} - { env: "m5stack-cplus1_1", family: "ESP32",} @@ -348,6 +349,8 @@ jobs: generate_release_notes: true files: | Bruce-*.bin + Bruce-*.sha256 + Bruce-*.json - name: Close "Fixed on Beta" issues env: diff --git a/.github/workflows/manual_build_sel_env.yml b/.github/workflows/manual_build_sel_env.yml index 08accee001..56b4b3e9b5 100644 --- a/.github/workflows/manual_build_sel_env.yml +++ b/.github/workflows/manual_build_sel_env.yml @@ -15,6 +15,7 @@ on: type: choice options: - "m5stack-cardputer" + - "heltec-v4-expansion-v2" - "m5stack-cplus2" - "m5stack-cplus1_1" - "LAUNCHER_m5stack-cplus1_1" diff --git a/.gitignore b/.gitignore index f3f594b915..f4c5ffdbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ include/webFiles.h include/current_year.h checksum.sha256 /*.bin +/Bruce-*.sha256 +/Bruce-*.json .vscode/extensions.json mqjs_build docker-logs diff --git a/README.md b/README.md index e87968ed23..e1b1fbd083 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ Also, [read our FAQ](https://wiki.bruce.computer/faq/) | [Lilygo T‑Deck](https://lilygo.cc/products/t-deck) ([and pro](https://lilygo.cc/products/t-deck-plus-1)) | :ok: | :x: | :x: | :x: | :x: | :ok: | :x: | :x: | :x: | :x: | | [Lilygo T-Watch-S3](https://lilygo.cc/products/t-watch-s3) | :x: | :x: | :x: | :x: | :x: | :ok: | :x: | :x: | :x: | :x: | | [Lilygo T-LoRa Pager](https://lilygo.cc/products/t-lora-pager) | :x: | :x: | :x: | :x: | :x: | :ok: | :x: | :x: | :x: | :x: | +| [Heltec WiFi LoRa 32 V4-R8 Expansion Kit V2 (high-band)](boards/heltec-v4-expansion-v2/) | :x: | :x: | :x: | :x: | :x: | :ok: | :x: | Tone | :x: | :x: | | [Smoochiee V2](https://www.pcbway.com/project/shareproject/Bruce_PCB_Smoochiee_d6a0284b.html) | :ok: | :ok: | :x: | :ok: | :x: | :ok: | :x: | :x: | :x: | :x: | | [ESP32-C5](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c5/esp32-c5-devkitc-1/user_guide.html) | :ok: | :ok: | :x: | :ok: | :x: | :x: | :x: | :x: | :x: | :x: | | [Bruce RF Reaper](https://www.elecrow.com/bruce-pcb-rf-reaper.html) | :ok: | :ok: | :x: | :ok: but w/ ST25R3916 | :x: | :ok: | :ok: | :x: | :ok: | :x: | @@ -251,6 +252,8 @@ Also, [read our FAQ](https://wiki.bruce.computer/faq/) _LITE_VERSION_: TelNet, SSH, WireGuard, ScanHosts, RawSniffer, Brucegotchi, BLEBacon, BLEScan and Interpreter are NOT available for M5Launcher Compatibility +The `heltec-v4-expansion-v2` build targets the high-band V4-R8 board with the Expansion Kit V2: 16 MB flash, 8 MB PSRAM, a 240 × 320 touchscreen, microSD, GNSS, SX1262 LoRa and a buzzer. It does not target the bare V4, Expansion Kit V1 or low-band radio variant. Optional external modules are not included in this kit; its fixed display, touch, storage and radio pins must not be reassigned to them. + ## :sparkles: Why and how does it look? Bruce stems from a keen observation within the community focused on devices like Flipper Zero. While these devices offered a glimpse into the world of offensive security, there was a palpable sense that something more could be achieved without being that overpriced, particularly with the robust and modular hardware ecosystem provided by ESP32 Devices, Lilygo and M5Stack products. diff --git a/boards/_boards_json/heltec-v4-expansion-v2.json b/boards/_boards_json/heltec-v4-expansion-v2.json new file mode 100644 index 0000000000..7b00ae7ed3 --- /dev/null +++ b/boards/_boards_json/heltec-v4-expansion-v2.json @@ -0,0 +1,37 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DHELTEC_V4_EXPANSION_V2", + "-DBOARD_HAS_PSRAM", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "pinouts" + }, + "connectivity": ["bluetooth", "wifi"], + "debug": { "openocd_target": "esp32s3-builtin.cfg" }, + "frameworks": ["arduino", "espidf"], + "name": "Heltec WiFi LoRa 32 V4-R8 Expansion V2", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/project/v4-r8-ex/", + "vendor": "Heltec" +} diff --git a/boards/heltec-v4-expansion-v2/NOTICE.md b/boards/heltec-v4-expansion-v2/NOTICE.md new file mode 100644 index 0000000000..e1fdca1e60 --- /dev/null +++ b/boards/heltec-v4-expansion-v2/NOTICE.md @@ -0,0 +1,31 @@ +# LORA Tunnel attribution + +The CHSC6xxx report decoding and expansion-board initialization/sleep sequencing +in `board_input.h` and `interface.cpp` are adapted from the LORA Tunnel project's +`src/hardware.cpp`, with the hardware mapping cross-checked against Heltec's +documentation. The original portions are distributed under the following MIT +license. The Bruce integration follows this repository's license. + +Source: https://github.com/valleytechsolutions/LORA-Tunnel + +MIT License + +Copyright (c) 2026 Valleytech Solutions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/boards/heltec-v4-expansion-v2/README.md b/boards/heltec-v4-expansion-v2/README.md new file mode 100644 index 0000000000..222247df97 --- /dev/null +++ b/boards/heltec-v4-expansion-v2/README.md @@ -0,0 +1,151 @@ +# Heltec WiFi LoRa 32 V4-R8 + Expansion Kit V2 + +Build target: `heltec-v4-expansion-v2`. + +This target is for the ESP32-S3 V4-R8 with 16 MB flash, 8 MB OPI PSRAM and the +Expansion Kit V2. It uses the high-frequency SX1262 hardware. The original +Expansion Kit, bare WiFi LoRa 32 boards and low-frequency radio variants need +their own hardware definitions. + +```sh +pio run -e heltec-v4-expansion-v2 +``` + +The board uses Bruce's standard 16 MB partition layout, native USB console and +USB HID support. As on Bruce's other ESP32-S3 targets, the console uses hardware +CDC at boot and selecting a USB function starts TinyUSB on the same connector. +The serial console can disconnect when that function takes over; restart to +restore it. Bootloader download mode is available by holding USER/G0 +while resetting or connecting USB. Restore a full flash backup at address zero +if recovery to the previous firmware is required. + +## Installation images + +The build automatically creates these files in the repository root: + +| File | Installation route | +| --- | --- | +| `Bruce-heltec-v4-expansion-v2-FACTORY-0x0.bin` | Standalone USB/web flashing at address `0x0` | +| `Bruce-heltec-v4-expansion-v2-LAUNCHER-INSTALL.bin` | Select **Install** on this file inside Bmorcelli's Launcher | +| `Bruce-heltec-v4-expansion-v2.bin` | Existing Bruce merged-image name, retained for normal tooling | +| `Bruce-heltec-v4-expansion-v2.sha256` | SHA-256 checksums for all three BIN names | +| `Bruce-heltec-v4-expansion-v2.json` | Application identity, partition layout and artifact hashes | + +All three BIN files are byte-identical merged images from the same application +build. The explicit names identify the intended installation workflow. Flashing +the factory image at `0x0` replaces the installed bootloader and partition table; +use Launcher's **Install** action to keep Launcher as the boot environment. + +Copy `LAUNCHER-INSTALL.bin` to the microSD card, open it in Launcher and select +**Install**. Launcher's merged-image installer reads the partition table at +`0x8000`, extracts the application at `0x10000` and chooses its destination +partition. The installer also sees the `spiffs` entry even though no filesystem +payload is included. Bruce needs this partition for LittleFS even when SD is +available; a raw application-only BIN cannot communicate that requirement. +This format was reviewed against [Launcher commit `2f43aa7`](https://github.com/bmorcelli/Launcher/blob/2f43aa7bd3ef03e770c0a97135cbcfcc79c15aba/src/sd_functions.cpp#L750). + +At that revision, a 16 MB device receives a default empty 448 KiB `spiffs` +partition for this package, instead of reserving the standalone image's large +filesystem. Bruce initializes LittleFS on that partition. Store larger files on +SD. See [Launcher's default data-partition sizes](https://github.com/bmorcelli/Launcher/blob/2f43aa7bd3ef03e770c0a97135cbcfcc79c15aba/src/partition_table_model.cpp#L171). +Older Launcher versions can have different installation behavior; verify the +installed version, free flash and filesystem creation before relying on them. +Launcher can reuse an existing `spiffs` partition. Bruce uses LittleFS and +formats that partition if mounting fails, so back up any existing application +filesystem before installing. Keeping other application binaries does not +guarantee their shared filesystem contents are preserved. + +To return to Launcher, exit the active Bruce feature, restart with USER/G0 +released and tap the center of Launcher's startup screen. Holding USER/G0 +during reset enters USB download mode instead. Launcher's button mapping also +differs from Bruce's: USER click moves forward and hold/double-click selects; +IO click moves backward and hold/double-click returns. + +The packager checks ESP32-S3 image identity, partition-table MD5, application +fit and exact application bytes before creating the named files. It does not +prove a hardware installation succeeded. Launcher installation, boot, +settings retention, other installed applications and return to the Launcher +menu must be verified on the device and recorded with the Launcher version. + +To regenerate the names and checksums from an existing build without compiling: + +```sh +python boards/heltec-v4-expansion-v2/package_release.py --build-dir .pio/build/heltec-v4-expansion-v2 +``` + +## Hardware + +| Function | Connection | +| --- | --- | +| ST7789 display | 240 × 320, HSPI: SCK16, MOSI15, MISO45, CS47, DC48, reset21 | +| Backlight | GPIO44, active high PWM | +| microSD | Same HSPI bus, CS3; mounted through Bruce's shared SPI HAL | +| CHSC6xxx touch | I²C address `0x2e`, SDA17, SCL18; polled without the optional interrupt pad | +| Display/touch/SD power | GPIO40, active low | +| SX1262 | FSPI: SCK9, MOSI10, MISO11, CS8, reset12, DIO1 14, BUSY13 | +| LoRa front end | Power7, CSD2, CTX5; 1.8 V TCXO | +| L76K GNSS | 9600 baud, MCU RX39/TX38, enable42 active low, PPS41 | +| Buzzer | GPIO4 | +| Battery voltage | GPIO1 through a 390 kΩ / 100 kΩ divider | +| Buttons | USER/G0 active low; KEY/GPIO46 active high | + +TFT_eSPI owns HSPI and the SD driver uses that same SPI instance. The second +controller is reserved for the SX1262. Touch starts after the display reset, +because GPIO21 resets both devices. GNSS runs from boot and Bruce's existing GPS +modules parse its NMEA data without startup commands intended for other receivers. +The touchscreen uses `Wire1` explicitly. The default `Wire` SDA/SCL aliases are +disconnected, so an unavailable optional I²C accessory cannot fall back to the +touch pins and detach their system bus. + +The LoRa target is constrained to 863–928 MHz occupied bandwidth. Raw SX1262 +transmit drive is conservatively capped at 0 dBm pending measurement of the +board's external amplifier; this is **not** a claim of 0 dBm antenna output or +calibrated maximum power. Use the correct antenna and regional settings. + +Both hardware SPI controllers are occupied. CC1101, NRF24, NFC, Ethernet, +infrared, FM radio, microphone and RGB hardware are not fitted. No spare +multiwire expansion bus is available in this target. GPIO6 is the sole +unreserved pad and may be explicitly configured for a suitable single-wire +accessory. Built-in SD, touch, LoRa and GNSS pins are fixed; loading or saving +configuration restores their wiring and rejects conflicting optional pins. + +## Controls + +- Touch Bruce's on-screen controls; the bottom strip provides Previous, Select + and Next. The upper-left touch area returns/back. +- Tap USER/G0 to select; hold it for 650 ms to go back. +- Tap KEY/GPIO46 for Next; hold it for 650 ms for Previous. +- Bruce's on-screen keyboard supports text entry using touch or the buttons. +- The first input while the screen is dimmed/off wakes the screen. Release and + press again to activate a control. + +All four display rotations map touch against the complete physical display, +including the navigation strip. Button clicks are debounced and emitted on +release; a hold does not also emit a click when released. Input sampling runs +each input-task tick and uses an eight-event queue, so the UI's event-consumption +delay does not discard a rapid second touch. + +## Power + +Battery percentage is an approximate voltage estimate, not a fuel-gauge +measurement. The kit provides no readable charging-status signal to this port. + +Power off and Deep sleep enter ESP32 deep sleep and disable the display/SD +rail, GNSS main supply and radio front end. They do not disconnect the battery +or remove the L76K backup supply. USER/G0 wakes the device. The hardware power +button controls the expansion board's physical power latch. + +## Validation + +Host tests for CHSC decoding, rotation and button transitions are in +`tests/heltec_v4_expansion_v2`. Compilation and those tests do not establish +physical-device acceptance. Display/touch alignment, SD readback, GPS fixes, +battery estimates, USB, LoRa reception/transmission and sleep/wake must be +verified on the V4-R8 Expansion Kit V2 before claiming full hardware support. + +## Hardware references + +- [Heltec Expansion Kit V2](https://heltec.org/project/v4-r8-ex/) +- [Expansion board V2.03 schematic](https://resource.heltec.cn/download/WiFi_LoRa_32_Expansion_Kit-V2/schematic/Expansion_board_V2.03.pdf) +- [Heltec V4-R8 factory example](https://github.com/HelTecAutomation/Heltec_ESP32/blob/dc5e7f92701b80b0e30d90980820dd742b99df01/examples/Factory_Test/Lora32_R8_V4.3_Exp_Board_HF/Lora32_R8_V4.3_Exp_Board_HF.ino) +- [LORA Tunnel hardware implementation](https://github.com/valleytechsolutions/LORA-Tunnel), attribution in [NOTICE.md](NOTICE.md) diff --git a/boards/heltec-v4-expansion-v2/board_input.h b/boards/heltec-v4-expansion-v2/board_input.h new file mode 100644 index 0000000000..34cee311b6 --- /dev/null +++ b/boards/heltec-v4-expansion-v2/board_input.h @@ -0,0 +1,108 @@ +#pragma once + +// CHSC report decoding adapted from LORA Tunnel. See NOTICE.md for attribution. + +#include +#include + +namespace heltec_v4 { + +// CHSC6xxx normal mapped report: point count, event flags, 12-bit coordinates. +inline bool decodeTouch(const uint8_t *report, size_t size, uint16_t &x, uint16_t &y) { + if (size != 7) return false; + const uint8_t count = report[2] & 7; + const uint8_t event = report[3] >> 6; + if (count == 0 || count > 2 || event == 1 || event == 3) return false; + const uint16_t rawX = ((report[3] & 15) << 8) | report[4]; + const uint16_t rawY = ((report[5] & 15) << 8) | report[6]; + if (rawX >= 240 || rawY >= 320) return false; + x = rawX; + y = rawY; + return true; +} + +// Raw touch coordinates match ST7789 rotation 0. Use physical panel dimensions, +// including Bruce's bottom 20-pixel navigation strip, when rotating them. +inline void rotateTouch(uint8_t rotation, uint16_t &x, uint16_t &y) { + const uint16_t rawX = x; + const uint16_t rawY = y; + switch (rotation & 3) { + case 1: x = rawY; y = 239 - rawX; break; + case 2: x = 239 - rawX; y = 319 - rawY; break; + case 3: x = 319 - rawY; y = rawX; break; + default: break; + } +} + +enum class ButtonEvent { None, Click, Hold }; + +enum InputAction : uint8_t { Select = 1, Back = 2, Next = 4, Previous = 8, Touch = 16 }; + +struct InputEvent { + uint8_t actions = 0; + uint16_t x = 0; + uint16_t y = 0; +}; + +// Sampling continues while the UI consumes an earlier event. Keep short taps +// in a bounded FIFO instead of losing their release during the UI's 75 ms gate. +class InputQueue { + static constexpr uint8_t Capacity = 8; + InputEvent events[Capacity]{}; + uint8_t head = 0; + uint8_t count = 0; + +public: + bool push(const InputEvent &event) { + if (count == Capacity) return false; + events[(head + count) % Capacity] = event; + ++count; + return true; + } + + bool pop(InputEvent &event) { + if (count == 0) return false; + event = events[head]; + head = (head + 1) % Capacity; + --count; + return true; + } +}; + +class Button { + bool sampled = false; + bool stable = false; + bool held = false; + uint32_t changedAt = 0; + uint32_t pressedAt = 0; + +public: + void begin(bool pressed, uint32_t now) { + sampled = stable = pressed; + held = pressed; // Ignore a button already held during boot/wake. + changedAt = pressedAt = now; + } + + ButtonEvent update(bool pressed, uint32_t now) { + if (pressed != sampled) { + sampled = pressed; + changedAt = now; + } + if (sampled != stable && uint32_t(now - changedAt) >= 25) { + stable = sampled; + if (stable) { + pressedAt = now; + held = false; + } else if (!held) { + return ButtonEvent::Click; + } + } + if (stable && sampled && !held && uint32_t(now - pressedAt) >= 650) { + held = true; + return ButtonEvent::Hold; + } + return ButtonEvent::None; + } +}; + +} // namespace heltec_v4 diff --git a/boards/heltec-v4-expansion-v2/heltec-v4-expansion-v2.ini b/boards/heltec-v4-expansion-v2/heltec-v4-expansion-v2.ini new file mode 100644 index 0000000000..b5fac26d38 --- /dev/null +++ b/boards/heltec-v4-expansion-v2/heltec-v4-expansion-v2.ini @@ -0,0 +1,23 @@ +; Heltec WiFi LoRa 32 V4-R8 with Expansion Kit V2 (high-frequency SX1262). +[env:heltec-v4-expansion-v2] +board = heltec-v4-expansion-v2 +board_build.partitions = custom_16Mb.csv +extra_scripts = + ${env.extra_scripts} + post:boards/heltec-v4-expansion-v2/post_build.py +build_src_filter = ${env.build_src_filter} +<../boards/heltec-v4-expansion-v2> +build_flags = + ${env.build_flags} + -Iboards/heltec-v4-expansion-v2 + -DHELTEC_V4_EXPANSION_V2 + -DCORE_DEBUG_LEVEL=1 + -DDISABLE_ALL_LIBRARY_WARNINGS + -DREDRAW_DELAY=1 + +lib_deps = + ${env.lib_deps} + +; The kit has no addressable RGB LEDs. Avoid FastLED's unrelated console/ +; startup overrides as well as its code and RAM on this fixed hardware. +lib_ignore = + FastLED diff --git a/boards/heltec-v4-expansion-v2/interface.cpp b/boards/heltec-v4-expansion-v2/interface.cpp new file mode 100644 index 0000000000..f5a4a64647 --- /dev/null +++ b/boards/heltec-v4-expansion-v2/interface.cpp @@ -0,0 +1,294 @@ +#include "board_input.h" +// Expansion hardware sequencing and touch reads adapted from LORA Tunnel; +// see NOTICE.md. Bruce input/display integration is specific to this port. +#include "core/bus_HAL.h" +#include "core/display.h" +#include "core/powerSave.h" +#include "core/utils.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +heltec_v4::Button userButton; +heltec_v4::Button nextButton; +heltec_v4::InputQueue inputQueue; +bool touchReady = false; +bool backlightReady = false; +bool touchDown = false; +bool touchWokeScreen = false; +uint32_t touchRepeatAt = 0; +uint32_t droppedInputs = 0; +std::atomic sleepPauseRequested{false}; +std::atomic sleepPauseAcknowledged{false}; + +bool pauseInputForSleep() { + if (xHandle == nullptr) return true; + // Never wait for an acknowledgement from the current task. Power actions + // are dispatched by the UI task, not from the input-polling task. + if (xTaskGetCurrentTaskHandle() == xHandle) return false; + sleepPauseRequested.store(true, std::memory_order_release); + const uint32_t started = millis(); + while (!sleepPauseAcknowledged.load(std::memory_order_acquire)) { + if (uint32_t(millis() - started) >= 500) { + sleepPauseRequested.store(false, std::memory_order_release); + return false; + } + vTaskDelay(1); + } + return true; +} + +void queueInput(const heltec_v4::InputEvent &event) { + if (!inputQueue.push(event) && ++droppedInputs == 1) { + Serial.println("[Heltec V4] Input queue full; dropping newest input"); + } +} + +struct SleepOutput { + gpio_num_t pin; + uint8_t level; +}; +constexpr SleepOutput sleepOutputs[] = { + {gpio_num_t(HELTEC_VEXT_ENABLE), HIGH}, + {gpio_num_t(HELTEC_GNSS_ENABLE), HIGH}, + {gpio_num_t(TFT_BL), LOW}, + {gpio_num_t(LORA_FEM_ENABLE), LOW}, + {gpio_num_t(LORA_FEM_CTX), LOW}, + {gpio_num_t(LORA_FEM_POWER), LOW}, + {gpio_num_t(LORA_RST), LOW}, + {gpio_num_t(BUZZ_PIN), LOW}, +}; + +void outputLevel(int pin, uint8_t level) { + // Set the latch before enabling the output; Arduino 3 digitalWrite() alone + // does not set the latch until periman knows the pin is a GPIO output. + gpio_set_level(gpio_num_t(pin), level); + pinMode(pin, OUTPUT); + digitalWrite(pin, level); +} + +void releaseSleepHolds() { + gpio_deep_sleep_hold_dis(); + for (const auto &output : sleepOutputs) { + gpio_set_level(output.pin, output.level); + gpio_set_direction(output.pin, GPIO_MODE_OUTPUT); + gpio_hold_dis(output.pin); + } + rtc_gpio_deinit(gpio_num_t(HELTEC_USER_BUTTON)); +} + +bool readTouch(uint16_t &x, uint16_t &y) { + if (!touchReady) return false; + Wire1.beginTransmission(HELTEC_TOUCH_ADDRESS); + Wire1.write(uint8_t(0)); + if (Wire1.endTransmission(true) != 0) return false; + uint8_t report[7]; + if (Wire1.requestFrom(uint8_t(HELTEC_TOUCH_ADDRESS), sizeof(report), true) != sizeof(report)) { + while (Wire1.available()) Wire1.read(); + return false; + } + for (auto &byte : report) byte = uint8_t(Wire1.read()); + if (!heltec_v4::decodeTouch(report, sizeof(report), x, y)) return false; + heltec_v4::rotateTouch(bruceConfigPins.rotation, x, y); + return true; +} + +void prepareDeepSleep() { + if (xHandle != nullptr && xTaskGetCurrentTaskHandle() == xHandle) { + Serial.println("[Heltec V4] Request sleep from the UI, not the input task"); + return; + } + // USER is also the wake source. A held key would immediately wake us. + if (digitalRead(HELTEC_USER_BUTTON) == LOW) { + displayWarning("Release USER, then retry", true); + return; + } + const gpio_num_t wake = gpio_num_t(HELTEC_USER_BUTTON); + if (esp_sleep_enable_ext0_wakeup(wake, LOW) != ESP_OK) { + displayError("Wake source unavailable", true); + return; + } + rtc_gpio_pullup_en(wake); + rtc_gpio_pulldown_dis(wake); + + // Park polling at a boundary outside Wire transactions. Arbitrarily + // suspending that task can strand its Wire mutex and deadlock Wire1.end(). + if (!pauseInputForSleep()) { + esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_EXT0); + rtc_gpio_deinit(wake); + pinMode(HELTEC_USER_BUTTON, INPUT_PULLUP); + displayError("Input busy; retry sleep", true); + return; + } + if (sdcardMounted) SD.end(); + sdcardMounted = false; + _setBrightness(0); + panelSleep(true); + Wire1.end(); + touchReady = false; + if (backlightReady) { + ledcDetach(TFT_BL); + backlightReady = false; + } + noTone(BUZZ_PIN); + + // Remove the front-end enable before its supply, and hold the SX1262 reset. + outputLevel(LORA_FEM_ENABLE, LOW); + outputLevel(LORA_FEM_CTX, LOW); + outputLevel(LORA_FEM_POWER, LOW); + constexpr int floatingPins[] = { + TFT_SCLK, TFT_MOSI, TFT_MISO, TFT_CS, TFT_DC, TFT_RST, SDCARD_CS, + SYS_I2C_SDA, SYS_I2C_SCL, GPS_SERIAL_RX, GPS_SERIAL_TX, HELTEC_GNSS_PPS, + LORA_SCK, LORA_MOSI, LORA_MISO, LORA_CS, LORA_IRQ, LORA_BUSY, + }; + for (int pin : floatingPins) { + pinMode(pin, INPUT); + gpio_set_pull_mode(gpio_num_t(pin), GPIO_FLOATING); + } + for (const auto &output : sleepOutputs) { + outputLevel(output.pin, output.level); + gpio_hold_en(output.pin); + } + gpio_deep_sleep_hold_en(); + esp_deep_sleep_start(); +} +} // namespace + +void _setup_gpio() { + releaseSleepHolds(); + outputLevel(LORA_FEM_ENABLE, LOW); + outputLevel(LORA_FEM_CTX, LOW); + outputLevel(LORA_FEM_POWER, LOW); + outputLevel(LORA_CS, HIGH); + outputLevel(SDCARD_CS, HIGH); + outputLevel(TFT_CS, HIGH); + outputLevel(TFT_BL, LOW); + outputLevel(BUZZ_PIN, LOW); + outputLevel(HELTEC_VEXT_ENABLE, LOW); + outputLevel(HELTEC_GNSS_ENABLE, LOW); + pinMode(HELTEC_GNSS_PPS, INPUT); + pinMode(HELTEC_USER_BUTTON, INPUT_PULLUP); + pinMode(HELTEC_NEXT_BUTTON, INPUT); + const uint32_t now = millis(); + userButton.begin(digitalRead(HELTEC_USER_BUTTON) == LOW, now); + nextButton.begin(digitalRead(HELTEC_NEXT_BUTTON) == HIGH, now); + analogSetPinAttenuation(HELTEC_BATTERY_ADC, ADC_2_5db); + setSysI2CBus(&Wire1); + delay(1000); // VEXT and expansion peripherals settle before the first TFT reset. +} + +void _pre_storage_gpio() { + // TFT_eSPI has completed its hardware reset. GPIO21 also resets touch, so + // do not reset the touchscreen separately after the display is running. + Wire1.begin(SYS_I2C_SDA, SYS_I2C_SCL, 100000); + Wire1.setTimeOut(10); + delay(60); + Wire1.beginTransmission(HELTEC_TOUCH_ADDRESS); + touchReady = Wire1.endTransmission() == 0; + Serial.printf("[Heltec V4] CHSC6xxx touch: %s\n", touchReady ? "ready" : "not found; use buttons"); + backlightReady = ledcAttach(TFT_BL, 20000, 8); + _setBrightness(100); +} + +void _post_setup_gpio() { + // begin_tft() restores the saved brightness before this hook. +} + +int getBattery() { + static uint32_t sampledAt = 0; + static int percent = 0; + const uint32_t now = millis(); + if (sampledAt != 0 && uint32_t(now - sampledAt) < 5000) return percent; + uint32_t sum = 0; + for (unsigned i = 0; i < 8; ++i) sum += analogReadMilliVolts(HELTEC_BATTERY_ADC); + const uint32_t millivolts = (sum * 49) / 80; // 390k/100k divider: 4.9x. + // Voltage estimate only: no fuel gauge or readable charger-status signal. + percent = millivolts >= 2500 && millivolts <= 4500 + ? constrain((int(millivolts) - 3300) * 100 / 900, 1, 100) + : 0; + sampledAt = now; + return percent; +} + +bool isCharging() { return false; } + +void _setBrightness(uint8_t brightval) { + if (!backlightReady) return; + const uint32_t percent = min(uint32_t(brightval), uint32_t(100)); + const uint32_t duty = percent == 0 ? 0 : MINBRIGHT + ((255 - MINBRIGHT) * percent) / 100; + ledcWrite(TFT_BL, duty); +} + +// Called by the input task on every tick, before its event-consumption gate. +// Both sampling and publication stay in that task; no additional task/heap is used. +void _pollInput() { + if (sleepPauseRequested.load(std::memory_order_acquire)) { + sleepPauseAcknowledged.store(true, std::memory_order_release); + // Stay inside this hook so the generic task cannot run bus recovery or + // publish more input while the UI shuts down the peripheral supplies. + while (sleepPauseRequested.load(std::memory_order_acquire)) vTaskDelay(1); + sleepPauseAcknowledged.store(false, std::memory_order_release); + return; + } + const uint32_t now = millis(); + const auto user = userButton.update(digitalRead(HELTEC_USER_BUTTON) == LOW, now); + const auto next = nextButton.update(digitalRead(HELTEC_NEXT_BUTTON) == HIGH, now); + if (user != heltec_v4::ButtonEvent::None || next != heltec_v4::ButtonEvent::None) { + if (!wakeUpScreen()) { + heltec_v4::InputEvent event; + if (user == heltec_v4::ButtonEvent::Click) event.actions |= heltec_v4::Select; + if (user == heltec_v4::ButtonEvent::Hold) event.actions |= heltec_v4::Back; + if (next == heltec_v4::ButtonEvent::Click) event.actions |= heltec_v4::Next; + if (next == heltec_v4::ButtonEvent::Hold) event.actions |= heltec_v4::Previous; + queueInput(event); + } + } + + uint16_t x = 0, y = 0; + if (!readTouch(x, y)) { + touchDown = false; + touchWokeScreen = false; + return; + } + const bool first = !touchDown; + touchDown = true; + if (first) { + touchRepeatAt = now + 450; + touchWokeScreen = wakeUpScreen(); + } + if (touchWokeScreen) return; + if (!first && (!LongPress || int32_t(now - touchRepeatAt) < 0)) return; + if (!first) { + touchRepeatAt = now + 200; + wakeUpScreen(); // Keep the dimmer awake during deliberate repeat input. + } + queueInput({heltec_v4::Touch, x, y}); +} + +void InputHandler() { + if (sleepPauseRequested.load(std::memory_order_acquire)) return; + heltec_v4::InputEvent event; + if (!inputQueue.pop(event)) return; + AnyKeyPress = true; + SelPress = (event.actions & heltec_v4::Select) != 0; + EscPress = (event.actions & heltec_v4::Back) != 0; + NextPress = (event.actions & heltec_v4::Next) != 0; + PrevPress = (event.actions & heltec_v4::Previous) != 0; + if (event.actions & heltec_v4::Touch) { + touchPoint.x = event.x; + touchPoint.y = event.y; + touchPoint.pressed = true; + touchHeatMap(touchPoint); + } +} + +// Software cannot operate the expansion board's physical battery latch. +void powerOff() { prepareDeepSleep(); } +void goToDeepSleep() { prepareDeepSleep(); } +void checkReboot() {} diff --git a/boards/heltec-v4-expansion-v2/package_release.py b/boards/heltec-v4-expansion-v2/package_release.py new file mode 100644 index 0000000000..1c79577f28 --- /dev/null +++ b/boards/heltec-v4-expansion-v2/package_release.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Validate and name the Heltec factory/Launcher images from one application build.""" + +import argparse +import hashlib +import json +from pathlib import Path +import struct + + +TARGET = "heltec-v4-expansion-v2" +PREFIX = f"Bruce-{TARGET}" +TABLE_OFFSET = 0x8000 +APP_OFFSET = 0x10000 +FLASH_SIZE = 0x1000000 +APP_CAPACITY = 0x470000 +EXPECTED_PARTITIONS = ( + ("nvs", 1, 2, 0x9000, 0x6000), + ("factory", 0, 0, APP_OFFSET, APP_CAPACITY), + ("spiffs", 1, 0x82, 0x480000, 0xB70000), + ("coredump", 1, 3, 0xFF0000, 0x10000), +) + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def validate_image_header(data, description): + if len(data) < 24 or data[0] != 0xE9 or not 1 <= data[1] <= 16: + raise ValueError(f"{description}: invalid ESP image header") + chip_id = struct.unpack_from(" 0x1000 or len(data) % 32: + raise ValueError("Invalid partition-table length") + partitions = [] + for offset in range(0, len(data), 32): + record = data[offset : offset + 32] + if record[:16] == b"\xeb\xeb" + b"\xff" * 14: + if hashlib.md5(data[:offset]).digest() != record[16:]: + raise ValueError("Partition-table MD5 mismatch") + if data[offset + 32 :] != b"\xff" * (len(data) - offset - 32): + raise ValueError("Unexpected data after partition-table MD5") + break + magic, kind, subtype, start, size, raw_label, flags = struct.unpack(" FLASH_SIZE: + raise ValueError(f"Invalid or unsupported partition: {label}") + if any(start < entry[3] + entry[4] and start + size > entry[3] for entry in partitions): + raise ValueError(f"Overlapping partition: {label}") + partitions.append((label, kind, subtype, start, size)) + else: + raise ValueError("Missing partition-table MD5") + if tuple(partitions) != EXPECTED_PARTITIONS: + raise ValueError("Partition layout differs from this target's custom_16Mb.csv") + return partitions + + +def validate_build(merged, bootloader, partitions, application): + validate_image_header(bootloader, "Bootloader") + validate_image_header(application, "Application") + layout = read_partitions(partitions) + if len(bootloader) > TABLE_OFFSET: + raise ValueError("Bootloader overlaps the partition table") + if len(application) > APP_CAPACITY: + raise ValueError("Application exceeds its factory partition") + if len(merged) != APP_OFFSET + len(application): + raise ValueError("Merged image must end at the application, without filesystem payload/padding") + if merged[: len(bootloader)] != bootloader: + raise ValueError("Merged bootloader differs from this build") + if merged[TABLE_OFFSET : TABLE_OFFSET + len(partitions)] != partitions: + raise ValueError("Merged partition table differs from this build") + if merged[APP_OFFSET:] != application: + raise ValueError("Merged application differs from this build") + return layout + + +def package_release(project_dir, build_dir): + project_dir = Path(project_dir) + build_dir = Path(build_dir) + original_name = f"{PREFIX}.bin" + merged = (project_dir / original_name).read_bytes() + bootloader = (build_dir / "bootloader.bin").read_bytes() + partitions = (build_dir / "partitions.bin").read_bytes() + application = (build_dir / "firmware.bin").read_bytes() + layout = validate_build(merged, bootloader, partitions, application) + + names = [original_name, f"{PREFIX}-FACTORY-0x0.bin", f"{PREFIX}-LAUNCHER-INSTALL.bin"] + for name in names[1:]: + (project_dir / name).write_bytes(merged) + digest = sha256(merged) + checksums_name = f"{PREFIX}.sha256" + (project_dir / checksums_name).write_text( + "".join(f"{digest} {name}\n" for name in names), encoding="utf-8" + ) + + metadata = { + "board": TARGET, + "chip": "ESP32-S3", + "flash_bytes": FLASH_SIZE, + "application": { + "sha256": sha256(application), + "size_bytes": len(application), + "offset_in_package": APP_OFFSET, + "maximum_size_bytes": APP_CAPACITY, + }, + "partition_table_sha256": sha256(partitions), + "partitions": [ + {"name": name, "type": kind, "subtype": subtype, "offset": offset, "size": size} + for name, kind, subtype, offset, size in layout + ], + "artifacts": [ + {"name": name, "sha256": digest, "size_bytes": len(merged)} for name in names + ], + "factory_install": "Flash the complete FACTORY-0x0 image at address 0x0.", + "launcher_install": ( + "Use Launcher Install on LAUNCHER-INSTALL.bin. This is a merged install image; " + "Launcher reads its partition metadata and chooses destination partitions. " + "It is not a raw application image." + ), + "launcher_format_review": { + "source_commit": "2f43aa7bd3ef03e770c0a97135cbcfcc79c15aba", + "source_repository": "https://github.com/bmorcelli/Launcher", + "expected_default_empty_spiffs_bytes_on_16mb": 0x70000, + "physical_installation_tested_by_packager": False, + }, + "filesystem_payload_included": False, + "hardware_validation": "Not established by packaging checks.", + } + (project_dir / f"{PREFIX}.json").write_text( + json.dumps(metadata, indent=2) + "\n", encoding="utf-8" + ) + return metadata + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project-dir", type=Path, default=Path.cwd()) + parser.add_argument("--build-dir", type=Path, required=True) + args = parser.parse_args() + try: + result = package_release(args.project_dir, args.build_dir) + except (OSError, ValueError) as error: + raise SystemExit(f"Heltec packaging failed: {error}") from error + print(f"Packaged {len(result['artifacts'])} verified images from one Heltec application build") + + +if __name__ == "__main__": + main() diff --git a/boards/heltec-v4-expansion-v2/pins_arduino.h b/boards/heltec-v4-expansion-v2/pins_arduino.h new file mode 100644 index 0000000000..056f168cff --- /dev/null +++ b/boards/heltec-v4-expansion-v2/pins_arduino.h @@ -0,0 +1,111 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include "soc/soc_caps.h" +#include + +#define DEVICE_NAME "Heltec V4 Expansion V2" +#define USB_as_HID 1 + +// The expansion kit occupies both SPI controllers. Display and SD share HSPI; +// the onboard SX1262 uses FSPI. Do not offer these pins to optional peripherals. +#define SPI_SCK_PIN 16 +#define SPI_MISO_PIN 45 +#define SPI_MOSI_PIN 15 +#define SPI_SS_PIN 3 +static const uint8_t SS = SPI_SS_PIN; +static const uint8_t MOSI = SPI_MOSI_PIN; +static const uint8_t MISO = SPI_MISO_PIN; +static const uint8_t SCK = SPI_SCK_PIN; + +#define SDCARD_CS 3 +#define SDCARD_SCK SPI_SCK_PIN +#define SDCARD_MISO SPI_MISO_PIN +#define SDCARD_MOSI SPI_MOSI_PIN + +#define USER_SETUP_LOADED +#define USE_HSPI_PORT +#define SUPPORT_TRANSACTIONS +#define ST7789_DRIVER 1 +#define TFT_RGB_ORDER TFT_BGR +#define TFT_INVERSION_ON +#define TFT_WIDTH 240 +#define TFT_HEIGHT 320 +#define TFT_MOSI SPI_MOSI_PIN +#define TFT_MISO SPI_MISO_PIN +#define TFT_SCLK SPI_SCK_PIN +#define TFT_CS 47 +#define TFT_DC 48 +#define TFT_RST 21 // Also resets the CHSC6xxx touchscreen. +#define TFT_BL 44 +#define TFT_BACKLIGHT_ON HIGH +#define SPI_FREQUENCY 40000000 +#define SPI_READ_FREQUENCY 8000000 +#define SMOOTH_FONT 1 +#define HAS_SCREEN +#define HAS_TOUCH +#define HAS_CAPACITIVE_TOUCH +#define ROTATION 1 +#define MINBRIGHT 5 + +#define SYS_I2C_SDA 17 +#define SYS_I2C_SCL 18 +#define HELTEC_TOUCH_ADDRESS 0x2E +// No external bus is free on this fixed kit; the system bus is touch-only. +#define GROVE_SDA -1 +#define GROVE_SCL -1 +// Wire.begin(-1, -1) falls back to these defaults. Keep Wire0 disconnected so +// an absent accessory cannot steal Wire1's fixed touchscreen pins. +static const uint8_t SDA = 255; +static const uint8_t SCL = 255; +#define WIRE1_PIN_DEFINED +static const uint8_t SDA1 = SYS_I2C_SDA; +static const uint8_t SCL1 = SYS_I2C_SCL; + +#define LORA_SCK 9 +#define LORA_MISO 11 +#define LORA_MOSI 10 +#define LORA_CS 8 +#define LORA_RST 12 +#define LORA_DIO0 14 // Existing Bruce configuration names SX1262 DIO1 as io2. +#define LORA_IRQ 14 +#define LORA_BUSY 13 +#define LORA_FEM_POWER 7 +#define LORA_FEM_ENABLE 2 +#define LORA_FEM_CTX 5 +#define LORA_TCXO_VOLTAGE 1.8f +#define LORA_FIXED_SX1262 +#define LORA_MIN_FREQ_MHZ 863.0f +#define LORA_MAX_FREQ_MHZ 928.0f +#define LORA_DEFAULT_FREQ_HZ "915000000.00" +// Conservative raw SX1262 drive until the external PA is measured/calibrated. +#define LORA_MAX_TX_POWER 0 + +#define GPS_SERIAL_RX 39 +#define GPS_SERIAL_TX 38 +#define HELTEC_GNSS_ENABLE 42 // Active low; L76K backup supply is separate. +#define HELTEC_GNSS_PPS 41 +#define HELTEC_VEXT_ENABLE 40 // Active low: display, touch and SD supply. +#define HELTEC_BATTERY_ADC 1 + +// USB CDC is the console. UART0's usual RX44 conflicts with the backlight. +#define SERIAL_RX -1 +#define SERIAL_TX -1 +static const uint8_t TX = 255; +static const uint8_t RX = 255; + +#define BUZZ_PIN 4 +#define HELTEC_USER_BUTTON 0 +#define HELTEC_NEXT_BUTTON 46 // Active high, externally biased; input-only pin. +#define BTN_ALIAS "USER" +#define DEEPSLEEP_WAKEUP_PIN HELTEC_USER_BUTTON +#define DEEPSLEEP_PIN_ACT LOW + +#define TXLED -1 +#define RXLED -1 +#define IR_TX_PINS {{"Disabled", -1}, {"GPIO 6", 6}} +#define IR_RX_PINS {{"Disabled", -1}, {"GPIO 6", 6}} +#define RF_TX_PINS {{"Disabled", -1}, {"GPIO 6", 6}} +#define RF_RX_PINS {{"Disabled", -1}, {"GPIO 6", 6}} + +#endif diff --git a/boards/heltec-v4-expansion-v2/post_build.py b/boards/heltec-v4-expansion-v2/post_build.py new file mode 100644 index 0000000000..d3480588c4 --- /dev/null +++ b/boards/heltec-v4-expansion-v2/post_build.py @@ -0,0 +1,30 @@ +"""Name and verify Heltec release artifacts after Bruce's standard image merger.""" + +from pathlib import Path +import runpy +from SCons.Script import Import + +Import("env") + +project_dir = Path(env.subst("$PROJECT_DIR")) +build_dir = Path(env.subst("$BUILD_DIR")) +package_release = runpy.run_path( + str(project_dir / "boards" / "heltec-v4-expansion-v2" / "package_release.py") +)["package_release"] + + +def package_heltec(target, source, env): + try: + metadata = package_release(project_dir, build_dir) + except (OSError, ValueError) as error: + print(f"[Heltec package] {error}") + env.Exit(1) + print( + "[Heltec package] FACTORY and LAUNCHER-INSTALL verified; application SHA-256 " + + metadata["application"]["sha256"] + ) + + +# Global post:build.py registers its merge callback first. This callback then +# rejects missing/stale/mismatched merged output before publishing named copies. +env.AddPostAction(str(build_dir / "firmware.bin"), package_heltec) diff --git a/boards/pinouts/pins_arduino.h b/boards/pinouts/pins_arduino.h index def219d4b0..0933503a33 100644 --- a/boards/pinouts/pins_arduino.h +++ b/boards/pinouts/pins_arduino.h @@ -1,4 +1,6 @@ -#ifdef T_DISPLAY_S3 +#ifdef HELTEC_V4_EXPANSION_V2 +#include "../heltec-v4-expansion-v2/pins_arduino.h" +#elif T_DISPLAY_S3 #include "../lilygo-t-display-s3/pins_arduino.h" #elif T_EMBED_1101 #include "../lilygo-t-embed-cc1101/pins_arduino.h" diff --git a/include/interface.h b/include/interface.h index 9a29866c1c..68a8a110b1 100644 --- a/include/interface.h +++ b/include/interface.h @@ -43,6 +43,8 @@ void _setBrightness(uint8_t brightval); ** Function: InputHandler ** Handles the variables PrevPress, NextPress, SelPress, AnyKeyPress and EscPress **********************************************************************/ +// Optional sampler called every input-task tick, before UI-event consumption. +void _pollInput(void); void InputHandler(void); /********************************************************************* diff --git a/platformio.ini b/platformio.ini index 49d7584766..0dd530b361 100644 --- a/platformio.ini +++ b/platformio.ini @@ -202,7 +202,8 @@ lib_deps = bitbank2/AnimatedGIF bitbank2/PNGdec @ ^1.1.2 ESP32Async/ESPAsyncWebServer - fastled/FastLED @^3.10.3 + ; 3.10.9 collides with board FP font macros and its USB console fails LTO linking. + fastled/FastLED @3.10.3 jgromes/RadioLib @ ^7.4.0 ; Pinned to fixed commits so upstream changes never break our patches ; (applied by patch_library_conflicts.py): IRQ-less I2C polling for diff --git a/src/core/USBSerial/USBSerial.h b/src/core/USBSerial/USBSerial.h index 81083aa33c..9ec5df41ea 100644 --- a/src/core/USBSerial/USBSerial.h +++ b/src/core/USBSerial/USBSerial.h @@ -9,7 +9,7 @@ class USBSerial : public SerialDevice { size_t println(const String &s) override { return out->println(s); } size_t print(const String &s) override { return out->print(s); } size_t print(const int n, int format) override { return out->print(n, format); } - void vprintf(const char *fmt, va_list args) override { out->printf(fmt, args); } + void vprintf(const char *fmt, va_list args) override { out->vprintf(fmt, args); } size_t println() override { return out->println(); } size_t println(size_t n) override { return out->println(n); } size_t println(const uint32_t n) override { return out->println(n); } diff --git a/src/core/config.cpp b/src/core/config.cpp index ab133884e4..aa89cfa42b 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -449,7 +449,6 @@ void BruceConfig::saveFile() { }; // Serialize JSON to file - serializeJsonPretty(jsonDoc, Serial); if (serializeJsonPretty(jsonDoc, file) < 5) log_e("Failed to write config file"); else log_i("config file written successfully"); diff --git a/src/core/configPins.cpp b/src/core/configPins.cpp index cefab9190c..2c7b00a073 100644 --- a/src/core/configPins.cpp +++ b/src/core/configPins.cpp @@ -2,6 +2,102 @@ #include "esp_mac.h" #include "sd_functions.h" #include + +#ifdef HELTEC_V4_EXPANSION_V2 +namespace { +bool heltecReservedPin(int pin) { + // GPIO22-37 are absent or connected to the module's flash/OPI PSRAM. + if (pin < 0 || pin > 48 || (pin >= 22 && pin <= 37) || pin == 43) return true; + const int reserved[] = { + HELTEC_USER_BUTTON, + HELTEC_NEXT_BUTTON, + HELTEC_BATTERY_ADC, + BUZZ_PIN, + HELTEC_VEXT_ENABLE, + HELTEC_GNSS_ENABLE, + HELTEC_GNSS_PPS, + TFT_SCLK, + TFT_MISO, + TFT_MOSI, + TFT_CS, + TFT_DC, + TFT_RST, + TFT_BL, + SDCARD_CS, + SYS_I2C_SDA, + SYS_I2C_SCL, + GPS_SERIAL_RX, + GPS_SERIAL_TX, + LORA_SCK, + LORA_MISO, + LORA_MOSI, + LORA_CS, + LORA_RST, + LORA_IRQ, + LORA_BUSY, + LORA_FEM_POWER, + LORA_FEM_ENABLE, + LORA_FEM_CTX, + 19, + 20 // Native USB D-/D+. + }; + for (int used : reserved) { + if (pin == used) return true; + } + return false; +} + +bool heltecOptionalPinInvalid(int pin) { return pin != GPIO_NUM_NC && heltecReservedPin(pin); } + +void constrainHeltecOptionalSPI(BruceConfigPins::SPIPins &bus) { + if (heltecReservedPin(bus.sck) || heltecReservedPin(bus.miso) || heltecReservedPin(bus.mosi) || + heltecReservedPin(bus.cs) || heltecOptionalPinInvalid(bus.io0) || heltecOptionalPinInvalid(bus.io2)) { + bus = BruceConfigPins::SPIPins(); + } + // Only GPIO6 is unassigned on this kit; duplicate signals are not a bus. + if (bus.sck == bus.miso || bus.sck == bus.mosi || bus.sck == bus.cs || bus.miso == bus.mosi || + bus.miso == bus.cs || bus.mosi == bus.cs) { + bus = BruceConfigPins::SPIPins(); + } +} +} // namespace + +void BruceConfigPins::enforceBoardPinConstraints() { + SDCARD_bus = { + (gpio_num_t)SDCARD_SCK, (gpio_num_t)SDCARD_MISO, (gpio_num_t)SDCARD_MOSI, (gpio_num_t)SDCARD_CS + }; + sys_i2c = {(gpio_num_t)SYS_I2C_SDA, (gpio_num_t)SYS_I2C_SCL}; + gps_bus = {(gpio_num_t)GPS_SERIAL_RX, (gpio_num_t)GPS_SERIAL_TX}; + gpsBaudrate = 9600; +#if !defined(LITE_VERSION) + LoRa_bus = { + (gpio_num_t)LORA_SCK, + (gpio_num_t)LORA_MISO, + (gpio_num_t)LORA_MOSI, + (gpio_num_t)LORA_CS, + (gpio_num_t)LORA_RST, + (gpio_num_t)LORA_DIO0 + }; + constrainHeltecOptionalSPI(W5500_bus); +#endif + constrainHeltecOptionalSPI(ST25R_bus); + constrainHeltecOptionalSPI(CC1101_bus); + constrainHeltecOptionalSPI(NRF24_bus); + constrainHeltecOptionalSPI(PN532_bus); + if (heltecReservedPin(i2c_bus.sda) || heltecReservedPin(i2c_bus.scl) || i2c_bus.sda == i2c_bus.scl) { + i2c_bus = {(gpio_num_t)GROVE_SDA, (gpio_num_t)GROVE_SCL}; + } + if (heltecReservedPin(uart_bus.rx) || heltecReservedPin(uart_bus.tx) || uart_bus.rx == uart_bus.tx) { + uart_bus = {(gpio_num_t)SERIAL_RX, (gpio_num_t)SERIAL_TX}; + } + if (heltecOptionalPinInvalid(irTx)) irTx = GPIO_NUM_NC; + if (heltecOptionalPinInvalid(irRx)) irRx = GPIO_NUM_NC; + if (heltecOptionalPinInvalid(rfTx)) rfTx = GPIO_NUM_NC; + if (heltecOptionalPinInvalid(rfRx)) rfRx = GPIO_NUM_NC; + if (heltecOptionalPinInvalid(iButton)) iButton = GPIO_NUM_NC; +} +#endif + String getMacAddress() { uint8_t mac[6]; esp_read_mac(mac, ESP_MAC_WIFI_STA); @@ -290,6 +386,9 @@ void BruceConfigPins::toJson(JsonObject obj) const { } void BruceConfigPins::loadFile(JsonDocument &jsonDoc, bool checkFS) { +#ifdef HELTEC_V4_EXPANSION_V2 + enforceBoardPinConstraints(); +#endif FS *fs; if (checkFS) { if (!getFsStorage(fs)) return; @@ -317,6 +416,9 @@ void BruceConfigPins::loadFile(JsonDocument &jsonDoc, bool checkFS) { } void BruceConfigPins::fromFile(bool checkFS) { +#ifdef HELTEC_V4_EXPANSION_V2 + enforceBoardPinConstraints(); +#endif JsonDocument jsonDoc; loadFile(jsonDoc, checkFS); @@ -325,6 +427,9 @@ void BruceConfigPins::fromFile(bool checkFS) { } void BruceConfigPins::createFile() { +#ifdef HELTEC_V4_EXPANSION_V2 + enforceBoardPinConstraints(); +#endif JsonDocument jsonDoc; toJson(jsonDoc.to()); serializeJsonPretty(jsonDoc, Serial); @@ -350,6 +455,10 @@ void BruceConfigPins::createFile() { } void BruceConfigPins::saveFile() { +#ifdef HELTEC_V4_EXPANSION_V2 + // Apply before loadFile(), which can mount the SD card using these pins. + enforceBoardPinConstraints(); +#endif JsonDocument jsonDoc; loadFile(jsonDoc); @@ -384,6 +493,9 @@ void BruceConfigPins::factoryReset() { } void BruceConfigPins::validateConfig() { +#ifdef HELTEC_V4_EXPANSION_V2 + enforceBoardPinConstraints(); +#endif validateRotationValue(); validateRfScanRangeValue(); validateRfModuleValue(); @@ -410,7 +522,7 @@ void BruceConfigPins::setLoRaPins(SPIPins value) { saveFile(); } void BruceConfigPins::setW5500Pins(SPIPins value) { - LoRa_bus = value; + W5500_bus = value; validateSpiPins(W5500_bus); saveFile(); } diff --git a/src/core/configPins.h b/src/core/configPins.h index d1a3650dbe..b683b74ef7 100644 --- a/src/core/configPins.h +++ b/src/core/configPins.h @@ -244,7 +244,11 @@ class BruceConfigPins { ///////////////////////////////////////////////////////////////////////////////////// // Constructor ///////////////////////////////////////////////////////////////////////////////////// - BruceConfigPins() {}; + BruceConfigPins() { +#ifdef HELTEC_V4_EXPANSION_V2 + enforceBoardPinConstraints(); +#endif + }; ///////////////////////////////////////////////////////////////////////////////////// // Operations @@ -255,6 +259,10 @@ class BruceConfigPins { void loadFile(JsonDocument &jsonDoc, bool checkFS = true); void factoryReset(); void validateConfig(); +#ifdef HELTEC_V4_EXPANSION_V2 + // The expansion kit's wired peripherals must survive imported pin settings. + void enforceBoardPinConstraints(); +#endif void fromJson(JsonObject obj); void toJson(JsonObject obj) const; diff --git a/src/core/file_hash_stream.h b/src/core/file_hash_stream.h new file mode 100644 index 0000000000..160bf913e8 --- /dev/null +++ b/src/core/file_hash_stream.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +// Feed exact binary bytes to a hash without allocating a file-sized buffer. +// The caller opens the file at position zero and initializes/finalizes the hash. +template +bool feedFileToHash(FileType &file, HashType &hash, YieldType pause) { + const size_t expectedSize = file.size(); + size_t remaining = expectedSize; + uint8_t buffer[512]; + while (remaining > 0) { + const size_t wanted = remaining < sizeof(buffer) ? remaining : sizeof(buffer); + const int received = file.read(buffer, wanted); + if (received <= 0 || size_t(received) > wanted) return false; + hash.add(buffer, size_t(received)); + remaining -= size_t(received); + pause(); + } + // A truncated or extended file must not produce a successful partial hash. + return file.size() == expectedSize; +} diff --git a/src/core/menu_items/ConfigMenu.cpp b/src/core/menu_items/ConfigMenu.cpp index a2e77261a5..50f5763cd7 100644 --- a/src/core/menu_items/ConfigMenu.cpp +++ b/src/core/menu_items/ConfigMenu.cpp @@ -270,7 +270,9 @@ void ConfigMenu::devMenu() { while (true) { std::vector