Skip to content

feat: add driver for MCP23017#11015

Open
myembeddedstuff wants to merge 1 commit into
meshtastic:developfrom
myembeddedstuff:develop
Open

feat: add driver for MCP23017#11015
myembeddedstuff wants to merge 1 commit into
meshtastic:developfrom
myembeddedstuff:develop

Conversation

@myembeddedstuff

@myembeddedstuff myembeddedstuff commented Jul 15, 2026

Copy link
Copy Markdown

Description

This PR adds a driver for the MCP23017. It is a simple implementation, following a similar structure to the MPR121 driver.
I have added the option to define a custom keymap connection using the CUSTOM_MCP23017_MAP definition.
Please note that I am unable to test this code on the specific devices listed below, as I do not have access to them.

🤝 Attestations

  • I have tested that my proposed changes behave as described.
  • I have tested that my proposed changes do not cause any obvious regressions on the following devices:
    • Heltec (Lora32) V3
    • LilyGo T-Deck
    • LilyGo T-Beam
    • RAK WisBlock 4631
    • Seeed Studio T-1000E tracker card
    • Other (please specify below)
    • seeed_xiao_s3

Summary by CodeRabbit

  • New Features
    • Added support for MCP23017-based I2C keyboards.
    • Automatically detects and initializes MCP23017 keyboard hardware.
    • Supports key presses, long presses, multi-tap input, navigation buttons, and event queuing.
    • Improved device scanning to distinguish MCP23017 devices from similar I2C hardware.

@CLAassistant

CLAassistant commented Jul 15, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

Copy link
Copy Markdown
Contributor

@myembeddedstuff, Welcome to Meshtastic!

Thanks for opening your first pull request. We really appreciate it.

We discuss work as a team in discord, please join us in the #firmware channel.
There's a big backlog of patches at the moment. If you have time,
please help us with some code review and testing of other PRs!

Welcome to the team 😄

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds MCP23017 I2C keyboard detection, a complete keyboard driver with tap and long-press handling, and integration that maps generated key events into existing input events.

Changes

MCP23017 keyboard support

Layer / File(s) Summary
MCP23017 device detection
src/detect/ScanI2C.*, src/detect/ScanI2CTwoWire.cpp
Adds the MCP23017 device type, distinguishes it from TCA9535 using register 0x0A, and includes MPR121KB in keyboard selection.
Keyboard driver and event state machine
src/input/MCP23017Keyboard.*
Adds MCP23017 register access, initialization, interrupt helpers, key counting, tap/long-press state handling, and a character event queue.
Input integration
src/input/cardKbI2cImpl.cpp, src/input/kbI2cBase.*, src/main.cpp
Scans and maps MCP23017 keyboards to model 0x20, initializes the driver, and converts queued characters into InputEvent values.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant I2CScanner
  participant MCP23017Keyboard
  participant KbI2cBase
  participant InputObserver
  I2CScanner->>I2CScanner: detect MCP23017
  I2CScanner->>KbI2cBase: select keyboard model 0x20
  KbI2cBase->>MCP23017Keyboard: initialize on selected TwoWire bus
  KbI2cBase->>MCP23017Keyboard: trigger()
  MCP23017Keyboard-->>KbI2cBase: queued character events
  KbI2cBase->>InputObserver: notify mapped InputEvent
Loading

Suggested labels: enhancement

Suggested reviewers: thebentern, mverch67

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding an MCP23017 driver.
Description check ✅ Passed The description includes a summary and attestations, and it notes the limited hardware testing as requested by the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/input/cardKbI2cImpl.cpp (1)

15-20: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Array size mismatch prevents scanning of all keyboards.

The array i2caddr_scan now contains 6 elements since MCP23017_KB_ADDR was added. However, the else branch (when T_LORA_PAGER is not defined) still hardcodes i2caddr_asize to 5. This will truncate the scan, causing the last device in the array (e.g., TCA8418_KB_ADDR) to be ignored entirely.

To prevent this class of bugs entirely, compute the size dynamically for all cases instead of relying on a hardcoded number.

🐛 Proposed fix
-        uint8_t i2caddr_scan[] = {CARDKB_ADDR, TDECK_KB_ADDR, BBQ10_KB_ADDR, MCP23017_KB_ADDR, MPR121_KB_ADDR, TCA8418_KB_ADDR};
-#if defined(T_LORA_PAGER)
-        uint8_t i2caddr_asize = sizeof(i2caddr_scan) / sizeof(i2caddr_scan[0]);
-#else
-        uint8_t i2caddr_asize = 5;
-#endif
+        uint8_t i2caddr_scan[] = {CARDKB_ADDR, TDECK_KB_ADDR, BBQ10_KB_ADDR, MCP23017_KB_ADDR, MPR121_KB_ADDR, TCA8418_KB_ADDR};
+        uint8_t i2caddr_asize = std::size(i2caddr_scan);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/input/cardKbI2cImpl.cpp` around lines 15 - 20, Update i2caddr_asize
alongside i2caddr_scan so both preprocessor branches compute the array length
dynamically using sizeof, removing the hardcoded value 5 and ensuring every
address, including TCA8418_KB_ADDR, is scanned.
🧹 Nitpick comments (3)
src/detect/ScanI2C.cpp (1)

34-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid hardcoding the array size.

Using a hardcoded number 7 can lead to an out-of-bounds read or missed device type if the array is modified again in the future without updating the count. Since this project uses C++17, prefer using std::size(types).

♻️ Proposed refactor
-    ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MCP23017, MPR121KB, TCA8418KB};
-    return firstOfOrNONE(7, types);
+    ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MCP23017, MPR121KB, TCA8418KB};
+    return firstOfOrNONE(std::size(types), types);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/detect/ScanI2C.cpp` around lines 34 - 35, Update the firstOfOrNONE call
in ScanI2C’s device-type selection to derive the count from the local types
array using std::size(types) instead of the hardcoded 7, preserving the existing
array contents and return behavior.
src/input/MCP23017Keyboard.cpp (2)

167-176: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove excessive newlines from logs and fix typo.

Standard logging macros usually append their own newline. The triple \n\n\n adds unnecessary noise. Also, "deattached" should be "detached".

🧹 Proposed cleanup
 void MCP23017Keyboard::attachInterrupt(uint8_t pin, void (*func)(void)) const {
-    LOG_DEBUG("MCP23017 attached interrupt\n\n\n");
+    LOG_DEBUG("MCP23017 attached interrupt");
     pinMode(pin, INPUT_PULLUP);
     ::attachInterrupt(digitalPinToInterrupt(pin), func, FALLING); 
 }
 
 void MCP23017Keyboard::detachInterrupt(uint8_t pin) const {
-    LOG_DEBUG("MCP23017 deattached interrupt\n\n\n");
+    LOG_DEBUG("MCP23017 detached interrupt");
     ::detachInterrupt(pin);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/input/MCP23017Keyboard.cpp` around lines 167 - 176, Update
MCP23017Keyboard::attachInterrupt and MCP23017Keyboard::detachInterrupt to
remove the embedded triple newline characters from both LOG_DEBUG messages,
relying on the logging macro’s normal line termination. Correct the detach
message spelling from “deattached” to “detached”.

249-252: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove empty else block.

🧹 Proposed cleanup
-    if (keyCount(keyRegister) != 1) {
-            return;
-        } else {
-        }
+    if (keyCount(keyRegister) != 1) {
+        return;
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/input/MCP23017Keyboard.cpp` around lines 249 - 252, Remove the empty else
block following the keyCount(keyRegister) check, leaving the early return for
counts not equal to one and allowing normal execution to continue afterward.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/detect/ScanI2CTwoWire.cpp`:
- Around line 651-667: Verify the TCA9535 behavior when reading register 0x0A,
including wrapped-command and NACK outcomes, and update the MCP23017/TCA9535
detection in the surrounding switch case so failed or wrapped reads cannot
misclassify the device; preserve RAK12035 detection. Also complete the missing
closing parenthesis in the TCA9535/MCP23017 comment and correct the indentation
of the nested register-read line to match trunk fmt.

In `@src/input/kbI2cBase.cpp`:
- Around line 394-396: Reorder the switch cases in the input handler so CARDKB’s
`case 0x00` no longer falls through into the MCP23017 block. Move `case 0x00` to
immediately precede `case 0x10`, preserving the shared CARDKB/T-DECK request
flow while leaving the `case 0x20` MCP23017 handler separate.
- Around line 82-84: Update the ScanI2C::WIRE1 case to initialize MCPkeyboard
when cardkb_found.address equals MCP23017_KB_ADDR, passing &Wire1 to
MCPkeyboard.begin; keep the existing Wire initialization in the other bus case
unchanged.

In `@src/input/MCP23017Keyboard.cpp`:
- Around line 264-274: Replace the raw millis arithmetic in
src/input/MCP23017Keyboard.cpp lines 264-274 with
Throttle::isWithinTimespanMs(last_tap, MULTI_TAP_THRESHOLD), removing
tap_interval and its negative-wraparound branch while preserving the surrounding
key-state behavior. Also update lines 301-311 to remove held_interval and its
negative-wraparound logic, using !Throttle::isWithinTimespanMs(last_tap,
LONG_PRESS_THRESHOLD) for the long-press timeout check.

---

Outside diff comments:
In `@src/input/cardKbI2cImpl.cpp`:
- Around line 15-20: Update i2caddr_asize alongside i2caddr_scan so both
preprocessor branches compute the array length dynamically using sizeof,
removing the hardcoded value 5 and ensuring every address, including
TCA8418_KB_ADDR, is scanned.

---

Nitpick comments:
In `@src/detect/ScanI2C.cpp`:
- Around line 34-35: Update the firstOfOrNONE call in ScanI2C’s device-type
selection to derive the count from the local types array using std::size(types)
instead of the hardcoded 7, preserving the existing array contents and return
behavior.

In `@src/input/MCP23017Keyboard.cpp`:
- Around line 167-176: Update MCP23017Keyboard::attachInterrupt and
MCP23017Keyboard::detachInterrupt to remove the embedded triple newline
characters from both LOG_DEBUG messages, relying on the logging macro’s normal
line termination. Correct the detach message spelling from “deattached” to
“detached”.
- Around line 249-252: Remove the empty else block following the
keyCount(keyRegister) check, leaving the early return for counts not equal to
one and allowing normal execution to continue afterward.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a02defa-cc8e-4dc7-a0c1-65eac4c8198f

📥 Commits

Reviewing files that changed from the base of the PR and between de0380c and 0064646.

📒 Files selected for processing (9)
  • src/detect/ScanI2C.cpp
  • src/detect/ScanI2C.h
  • src/detect/ScanI2CTwoWire.cpp
  • src/input/MCP23017Keyboard.cpp
  • src/input/MCP23017Keyboard.h
  • src/input/cardKbI2cImpl.cpp
  • src/input/kbI2cBase.cpp
  • src/input/kbI2cBase.h
  • src/main.cpp

Comment on lines +651 to +667
case TCA9535_ADDR: // this can also be MCP23017_ADDR (both 0x20
case RAK120352_ADDR:
case RAK120353_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x02), 1);
if (registerValue == addr.address) { // RAK12035 returns its I2C address at 0x02 (eg 0x20)
type = RAK12035;
logFoundDevice("RAK12035", (uint8_t)addr.address);
} else {
type = TCA9535;
logFoundDevice("TCA9535", (uint8_t)addr.address);
// TCA9535 only has registers 0x00-0x07; MCP23017 has IOCON at 0x0A
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0A), 1);
if (registerValue != 0xFF) {
type = MCP23017;
logFoundDevice("MCP23017", (uint8_t)addr.address);
} else {
type = TCA9535;
logFoundDevice("TCA9535", (uint8_t)addr.address);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Verify register 0x0A read behavior for TCA9535.

The differentiation between MCP23017 and TCA9535 relies on reading register 0x0A. The code assumes registerValue will be 0xFF for a TCA9535. If the TCA9535 wraps the command byte (e.g., 0x0A % 8 = 0x02), register 0x02 (Output Port 0) must default to 0xFF for this logic to hold. If the device NACKs the address instead, getRegisterValue() might return 0x00 (since available() would be 0), causing it to be incorrectly identified as an MCP23017 (0x00 != 0xFF).

Also, fix the missing closing parenthesis in the comment on line 651, and the off-by-one indentation on line 659 to comply with trunk fmt as per coding guidelines.

💻 Proposed fixes (assuming register logic is verified to work)
-            case TCA9535_ADDR: // this can also be MCP23017_ADDR (both 0x20
+            case TCA9535_ADDR: // this can also be MCP23017_ADDR (both 0x20)
             case RAK120352_ADDR:
             case RAK120353_ADDR:
                 registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x02), 1);
                 if (registerValue == addr.address) { // RAK12035 returns its I2C address at 0x02 (eg 0x20)
                     type = RAK12035;
                     logFoundDevice("RAK12035", (uint8_t)addr.address);
                 } else {
-                     // TCA9535 only has registers 0x00-0x07; MCP23017 has IOCON at 0x0A
+                    // TCA9535 only has registers 0x00-0x07; MCP23017 has IOCON at 0x0A
                     registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0A), 1);
                     if (registerValue != 0xFF) { 
                         type = MCP23017;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/detect/ScanI2CTwoWire.cpp` around lines 651 - 667, Verify the TCA9535
behavior when reading register 0x0A, including wrapped-command and NACK
outcomes, and update the MCP23017/TCA9535 detection in the surrounding switch
case so failed or wrapped reads cannot misclassify the device; preserve RAK12035
detection. Also complete the missing closing parenthesis in the TCA9535/MCP23017
comment and correct the indentation of the nested register-read line to match
trunk fmt.

Source: Coding guidelines

Comment thread src/input/kbI2cBase.cpp
Comment on lines +82 to +84
if (cardkb_found.address == MCP23017_KB_ADDR) {
MCPkeyboard.begin(MCP23017_KB_ADDR, &Wire);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Initialize MCP23017 for the secondary I2C bus (Wire1).

You have added initialization for Wire (bus 0), but the equivalent logic is missing for the Wire1 case block above this (around line 70). Without it, the MCP23017 keyboard will fail to initialize on boards that connect it to the secondary I2C interface.

Add the following initialization to the ScanI2C::WIRE1 case:

if (cardkb_found.address == MCP23017_KB_ADDR) {
    MCPkeyboard.begin(MCP23017_KB_ADDR, &Wire1);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/input/kbI2cBase.cpp` around lines 82 - 84, Update the ScanI2C::WIRE1 case
to initialize MCPkeyboard when cardkb_found.address equals MCP23017_KB_ADDR,
passing &Wire1 to MCPkeyboard.begin; keep the existing Wire initialization in
the other bus case unchanged.

Comment thread src/input/kbI2cBase.cpp
Comment on lines +394 to +396
case 0x20: { // MCP23017 (Dirección I2C por defecto)
MCPkeyboard.trigger();
InputEvent e = {};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Fix hijacked CARDKB fallthrough.

By inserting case 0x20: directly after case 0x00: (line 393), the CARDKB logic now falls through into the MCP23017 handler instead of its original destination. This completely breaks CARDKB support because it will attempt to read MCP23017-specific registers.

To restore correctness, move case 0x00: to the end of the case 0x20: block so that it correctly falls through to case 0x10: (T-DECK), as it did prior to this PR.

// Move the CARDKB case from line 393 to immediately precede the T-DECK case:
    case 0x00:   // CARDKB
    case 0x10: { // T-DECK
        i2cBus->requestFrom((int)cardkb_found.address, 1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/input/kbI2cBase.cpp` around lines 394 - 396, Reorder the switch cases in
the input handler so CARDKB’s `case 0x00` no longer falls through into the
MCP23017 block. Move `case 0x00` to immediately precede `case 0x10`, preserving
the shared CARDKB/T-DECK request flow while leaving the `case 0x20` MCP23017
handler separate.

Comment on lines +264 to +274
uint32_t now = millis();
int32_t tap_interval = now - last_tap;

if (tap_interval < 0) {
// long running, millis has overflowed.
last_tap = 0;
state = Busy;
return;
}

if (next_key != last_key || tap_interval > MULTI_TAP_THRESHOLD) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use Throttle for time-based checks instead of raw millis() arithmetic.

As per coding guidelines, time-elapsed checks must use the Throttle helper rather than manual millis() arithmetic. The current logic subtracts and converts to a signed integer (int32_t), which fails to properly handle unsigned millis() wraparounds and causes bugs if the device runs for extended periods.

  • src/input/MCP23017Keyboard.cpp#L264-L274: Remove the tap_interval and < 0 logic. Replace the timeout check with !Throttle::isWithinTimespanMs(last_tap, MULTI_TAP_THRESHOLD).
  • src/input/MCP23017Keyboard.cpp#L301-L311: Remove the held_interval and < 0 logic. Replace the timeout check with !Throttle::isWithinTimespanMs(last_tap, LONG_PRESS_THRESHOLD).
📍 Affects 1 file
  • src/input/MCP23017Keyboard.cpp#L264-L274 (this comment)
  • src/input/MCP23017Keyboard.cpp#L301-L311
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/input/MCP23017Keyboard.cpp` around lines 264 - 274, Replace the raw
millis arithmetic in src/input/MCP23017Keyboard.cpp lines 264-274 with
Throttle::isWithinTimespanMs(last_tap, MULTI_TAP_THRESHOLD), removing
tap_interval and its negative-wraparound branch while preserving the surrounding
key-state behavior. Also update lines 301-311 to remove held_interval and its
negative-wraparound logic, using !Throttle::isWithinTimespanMs(last_tap,
LONG_PRESS_THRESHOLD) for the long-press timeout check.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants