Dies ist eine alte Version des Dokuments!
NOTE: Currently in the testing phase!
Author: team-nessoN1-meshcore — team-nessoN1-meshcore@posteo.de
Date: April 2026
This page describes the Hardware Abstraction Layer (HAL) for the Arduino Nesso N1 as a MeshCore target platform. The board was co-developed by Arduino and M5Stack, combining an ESP32-C6 microcontroller with an SX1262 LoRa transceiver, Wi-Fi 6, Bluetooth 5.3, Thread/Zigbee, and a 1.14″ touchscreen in a compact, battery-powered enclosure.
| Component | Details |
|---|---|
| MCU | Espressif ESP32-C6 (RISC-V, 160 MHz) |
| Flash | 16 MB |
| RAM | 512 KB |
| LoRa | Semtech SX1262, 850–960 MHz, up to +22 dBm |
| RF Switch / LNA | External, controlled via I²C GPIO expander |
| I²C GPIO Expander | PI4IOE5V6408 (2× instances, addresses 0x43 and 0x44) |
| Display | ST7789P3, 1.14″, 240×135 px, touchscreen (FT6336U) |
| IMU | BMI270 (6-axis) |
| Battery | 250 mAh LiPo, USB-C charging |
| Connectivity | Wi-Fi 6, BT 5.3, Thread/Zigbee (802.15.4), LoRa |
Unlike most other MeshCore targets, the LoRa control lines on the Nesso N1 are not connected directly to ESP32-C6 GPIO pins. They are routed through a PI4IOE5V6408 I²C GPIO expander (address 0x43):
| Signal | Expander Pin | Function |
|---|---|---|
| SX_NRST | P7 | SX1262 hardware reset |
| SX_ANT_SW | P6 | Antenna RF switch |
| SX_LNA_EN | P5 | Low-noise amplifier enable |
| KEY1 | P0 | Button A (input) |
| KEY2 | P1 | Button B (input) |
RadioLib's setRfSwitchTable() (direct GPIO only) cannot be used. NessoN1Board implements the RfSwitchCallback interface instead, which CustomSX1262Wrapper calls on every TX/RX transition.
| Signal | GPIO |
|---|---|
| MOSI | G21 |
| MISO | G22 |
| SCK | G20 |
| LoRa CS (NSS) | G23 (software CS) |
| LCD CS | G17 (software CS) |
| LCD DC | G16 |
lora_spi.begin() is called without the NSS argument to avoid registering a duplicate APB hardware CS-callback (see Fix 4).
The ESP32-C6 has no VSPI bus: SPIClass lora_spi(FSPI).
| Firmware | ARDUINO_USB_CDC_ON_BOOT | Debug output |
|---|---|---|
| Repeater | 1 | USB-Serial active |
| Companion BLE | 0 | no USB-Serial |
| Companion WiFi | 0 | no USB-Serial |
The ST7789P3 has 240×135 pixels (Landscape, setRotation(1)). All UITask default layouts fit without overflow:
| Element | Width | x (centred) | Right edge | Fits |
|---|---|---|---|---|
| MeshCore logo (64×36 px XBM) | 64 px | 88 | 152 | ✅ |
| Version line (setTextSize 2) | 156 px | 42 | 198 | ✅ |
| Node name „NessoN1 Repeater“ | 192 px | 24 | 216 | ✅ |
| Frequency line „869.6 SF8 BW62.5“ | 204 px | 18 | 222 | ✅ |
All four elements together occupy ~100 px of 135 px height — no vertical overflow.
radio_init() in target.cpp: 1. board.begin() SPI2, Wire, expanders, loraReset() 2. board.onRfRx() RF switch active for calibration 3. radio.std_init(nullptr) RadioLib — nullptr: no second spi->begin() 4. display.begin() M5GFX lazy construction — must follow std_init!
Date: April 2026
Affected file: platformio.ini, [env:NessoN1_repeater_display]
Symptom: Display shows boot screen (~4 s) and home screen (~10 s), then stays permanently black. Button presses have no effect.
Root cause: UITask.h (MeshCore, examples/simple_repeater/UITask.h) defines SCREEN_TIMEOUT (default: 10 000 ms). After expiry it calls display.turnOff(). The wakeup path in UITask polls PIN_USER_BTN — a direct GPIO pin. On the Nesso N1, KEY1 and KEY2 are connected via I²C expander 0 (address 0x43, pins P0/P1) and are not direct GPIOs. UITask therefore has no wakeup path — once the timeout fires, the display stays off permanently.
Fix:
; platformio.ini — [env:NessoN1_repeater_display]: -D SCREEN_TIMEOUT=0 -D DISPLAY_TIMEOUT=0
Both flags set to 0 disable the automatic timeout entirely. DISPLAY_TIMEOUT is also set because different UITask versions use different macro names.
Note for battery operation: Use e.g.-D SCREEN_TIMEOUT=30000. Button wakeup is already integrated vianesso_ui_tick()(see Fix 7).
Date: April 2026
Affected file: NessoDisplayDriver.cpp
Symptom: Serial reports M5GFX reports display: 135 x 240 px. UITask text is clipped with a negative x-offset and is not readable.
Root cause: setRotation(1) was called after querying width() and height(). M5GFX swaps dimensions only after setRotation(), so the pre-rotation query always returned 135×240. The problem also occurred when display.begin() ran before radio_init() from main.cpp: the idempotent guard on the second begin() call skipped setRotation(1) entirely.
Fix in NessoDisplayDriver.cpp:
_gfx->setRotation(1); // FIRST — M5GFX swaps dimensions only after this Serial.printf("[display] M5GFX ready: %d x %d px\n", _gfx->width(), _gfx->height()); // now correctly 240 x 135
Date: April 2026
Affected file: NessoN1Board.cpp
Symptom: ERROR: radio init failed: -2, serial shows [loraReset] TIMEOUT!.
Root cause: examples/simple_repeater/main.cpp calls board.begin() in setup() under #ifdef DISPLAY_CLASS — before radio_init(). On the Nesso N1, board.begin() calls loraReset(): SX1262 boots, BUSY goes HIGH. Then radio_init() calls board.begin() again — second loraReset() — SX1262 boots again, BUSY HIGH again — radio.std_init() hits the chip during boot — RADIOLIB_ERR_CHIP_NOT_FOUND = -2.
Fix in NessoN1Board::begin():
static bool s_boardBeginCalled = false; if (s_boardBeginCalled) { Serial.println("[board] begin() already initialised — guard active"); return; // loraReset() is NOT called again } s_boardBeginCalled = true;
Complete solution: Remove board.begin() and display.begin() from setup() in main.cpp (see main_cpp_setup_hinweis.txt).
Date: April 2026
Affected files: NessoN1Board.cpp, NessoDisplayDriver.cpp
Symptom:
[E][Wire.cpp:131] setPins(): bus already initialized. [E][esp32-hal-cpu.c:123] addApbChangeCallback(): duplicate func=... [E][esp32-hal-cpu.c:146] removeApbChangeCallback(): not found func=...
Root cause: main.cpp calls display.begin() before radio_init(). M5GFX calls spi_bus_initialize(SPI2_HOST) and Wire.begin() internally. When radio_init() → board.begin() → lora_spi.begin() runs afterwards, the ESP-IDF SPI driver tries to register the same APB callback again → duplicate.
Fix: The board guard (Fix 3) prevents the second lora_spi.begin() call. The three [E] lines are functionally harmless but remain visible while main.cpp retains the wrong call order.
Date: April 2026
Affected file: NessoN1Board.cpp
Symptom: Log reports BUSY immediately after NRST HIGH: LOW (unexpected) even though std_init succeeds.
Root cause: delay(20) after NRST HIGH was too long. The SX1262 on the Nesso N1 produces a BUSY-HIGH pulse of less than 1 ms after reset — already LOW by the time digitalRead() ran after 20 ms. No hardware fault, just overly conservative timing.
Fix:
_exp0.digitalWrite(NESSO_EXP0_SX_NRST, true); delay(2); // was: delay(20) — 2 ms sufficient, BUSY pulse on Nesso N1 < 1 ms
Updated log message:
[loraReset] BUSY 2 ms after NRST HIGH: LOW (pulse complete — normal on Nesso N1)
Date: April 2026
Affected files: simple_repeater/main.cpp, simple_repeater/UITask.h, simple_repeater/UITask.cpp, simple_repeater/MyMesh.h
Symptom: KEY1 and KEY2 appear to do nothing. Serial log shows [ui] KEY1 erkannt — the keypress is detected, but the display does not change.
Root cause: nesso_ui_tick() correctly returns -1 (KEY1) or -2 (KEY2) when the display is already on. In main.cpp, evaluation of these return values was entirely commented out. Additionally, UITask had no navigable screens and no connection to MyMesh to fetch neighbour data or send a discover request.
Fix — three levels:
1. New interface UIActions.h — decouples UITask from MyMesh:
class UIActions { public: virtual void uiGetNeighborList(char* buf, int bufSize) = 0; virtual void uiSendDiscover() = 0; };
2. MyMesh implements UIActions — in MyMesh.h:
class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public UIActions { void uiGetNeighborList(char* buf, int bufSize) override { formatNeighborsReply(buf); } void uiSendDiscover() override { sendNodeDiscoverReq(); } };
3. UITask gains 3 screens + navigation, connected via setActions():
SCREEN_HOME — node name, frequency, SF/BW/CR SCREEN_NEIGHBOURS — neighbour list (hex ID, age, SNR) SCREEN_POLL_SENT — "Poll sent" feedback for 2 seconds
4. main.cpp evaluates btn (previously commented out):
if (btn == -1) ui_task.nextScreen(); // KEY1: forward / poll if (btn == -2) ui_task.prevScreen(); // KEY2: back
Button behaviour after patch:
| Button | Display OFF | Display ON (Home) | Display ON (Neighbours) |
|---|---|---|---|
| KEY1 | Wakeup | → Neighbours screen | → Send poll |
| KEY2 | Wakeup | stays Home | → Home screen |
After sending a poll, the screen shows „Poll sent“ for 2 seconds, then automatically returns to the refreshed neighbour list.
Date: April 2026
Affected files: NessoDisplayDriver.cpp, NessoDisplayDriver.h, platformio.ini
Symptom: KEY1 and KEY2 are not reliably registered despite correct edge detection and boot lock. Serial log shows:
[E][Wire.cpp:131] setPins(): bus already initialized. change pins only when not. [E][esp32-hal-cpu.c:123] addApbChangeCallback(): duplicate func=...
Root cause: M5GFX initialises the I²C bus internally via WireInternal.begin(SDA, SCL). The custom PI4IOE5V6408 driver in NessoN1Board also initialises Wire directly. Both access the same bus (SDA=GPIO10, SCL=GPIO8) with different TwoWire instances, causing bus collisions and unstable expander reads. Documented in the Arduino Forum (November 2025).
Fix: M5Unified takes over button handling and display initialisation in a coordinated way. M5.begin() initialises Wire, M5GFX and the GPIO expander in the correct order — no more Wire conflict.
// NessoDisplayDriver.cpp — begin(): M5.begin(cfg); // instead of: _gfx->begin() _gfx = &M5.Display; // instead of: static M5GFX gfx_instance // checkButtons(): M5.update(); if (M5.BtnA.wasPressed()) return 1; // KEY1 if (M5.BtnB.wasPressed()) return 2; // KEY2
; platformio.ini — nesso_n1_base: lib_deps = m5stack/M5GFX m5stack/M5Unified ; NEW: Fix 8
Removed: _prevBtnState, _btnReadyAt, _lastBtnCheck — manual debounce is no longer needed; M5Unified handles edge detection internally.
cd <project root> cp variants/arduino_nesso_n1/credentials.ini.example credentials.ini
Edit credentials.ini:
[credentials] build_flags_repeater = -D ADMIN_PASSWORD='"your_password"' build_flags_wifi = -D WIFI_SSID='"your_network"' -D WIFI_PWD='"your_password"'
credentials.iniis listed in.gitignoreand will not be committed to the repository.
Repeater with display (recommended starting point):
pio run -e NessoN1_repeater_display --target upload pio device monitor -b 115200
Repeater without display:
pio run -e NessoN1_repeater --target upload
Companion Radio — BLE:
pio run -e NessoN1_companion_ble --target upload
Companion Radio — WiFi/TCP:
pio run -e NessoN1_companion_wifi --target upload
[loraReset] BUSY 2 ms after NRST HIGH: LOW (normal on Nesso N1) [loraReset] BUSY=LOW after 0 ms — SX1262 ready [display] M5GFX ready: 240 x 135 px (after setRotation(1)) [display] ST7789 ready, backlight on [board] begin() already initialised — guard active [radio] std_init OK [init] === radio_init() complete — radio ready ===
| Function | GPIO |
|---|---|
| LoRa MOSI | G21 |
| LoRa MISO | G22 |
| LoRa SCK | G20 |
| LoRa CS (NSS) | G23 |
| LoRa BUSY | G19 |
| LoRa IRQ (DIO1) | G15 |
| LCD CS | G17 |
| LCD DC | G16 |
| I²C SDA | G10 |
| I²C SCL | G8 |
| Touch INT | G3 |
| Function | Expander Pin |
|---|---|
| Button A (KEY1) | P0 |
| Button B (KEY2) | P1 |
| LNA Enable | P5 |
| Antenna Switch | P6 |
| LoRa Reset (NRST) | P7 |
| Function | Expander Pin |
|---|---|
| LCD Reset | P1 |
| LCD Backlight | P6 |
| Mode | ANT_SW | LNA_EN | Description |
|---|---|---|---|
| TX | HIGH | LOW | TX path active, LNA protected |
| RX | HIGH | HIGH | RX path active with LNA |
| IDLE | LOW | LOW | RF path disconnected, low power |
target.cpp
└─ radio_init()
├─ board.begin() SPI2, Wire, I²C-scan, expanders, loraReset()
├─ rtc_clock.begin(Wire)
├─ board.onRfRx() RF switch active for calibration
├─ radio.std_init(nullptr) no second spi->begin()
├─ radio_driver.setRfSwitchCallback(&board)
└─ display.begin() M5GFX lazy construction — after std_init
NessoDisplayDriver
├─ begin() static M5GFX; setRotation(1) before width()/height()
└─ checkButtons() polling KEY1/KEY2 via I²C expander 0 (50 ms debounce)
NessoN1Board (RfSwitchCallback)
├─ begin() guard (single call) + loraReset()
├─ onRfTx() ANT_SW=HIGH, LNA_EN=LOW
├─ onRfRx() ANT_SW=HIGH, LNA_EN=HIGH
└─ onRfIdle() ANT_SW=LOW, LNA_EN=LOW
variants/arduino_nesso_n1/ ├── NessoN1Board.h — pin constants, PI4IOE5V6408 driver, board class ├── NessoN1Board.cpp — begin() with guard (Fix 3), loraReset() (Fix 5) ├── NessoDisplayDriver.h — DisplayDriver extension, lazy construction ├── NessoDisplayDriver.cpp — begin() with setRotation fix (Fix 2) ├── target.h — extern declarations, nesso_check_buttons() ├── target.cpp — radio_init() mandatory sequence, global objects ├── platformio.ini — incl. SCREEN_TIMEOUT=0 (Fix 1) ├── credentials.ini.example — template for local credentials └── main_cpp_setup_hinweis.txt — correct main.cpp structure (Fix 3/4) src/helpers/ui/ └── DisplayDriver.h — abstract interface (pure virtuals) src/helpers/radiolib/ ├── RfSwitchCallback.h — abstract interface for external RF switch └── CustomSX1262Wrapper.h — SX1262 wrapper with RF switch callback
| # | Topic | Status |
|---|---|---|
| 1 | Remove board.begin() / display.begin() from main.cpp setup() | workaround active (guard) |
| 2 | Hook KEY1/KEY2 into UITask screen wakeup | open |
| 3 | Touch FT6336U (I²C 0x38) | open |
| 4 | IMU BMI270 (I²C 0x68) for auto-rotation | open |
| 5 | Battery monitor (I²C 0x49) | open |
MIT License, identical to MeshCore. Full text in the repository root.