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