Files
meshtastic_firmware/src/platform/esp32/ExtensionIOMCP23017.cpp
T
Ben MeadorsandGitHub 97302f1845 Add Meshnology W10 AIOT Dev Kit (SX1262 via MCP23017 I2C expander) (#10911)
* Add software LoRa IRQ polling and an MCP23017 expander HAL for radios behind an I2C expander

Some boards route the SX126x control lines (NRESET/DIO1/BUSY) through an I2C
GPIO expander instead of native GPIOs, and don't wire the expander's /INT to
the MCU, so RadioLib cannot take a hardware DIO1 interrupt.

Two reusable pieces, both gated behind USE_MCP23017 / LORA_DIO1_SOFTWARE_POLL
so builds that don't define them are unaffected:

- ExtensionIOMCP23017 + MCP23017LockingArduinoHal: a RadioLib HAL that maps
  virtual pins (MCP23017_VPIN_BASE .. +15) onto an MCP23017's GPIO, so
  RESET/BUSY/DIO1 reads and writes become I2C transactions while the real SPI
  GPIOs (SCK/MOSI/MISO/CS) pass straight through. A failed I2C read skips the
  read-modify-write rather than clobbering the rest of the bank.
- LORA_DIO1_SOFTWARE_POLL: with no DIO1 interrupt available, the SX126x
  interface polls the radio IRQ status register from the radio thread and
  synthesizes the ISR_TX/ISR_RX events, filtering noisy preamble/header IRQs so
  they can't starve TX. A 1 ms ISR_POLL_TICK drives the poll; TX timers may
  overwrite the pending tick (pollMissedIrqs() is the backup that bounds the
  latency of the busy-Rx contention window). Behaviour is unchanged on all
  boards that keep the hardware interrupt.

* Add Meshnology W10 AIOT Dev Kit variant (SX1262 via MCP23017 expander)

ESP32-S3R8 + EBYTE E22-900MM22S (SX1262) + AXP2101 PMIC + Quectel L76KB GPS +
SPI TFT, with the radio's RESET/DIO1/BUSY and the LCD reset routed through an
MCP23017 I2C expander at 0x20. The expander's /INT is not wired to the ESP32,
so DIO1 uses the software poll. Pins come from the board schematic and the
vendor firmware; the expander is an MCP23017 despite the V1.1 schematic's stale
'TCA9555' label (the V1.2 placement diagram and all vendor code use the MCP23017
register map).

A local pins_arduino.h shadows the generic esp32s3 variant to drop RGB_BUILTIN,
which otherwise pulls the Arduino RMT HAL into the link and fails against this
build's trimmed FreeRTOS config.

Reports HardwareModel 140 (MESHNOLOGY_W10, already present in the protobufs).

Hardware-verified on a real W10 AIOT Dev Kit: AXP2101 PMU, MCP23017, SX1262
init + a full over-the-air TX/RX round trip through the expander, PCF85063 RTC,
SHT41 and QMI8658 auto-detected, and an L76KB GPS fix.

* meshnology-w10: enable ES8311 speaker for notification tones

Bring up the ES8311 codec (I2C 0x18) -> NS4150 amp -> speaker so the board can
play notification tones / ringtones over the I2S buzzer path (the
use_i2s_as_buzzer external-notification option). Codec2 voice stays out of
scope: it is SX1280-only and this is a sub-GHz board.

- variant.h: HAS_I2S + DAC_I2S pins (MCLK=1, BCK=2, WS=4, DOUT=5, DIN=3)
- platformio.ini: arduino-audio-driver + ESP8266Audio + ESP8266SAM
- extra_variants/meshnology_w10/variant.cpp: lateInitVariant() configures the
  ES8311, mirroring the other Meshtastic ES8311 boards. Kept codec-only (no
  main.h) so the audio driver's 'using namespace audio_driver' does not pull a
  conflicting GpioPin into scope alongside Meshtastic's class GpioPin.
- AudioThread.h: toggle the NS4150 amp (MCP23017 EXIO_PA_CTRL) around playback.

Verified on hardware: a test tune played audibly through the speaker.

* meshnology-w10: address review feedback on the MCP23017 driver

- ExtensionIOMCP23017: serialize register access with a mutex, since the radio
  HAL (BUSY/DIO1/RESET) and AudioThread (amp enable) now reach the expander from
  different threads and the read-modify-write paths are not atomic.
- digitalRead: return a fail-safe HIGH on a failed read instead of LOW, so a
  transient I2C error on the LoRa BUSY line can't look like 'ready' and let
  RadioLib start an SPI transaction early. (DIO1 is polled via the radio IRQ
  register, not this pin.)
- enablePinChangeInterrupt: use the checked readReg() overload and skip on a
  failed read, matching the other read-modify-write helpers.
- meshnology_w10 variant.cpp: log a warning if an ES8311 register write NACKs
  instead of silently leaving the codec half-configured.
- RadioInterface.cpp: guard the MCP23017 HAL branch with ARCH_ESP32 to match the
  include and driver-file guards.

* Replace board-model audio/sleep ifdefs with reusable variant capability macros

Two shared-code spots keyed off specific hardware models; move the board-specific
detail into opt-in macros the variants define, so the core code stays generic and
new boards can opt into the behavior class without touching shared files.

- AudioThread amp control: drop the T_LORA_PAGER / MESHNOLOGY_W10 ifdefs. A board
  with an I2S amp now defines AUDIO_AMP_ENABLE(on) in its variant.h (T-LoRa Pager
  and Meshnology W10 do), and AudioThread just calls it around playback. The
  expander includes are keyed on USE_XL9555 / USE_MCP23017 (capabilities) instead
  of the model name.

- Light-sleep / DIO1 wakeup: drop the SENSECAP_INDICATOR check. Boards whose
  LORA_DIO1 is an expander pin (not an ESP32 GPIO) define LORA_DIO1_EXTENDED_IO
  (SenseCAP Indicator and Meshnology W10), and sleep.cpp keys off that for both
  the light-sleep skip and the DIO1 GPIO-wakeup skip. This also fixes a latent
  issue on the SenseCAP: it previously reached the GPIO-wakeup path and called
  gpio_pulldown_en() on a virtual expander pin; it now skips that cleanly.

Builds verified on meshnology_w10, tlora-pager, and seeed-sensecap-indicator.

* meshnology-w10: silence cppcheck constParameterPointer on the ISR-callback helper

isIsrTxCallback() takes a function pointer it only compares; cppcheck's
constParameterPointer wants it 'pointer to const', which is meaningless for a
function pointer (the codebase already globally suppresses the sibling
constParameterCallback). Inline-suppress it to keep the check green.

* HopScaling: qualify the member assignment as this->count

cppcheck (CI's version) flags 'count = newCount;' at the end of trimIfNeeded()
as uselessAssignmentArg ('assignment of function parameter has no effect') - a
false positive, since count is a member that outlives the call. Write it as
this->count (matching the Step-1 assignment above) so the analyzer sees a member
write. This finding comes in via the develop merge, not this board; fixing it
here to unblock the PR's cppcheck check.
2026-07-07 09:58:54 -05:00

193 lines
5.0 KiB
C++

#include "configuration.h"
#if defined(ARCH_ESP32) && defined(USE_MCP23017)
#include "ExtensionIOMCP23017.h"
#include "concurrency/LockGuard.h"
ExtensionIOMCP23017 mcpIoExpander;
void ExtensionIOMCP23017::begin(TwoWire &wire, uint8_t addr, int sda, int scl)
{
if (_begun)
return;
_wire = &wire;
_addr = addr;
wire.begin(sda, scl);
_begun = true;
}
bool ExtensionIOMCP23017::readReg(uint8_t reg, uint8_t &val)
{
if (!_wire || !_begun)
return false;
_wire->beginTransmission(_addr);
_wire->write(reg);
if (_wire->endTransmission() != 0)
return false;
if (_wire->requestFrom((int)_addr, 1) != 1 || !_wire->available())
return false;
val = (uint8_t)_wire->read();
return true;
}
uint8_t ExtensionIOMCP23017::readReg(uint8_t reg)
{
uint8_t val = 0;
readReg(reg, val);
return val;
}
void ExtensionIOMCP23017::writeReg(uint8_t reg, uint8_t val)
{
if (!_wire || !_begun)
return;
_wire->beginTransmission(_addr);
_wire->write(reg);
_wire->write(val);
_wire->endTransmission();
}
void ExtensionIOMCP23017::updateDirectionBit(uint8_t pin, bool asOutput)
{
uint8_t reg = iodirRegForPin(pin);
uint8_t v;
if (!readReg(reg, v)) // skip the write rather than clobber the other 7 pins on a failed read
return;
uint8_t mask = bitForPin(pin);
if (asOutput)
v &= ~mask; // output: bit 0
else
v |= mask; // input: bit 1
writeReg(reg, v);
}
void ExtensionIOMCP23017::pinMode(uint8_t pin, uint8_t mode)
{
if (pin > 15)
return;
concurrency::LockGuard guard(&_lock);
if (mode == OUTPUT) {
updateDirectionBit(pin, true);
} else {
updateDirectionBit(pin, false);
uint8_t reg = gppuRegForPin(pin);
uint8_t v;
if (!readReg(reg, v)) // skip rather than clobber the bank on a failed read
return;
if (mode == INPUT_PULLUP)
v |= bitForPin(pin);
else
v &= ~bitForPin(pin);
writeReg(reg, v);
}
}
void ExtensionIOMCP23017::digitalWrite(uint8_t pin, uint8_t value)
{
if (pin > 15)
return;
concurrency::LockGuard guard(&_lock);
uint8_t reg = olatRegForPin(pin);
uint8_t v;
if (!readReg(reg, v)) // skip rather than clobber the bank (e.g. drop LORA_NRST) on a failed read
return;
uint8_t mask = bitForPin(pin);
if (value == LOW)
v &= ~mask;
else
v |= mask;
writeReg(reg, v);
}
int ExtensionIOMCP23017::digitalRead(uint8_t pin)
{
if (pin > 15)
return LOW;
concurrency::LockGuard guard(&_lock);
uint8_t v;
if (!readReg(gpioRegForPin(pin), v))
return HIGH; // fail-safe: a failed read must not look like "LoRa BUSY released" and let RadioLib
// start an SPI transaction too early. DIO1 is polled via the radio IRQ register, not
// this pin, so reading it high on error is harmless there.
return (v & bitForPin(pin)) ? HIGH : LOW;
}
void ExtensionIOMCP23017::enablePinChangeInterrupt(uint8_t pin, bool enable)
{
if (pin > 15)
return;
concurrency::LockGuard guard(&_lock);
uint8_t regG = gpintenRegForPin(pin);
uint8_t v;
if (!readReg(regG, v)) // skip rather than clobber the bank on a failed read
return;
if (enable)
v |= bitForPin(pin);
else
v &= ~bitForPin(pin);
writeReg(regG, v);
// INTCON: 0 = interrupt on pin change from previous value
uint8_t regI = intconRegForPin(pin);
if (!readReg(regI, v))
return;
v &= ~bitForPin(pin);
writeReg(regI, v);
}
void ExtensionIOMCP23017::clearInterruptLatches()
{
if (!_begun)
return;
concurrency::LockGuard guard(&_lock);
(void)readReg(0x10); // INTCAPA - clears INTA condition
(void)readReg(0x11); // INTCAPB
}
uint8_t ExtensionIOMCP23017::readRegister(uint8_t reg)
{
concurrency::LockGuard guard(&_lock);
return readReg(reg);
}
void mcp23017EarlyInit()
{
mcpIoExpander.begin(Wire, MCP23017_ADDR, I2C_SDA, I2C_SCL);
#ifdef EXIO_LCD_RST
mcpIoExpander.pinMode(EXIO_LCD_RST, OUTPUT);
mcpIoExpander.digitalWrite(EXIO_LCD_RST, LOW);
delay(10);
mcpIoExpander.digitalWrite(EXIO_LCD_RST, HIGH);
delay(20);
#endif
#ifdef EXIO_LORA_NRST
mcpIoExpander.pinMode(EXIO_LORA_NRST, OUTPUT);
mcpIoExpander.digitalWrite(EXIO_LORA_NRST, HIGH);
#endif
#ifdef EXIO_LORA_BUSY
mcpIoExpander.pinMode(EXIO_LORA_BUSY, INPUT);
#endif
#ifdef EXIO_LORA_DIO1
mcpIoExpander.pinMode(EXIO_LORA_DIO1, INPUT);
#endif
#ifdef EXIO_GPS_WAKE
mcpIoExpander.pinMode(EXIO_GPS_WAKE, OUTPUT);
mcpIoExpander.digitalWrite(EXIO_GPS_WAKE, HIGH);
#endif
#ifdef EXIO_IMU_INT1
mcpIoExpander.pinMode(EXIO_IMU_INT1, INPUT);
#endif
LOG_INFO("MCP23017 0x%02x: IODIRA=0x%02x IODIRB=0x%02x GPIOA=0x%02x GPIOB=0x%02x", MCP23017_ADDR,
mcpIoExpander.readRegister(0x00), mcpIoExpander.readRegister(0x01), mcpIoExpander.readRegister(0x12),
mcpIoExpander.readRegister(0x13));
}
#endif // ARCH_ESP32 && USE_MCP23017