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