feat(heltec): solo dual-transport builds for Heltec V3/V4

Port the Wio Tracker L1 solo firmware (full on-device UI, dual BLE/USB
companion transport) to Heltec V3 and V4 OLED boards. Neither board has
a joystick or CardKB on-board, so each new env wires up both as optional
peripherals with default pins from what the board leaves free, gated
behind the existing UI_HAS_JOYSTICK/ENV_PIN_SDA+SCL flags.

DUAL_SERIAL was nRF52-only; added an ESP32 helpers/esp32/DualSerialInterface.h
counterpart so the flag isn't silently ignored on these boards. On V4's
native USB CDC, isClientConnected() also honours (bool)Serial (real DTR),
same as the nRF52 version; V3 has no native CDC so it stays BLE-only there.

Screen (SDA 17/SCL 18) and CardKB (SDA 3/SCL 4) confirmed working on real
V4 hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-08-06 20:08:31 +02:00
co-authored by Claude Sonnet 5
parent f8b9e1acb8
commit 2bde84a573
10 changed files with 307 additions and 0 deletions
+5
View File
@@ -41,6 +41,9 @@ static uint32_t _atoi(const char* sp) {
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#elif defined(DUAL_SERIAL)
#include <helpers/esp32/DualSerialInterface.h>
DualSerialInterface serial_interface;
#elif defined(BLE_PIN_CODE)
#include <helpers/esp32/SerialBLEInterface.h>
SerialBLEInterface serial_interface;
@@ -220,6 +223,8 @@ void setup() {
WiFi.begin(WIFI_SSID, WIFI_PWD);
serial_interface.begin(TCP_PORT);
#elif defined(DUAL_SERIAL)
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin(), Serial);
#elif defined(BLE_PIN_CODE)
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
#elif defined(SERIAL_RX)
@@ -13,6 +13,13 @@
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 15000 // 15 seconds
#endif
// Upstream MeshCore version, shown on the splash screen. Most variants set it in
// their platformio.ini; the ones that don't used to fail to compile this file
// outright rather than fall back, which quietly made every ui-new env on those
// boards unbuildable (heltec v3/v4, thinknode m1/m5, mesh pocket, techo).
#ifndef MESHCORE_VERSION
#define MESHCORE_VERSION "1.16"
#endif
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
#ifdef PIN_STATUS_LED
+1
View File
@@ -8,6 +8,7 @@
- **Bot Trigger fields accept multiple phrases.** Pack several trigger words into one Trigger field, comma-separated (`hi,hello there,yo`) — matching any one of them fires the reply, same as before for a single phrase.
- **Remote Bot Actions gain `!gpio1`..`!gpio4`** (Wio Tracker L1 only — 4 otherwise-unused pins). Each pin is independently set to Off/Input/Output (GPIO1/GPIO2 also offer Analog) from a new **Tools GPIO** screen; `!gpio1 on`/`!gpio1 off` drives an Output pin remotely, a bare `!gpio1` reports the current mode and reading (including a millivolt value in Analog mode). Gated by the same per-target Actions toggle as `!buzz`/`!gps`/`!advert`.
- **Optional CardKB support (Wio Tracker L1, Grove I2C) — full keyboard-only navigation.** Plug an M5Stack CardKB into the Grove connector and it's auto-detected at boot — no setting to flip. Typing goes straight into the message/name field instead of navigating the on-screen keyboard grid, always as plain Latin text regardless of the Settings Keyboard language/type — arrows and Esc work as expected everywhere else too (including inside the placeholder and accent popups). Enter acts like the physical centre button (advances the on-screen grid selection) rather than submitting, so **Fn+Enter** sends the message/confirms the field from anywhere, and **Fn+`<letter>`** opens that letter's accent popup directly (no arrow-hunting needed — handy for Polish/Czech/etc. diacritics — and works no matter what language/keyboard type is configured, since CardKB always types Latin either way). **Tab** is the Hold-Enter equivalent everywhere, including message reply/navigate and the Bot/Admin/Repeater menus — same single shortcut whether or not the on-screen keyboard is showing.
- **Solo builds for Heltec V3 and V4** (`Heltec_v3_companion_solo_dual`, `heltec_v4_companion_solo_dual`) — the full standalone on-device UI instead of a screen that only mirrors the phone app, with dual transport so the companion app can attach over BLE or USB serial (BLE wins while both are live), same as the Wio Tracker L1 solo builds. Dual transport had only ever been built for nRF52, so ESP32 got its own `DualSerialInterface` to match. Both boards ship with a single button, which the solo UI can't be driven from, so each env enables both supported input devices with default pins picked from what that board leaves free: a **CardKB** on a second I2C bus (pair it with Ext. KB = Compact and no joystick is needed at all) and a **wired joystick** (four direction pins plus a Back button, wired straight to GND — the firmware enables the internal pull-ups). Either is enough on its own, and pins with nothing attached read as not-pressed, so the unused half costs nothing. The env comments list which GPIOs each board has already claimed, for anyone wiring theirs differently.
- **GAT562 30S solo builds now use the same OLED font as the Wio Tracker L1.** The misc-fixed 6x9 font (full Latin, Greek and Cyrillic) had only ever reached the SH1106 and e-ink drivers, so on this board every keyboard alphabet beyond ASCII, and every accented contact name, was still being transliterated (`Łódź``Lodz`) or drawn as a filled block. Solo builds only — the font costs ~14 KB of flash and the repeater/companion builds have no keyboard to type those alphabets on; they keep the built-in font and are byte-for-byte unchanged.
- **Settings Keyboard gets an "Ext. KB" row** (boards that support CardKB, above). Switching it to **Compact** hides the on-screen letter grid, special-row icons, and status line entirely — a CardKB typist never looks at any of it, and none of it (script/page, T9-vs-ABC, caps) is actually actionable from CardKB anyway — replacing them with just a reminder of the Tab/Fn+letter shortcuts; the accent and placeholder popups still show normally. With the grid hidden, arrows and Tab take on more useful direct meanings instead of driving an invisible grid selection: **arrows** move the text cursor immediately (no more entering cursor mode first), **plain Enter** submits the message/field (same as Fn+Enter — there's no grid cell to commit), and **plain Tab** opens the placeholder picker directly. Compact is designed to need no joystick/physical button at all, and reclaims the freed space for extra message-preview lines. Off (**Full**) by default.
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include "../BaseSerialInterface.h"
#include "../ArduinoSerialInterface.h"
#include "SerialBLEInterface.h"
// ESP32 counterpart of helpers/nrf52/DualSerialInterface.h: wraps BLE + USB
// serial so one build serves both companion transports. BLE takes priority when
// connected, USB is always ready as a fallback.
// enable()/disable() control BLE only — USB is always on.
// BLE state machine is only pumped when BLE is enabled; USB is not read while
// BLE is connected.
class DualSerialInterface : public BaseSerialInterface {
SerialBLEInterface _ble;
ArduinoSerialInterface _usb;
uint8_t _ble_buf[MAX_FRAME_SIZE];
uint8_t _usb_buf[MAX_FRAME_SIZE];
bool _ble_enabled;
bool _ble_was_connected;
public:
DualSerialInterface() : _ble_enabled(false), _ble_was_connected(false) {}
void begin(const char* ble_prefix, char* node_name, uint32_t pin_code, Stream& usb_stream) {
_ble.begin(ble_prefix, node_name, pin_code);
_usb.begin(usb_stream);
_usb.enable(); // USB is always on
}
void enable() override { _ble.enable(); _ble_enabled = true; }
void disable() override { _ble.disable(); _ble_enabled = false; }
bool isEnabled() const override { return _ble_enabled; }
// Always true — USB is always available as fallback, so the mesh can send.
bool isConnected() const override { return true; }
// True only when a BLE companion app is paired and connected.
bool isBLEConnected() const override { return _ble_enabled && _ble.isConnected(); }
#if defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 0 && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT == 1
// Native USB CDC (e.g. Heltec V4): Serial's bool operator reflects whether
// the host actually has the port open, same DTR-style signal the nRF52
// version reads. Counts as a connected client same as BLE, matching that
// version's behaviour.
bool isClientConnected() const override { return isBLEConnected() || (bool)Serial; }
#else
// BLE only, unlike the nRF52 version, which also counts a USB host holding the
// CDC port open. These boards reach USB through a UART bridge rather than
// native CDC, so there is no DTR to read: (bool)Serial is a plain "is the
// peripheral initialised", i.e. always true once begin() has run, and
// ArduinoSerialInterface::isConnected() likewise hardcodes true. Reporting
// that as a connected client would permanently convince the UI an app is
// watching the device and stop it waking the display for new messages.
bool isClientConnected() const override { return isBLEConnected(); }
#endif
bool isWriteBusy() const override {
return (_ble_enabled && _ble.isConnected()) ? _ble.isWriteBusy() : _usb.isWriteBusy();
}
size_t writeFrame(const uint8_t src[], size_t len) override {
return (_ble_enabled && _ble.isConnected()) ? _ble.writeFrame(src, len) : _usb.writeFrame(src, len);
}
size_t checkRecvFrame(uint8_t dest[]) override {
if (_ble_enabled) {
size_t ble_len = _ble.checkRecvFrame(_ble_buf);
bool ble_now = _ble.isConnected();
if (ble_now) {
_ble_was_connected = true;
if (ble_len > 0) { memcpy(dest, _ble_buf, ble_len); return ble_len; }
return 0; // BLE active — don't read USB to keep its state machine clean
}
if (_ble_was_connected) {
// BLE just disconnected — reset USB state machine so stale partial frames don't block it
_ble_was_connected = false;
_usb.enable();
return 0;
}
}
size_t usb_len = _usb.checkRecvFrame(_usb_buf);
if (usb_len > 0) { memcpy(dest, _usb_buf, usb_len); return usb_len; }
return 0;
}
};
+78
View File
@@ -179,6 +179,84 @@ lib_deps =
${Heltec_lora32_v3.lib_deps}
densaugeo/base64 @ ~1.4.0
; Solo build: the full standalone on-device UI (message history, contacts, map,
; tools) rather than a screen that only mirrors the phone app. Dual transport --
; the companion app can attach over BLE or USB serial, whichever it finds, and
; BLE wins while both are live.
;
; This firmware needs a real input device: the solo UI cannot be driven from the
; PRG button alone. Both supported ones are wired up in target.cpp and enabled
; below, so either is enough on its own and having both is fine. The pins are
; defaults picked from what this board leaves free -- change them to match how
; the device is actually built. Pins with nothing attached read as not-pressed,
; so the unused half costs nothing.
;
; * CardKB (M5Stack I2C keyboard, addr 0x5F) on a second I2C bus. Any two free
; GPIOs -- NOT 17/18, the OLED already owns those on Wire. It's probed at
; boot, so a build with these set still runs fine with nothing plugged in.
; Pair it with Settings > Keyboard > Ext. KB = Compact, which is designed to
; need no joystick at all. The same bus is scanned for environment sensors.
; Defaults below: SDA 41, SCL 42.
;
; * Wired joystick: four direction pins plus a separate Back button -- the UI
; uses Back unconditionally once UI_HAS_JOYSTICK is set, so it isn't optional.
; PIN_USER_BTN (PRG) stays the centre/Enter press. Each contact just shorts
; its pin to GND; target.cpp enables the internal pull-ups. Drop
; UI_HAS_JOYSTICK_UPDOWN for a left/right-only stick. JOYSTICK_ROTATION (0-3)
; rotates the mapping if the stick is mounted sideways -- it's also a runtime
; setting, so leave it out unless you want a different default.
; Comment the whole block out for a CardKB-only build. UI_HAS_JOYSTICK
; replaces the single-button handling rather than adding to it (UITask.cpp
; dispatches on '#if UI_HAS_JOYSTICK ... #elif defined(PIN_USER_BTN)'), which
; costs nothing here -- that fallback only feeds KEY_NEXT/KEY_PREV, which the
; solo screens don't act on.
;
; Pins already taken on this board: 8/9/10/11/13/14 (LoRa), 17/18 (OLED I2C),
; 21 (OLED reset), 0 (PRG), 35 (TX LED), 36 (VEXT), 37 (ADC ctrl), 1 (battery
; ADC), 26/47/48 (GPS). On top of that the ESP32-S3 reserves 19/20 (USB), 26-32
; (flash) and 43/44 (UART0), and 0/3/45/46 are strapping pins. That leaves
; 2, 4-7, 33/34 and 38-42, which is where the defaults below come from -- verify
; against your own module before soldering. The GPS defines above already look
; optimistic: on a quad-flash S3, GPIO26 is a flash line.
[env:Heltec_v3_companion_solo_dual]
extends = Heltec_lora32_v3
build_flags =
${Heltec_lora32_v3.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D DISPLAY_CLASS=SSD1306Display
-D BLE_PIN_CODE=123456 ; dynamic, random PIN
-D DUAL_SERIAL=1 ; companion app over BLE *or* USB serial
-D AUTO_SHUTDOWN_MILLIVOLTS=3400
-D OFFLINE_QUEUE_SIZE=256
-D FIRMWARE_SOLO_BUILD=1
-D UI_SENSORS_PAGE=1
-D OLED_MISC_FIXED_FONT=1 ; full Latin/Greek/Cyrillic 6x9 font, ~14 KB flash
; CardKB on the second I2C bus. Harmless with nothing plugged in.
-D ENV_PIN_SDA=41
-D ENV_PIN_SCL=42
; Wired joystick — comment the block out for a CardKB-only build.
-D UI_HAS_JOYSTICK=1
-D UI_HAS_JOYSTICK_UPDOWN=1
-D JOYSTICK_UP=4
-D JOYSTICK_DOWN=5
-D JOYSTICK_LEFT=6
-D JOYSTICK_RIGHT=7
-D PIN_BACK_BTN=2
; -D PIN_BUZZER=<gpio>
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/buzzer.cpp>
+<helpers/esp32/*.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${Heltec_lora32_v3.lib_deps}
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
[env:Heltec_v3_companion_radio_wifi]
extends = Heltec_lora32_v3
build_flags =
+14
View File
@@ -26,6 +26,20 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#if UI_HAS_JOYSTICK
// Optional wired joystick — see the Heltec_v3_companion_solo_dual env for the
// pin defines this needs. Unlike the Wio Tracker L1 (external pull-ups on
// board) these pass pulldownup = true, so each contact only has to short its
// pin to GND; the internal pull-up does the rest. Back gets multiclick = true
// because the UI's triple-click buzzer toggle lives on it.
MomentaryButton joystick_left (JOYSTICK_LEFT, 1000, true, true, false);
MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, true, false);
MomentaryButton back_btn (PIN_BACK_BTN, 1000, true, true, true);
#if UI_HAS_JOYSTICK_UPDOWN
MomentaryButton joystick_up (JOYSTICK_UP, 1000, true, true, false);
MomentaryButton joystick_down(JOYSTICK_DOWN, 1000, true, true, false);
#endif
#endif
#endif
bool radio_init() {
+9
View File
@@ -21,6 +21,15 @@ extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#if UI_HAS_JOYSTICK
extern MomentaryButton joystick_left;
extern MomentaryButton joystick_right;
extern MomentaryButton back_btn;
#if UI_HAS_JOYSTICK_UPDOWN
extern MomentaryButton joystick_up;
extern MomentaryButton joystick_down;
#endif
#endif
#endif
bool radio_init();
+84
View File
@@ -222,6 +222,90 @@ lib_deps =
${heltec_v4_oled.lib_deps}
densaugeo/base64 @ ~1.4.0
; Solo build: the full standalone on-device UI (message history, contacts, map,
; tools) rather than a screen that only mirrors the phone app. Dual transport --
; the companion app can attach over BLE or USB serial, whichever it finds, and
; BLE wins while both are live. OLED board only -- the TFT variant would need
; its own DISPLAY_CLASS.
;
; This firmware needs a real input device: the solo UI cannot be driven from the
; PRG button alone. Both supported ones are wired up in target.cpp and enabled
; below, so either is enough on its own and having both is fine. The pins are
; defaults picked from what this board leaves free -- change them to match how
; the device is actually built. Pins with nothing attached read as not-pressed,
; so the unused half costs nothing.
;
; * CardKB (M5Stack I2C keyboard, addr 0x5F) on a second I2C bus. Any two free
; GPIOs -- NOT 17/18, the OLED already owns those on Wire. It's probed at
; boot, so a build with these set still runs fine with nothing plugged in.
; Pair it with Settings > Keyboard > Ext. KB = Compact, which is designed to
; need no joystick at all. The same bus is scanned for environment sensors.
; Defaults below: SDA 3, SCL 4 -- the pins heltec_v4_sensor and
; heltec_v4_expansionkit_repeater already use for their second I2C bus, i.e.
; the expansion kit's. (Those envs disagree on the order: two say SDA 3 /
; SCL 4, one says the reverse. 3/4 is the pair either way; swap them if the
; keyboard doesn't answer.)
;
; * Wired joystick: four direction pins plus a separate Back button -- the UI
; uses Back unconditionally once UI_HAS_JOYSTICK is set, so it isn't optional.
; PIN_USER_BTN (PRG) stays the centre/Enter press. Each contact just shorts
; its pin to GND; target.cpp enables the internal pull-ups. Drop
; UI_HAS_JOYSTICK_UPDOWN for a left/right-only stick. JOYSTICK_ROTATION (0-3)
; rotates the mapping if the stick is mounted sideways -- it's also a runtime
; setting, so leave it out unless you want a different default.
; Comment the whole block out for a CardKB-only build. UI_HAS_JOYSTICK
; replaces the single-button handling rather than adding to it (UITask.cpp
; dispatches on '#if UI_HAS_JOYSTICK ... #elif defined(PIN_USER_BTN)'), which
; costs nothing here -- that fallback only feeds KEY_NEXT/KEY_PREV, which the
; solo screens don't act on.
;
; Free on this board, per its pinout: 3, 4, 6, 23, 33, 43, 44, 45, 46, 47, 48.
; Two of those can't take an I2C pull-up: 45 selects VDD_SPI at boot, so held
; high the chip comes up expecting 1.8V flash and won't boot at all, and 46 is
; the boot-mode strap. 3 is a strapping pin too, but it only picks the JTAG
; source -- and it's what this board's own expansion kit uses for I2C anyway.
; 43/44 are UART0: unused by this build, since ARDUINO_USB_CDC_ON_BOOT (set in
; boards/heltec_v4.json) makes Serial the native USB port, but a USB-UART bridge
; may still be wired to them, so they're left alone.
[env:heltec_v4_companion_solo_dual]
extends = heltec_v4_oled
build_flags =
${heltec_v4_oled.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D DISPLAY_CLASS=SSD1306Display
-D BLE_PIN_CODE=123456 ; dynamic, random PIN
-D DUAL_SERIAL=1 ; companion app over BLE *or* USB serial
-D AUTO_SHUTDOWN_MILLIVOLTS=3400
-D OFFLINE_QUEUE_SIZE=256
-D FIRMWARE_SOLO_BUILD=1
-D UI_SENSORS_PAGE=1
-D OLED_MISC_FIXED_FONT=1 ; full Latin/Greek/Cyrillic 6x9 font, ~14 KB flash
; CardKB on the second I2C bus. Harmless with nothing plugged in.
-D ENV_PIN_SDA=3
-D ENV_PIN_SCL=4
; Wired joystick — comment the block out for a CardKB-only build.
-D UI_HAS_JOYSTICK=1
-D UI_HAS_JOYSTICK_UPDOWN=1
-D JOYSTICK_UP=23
-D JOYSTICK_DOWN=6
-D JOYSTICK_LEFT=47
-D JOYSTICK_RIGHT=48
-D PIN_BACK_BTN=33
; -D PIN_BUZZER=<gpio>
build_src_filter = ${heltec_v4_oled.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/buzzer.cpp>
+<helpers/esp32/*.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${heltec_v4_oled.lib_deps}
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
[env:heltec_v4_companion_radio_wifi]
extends = heltec_v4_oled
build_flags =
+14
View File
@@ -26,6 +26,20 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display(NULL);
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#if UI_HAS_JOYSTICK
// Optional wired joystick — see the heltec_v4_companion_solo_dual env for the
// pin defines this needs. Unlike the Wio Tracker L1 (external pull-ups on
// board) these pass pulldownup = true, so each contact only has to short its
// pin to GND; the internal pull-up does the rest. Back gets multiclick = true
// because the UI's triple-click buzzer toggle lives on it.
MomentaryButton joystick_left (JOYSTICK_LEFT, 1000, true, true, false);
MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, true, false);
MomentaryButton back_btn (PIN_BACK_BTN, 1000, true, true, true);
#if UI_HAS_JOYSTICK_UPDOWN
MomentaryButton joystick_up (JOYSTICK_UP, 1000, true, true, false);
MomentaryButton joystick_down(JOYSTICK_DOWN, 1000, true, true, false);
#endif
#endif
#endif
bool radio_init() {
+9
View File
@@ -25,6 +25,15 @@ extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#if UI_HAS_JOYSTICK
extern MomentaryButton joystick_left;
extern MomentaryButton joystick_right;
extern MomentaryButton back_btn;
#if UI_HAS_JOYSTICK_UPDOWN
extern MomentaryButton joystick_up;
extern MomentaryButton joystick_down;
#endif
#endif
#endif
bool radio_init();