From bfe2ecdf25e4d085bda8e58958fb3f4cda5108df Mon Sep 17 00:00:00 2001 From: Tony Gies Date: Wed, 16 Sep 2026 21:57:49 -0500 Subject: [PATCH] Add Meshnology W10 board support Add support for Meshnology W10 board, including display, PMU, RTC, GPS, and sensor support. Radio is controlled through an MCP23017 and polled IRQs. RadioLib HAL for this variant adds a wrapper that polls DIO1. This necessitates adding virtual isIRQPending() and isRecvIRQPending() hooks so that this board can work without changing the path taken on boards that use the latched ISR flag. RTC behavior is overridden so that the PCF85063A is not auto-detected as the slightly different and incompatible PCF8563. --- .github/workflows/pr-build-check.yml | 5 + .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 39 ++- src/helpers/radiolib/RadioLibWrappers.cpp | 13 +- src/helpers/radiolib/RadioLibWrappers.h | 3 + src/helpers/ui/ST7789LCDDisplay.cpp | 8 +- test/mocks/hardware/Arduino.h | 16 + test/mocks/hardware/RadioLib.h | 63 ++++ test/mocks/w10/RTClib.h | 39 +++ test/mocks/w10/Wire.h | 36 +++ .../helpers/radiolib/CustomSX1262Wrapper.h | 22 ++ .../test_radiolib_wrappers.cpp | 139 ++++++++ test/test_w10/test_w10.cpp | 302 ++++++++++++++++++ variants/meshnology_w10/MCP23017.cpp | 71 ++++ variants/meshnology_w10/MCP23017.h | 33 ++ .../meshnology_w10/MeshnologyW10Board.cpp | 75 +++++ variants/meshnology_w10/MeshnologyW10Board.h | 34 ++ variants/meshnology_w10/MeshnologyW10Hal.h | 48 +++ variants/meshnology_w10/MeshnologyW10RTC.h | 85 +++++ variants/meshnology_w10/MeshnologyW10Radio.h | 44 +++ variants/meshnology_w10/platformio.ini | 128 ++++++++ variants/meshnology_w10/target.cpp | 65 ++++ variants/meshnology_w10/target.h | 22 ++ 23 files changed, 1284 insertions(+), 8 deletions(-) create mode 100644 test/mocks/hardware/Arduino.h create mode 100644 test/mocks/hardware/RadioLib.h create mode 100644 test/mocks/w10/RTClib.h create mode 100644 test/mocks/w10/Wire.h create mode 100644 test/mocks/w10/helpers/radiolib/CustomSX1262Wrapper.h create mode 100644 test/test_radiolib_wrappers/test_radiolib_wrappers.cpp create mode 100644 test/test_w10/test_w10.cpp create mode 100644 variants/meshnology_w10/MCP23017.cpp create mode 100644 variants/meshnology_w10/MCP23017.h create mode 100644 variants/meshnology_w10/MeshnologyW10Board.cpp create mode 100644 variants/meshnology_w10/MeshnologyW10Board.h create mode 100644 variants/meshnology_w10/MeshnologyW10Hal.h create mode 100644 variants/meshnology_w10/MeshnologyW10RTC.h create mode 100644 variants/meshnology_w10/MeshnologyW10Radio.h create mode 100644 variants/meshnology_w10/platformio.ini create mode 100644 variants/meshnology_w10/target.cpp create mode 100644 variants/meshnology_w10/target.h diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index cebf0cfe5c..3daa54cd52 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -29,6 +29,11 @@ jobs: - Heltec_v3_companion_radio_ble - Heltec_v3_repeater - Heltec_v3_room_server + # SX1262 control/IRQ through an I2C expander + - meshnology_w10_companion_radio_ble + - meshnology_w10_companion_radio_usb + - meshnology_w10_repeater + - meshnology_w10_room_server # nRF52 - RAK_4631_companion_radio_ble - RAK_4631_companion_radio_ethernet diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67b..6ca879700f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_radiolib -e native_w10 -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index de4d6c29c3..b1c6bd73f0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -165,7 +165,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_radiolib_wrappers, test_w10 build_src_filter = -<*> +<../src/Utils.cpp> @@ -189,3 +189,40 @@ build_src_filter = +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 + +[env:native_w10] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks/w10 + -I src + -I test/mocks/hardware + -I test/mocks + -I variants/meshnology_w10 + -D LORA_SF=8 + -D P_LORA_DIO_1=109 + -D P_LORA_BUSY=110 +test_build_src = yes +test_filter = test_w10 +build_src_filter = + -<*> + +<../variants/meshnology_w10/MCP23017.cpp> + + +lib_deps = + google/googletest @ 1.17.0 + +[env:native_radiolib] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks/hardware + -I test/mocks + -D LORA_SF=8 +test_build_src = yes +test_filter = test_radiolib_wrappers +build_src_filter = + -<*> + + +lib_deps = + google/googletest @ 1.17.0 diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c27..70b74525e3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -24,6 +24,10 @@ void setFlag(void) { state |= STATE_INT_READY; } +bool RadioLibWrapper::isIRQPending() const { + return (state & STATE_INT_READY) != 0; +} + void RadioLibWrapper::begin() { _radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt _preamble_sf = getSpreadingFactor(); @@ -31,6 +35,7 @@ void RadioLibWrapper::begin() { state = STATE_IDLE; if (_board->getStartupReason() == BD_STARTUP_RX_PACKET) { // received a LoRa packet (while in deep sleep) + state = STATE_RX; setFlag(); // LoRa packet is already received } @@ -73,7 +78,7 @@ void RadioLibWrapper::doResetAGC() { void RadioLibWrapper::resetAGC() { // make sure we're not mid-receive of packet! - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; + if (isIRQPending() || isReceivingPacket()) return; doResetAGC(); state = STATE_IDLE; // trigger a startReceive() @@ -89,7 +94,7 @@ void RadioLibWrapper::resetAGC() { void RadioLibWrapper::loop() { if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!isReceivingPacket()) { + if (!isIRQPending() && !isReceivingPacket()) { int rssi = getCurrentRSSI(); if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD _num_floor_samples++; @@ -127,7 +132,7 @@ bool RadioLibWrapper::isInRecvMode() const { int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; - if (state & STATE_INT_READY) { + if (isRecvIRQPending()) { len = _radio->getPacketLength(); if (len > 0) { if (len > sz) { len = sz; } @@ -177,7 +182,7 @@ bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { } bool RadioLibWrapper::isSendComplete() { - if (state & STATE_INT_READY) { + if ((state & ~STATE_INT_READY) == STATE_TX_WAIT && isIRQPending()) { state = STATE_IDLE; n_sent++; return true; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b..1d8f11711d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -26,6 +26,9 @@ class RadioLibWrapper : public mesh::Radio { void startRecv(); float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; + virtual bool isIRQPending() const; // DIO1 completion flag; the base reads the ISR-latched bit + // Polling boards, which read DIO1 rather than latching it, override this to require RX state + virtual bool isRecvIRQPending() const { return isIRQPending(); } virtual void doResetAGC(); public: diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index a9f30dd56a..6fb706ad42 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -16,8 +16,12 @@ #define DISPLAY_SCALE_Y 3.75f // 240 / 64 #endif -#define DISPLAY_WIDTH 240 -#define DISPLAY_HEIGHT 320 +#ifndef DISPLAY_WIDTH + #define DISPLAY_WIDTH 240 +#endif +#ifndef DISPLAY_HEIGHT + #define DISPLAY_HEIGHT 320 +#endif bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) { return true; diff --git a/test/mocks/hardware/Arduino.h b/test/mocks/hardware/Arduino.h new file mode 100644 index 0000000000..b436dbf41f --- /dev/null +++ b/test/mocks/hardware/Arduino.h @@ -0,0 +1,16 @@ +#pragma once + +#include "../Arduino.h" +#include + +#define LOW 0 +#define HIGH 1 +#define INPUT 1 +#define OUTPUT 3 +#define INPUT_PULLUP 5 +#define MSBFIRST 1 +#define SPI_MODE0 0 + +using std::min; +using std::max; +inline long random(long low, long high) { return low; } diff --git a/test/mocks/hardware/RadioLib.h b/test/mocks/hardware/RadioLib.h new file mode 100644 index 0000000000..6a3e226e38 --- /dev/null +++ b/test/mocks/hardware/RadioLib.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include + +#define RADIOLIB_NC UINT32_MAX +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_CHANNEL_FREE -15 +#define RADIOLIB_LORA_DETECTED -702 +#define RADIOLIB_ERR_RX_TIMEOUT -6 +#define RADIOLIB_ERR_SPI_CMD_FAILED -707 + +class SPIClass { }; +class SPISettings { +public: + SPISettings(uint32_t, uint8_t, uint8_t) { } +}; + +class ArduinoHal { +public: + unsigned native_calls = 0; + uint32_t last_pin = RADIOLIB_NC; + ArduinoHal(SPIClass&, SPISettings) { } + virtual void pinMode(uint32_t pin, uint32_t) { native_calls++; last_pin = pin; } + virtual void digitalWrite(uint32_t pin, uint32_t) { native_calls++; last_pin = pin; } + virtual uint32_t digitalRead(uint32_t pin) { native_calls++; last_pin = pin; return LOW; } + virtual uint32_t pinToInterrupt(uint32_t pin) { return pin; } + virtual void attachInterrupt(uint32_t pin, void (*)(), uint32_t) { native_calls++; last_pin = pin; } + virtual void detachInterrupt(uint32_t pin) { native_calls++; last_pin = pin; } +}; + +class PhysicalLayer { +public: + void (*callback)() = nullptr; + bool pending = false; + int rx_starts = 0, reads = 0, sleeps = 0; + int rx_error = 0, tx_error = 0, read_error = 0; + std::vector packet{0x12, 0x34, 0x56}; + + void setPacketReceivedAction(void (*action)()) { callback = action; } + void setPreambleLength(uint16_t) { } + int16_t sleep() { sleeps++; return 0; } + int16_t standby() { return 0; } + int16_t startReceive() { rx_starts++; pending = false; return rx_error; } + size_t getPacketLength() { return packet.size(); } + int16_t readData(uint8_t* bytes, size_t len) { + reads++; + pending = false; + memcpy(bytes, packet.data(), len); + return read_error; + } + uint32_t getTimeOnAir(int) { return 100000; } + int16_t startTransmit(uint8_t*, int) { pending = false; return tx_error; } + int16_t finishTransmit() { pending = false; return 0; } + int16_t scanChannel() { if (callback) callback(); pending = true; return RADIOLIB_CHANNEL_FREE; } + float getRSSI() { return -100; } + float getSNR() { return 8; } + int16_t setOutputPower(int8_t) { return 0; } + long random(long) { return 42; } + uint8_t randomByte() { return 42; } +}; diff --git a/test/mocks/w10/RTClib.h b/test/mocks/w10/RTClib.h new file mode 100644 index 0000000000..df1dcb554f --- /dev/null +++ b/test/mocks/w10/RTClib.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +// Use the host clock to test RTC register serialization. +class DateTime { + std::tm fields{}; +public: + inline static unsigned component_constructions = 0; + DateTime(uint16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second) { + ++component_constructions; + fields.tm_year = year - 1900; + fields.tm_mon = month - 1; + fields.tm_mday = day; + fields.tm_hour = hour; + fields.tm_min = minute; + fields.tm_sec = second; + } + explicit DateTime(uint32_t value) { + time_t time = value; + gmtime_r(&time, &fields); + } + uint32_t unixtime() const { auto value = fields; return timegm(&value); } + bool isValid() const { + auto value = fields; + timegm(&value); + return value.tm_year == fields.tm_year && value.tm_mon == fields.tm_mon + && value.tm_mday == fields.tm_mday && value.tm_hour == fields.tm_hour + && value.tm_min == fields.tm_min && value.tm_sec == fields.tm_sec; + } + uint16_t year() const { return fields.tm_year + 1900; } + uint8_t month() const { return fields.tm_mon + 1; } + uint8_t day() const { return fields.tm_mday; } + uint8_t hour() const { return fields.tm_hour; } + uint8_t minute() const { return fields.tm_min; } + uint8_t second() const { return fields.tm_sec; } + uint8_t dayOfTheWeek() const { auto value = fields; timegm(&value); return value.tm_wday; } +}; diff --git a/test/mocks/w10/Wire.h b/test/mocks/w10/Wire.h new file mode 100644 index 0000000000..8eb5d7f871 --- /dev/null +++ b/test/mocks/w10/Wire.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +// Register-backed I2C device with injectable address, write and short-read failures +class TwoWire { + std::vector tx; + uint8_t cursor = 0; +public: + std::array registers{}; + bool nack = false; + bool fail_write = false; + bool short_read = false; + int fail_write_register = -1; + unsigned writes = 0; + + void beginTransmission(uint8_t address) { tx.clear(); } + size_t write(uint8_t value) { tx.push_back(value); return 1; } + size_t write(const uint8_t* data, size_t len) { + tx.insert(tx.end(), data, data + len); + return len; + } + uint8_t endTransmission() { + if (nack || (tx.size() > 1 && (fail_write || tx[0] == fail_write_register))) return 4; + cursor = tx[0]; + if (tx.size() > 1) { + writes++; + for (size_t i = 1; i < tx.size(); i++) registers[cursor++] = tx[i]; + } + return 0; + } + uint8_t requestFrom(uint8_t address, uint8_t count) { return short_read ? 0 : count; } + int read() { return registers[cursor++]; } +}; diff --git a/test/mocks/w10/helpers/radiolib/CustomSX1262Wrapper.h b/test/mocks/w10/helpers/radiolib/CustomSX1262Wrapper.h new file mode 100644 index 0000000000..879a175ab4 --- /dev/null +++ b/test/mocks/w10/helpers/radiolib/CustomSX1262Wrapper.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +// Stub the actual chip-specific layer. +class CustomSX1262 : public PhysicalLayer { +public: + int16_t scan_start_error = RADIOLIB_ERR_NONE; + int16_t scan_result = RADIOLIB_CHANNEL_FREE; + unsigned scan_results = 0; + int16_t startChannelScan() { return scan_start_error; } + int16_t getChannelScanResult() { scan_results++; return scan_result; } +}; + +class CustomSX1262Wrapper : public RadioLibWrapper { +public: + CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) + : RadioLibWrapper(radio, board) { } + void setParams(float, float, uint8_t, uint8_t) override { } + bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { return -100; } +}; diff --git a/test/test_radiolib_wrappers/test_radiolib_wrappers.cpp b/test/test_radiolib_wrappers/test_radiolib_wrappers.cpp new file mode 100644 index 0000000000..a3aeb85f80 --- /dev/null +++ b/test/test_radiolib_wrappers/test_radiolib_wrappers.cpp @@ -0,0 +1,139 @@ +#include +#include + +class TestBoard : public mesh::MainBoard { +public: + uint8_t startup = BD_STARTUP_NORMAL; + int before = 0, after = 0; + uint16_t getBattMilliVolts() override { return 4200; } + const char* getManufacturerName() const override { return "test"; } + void reboot() override { } + uint8_t getStartupReason() const override { return startup; } + void onBeforeTransmit() override { before++; } + void onAfterTransmit() override { after++; } +}; + +class TestRadio : public RadioLibWrapper { + bool _poll; +protected: + bool isIRQPending() const override { + return _poll ? _radio->pending : RadioLibWrapper::isIRQPending(); + } + bool isRecvIRQPending() const override { + return _poll ? isInRecvMode() && isIRQPending() : RadioLibWrapper::isRecvIRQPending(); + } + bool isReceivingPacket() override { return false; } +public: + TestRadio(PhysicalLayer& radio, TestBoard& board, bool poll) + : RadioLibWrapper(radio, board), _poll(poll) { } + void setParams(float, float, uint8_t, uint8_t) override { } + float getCurrentRSSI() override { return -100; } +}; + +class RadioCompletion : public ::testing::TestWithParam { }; + +TEST_P(RadioCompletion, ReceiveAndTransmitEachCompleteOnceThenReturnToRX) { + PhysicalLayer phy; + TestBoard board; + TestRadio radio(phy, board, GetParam()); + radio.begin(); + uint8_t bytes[8]; + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_TRUE(radio.isInRecvMode()); + phy.pending = true; + if (!GetParam()) phy.callback(); + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 3); + EXPECT_EQ(bytes[0], 0x12); + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_EQ(radio.getPacketsRecv(), 1U); + ASSERT_TRUE(radio.startSendRaw(bytes, 3)); + EXPECT_FALSE(radio.isSendComplete()); + phy.pending = true; + if (!GetParam()) phy.callback(); + EXPECT_TRUE(radio.isSendComplete()); + EXPECT_FALSE(radio.isSendComplete()); + EXPECT_EQ(radio.getPacketsSent(), 1U); + radio.onSendFinished(); + EXPECT_EQ(board.before, 1); + EXPECT_EQ(board.after, 1); + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_TRUE(radio.isInRecvMode()); +} + +TEST_P(RadioCompletion, PendingRXSurvivesAGCAndCRCFailureRestartsReception) { + PhysicalLayer phy; + TestBoard board; + TestRadio radio(phy, board, GetParam()); + radio.begin(); + radio.resetStats(); + uint8_t bytes[8]; + radio.recvRaw(bytes, sizeof(bytes)); + phy.pending = true; + if (!GetParam()) phy.callback(); + radio.resetAGC(); + EXPECT_EQ(phy.sleeps, 0); + phy.read_error = -7; + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_EQ(radio.getPacketsRecvErrors(), 1U); + EXPECT_TRUE(radio.isInRecvMode()); +} + +TEST_P(RadioCompletion, CADCompletionDoesNotBecomeAPacket) { + PhysicalLayer phy; + TestBoard board; + TestRadio radio(phy, board, GetParam()); + radio.begin(); + radio.setCADEnabled(true); + EXPECT_FALSE(radio.isChannelActive()); + uint8_t bytes[8]; + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_EQ(phy.reads, 0); +} + +TEST_P(RadioCompletion, FailedSendRejectsTXCompletionAndPreservesLatchedRX) { + PhysicalLayer phy; + TestBoard board; + TestRadio radio(phy, board, GetParam()); + radio.begin(); + uint8_t bytes[8]{}; + ASSERT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + ASSERT_TRUE(radio.isInRecvMode()); + phy.tx_error = -1; + ASSERT_FALSE(radio.startSendRaw(bytes, 3)); + ASSERT_FALSE(radio.isInRecvMode()); + // A delayed completion arrives after the failed send has put the wrapper in IDLE. + phy.pending = true; + if (!GetParam()) phy.callback(); + EXPECT_FALSE(radio.isSendComplete()); + EXPECT_EQ(radio.getPacketsSent(), 0U); + EXPECT_EQ(board.after, 1); + // Preserve latched interrupts on existing boards; W10 polling requires active RX. + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), GetParam() ? 0 : 3); + EXPECT_EQ(phy.reads, GetParam() ? 0 : 1); + EXPECT_EQ(radio.getPacketsRecv(), GetParam() ? 0U : 1U); + if (!GetParam()) EXPECT_EQ(bytes[0], 0x12); + EXPECT_TRUE(radio.isInRecvMode()); + EXPECT_EQ(phy.rx_starts, 2); + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_EQ(phy.reads, GetParam() ? 0 : 1); +} + +INSTANTIATE_TEST_SUITE_P(InterruptAndPolling, RadioCompletion, ::testing::Bool()); + +TEST(RadioCompletion, DeepSleepWakeReportsRXAndDeliversTheAlreadyReceivedPacket) { + PhysicalLayer phy; + TestBoard board; + board.startup = BD_STARTUP_RX_PACKET; + TestRadio radio(phy, board, false); + radio.begin(); + EXPECT_TRUE(radio.isInRecvMode()); + uint8_t bytes[8]; + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 3); + EXPECT_EQ(phy.reads, 1); + EXPECT_TRUE(radio.isInRecvMode()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_w10/test_w10.cpp b/test/test_w10/test_w10.cpp new file mode 100644 index 0000000000..7b56d48976 --- /dev/null +++ b/test/test_w10/test_w10.cpp @@ -0,0 +1,302 @@ +#include +#include +#include +#include +#include + +TEST(W10Expander, OutputUpdatePreservesRadioResetAndUsesLatchNotInputs) { + TwoWire wire; + MCP23017 io(wire); + wire.registers[0x14] = 0x08; // radio NRST already high + wire.registers[0x12] = 0xFF; // unrelated input levels must not be written to OLAT + ASSERT_TRUE(io.digitalWrite(1, HIGH)); + EXPECT_EQ(wire.registers[0x14], 0x0A); + ASSERT_TRUE(io.digitalWrite(1, LOW)); + EXPECT_EQ(wire.registers[0x14], 0x08); +} + +TEST(W10Expander, NackAndShortReadCannotClobberOutputBank) { + for (bool short_read : {false, true}) { + TwoWire wire; + MCP23017 io(wire); + wire.registers[0x14] = 0xAA; + wire.nack = !short_read; + wire.short_read = short_read; + EXPECT_FALSE(io.digitalWrite(3, LOW)); + EXPECT_EQ(wire.registers[0x14], 0xAA); + EXPECT_EQ(wire.writes, 0U); + EXPECT_EQ(io.getErrorCount(), 1U); + } +} + +TEST(W10Expander, FailedWriteIsReportedAndCanBeRetried) { + TwoWire wire; + MCP23017 io(wire); + wire.fail_write = true; + EXPECT_FALSE(io.digitalWrite(12, HIGH)); + EXPECT_EQ(io.getErrorCount(), 1U); + wire.fail_write = false; + EXPECT_TRUE(io.digitalWrite(12, HIGH)); + EXPECT_EQ(wire.registers[0x15], 0x10); +} + +TEST(W10Expander, DirectionsAndPullupsAddressTheCorrectBank) { + TwoWire wire; + MCP23017 io(wire); + wire.registers[0x01] = 0xFF; + ASSERT_TRUE(io.pinMode(12, OUTPUT)); + EXPECT_EQ(wire.registers[0x01], 0xEF); + ASSERT_TRUE(io.pinMode(12, INPUT_PULLUP)); + EXPECT_EQ(wire.registers[0x01], 0xFF); + EXPECT_EQ(wire.registers[0x0D], 0x10); + ASSERT_TRUE(io.pinMode(12, INPUT)); + EXPECT_EQ(wire.registers[0x0D], 0); + EXPECT_FALSE(io.pinMode(16, OUTPUT)); + EXPECT_FALSE(io.digitalWrite(255, HIGH)); +} + +TEST(W10Hal, VirtualPinsNeverReachNativeGPIOOrInterruptAPIs) { + TwoWire wire; + MCP23017 io(wire); + SPIClass spi; + MeshnologyW10Hal hal(spi, io); + hal.pinMode(103, OUTPUT); + hal.digitalWrite(103, HIGH); + hal.digitalRead(110); + EXPECT_EQ(hal.pinToInterrupt(109), RADIOLIB_NC); + hal.attachInterrupt(hal.pinToInterrupt(109), nullptr, 1); + hal.detachInterrupt(hal.pinToInterrupt(109)); + hal.attachInterrupt(109, nullptr, 1); + EXPECT_EQ(hal.native_calls, 0U); + hal.digitalWrite(14, HIGH); + EXPECT_EQ(hal.native_calls, 1U); + EXPECT_EQ(hal.last_pin, 14U); +} + +TEST(W10Hal, FailedReadsKeepBusyHighAndCompletionLow) { + TwoWire wire; + MCP23017 io(wire); + SPIClass spi; + MeshnologyW10Hal hal(spi, io); + wire.nack = true; + EXPECT_EQ(hal.digitalRead(110), HIGH); + EXPECT_EQ(hal.digitalRead(109), LOW); + wire.nack = false; + wire.registers[0x13] = 0x02; + EXPECT_EQ(hal.digitalRead(110), LOW); + EXPECT_EQ(hal.digitalRead(109), HIGH); +} + +class W10RadioTest : public ::testing::Test { +protected: + class Board : public mesh::MainBoard { + public: + uint16_t getBattMilliVolts() override { return 4200; } + const char* getManufacturerName() const override { return "test"; } + void reboot() override { } + uint8_t getStartupReason() const override { return BD_STARTUP_NORMAL; } + } board; + TwoWire wire; + MCP23017 io{wire}; + CustomSX1262 chip; + MeshnologyW10Radio radio{chip, board, io}; + + void SetUp() override { + g_mock_millis = 0; + radio.begin(); + radio.setCADEnabled(true); + } +}; + +TEST_F(W10RadioTest, LateCompletionAfterFailedSendDoesNotBecomeAPacket) { + uint8_t bytes[8]{}; + ASSERT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + chip.tx_error = -1; + ASSERT_FALSE(radio.startSendRaw(bytes, 3)); + ASSERT_FALSE(radio.isInRecvMode()); + wire.registers[0x13] = 0x02; // DIO1 asserts after the failed send. + EXPECT_FALSE(radio.isSendComplete()); + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_EQ(chip.reads, 0); + EXPECT_EQ(radio.getPacketsRecv(), 0U); + EXPECT_EQ(radio.getPacketsSent(), 0U); + EXPECT_TRUE(radio.isInRecvMode()); + EXPECT_EQ(chip.rx_starts, 2); + wire.registers[0x13] = 0; // startReceive clears the hardware IRQ. + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + wire.registers[0x13] = 0x02; // A new packet completes in RX. + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 3); + EXPECT_EQ(chip.reads, 1); + EXPECT_EQ(radio.getPacketsRecv(), 1U); +} + +TEST_F(W10RadioTest, CADReadFailuresReturnBusyAndReceptionCanRecover) { + for (bool short_read : {false, true}) { + wire.nack = !short_read; + wire.short_read = short_read; + EXPECT_TRUE(radio.isChannelActive()); + EXPECT_EQ(chip.scan_results, 0U); + EXPECT_LT(g_mock_millis, 5000U); + wire.nack = wire.short_read = false; + wire.registers[0x13] = 0x02; // DIO1: scan completed + EXPECT_FALSE(radio.isChannelActive()); + EXPECT_TRUE(radio.isInRecvMode()); + wire.registers[0x13] = 0; + chip.scan_results = 0; + } +} + +TEST_F(W10RadioTest, MissingCADCompletionTimesOutAcrossMillisWrap) { + for (uint32_t start : {0U, UINT32_MAX - 100U}) { + g_mock_millis = start; + EXPECT_TRUE(radio.isChannelActive()); + EXPECT_EQ(uint32_t(g_mock_millis - start), 5000U); + EXPECT_EQ(chip.scan_results, 0U); + EXPECT_TRUE(radio.isInRecvMode()); + } +} + +TEST_F(W10RadioTest, FailedCADStartReturnsBusyWithoutReadingResult) { + chip.scan_start_error = RADIOLIB_ERR_SPI_CMD_FAILED; + EXPECT_TRUE(radio.isChannelActive()); + EXPECT_EQ(chip.scan_results, 0U); + EXPECT_EQ(g_mock_millis, 0U); + EXPECT_TRUE(radio.isInRecvMode()); +} + +TEST_F(W10RadioTest, CompletedCADPreservesResultAndDoesNotDeliverAPacket) { + wire.registers[0x13] = 0x02; + for (int16_t result : {RADIOLIB_CHANNEL_FREE, RADIOLIB_LORA_DETECTED, + RADIOLIB_ERR_SPI_CMD_FAILED}) { + chip.scan_result = result; + EXPECT_EQ(radio.isChannelActive(), result != RADIOLIB_CHANNEL_FREE); + EXPECT_TRUE(radio.isInRecvMode()); + } + EXPECT_EQ(chip.scan_results, 3U); + wire.registers[0x13] = 0; // startReceive clears the hardware IRQ + uint8_t bytes[8]; + EXPECT_EQ(radio.recvRaw(bytes, sizeof(bytes)), 0); + EXPECT_EQ(chip.reads, 0); +} + +class TestClock : public mesh::RTCClock { +public: + uint32_t time = 1234; + uint32_t getCurrentTime() override { return time; } + void setCurrentTime(uint32_t value) override { time = value; } +}; + +static void setRtcRegisters(TwoWire& wire) { + // 2024-02-29 12:34:56 UTC, Thursday + const uint8_t data[] = {0x56, 0x34, 0x12, 0x29, 0x04, 0x02, 0x24}; + std::copy(std::begin(data), std::end(data), wire.registers.begin() + 4); +} + +TEST(W10RTC, SeedsSystemClockFromValidPCF85063Time) { + TwoWire wire; + TestClock clock; + MeshnologyW10RTC rtc(clock, wire); + setRtcRegisters(wire); + ASSERT_TRUE(rtc.begin()); + EXPECT_EQ(rtc.getCurrentTime(), 1709210096U); + EXPECT_EQ(wire.writes, 0U); +} + +TEST(W10RTC, RejectsLostPowerStoppedClockAndInvalidCalendar) { + for (int fault = 0; fault < 8; fault++) { + TwoWire wire; + TestClock clock; + MeshnologyW10RTC rtc(clock, wire); + setRtcRegisters(wire); + switch (fault) { + case 0: wire.registers[4] |= 0x80; break; + case 1: wire.registers[0] = 0x20; break; + case 2: wire.registers[0] = 0x02; break; + case 3: wire.registers[7] = 0x30; break; // February 30 + case 4: wire.registers[5] = 0x1A; break; // Invalid BCD + case 5: wire.nack = true; break; + case 6: wire.short_read = true; break; + case 7: wire.registers[10] = 0x23; break; // February 29 in a non-leap year + } + EXPECT_FALSE(rtc.begin()); + EXPECT_EQ(rtc.getCurrentTime(), 1234U); + } +} + +TEST(W10RTC, AcceptsSupportedCalendarEndpoints) { + const uint8_t dates[][7] = { + {0x00, 0x00, 0x00, 0x01, 0x06, 0x01, 0x00}, // 2000-01-01 + {0x59, 0x59, 0x23, 0x31, 0x04, 0x12, 0x99}, // 2099-12-31 + }; + const uint32_t expected[] = {946684800U, 4102444799U}; + for (unsigned i = 0; i < 2; ++i) { + TwoWire wire; + TestClock clock; + MeshnologyW10RTC rtc(clock, wire); + std::copy(std::begin(dates[i]), std::end(dates[i]), wire.registers.begin() + 4); + ASSERT_TRUE(rtc.begin()); + EXPECT_EQ(clock.time, expected[i]); + } +} + +TEST(W10RTC, RejectsOutOfRangeFieldsBeforeCallingCalendarLibrary) { + const uint8_t invalid[][2] = { + {4, 0x60}, {4, 0x79}, // seconds + {5, 0x60}, {5, 0x79}, // minutes + {6, 0x24}, {6, 0x39}, // hours + {7, 0x00}, {7, 0x32}, {7, 0x39}, // day + {8, 0x07}, // weekday + {9, 0x00}, {9, 0x13}, {9, 0x19}, // month -- 0x19 would be a table overrun in RTCLib + }; + for (const auto& field : invalid) { + SCOPED_TRACE(::testing::Message() << "register=" << unsigned(field[0]) + << " value=" << unsigned(field[1])); + TwoWire wire; + TestClock clock; + MeshnologyW10RTC rtc(clock, wire); + setRtcRegisters(wire); + wire.registers[field[0]] = field[1]; + const unsigned constructions = DateTime::component_constructions; + EXPECT_FALSE(rtc.begin()); + EXPECT_EQ(DateTime::component_constructions, constructions); + EXPECT_EQ(clock.time, 1234U); + EXPECT_EQ(wire.writes, 0U); + } +} + +TEST(W10RTC, SettingTimeClearsLostPowerAndRestartsIn24HourMode) { + TwoWire wire; + TestClock clock; + MeshnologyW10RTC rtc(clock, wire); + setRtcRegisters(wire); + wire.registers[0] = 0x02; + wire.registers[4] = 0x80; + EXPECT_FALSE(rtc.begin()); + rtc.setCurrentTime(1709210096U); + EXPECT_EQ(clock.time, 1709210096U); + EXPECT_EQ(wire.registers[0] & 0x22, 0); + const uint8_t expected[] = {0x56, 0x34, 0x12, 0x29, 0x04, 0x02, 0x24}; + for (unsigned i = 0; i < sizeof(expected); i++) EXPECT_EQ(wire.registers[4+i], expected[i]); +} + +TEST(W10RTC, FailedTimeWriteKeepsSoftwareTimeAndMarksHardwareTimeUnusable) { + TwoWire wire; + TestClock clock; + MeshnologyW10RTC rtc(clock, wire); + setRtcRegisters(wire); + ASSERT_TRUE(rtc.begin()); + wire.fail_write_register = 4; + rtc.setCurrentTime(1789510000U); + EXPECT_EQ(rtc.getCurrentTime(), 1789510000U); + EXPECT_EQ(wire.registers[0] & 0x20, 0x20); + wire.fail_write_register = -1; + TestClock next_clock; + MeshnologyW10RTC next_boot(next_clock, wire); + EXPECT_FALSE(next_boot.begin()); + EXPECT_EQ(next_clock.time, 1234U); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/meshnology_w10/MCP23017.cpp b/variants/meshnology_w10/MCP23017.cpp new file mode 100644 index 0000000000..4ccd41071d --- /dev/null +++ b/variants/meshnology_w10/MCP23017.cpp @@ -0,0 +1,71 @@ +#include "MCP23017.h" +#include + +namespace { +constexpr uint8_t MCP23017_IODIRA_REG = 0x00; +constexpr uint8_t MCP23017_GPINTENA_REG = 0x04; +constexpr uint8_t MCP23017_GPINTENB_REG = 0x05; +constexpr uint8_t MCP23017_IOCON_REG = 0x0A; +constexpr uint8_t MCP23017_GPPUA_REG = 0x0C; +constexpr uint8_t MCP23017_GPIOA_REG = 0x12; +constexpr uint8_t MCP23017_OLATA_REG = 0x14; +constexpr uint8_t MCP23017_IOCON_BANK1_REG = 0x05; +} + +bool MCP23017::readRegister(uint8_t reg, uint8_t& value) { + _wire.beginTransmission(_address); + _wire.write(reg); + if (_wire.endTransmission() == 0 && _wire.requestFrom(_address, (uint8_t)1) == 1) { + int data = _wire.read(); + if (data >= 0) { + value = data; + return true; + } + } + _errors++; + MESH_DEBUG_PRINTLN("W10: MCP23017 read failed at register 0x%02x", reg); + return false; +} + +bool MCP23017::writeRegister(uint8_t reg, uint8_t value) { + _wire.beginTransmission(_address); + _wire.write(reg); + _wire.write(value); + if (_wire.endTransmission() == 0) return true; + _errors++; + MESH_DEBUG_PRINTLN("W10: MCP23017 write failed at register 0x%02x", reg); + return false; +} + +bool MCP23017::updateRegister(uint8_t reg, uint8_t mask, bool set) { + uint8_t value; + if (!readRegister(reg, value)) return false; + return writeRegister(reg, set ? value | mask : value & ~mask); +} + +bool MCP23017::begin() { + // BANK=1 IOCON aliases BANK=0 GPINTENB; safe to clear with /INT unconnected + return writeRegister(MCP23017_IOCON_BANK1_REG, 0) && writeRegister(MCP23017_IOCON_REG, 0) + && writeRegister(MCP23017_GPINTENA_REG, 0) && writeRegister(MCP23017_GPINTENB_REG, 0); +} + +bool MCP23017::pinMode(uint8_t pin, uint8_t mode) { + if (pin >= PIN_COUNT || (mode != INPUT && mode != INPUT_PULLUP && mode != OUTPUT)) return false; + uint8_t bank = pin / 8; + uint8_t mask = 1U << (pin % 8); + return updateRegister(MCP23017_GPPUA_REG + bank, mask, mode == INPUT_PULLUP) + && updateRegister(MCP23017_IODIRA_REG + bank, mask, mode != OUTPUT); +} + +bool MCP23017::digitalWrite(uint8_t pin, uint8_t value) { + if (pin >= PIN_COUNT) return false; + return updateRegister(MCP23017_OLATA_REG + pin / 8, 1U << (pin % 8), value != LOW); +} + +bool MCP23017::digitalRead(uint8_t pin, uint8_t& value) { + if (pin >= PIN_COUNT) return false; + uint8_t data; + if (!readRegister(MCP23017_GPIOA_REG + pin / 8, data)) return false; + value = (data & (1U << (pin % 8))) ? HIGH : LOW; + return true; +} diff --git a/variants/meshnology_w10/MCP23017.h b/variants/meshnology_w10/MCP23017.h new file mode 100644 index 0000000000..ed0bfd41ee --- /dev/null +++ b/variants/meshnology_w10/MCP23017.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +class MCP23017 { + TwoWire& _wire; + uint8_t _address; + uint32_t _errors = 0; + + bool readRegister(uint8_t reg, uint8_t& value); + bool writeRegister(uint8_t reg, uint8_t value); + bool updateRegister(uint8_t reg, uint8_t mask, bool set); + +public: + static constexpr uint8_t PIN_COUNT = 16; // GPA0..GPA7, GPB0..GPB7 + + MCP23017(TwoWire& wire, uint8_t address = 0x20) : _wire(wire), _address(address) { } + + bool begin(); + bool pinMode(uint8_t pin, uint8_t mode); + bool digitalWrite(uint8_t pin, uint8_t value); + bool digitalRead(uint8_t pin, uint8_t& value); + uint32_t getErrorCount() const { return _errors; } +}; + +// Board and RadioLib code address expander lines as virtual pins EXPANDER_PIN_BASE + n, +// n in [0, MCP23017::PIN_COUNT); MeshnologyW10Hal routes those to the driver. +constexpr uint8_t EXPANDER_PIN_BASE = 100; +constexpr bool isExpanderVirtualPin(uint32_t pin) { + return pin >= EXPANDER_PIN_BASE && pin < static_cast(EXPANDER_PIN_BASE + MCP23017::PIN_COUNT); +} +constexpr uint8_t expanderPin(uint32_t pin) { return static_cast(pin - EXPANDER_PIN_BASE); } diff --git a/variants/meshnology_w10/MeshnologyW10Board.cpp b/variants/meshnology_w10/MeshnologyW10Board.cpp new file mode 100644 index 0000000000..75b4f69063 --- /dev/null +++ b/variants/meshnology_w10/MeshnologyW10Board.cpp @@ -0,0 +1,75 @@ +#include "MeshnologyW10Board.h" +#include "target.h" +#include + +void MeshnologyW10Board::begin() { + // Deselect radio, display and the LCD expansion connector's SD chip select. + digitalWrite(P_LORA_NSS, HIGH); + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(PIN_TFT_CS, HIGH); + pinMode(PIN_TFT_CS, OUTPUT); + digitalWrite(PIN_SD_CS, HIGH); + pinMode(PIN_SD_CS, OUTPUT); + digitalWrite(PIN_TFT_LEDA_CTL, LOW); + pinMode(PIN_TFT_LEDA_CTL, OUTPUT); + pinMode(PIN_USER_BTN, INPUT_PULLUP); + + ESP32Board::begin(); + SPI.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS); + _pmu_ready = _pmu.begin(Wire, AXP2101_SLAVE_ADDRESS, PIN_BOARD_SDA, PIN_BOARD_SCL); + if (!_pmu_ready) { + Serial.println("ERROR: W10 AXP2101 not found at 0x34"); + return; + } + // DCDC1 supplies MCU, radio, GPS, display and expander, never cycle it during initialization + _pmu.setProtectedChannel(XPOWERS_DCDC1); + _pmu.clearIrqStatus(); + // XPowersLib disables TS during begin(), but the W10 has a populated NTC: + if (!_pmu.disableIRQ(XPOWERS_AXP2101_ALL_IRQ) + || !_pmu.enableBattDetection() || !_pmu.enableBattVoltageMeasure() + || !_pmu.enableVbusVoltageMeasure() || !_pmu.enableTSPinMeasure() + || !_pmu.setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA) + || !_pmu.setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2) + || !_pmu.setPowerKeyPressOffTime(XPOWERS_POWEROFF_4S)) { + Serial.println("ERROR: W10 AXP2101 power configuration failed"); + return; + } + _pmu.setChargingLedMode(XPOWERS_CHG_LED_CTRL_CHG); + + _ready = io.begin() + && io.digitalWrite(EXIO_LORA_RESET, HIGH) && io.pinMode(EXIO_LORA_RESET, OUTPUT) + && io.pinMode(EXIO_LORA_DIO1, INPUT) && io.pinMode(EXIO_LORA_BUSY, INPUT) + && io.digitalWrite(EXIO_AMP_ENABLE, LOW) && io.pinMode(EXIO_AMP_ENABLE, OUTPUT) +#if ENV_INCLUDE_GPS + && io.digitalWrite(EXIO_GPS_WAKE, HIGH) +#else + && io.digitalWrite(EXIO_GPS_WAKE, LOW) +#endif + && io.pinMode(EXIO_GPS_WAKE, OUTPUT) + && io.digitalWrite(EXIO_LCD_RESET, LOW) && io.pinMode(EXIO_LCD_RESET, OUTPUT); +#ifdef DISPLAY_CLASS + if (_ready) { + delay(10); + _ready = io.digitalWrite(EXIO_LCD_RESET, HIGH); + delay(20); + } +#endif + if (!_ready) Serial.println("ERROR: W10 MCP23017 initialization failed at 0x20"); +} + +uint16_t MeshnologyW10Board::getBattMilliVolts() { + return _pmu_ready && _pmu.isBatteryConnect() ? _pmu.getBattVoltage() : 0; +} + +void MeshnologyW10Board::powerOff() { +#ifdef DISPLAY_CLASS + display.turnOff(); +#endif + radio_driver.powerOff(); +#if ENV_INCLUDE_GPS + sensors.getLocationProvider()->stop(); +#endif + Serial.flush(); + if (_pmu_ready) _pmu.shutdown(); + ESP32Board::powerOff(); +} diff --git a/variants/meshnology_w10/MeshnologyW10Board.h b/variants/meshnology_w10/MeshnologyW10Board.h new file mode 100644 index 0000000000..ff41c5ba33 --- /dev/null +++ b/variants/meshnology_w10/MeshnologyW10Board.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include "MCP23017.h" + +class MeshnologyW10Board : public ESP32Board { + XPowersAXP2101 _pmu; + bool _pmu_ready = false; + bool _ready = false; + +public: + static constexpr uint8_t PIN_SD_CS = 9; + static constexpr uint8_t EXIO_LCD_RESET = 1; + static_assert(isExpanderVirtualPin(P_LORA_RESET) && isExpanderVirtualPin(P_LORA_DIO_1) && isExpanderVirtualPin(P_LORA_BUSY), + "SX1262 RESET, DIO1 and BUSY are MCP23017 lines and must be given as virtual pins"); + static constexpr uint8_t EXIO_LORA_RESET = expanderPin(P_LORA_RESET); + static constexpr uint8_t EXIO_LORA_DIO1 = expanderPin(P_LORA_DIO_1); + static constexpr uint8_t EXIO_LORA_BUSY = expanderPin(P_LORA_BUSY); + static constexpr uint8_t EXIO_AMP_ENABLE = 7; + static constexpr uint8_t EXIO_GPS_WAKE = 12; + + MCP23017 io; + + MeshnologyW10Board() : io(Wire) { } + void begin(); + bool isReady() const { return _ready; } + uint16_t getBattMilliVolts() override; + bool isExternalPowered() override { return _pmu_ready && _pmu.isVbusIn(); } + const char* getManufacturerName() const override { return "Meshnology W10"; } + uint32_t getIRQGpio() override { return (uint32_t)-1; } + void sleep(uint32_t secs) override { delay(1); } // Radio DIO1 can't wake MCU so no auto light sleep + void powerOff() override; +}; diff --git a/variants/meshnology_w10/MeshnologyW10Hal.h b/variants/meshnology_w10/MeshnologyW10Hal.h new file mode 100644 index 0000000000..19102aaa73 --- /dev/null +++ b/variants/meshnology_w10/MeshnologyW10Hal.h @@ -0,0 +1,48 @@ +#pragma once + +#include "MCP23017.h" +#include + +class MeshnologyW10Hal : public ArduinoHal { + MCP23017& _io; + +public: + MeshnologyW10Hal(SPIClass& spi, MCP23017& io) + : ArduinoHal(spi, SPISettings(8000000, MSBFIRST, SPI_MODE0)), _io(io) { } + + void pinMode(uint32_t pin, uint32_t mode) override { + if (isExpanderVirtualPin(pin)) { + _io.pinMode(expanderPin(pin), mode); + } else { + ArduinoHal::pinMode(pin, mode); + } + } + + void digitalWrite(uint32_t pin, uint32_t value) override { + if (isExpanderVirtualPin(pin)) { + _io.digitalWrite(expanderPin(pin), value); + } else { + ArduinoHal::digitalWrite(pin, value); + } + } + + uint32_t digitalRead(uint32_t pin) override { + if (!isExpanderVirtualPin(pin)) return ArduinoHal::digitalRead(pin); + uint8_t value; + if (_io.digitalRead(expanderPin(pin), value)) return value; + // Failed reads cannot release BUSY or fabricate a DIO1 completion, including blocking CAD + return pin == P_LORA_BUSY ? HIGH : LOW; + } + + uint32_t pinToInterrupt(uint32_t pin) override { + return isExpanderVirtualPin(pin) ? RADIOLIB_NC : ArduinoHal::pinToInterrupt(pin); + } + + void attachInterrupt(uint32_t pin, void (*callback)(), uint32_t mode) override { + if (pin != RADIOLIB_NC && !isExpanderVirtualPin(pin)) ArduinoHal::attachInterrupt(pin, callback, mode); + } + + void detachInterrupt(uint32_t pin) override { + if (pin != RADIOLIB_NC && !isExpanderVirtualPin(pin)) ArduinoHal::detachInterrupt(pin); + } +}; diff --git a/variants/meshnology_w10/MeshnologyW10RTC.h b/variants/meshnology_w10/MeshnologyW10RTC.h new file mode 100644 index 0000000000..863643c7f6 --- /dev/null +++ b/variants/meshnology_w10/MeshnologyW10RTC.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include + +// PCF85063A at I2C address 0x51, seeding and persisting the wrapped system clock. +// AutoDiscoverRTCClock probes 0x51 as a PCF8563 (RTClib RTC_PCF8563), whose register map +// differs (seconds at 0x02 versus 0x04 here), so this board selects the chip explicitly. +class MeshnologyW10RTC : public mesh::RTCClock { + static constexpr uint8_t ADDRESS = 0x51; + + mesh::RTCClock& _clock; + TwoWire& _wire; + bool _present = false; + + bool read(uint8_t reg, uint8_t* data, uint8_t len) { + _wire.beginTransmission(ADDRESS); + _wire.write(reg); + if (_wire.endTransmission() != 0 || _wire.requestFrom(ADDRESS, len) != len) return false; + for (uint8_t i = 0; i < len; i++) { + int value = _wire.read(); + if (value < 0) return false; + data[i] = value; + } + return true; + } + + bool write(uint8_t reg, const uint8_t* data, uint8_t len) { + _wire.beginTransmission(ADDRESS); + _wire.write(reg); + _wire.write(data, len); + return _wire.endTransmission() == 0; + } + + static uint8_t bcd(uint8_t value) { return (value / 10) * 16 + value % 10; } + static uint8_t decimal(uint8_t value) { return (value >> 4) * 10 + (value & 15); } + +public: + MeshnologyW10RTC(mesh::RTCClock& clock, TwoWire& wire) : _clock(clock), _wire(wire) { } + + bool begin() { + uint8_t control, data[7]; + _present = read(0, &control, 1) && read(4, data, sizeof(data)); + // Reject state left by another application + if (!_present || (control & 0x22) || (data[0] & 0x80)) return false; + const uint8_t masks[] = {0x7F, 0x7F, 0x3F, 0x3F, 0x07, 0x1F, 0xFF}; + for (uint8_t i = 0; i < sizeof(data); i++) { + data[i] &= masks[i]; + if ((data[i] & 15) > 9 || (data[i] >> 4) > 9) return false; + data[i] = decimal(data[i]); + } + // Pre-validate field ranges. RTCLib unsafely indexes a month table + if (data[0] > 59 || data[1] > 59 || data[2] > 23 || data[3] < 1 || data[3] > 31 + || data[4] > 6 || data[5] < 1 || data[5] > 12) return false; + DateTime dt(2000 + data[6], data[5], data[3], data[2], data[1], data[0]); + if (!dt.isValid()) return false; + _clock.setCurrentTime(dt.unixtime()); + return true; + } + + uint32_t getCurrentTime() override { return _clock.getCurrentTime(); } + void tick() override { _clock.tick(); } + + void setCurrentTime(uint32_t time) override { + _clock.setCurrentTime(time); + if (!_present) return; + DateTime dt(time); + if (dt.year() < 2000 || dt.year() > 2099) return; + uint8_t control; + if (!read(0, &control, 1)) { + MESH_DEBUG_PRINTLN("W10: PCF85063 control read failed"); + return; + } + control = (control & ~0x02) | 0x20; // Stop counters while updating and use 24-hour mode + uint8_t data[] = {bcd(dt.second()), bcd(dt.minute()), bcd(dt.hour()), bcd(dt.day()), + dt.dayOfTheWeek(), bcd(dt.month()), bcd(dt.year() - 2000)}; + if (!write(0, &control, 1) || !write(4, data, sizeof(data))) { + MESH_DEBUG_PRINTLN("W10: PCF85063 time write failed; using system clock"); + return; // Leave STOP set after a partial write so the next boot can't trust it + } + control &= ~0x20; + if (!write(0, &control, 1)) MESH_DEBUG_PRINTLN("W10: PCF85063 restart failed"); + } +}; diff --git a/variants/meshnology_w10/MeshnologyW10Radio.h b/variants/meshnology_w10/MeshnologyW10Radio.h new file mode 100644 index 0000000000..af829473a7 --- /dev/null +++ b/variants/meshnology_w10/MeshnologyW10Radio.h @@ -0,0 +1,44 @@ +#pragma once + +#include "MCP23017.h" +#include + +class MeshnologyW10Radio : public CustomSX1262Wrapper { + static_assert(isExpanderVirtualPin(P_LORA_DIO_1), "SX1262 DIO1 is an MCP23017 line and must be given as a virtual pin"); + static constexpr uint8_t EXIO_LORA_DIO1 = expanderPin(P_LORA_DIO_1); + + MCP23017& _io; + +protected: + bool isIRQPending() const override { + uint8_t value; + return _io.digitalRead(EXIO_LORA_DIO1, value) && value == HIGH; + } + + bool isRecvIRQPending() const override { + // Qualify the polled level without changing latched IRQ handling on other boards. + return isInRecvMode() && isIRQPending(); + } + +public: + int16_t performChannelScan() override { + auto* radio = static_cast(_radio); + int16_t result = radio->startChannelScan(); + if (result != RADIOLIB_ERR_NONE) return result; + + // RadioLib scan blocks with no DIO1 timeout -- don't wait forever on failed IO expander/radio + constexpr uint32_t CAD_TIMEOUT_MS = 5000; + uint32_t started = millis(); + while (uint32_t(millis() - started) < CAD_TIMEOUT_MS) { + uint8_t value; + if (!_io.digitalRead(EXIO_LORA_DIO1, value)) return RADIOLIB_ERR_SPI_CMD_FAILED; + if (value == HIGH) return radio->getChannelScanResult(); + delay(1); + } + // isChannelActive() treats errors as busy and restarts reception + return RADIOLIB_ERR_RX_TIMEOUT; + } + + MeshnologyW10Radio(CustomSX1262& radio, mesh::MainBoard& board, MCP23017& io) + : CustomSX1262Wrapper(radio, board), _io(io) { } +}; diff --git a/variants/meshnology_w10/platformio.ini b/variants/meshnology_w10/platformio.ini new file mode 100644 index 0000000000..8b620017e2 --- /dev/null +++ b/variants/meshnology_w10/platformio.ini @@ -0,0 +1,128 @@ +[meshnology_w10] +extends = esp32_base +board = esp32-s3-devkitc-1 +board_build.partitions = default_16MB.csv +board_upload.flash_size = 16MB +board_build.flash_mode = qio +board_build.psram_type = opi +board_build.arduino.memory_type = qio_opi +build_flags = + ${esp32_base.build_flags} + -I variants/meshnology_w10 + -D MESHNOLOGY_W10 + -D BOARD_HAS_PSRAM + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D ARDUINO_USB_MODE=1 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=MeshnologyW10Radio + -D P_LORA_SCLK=12 + -D P_LORA_MOSI=13 + -D P_LORA_MISO=11 + -D P_LORA_NSS=14 + ; Virtual pins 100..115 map to MCP23017 GPA0..GPB7 (EXPANDER_PIN_BASE in MCP23017.h). + ; [env:native_w10] in the root platformio.ini mirrors P_LORA_DIO_1 and P_LORA_BUSY. + -D P_LORA_RESET=103 + -D P_LORA_DIO_1=109 + -D P_LORA_BUSY=110 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=0.0f + ; Match Meshtastic's SX1262 PA current limit; RadioLib defaults to 60 mA. + -D SX126X_CURRENT_LIMIT=140 + -D LORA_TX_POWER=22 + -D MAX_LORA_TX_POWER=22 + -D PIN_BOARD_SDA=8 + -D PIN_BOARD_SCL=7 + -D PIN_USER_BTN=0 + ; EnvironmentSensorManager names these from the GPS module's perspective. + -D PIN_GPS_TX=44 + -D PIN_GPS_RX=43 + -D GPS_BAUD_RATE=9600 + -D ENV_INCLUDE_GPS=1 + -D ENV_INCLUDE_SHT4X=1 + ; Headless builds: remove DISPLAY_CLASS with build_unflags and exclude + ; helpers/ui/ST7789LCDDisplay.cpp and companion_radio/ui-new/*.cpp from build_src_filter. + -D DISPLAY_CLASS=ST7789LCDDisplay + ; The panel shares the radio SPI bus (P_LORA_SCLK/MOSI/MISO); its reset is EXIO_LCD_RESET on the expander. + -D PIN_TFT_CS=10 + -D PIN_TFT_DC=16 + -D PIN_TFT_RST=-1 + -D PIN_TFT_LEDA_CTL=6 + -D DISPLAY_WIDTH=240 + -D DISPLAY_HEIGHT=240 + -D DISPLAY_ROTATION=1 + -D DISPLAY_SCALE_X=1.875f + -D DISPLAY_SCALE_Y=3.75f +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/meshnology_w10> + + + + +lib_deps = + ${esp32_base.lib_deps} + lewisxhe/XPowersLib @ ^0.2.7 + adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + stevemarple/MicroNMEA @ ^2.0.6 + sensirion/Sensirion I2C SHT4x @ ^1.1.2 + +[env:meshnology_w10_companion_radio_usb] +extends = meshnology_w10 +build_flags = + ${meshnology_w10.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE +build_src_filter = ${meshnology_w10.build_src_filter} + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${meshnology_w10.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w10_companion_radio_ble] +extends = meshnology_w10 +build_flags = + ${meshnology_w10.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D BLE_PIN_CODE=123456 ; random with display, fixed default without +build_src_filter = ${meshnology_w10.build_src_filter} + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + + +lib_deps = + ${meshnology_w10.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w10_repeater] +extends = meshnology_w10 +build_flags = + ${meshnology_w10.build_flags} + -D ADVERT_NAME='"Meshnology W10 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${meshnology_w10.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${meshnology_w10.lib_deps} + ${esp32_ota.lib_deps} + +[env:meshnology_w10_room_server] +extends = meshnology_w10 +build_flags = + ${meshnology_w10.build_flags} + -D ADVERT_NAME='"Meshnology W10 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${meshnology_w10.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${meshnology_w10.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/meshnology_w10/target.cpp b/variants/meshnology_w10/target.cpp new file mode 100644 index 0000000000..dfcbf65818 --- /dev/null +++ b/variants/meshnology_w10/target.cpp @@ -0,0 +1,65 @@ +#include "target.h" +#include "MeshnologyW10Hal.h" +#if ENV_INCLUDE_GPS +#include +#endif + +MeshnologyW10Board board; +static MeshnologyW10Hal radio_hal(SPI, board.io); +static Module radio_module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +static RADIO_CLASS radio(&radio_module); +WRAPPER_CLASS radio_driver(radio, board, board.io); + +static ESP32RTCClock fallback_clock; +MeshnologyW10RTC rtc_clock(fallback_clock, Wire); + +#if ENV_INCLUDE_GPS +class W10LocationProvider : public MicroNMEALocationProvider { + bool _enabled = false; +public: + W10LocationProvider() : MicroNMEALocationProvider(Serial1, &rtc_clock) { } + void begin() override { + if (board.io.digitalWrite(MeshnologyW10Board::EXIO_GPS_WAKE, HIGH)) { + MicroNMEALocationProvider::begin(); + _enabled = true; + } + } + void stop() override { + if (board.io.digitalWrite(MeshnologyW10Board::EXIO_GPS_WAKE, LOW)) { + MicroNMEALocationProvider::stop(); + _enabled = false; + } + } + bool isEnabled() override { return _enabled; } +}; + +static W10LocationProvider nmea; +EnvironmentSensorManager sensors(nmea); +#else +EnvironmentSensorManager sensors; +#endif +#ifdef DISPLAY_CLASS +DISPLAY_CLASS display; +MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + if (!board.isReady()) { + Serial.println("ERROR: W10 radio init aborted: board power/expander setup failed"); + return false; + } + fallback_clock.begin(); + if (!rtc_clock.begin()) Serial.println("W10: RTC time unavailable; waiting for GPS/client time"); + uint32_t errors = board.io.getErrorCount(); + bool initialized = radio.std_init(); + if (board.io.getErrorCount() != errors) { + Serial.println("ERROR: W10 radio init failed: MCP23017 I2C error at 0x20"); + return false; + } + return initialized; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/meshnology_w10/target.h b/variants/meshnology_w10/target.h new file mode 100644 index 0000000000..0e824857f5 --- /dev/null +++ b/variants/meshnology_w10/target.h @@ -0,0 +1,22 @@ +#pragma once + +#include "MeshnologyW10Board.h" +#include "MeshnologyW10Radio.h" +#include "MeshnologyW10RTC.h" +#include +#ifdef DISPLAY_CLASS +#include +#include +#endif + +extern MeshnologyW10Board board; +extern WRAPPER_CLASS radio_driver; +extern MeshnologyW10RTC rtc_clock; +extern EnvironmentSensorManager sensors; +#ifdef DISPLAY_CLASS +extern DISPLAY_CLASS display; +extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity();