Inhaltsverzeichnis

NOTE: Currently in the testing phase!

NOTE: team-nessoN1-meshcore is taking a break for health reasons. If you’d like, you can contact him via email at — team-nessoN1-meshcore for details on handing over the project.

MeshCore — Arduino Nesso N1 (ESP32-C6)

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.

Hardware Overview

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

Key Porting Decisions

SX1262 Reset and RF Switch via I²C Expander

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.

Shared SPI Bus (SX1262 + ST7789)

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).

FSPI instead of default SPI

The ESP32-C6 has no VSPI bus: SPIClass lora_spi(FSPI).

USB-CDC and BLE are mutually exclusive

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

Display Architecture

Resolution and UITask Layout Verification

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.

Mandatory Initialisation Order

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!

Known Issues and Their Fixes

Fix 1 — Display Goes Black After a Few Seconds

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 via nesso_ui_tick() (see Fix 7).

Fix 2 — Display in Portrait Mode (135×240 Instead of 240×135)

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

Fix 3 — ''radio init failed: -2'' from Double board.begin() Call

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_CLASSbefore 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).

Fix 4 — APB Callback Duplicate and Wire.setPins Warning

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.

Fix 5 — BUSY Pulse Timing After loraReset()

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)

Fix 7 — Buttons Non-Functional in Repeater Display Mode

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.

Fix 8 — Buttons Not Recognised: Wire Conflict Between M5GFX and Expander Driver

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.

Quick Start

1. Prerequisites

2. Set up credentials

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.ini is listed in .gitignore and will not be committed to the repository.

3. Build and flash

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

4. Expected serial output (repeater with display)

[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 ===

Pin Reference

Direct ESP32-C6 GPIOs

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

Via I²C Expander 0 (0x43)

Function Expander Pin
Button A (KEY1) P0
Button B (KEY2) P1
LNA Enable P5
Antenna Switch P6
LoRa Reset (NRST) P7

Via I²C Expander 1 (0x44)

Function Expander Pin
LCD Reset P1
LCD Backlight P6

RF Switch Logic

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

Architecture

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

Directory Structure

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

Open Items

# 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

License

MIT License, identical to MeshCore. Full text in the repository root.