mirror of https://github.com/meshcore-dev/MeshCore
Browse Source
Adds support for running a RAK4631 over PoE using the RAK13800 (W5100S)
Ethernet module on a RAK19018 base, in two roles:
- PoE repeater: powers up reliably on the RAK19018 (Silvertel) converter.
The cold-start path is shortened and CPU sleep is disabled, because
dropping below the converter's hold current makes it fold back and
reset. Boot-voltage protection is bypassed when battery-less on PoE.
- Ethernet companion: exposes the MeshCore companion protocol as a TCP
server (default port 5000) over the W5100S, so Home Assistant connects
to the device's IP. Uses a static IP to avoid blocking DHCP at cold
start. W5100S PHY bring-up is deferred out of setup() into loop() to
avoid collapsing the marginal PoE supply during the cold-start window.
New files:
- src/helpers/SerialEthernetInterface.{cpp,h}: TCP transport for the
companion serial frame protocol.
- variants/rak4631/W5100SPoE.{cpp,h}: W5100S power/reset + init helper.
New platformio envs:
- RAK_4631_repeater_poe (+ _debug)
- RAK_4631_companion_radio_eth
All additions are guarded by WITH_W5100S_POE / WITH_ETHERNET_COMPANION,
so existing RAK4631 builds are unaffected.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
pull/2679/head
8 changed files with 533 additions and 7 deletions
@ -0,0 +1,151 @@ |
|||
#include "SerialEthernetInterface.h" |
|||
|
|||
void SerialEthernetInterface::begin(int port) { |
|||
// Ethernet hardware (Ethernet.init/begin) is brought up in setup();
|
|||
// here we only start the TCP server.
|
|||
server = new EthernetServer(port); |
|||
server->begin(); |
|||
} |
|||
|
|||
void SerialEthernetInterface::enable() { |
|||
if (_isEnabled) return; |
|||
_isEnabled = true; |
|||
send_queue_len = 0; |
|||
} |
|||
|
|||
void SerialEthernetInterface::disable() { |
|||
_isEnabled = false; |
|||
} |
|||
|
|||
size_t SerialEthernetInterface::writeFrame(const uint8_t src[], size_t len) { |
|||
if (len > MAX_FRAME_SIZE) { |
|||
ETH_DEBUG_PRINTLN("writeFrame(): frame too big, len=%d", (int)len); |
|||
return 0; |
|||
} |
|||
if (!_connected || len == 0) return 0; |
|||
|
|||
if (send_queue_len >= ETH_FRAME_QUEUE_SIZE) { |
|||
ETH_DEBUG_PRINTLN("writeFrame(): send_queue full (dropping code=0x%02x)", src[0]); |
|||
return 0; |
|||
} |
|||
|
|||
// PUSH codes (>= 0x80) go to all clients; command responses go to the
|
|||
// client that issued the most recent command.
|
|||
int8_t target = (src[0] >= 0x80) ? -1 : (int8_t)_last_rx; |
|||
|
|||
ETH_DEBUG_PRINTLN("TX code=0x%02x len=%d -> %s", src[0], (int)len, |
|||
target < 0 ? "all" : (target == 0 ? "slot0" : target == 1 ? "slot1" : "slot2")); |
|||
|
|||
send_queue[send_queue_len].target = target; |
|||
send_queue[send_queue_len].len = (uint8_t)len; |
|||
memcpy(send_queue[send_queue_len].buf, src, len); |
|||
send_queue_len++; |
|||
return len; |
|||
} |
|||
|
|||
size_t SerialEthernetInterface::checkRecvFrame(uint8_t dest[]) { |
|||
if (server == NULL) return 0; |
|||
|
|||
// ---- accept a new connection into a free slot --------------------------
|
|||
// accept() returns each new connection once and maintains the listen socket,
|
|||
// so it must be called every loop.
|
|||
EthernetClient nc = server->accept(); |
|||
if (nc) { |
|||
int slot = -1; |
|||
for (int i = 0; i < MAX_ETH_CLIENTS; i++) { |
|||
if (!clients[i].connected()) { slot = i; break; } |
|||
} |
|||
if (slot >= 0) { |
|||
clients[slot].stop(); // free any lingering socket in this slot
|
|||
clients[slot] = nc; |
|||
rx_header[slot].type = 0; |
|||
rx_header[slot].length = 0; |
|||
ETH_DEBUG_PRINTLN("Got connection (slot %d)", slot); |
|||
} else { |
|||
nc.stop(); // all slots busy — reject
|
|||
ETH_DEBUG_PRINTLN("Rejected connection (all %d slots busy)", MAX_ETH_CLIENTS); |
|||
} |
|||
} |
|||
|
|||
// ---- refresh connected state, free dropped sockets ---------------------
|
|||
bool any = false; |
|||
for (int i = 0; i < MAX_ETH_CLIENTS; i++) { |
|||
if (clients[i].connected()) { |
|||
any = true; |
|||
} else if (rx_header[i].type || rx_header[i].length) { |
|||
// a client that was active just dropped — reset its parse state
|
|||
rx_header[i].type = 0; |
|||
rx_header[i].length = 0; |
|||
clients[i].stop(); |
|||
ETH_DEBUG_PRINTLN("Disconnected (slot %d)", i); |
|||
} |
|||
} |
|||
_connected = any; |
|||
|
|||
// ---- drain the outbound queue ------------------------------------------
|
|||
while (send_queue_len > 0) { |
|||
Frame &f = send_queue[0]; |
|||
uint8_t pkt[3 + MAX_FRAME_SIZE]; |
|||
pkt[0] = '>'; |
|||
pkt[1] = (f.len & 0xFF); |
|||
pkt[2] = (f.len >> 8); |
|||
memcpy(&pkt[3], f.buf, f.len); |
|||
|
|||
if (f.target < 0) { // broadcast (push)
|
|||
for (int i = 0; i < MAX_ETH_CLIENTS; i++) { |
|||
if (clients[i].connected()) clients[i].write(pkt, 3 + f.len); |
|||
} |
|||
} else if (f.target < MAX_ETH_CLIENTS && clients[f.target].connected()) { |
|||
clients[f.target].write(pkt, 3 + f.len); // response to the requester
|
|||
} |
|||
|
|||
send_queue_len--; |
|||
for (int i = 0; i < send_queue_len; i++) send_queue[i] = send_queue[i + 1]; |
|||
} |
|||
|
|||
// ---- read ONE inbound frame (round-robin across clients) ---------------
|
|||
for (int k = 0; k < MAX_ETH_CLIENTS; k++) { |
|||
int i = (_rr + k) % MAX_ETH_CLIENTS; |
|||
EthernetClient &c = clients[i]; |
|||
if (!c.connected()) continue; |
|||
|
|||
// frame header = [type][len_lo][len_hi]
|
|||
if (rx_header[i].type == 0 || rx_header[i].length == 0) { |
|||
if (c.available() >= 3) { |
|||
c.readBytes(&rx_header[i].type, 1); |
|||
c.readBytes((uint8_t *)&rx_header[i].length, 2); |
|||
} |
|||
} |
|||
|
|||
if (rx_header[i].type != 0 && rx_header[i].length != 0) { |
|||
int avail = c.available(); |
|||
int frame_type = rx_header[i].type; |
|||
int frame_length = rx_header[i].length; |
|||
|
|||
if (frame_length > avail) continue; // wait for the rest
|
|||
|
|||
if (frame_length > MAX_FRAME_SIZE || frame_type != '<') { |
|||
// oversized or unexpected type — discard
|
|||
while (frame_length > 0) { |
|||
uint8_t skip[1]; |
|||
int n = c.read(skip, 1); |
|||
if (n <= 0) break; |
|||
frame_length -= n; |
|||
} |
|||
rx_header[i].type = 0; |
|||
rx_header[i].length = 0; |
|||
continue; |
|||
} |
|||
|
|||
c.readBytes(dest, frame_length); |
|||
rx_header[i].type = 0; |
|||
rx_header[i].length = 0; |
|||
_last_rx = i; // route responses back here
|
|||
_rr = (i + 1) % MAX_ETH_CLIENTS; // fairness
|
|||
ETH_DEBUG_PRINTLN("RX[%d] cmd=0x%02x len=%d", i, dest[0], frame_length); |
|||
return frame_length; |
|||
} |
|||
} |
|||
|
|||
return 0; |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
#pragma once |
|||
|
|||
#include "BaseSerialInterface.h" |
|||
#include <RAK13800_W5100S.h> |
|||
|
|||
// Multi-client TCP companion interface over a W5100S Ethernet module (RAK13800).
|
|||
// Lets several clients (e.g. Home Assistant AND the phone app) stay connected
|
|||
// at once — the single-client model had them kicking each other off the one
|
|||
// socket, causing an endless reconnect loop.
|
|||
//
|
|||
// Routing of outbound frames (the companion protocol isn't natively
|
|||
// multi-client, so we route by frame code):
|
|||
// - PUSH frames (code >= 0x80, e.g. LoRa-RX log, adverts) -> ALL clients
|
|||
// - command RESPONSES (code < 0x80) -> the client
|
|||
// that issued
|
|||
// the last command
|
|||
//
|
|||
// Ethernet hardware (Ethernet.init/begin) is brought up outside this class.
|
|||
|
|||
#ifndef MAX_ETH_CLIENTS |
|||
#define MAX_ETH_CLIENTS 3 // W5100S has 4 sockets: up to 3 clients + 1 listen
|
|||
#endif |
|||
|
|||
class SerialEthernetInterface : public BaseSerialInterface { |
|||
bool _isEnabled; |
|||
bool _connected; // true if at least one client is connected
|
|||
|
|||
EthernetServer* server; |
|||
EthernetClient clients[MAX_ETH_CLIENTS]; |
|||
|
|||
struct FrameHeader { uint8_t type; uint16_t length; }; |
|||
FrameHeader rx_header[MAX_ETH_CLIENTS]; // per-client inbound parse state
|
|||
|
|||
struct Frame { |
|||
int8_t target; // -1 = broadcast, else client index
|
|||
uint8_t len; |
|||
uint8_t buf[MAX_FRAME_SIZE]; |
|||
}; |
|||
|
|||
#define ETH_FRAME_QUEUE_SIZE 16 |
|||
int send_queue_len; |
|||
Frame send_queue[ETH_FRAME_QUEUE_SIZE]; |
|||
|
|||
int _last_rx; // client index of the most recent inbound command
|
|||
int _rr; // round-robin cursor for fair inbound polling
|
|||
|
|||
public: |
|||
SerialEthernetInterface() : server(NULL) { |
|||
_isEnabled = false; |
|||
_connected = false; |
|||
send_queue_len = 0; |
|||
_last_rx = -1; |
|||
_rr = 0; |
|||
for (int i = 0; i < MAX_ETH_CLIENTS; i++) { rx_header[i].type = 0; rx_header[i].length = 0; } |
|||
} |
|||
|
|||
void begin(int port); |
|||
|
|||
// BaseSerialInterface methods
|
|||
void enable() override; |
|||
void disable() override; |
|||
bool isEnabled() const override { return _isEnabled; } |
|||
|
|||
bool isConnected() const override { return _connected; } |
|||
bool isWriteBusy() const override { return false; } |
|||
|
|||
size_t writeFrame(const uint8_t src[], size_t len) override; |
|||
size_t checkRecvFrame(uint8_t dest[]) override; |
|||
}; |
|||
|
|||
#if ETH_DEBUG_LOGGING && ARDUINO |
|||
#include <Arduino.h> |
|||
#define ETH_DEBUG_PRINT(F, ...) Serial.printf("ETH: " F, ##__VA_ARGS__) |
|||
#define ETH_DEBUG_PRINTLN(F, ...) Serial.printf("ETH: " F "\n", ##__VA_ARGS__) |
|||
#else |
|||
#define ETH_DEBUG_PRINT(...) {} |
|||
#define ETH_DEBUG_PRINTLN(...) {} |
|||
#endif |
|||
@ -0,0 +1,113 @@ |
|||
#ifdef WITH_W5100S_POE |
|||
|
|||
#include <Arduino.h> |
|||
#include <nrf.h> |
|||
#include "W5100SPoE.h" |
|||
|
|||
// ── Early power-rail + RST release (constructor priority 200) ────────────────
|
|||
// Runs before setup(), right after SystemInit. Two jobs, as early as possible
|
|||
// so the W5100S draws its full operating current before the RAK19018
|
|||
// (Silvertel) converter folds back during PoE cold-start:
|
|||
//
|
|||
// 1. Drive PIN_3V3_EN (P1.02 / WB_IO2 / Arduino 34) HIGH — this is the
|
|||
// RAK19007 3.3 V PERIPHERAL POWER ENABLE that feeds the RAK13800/W5100S.
|
|||
// Meshtastic does this in initVariant(); stock MeshCore never did, so our
|
|||
// W5100S was only weakly powered via a default path (responds on USB but
|
|||
// can't pull its full ~130 mA on the marginal PoE rail).
|
|||
// 2. Drive W5100S RST (P0.21 / WB_IO3 / Arduino 21) HIGH — out of reset.
|
|||
//
|
|||
// Raw registers because the Arduino GPIO layer isn't up this early.
|
|||
static void __attribute__((constructor(200))) w5100s_early_power_init() { |
|||
// P1.02 = 3V3_EN → HIGH (power the peripheral rail FIRST)
|
|||
NRF_P1->PIN_CNF[2] = |
|||
(GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos) | |
|||
(GPIO_PIN_CNF_INPUT_Disconnect << GPIO_PIN_CNF_INPUT_Pos) | |
|||
(GPIO_PIN_CNF_PULL_Disabled << GPIO_PIN_CNF_PULL_Pos) | |
|||
(GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | |
|||
(GPIO_PIN_CNF_SENSE_Disabled << GPIO_PIN_CNF_SENSE_Pos); |
|||
NRF_P1->OUTSET = (1UL << 2); |
|||
|
|||
// P0.21 = W5100S RST → HIGH (release from reset)
|
|||
NRF_P0->PIN_CNF[21] = |
|||
(GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos) | |
|||
(GPIO_PIN_CNF_INPUT_Disconnect << GPIO_PIN_CNF_INPUT_Pos) | |
|||
(GPIO_PIN_CNF_PULL_Disabled << GPIO_PIN_CNF_PULL_Pos) | |
|||
(GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | |
|||
(GPIO_PIN_CNF_SENSE_Disabled << GPIO_PIN_CNF_SENSE_Pos); |
|||
NRF_P0->OUTSET = (1UL << 21); |
|||
} |
|||
|
|||
// ── Bit-banged SPI to the W5100S ────────────────────────────────────────────
|
|||
// Shares the SPI bus (SCK=3 / MOSI=30 / MISO=29) with the SX1262 radio; only
|
|||
// the chip-select differs (W5100S CS=26, SX1262 NSS=42). Bit-banged so it
|
|||
// works regardless of which SPIClass owns the bus and runs before RadioLib.
|
|||
// W5100S frame: write [0xF0][hi][lo][data], read [0x0F][hi][lo]->[data].
|
|||
#define ETH_SCK_PIN 3 |
|||
#define ETH_MOSI_PIN 30 |
|||
#define ETH_MISO_PIN 29 |
|||
#define LORA_NSS_PIN 42 |
|||
|
|||
static void bb_write_reg(uint16_t addr, uint8_t data) { |
|||
const uint8_t frame[4] = { 0xF0, (uint8_t)(addr >> 8), (uint8_t)(addr & 0xFF), data }; |
|||
digitalWrite(W5100S_CS_PIN, LOW); |
|||
for (uint8_t b = 0; b < 4; b++) { |
|||
uint8_t v = frame[b]; |
|||
for (int8_t i = 7; i >= 0; i--) { |
|||
digitalWrite(ETH_MOSI_PIN, (v >> i) & 1); |
|||
digitalWrite(ETH_SCK_PIN, HIGH); |
|||
digitalWrite(ETH_SCK_PIN, LOW); |
|||
} |
|||
} |
|||
digitalWrite(W5100S_CS_PIN, HIGH); |
|||
} |
|||
|
|||
static uint8_t bb_read_reg(uint16_t addr) { |
|||
const uint8_t frame[3] = { 0x0F, (uint8_t)(addr >> 8), (uint8_t)(addr & 0xFF) }; |
|||
digitalWrite(W5100S_CS_PIN, LOW); |
|||
for (uint8_t b = 0; b < 3; b++) { |
|||
uint8_t v = frame[b]; |
|||
for (int8_t i = 7; i >= 0; i--) { |
|||
digitalWrite(ETH_MOSI_PIN, (v >> i) & 1); |
|||
digitalWrite(ETH_SCK_PIN, HIGH); |
|||
digitalWrite(ETH_SCK_PIN, LOW); |
|||
} |
|||
} |
|||
uint8_t out = 0; |
|||
for (int8_t i = 7; i >= 0; i--) { |
|||
digitalWrite(ETH_SCK_PIN, HIGH); |
|||
out = (out << 1) | (digitalRead(ETH_MISO_PIN) & 1); |
|||
digitalWrite(ETH_SCK_PIN, LOW); |
|||
} |
|||
digitalWrite(W5100S_CS_PIN, HIGH); |
|||
return out; |
|||
} |
|||
|
|||
// ── Full W5100S bring-up ────────────────────────────────────────────────────
|
|||
// Called from RAK4631Board::begin(). Confirms the 3V3 rail + RST are driven
|
|||
// (Arduino API, in case the core re-init touched them), then soft-resets and
|
|||
// reads VERSIONR. Returns VERSIONR (0x51 = healthy W5100S).
|
|||
uint8_t w5100s_poe_init() { |
|||
// Make sure the peripheral power rail stays driven HIGH.
|
|||
pinMode(W5100S_3V3_EN_PIN, OUTPUT); digitalWrite(W5100S_3V3_EN_PIN, HIGH); |
|||
delay(20); // let the rail/W5100S settle after enable
|
|||
|
|||
// Park both chip-selects HIGH on the shared bus, RST released.
|
|||
pinMode(LORA_NSS_PIN, OUTPUT); digitalWrite(LORA_NSS_PIN, HIGH); |
|||
pinMode(W5100S_CS_PIN, OUTPUT); digitalWrite(W5100S_CS_PIN, HIGH); |
|||
pinMode(W5100S_RST_PIN, OUTPUT); digitalWrite(W5100S_RST_PIN, HIGH); |
|||
|
|||
pinMode(ETH_SCK_PIN, OUTPUT); digitalWrite(ETH_SCK_PIN, LOW); |
|||
pinMode(ETH_MOSI_PIN, OUTPUT); digitalWrite(ETH_MOSI_PIN, LOW); |
|||
pinMode(ETH_MISO_PIN, INPUT); |
|||
|
|||
bb_write_reg(0x0000, 0x80); // MR: software reset
|
|||
delay(2); |
|||
|
|||
uint8_t ver = bb_read_reg(0x0080); // VERSIONR (0x51 on W5100S)
|
|||
|
|||
// Chip stays powered and out of reset; PHY auto-negotiates with the switch
|
|||
// and the W5100S draws its full operating current.
|
|||
return ver; |
|||
} |
|||
|
|||
#endif // WITH_W5100S_POE
|
|||
@ -0,0 +1,42 @@ |
|||
#pragma once |
|||
|
|||
// W5100S activation for PoE operation on RAK10720 (RAK4631 + RAK13800 + RAK19018).
|
|||
//
|
|||
// ROOT CAUSE (confirmed via RAK forum + Meshtastic rak4631_eth_gw variant):
|
|||
// The RAK19018 PoE module (Silvertel Ag9905MT) enters a non-continuous
|
|||
// "gated pulse" mode when the load is below ~125-200 mA. A bare MeshCore
|
|||
// repeater draws only a few mA, so the converter never latches → the supply
|
|||
// pulses and the device never boots (fade-in / brighten / die / repeat LED).
|
|||
//
|
|||
// The fix that lets Meshtastic boot on PoE without a battery: bring the
|
|||
// W5100S PHY into its active state. An active W5100S draws ~120 mA — enough
|
|||
// to keep the Silvertel converter latched in continuous mode.
|
|||
//
|
|||
// The W5100S has no power-enable pin on this board (always powered), but its
|
|||
// RST must be HIGH for the PHY to run. We release RST as early as possible
|
|||
// (before setup(), via a constructor) so the PHY draws current and latches
|
|||
// the converter before its foldback timer expires.
|
|||
//
|
|||
// Pin mapping (from Meshtastic rak4631_eth_gw variant.h):
|
|||
// RST → PIN_ETHERNET_RESET = 21 (P0.21 / WB_IO3)
|
|||
// CS → PIN_ETHERNET_SS = 26 (WB_SPI_CS)
|
|||
// SPI → SPI1 (SCK=3, MISO=29, MOSI=30) [not used in Layer 1]
|
|||
|
|||
#ifndef W5100S_RST_PIN |
|||
#define W5100S_RST_PIN 21 // P0.21 / WB_IO3
|
|||
#endif |
|||
|
|||
#ifndef W5100S_CS_PIN |
|||
#define W5100S_CS_PIN 26 // WB_SPI_CS
|
|||
#endif |
|||
|
|||
// RAK19007 3.3 V peripheral power enable (feeds the RAK13800/W5100S).
|
|||
// Must be driven HIGH or the W5100S is only weakly powered — exactly what
|
|||
// Meshtastic's initVariant() does and stock MeshCore omits.
|
|||
#ifndef W5100S_3V3_EN_PIN |
|||
#define W5100S_3V3_EN_PIN 34 // P1.02 / WB_IO2
|
|||
#endif |
|||
|
|||
// Called from RAK4631Board::begin(); fully brings up the W5100S (soft reset +
|
|||
// activation) so it draws full operating current. Returns VERSIONR (0x51 = ok).
|
|||
uint8_t w5100s_poe_init(); |
|||
Loading…
Reference in new issue