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();