IO expanders with RP2040

caberolo

11 Nov 2022, 23:49

Hello all
tl;dr

Does anyone know or have documentation on configuring io expanders with a RP2040 microcontroller, via SPI?

Long question

I'm trying to program a keyboard controller with a raspberry pi pico board and a MCP23S08 expander; I want to make an alternative controller for a Model M, something like this (or this). I need 27 pins, 16 for the columns, 8 for the rows and 3 led. The pico only has 26. The idea is to connect the columns and led directly to the pins on the board, and the rows to the expander[*]. Currently the "keyboard" looks like this:
keyboard-test.jpeg
keyboard-test.jpeg (144.06 KiB) Viewed 4934 times
I've seen a few examples of keyboards using expanders, but almost none with RP2040, and none with the MCP30S08 (although it should be pretty close to the MCP23017, which is relatively common in keyboards). On the other hand, it seems to be more common to connect via I2C than SPI.

For testing purposes, I'm starting from Raspberry Pi 2040 onekey configuration. I've included (correctly, I think) the SPI configuration; at least it compiles without errors. What I'm not clear about, before I start programming matrix.c, is the initialisation of the MCP23S08.

\[*\] Alternative configuration

It would probably be much simpler to connect rows and columns directly to the pico, and control the LEDs through the MCP23S08, but I wanted to use this project to also learn how to program the matrices. In the future, I would like to design a board with a smaller controller, like a PI zero.

Note: I originally wrote this message on reddit.

MMcM

13 Nov 2022, 04:45

I assume you are following https://github.com/qmk/qmk_firmware/blo ... _driver.md and https://ww1.microchip.com/downloads/aem ... 001919.pdf.

Overall, I would expect:
  • Two address pins are hardwired to fix SPI address.
  • SCK, SI, and SO connected to corresponding pins on RP2020.
  • CS connected to some other GPIO.
  • Call spi_init in matrix_init.
  • I suppose for simplicity call spi_start once here, too, and keep the only connected device selected.
  • Use spi_write to set one side (rows or columns) to outputs in IODIR.
  • Set GPPU on the other side to keep them at a known state.
  • In matrix_scan, set exactly one output low by spi_write to OLAT (or GPIO, they are equivalent).
  • Use spi_write and spi_read to read GPIO; low means pressed.
Last edited by MMcM on 13 Nov 2022, 18:08, edited 2 times in total.

pandrew

13 Nov 2022, 16:10

Not really exactly the answer you're looking for, but just an idea:
The Pico has 26 GPIOs available, but RP2040 has more, so if you're gonna build SMD-assembled PCB and not use the pico, then you don't need an expander. (At least definitely not for normal ModelM-size keyboard)
Also if you do want to use the pico, you may be able too hook into one of the extra GPIOS,
for example:
  • You may be able to knock off the led with a soldering iron, and solder a wire to TP5. This 'pin' will have less drive strength due to the presence of the led current limiting resistor. But it's good enough for a keyboard matrix.
  • You may be able to knock off Q1, and solder to pin 2 of it. (Losing the ability to measure VSYS voltage)
Yet another option that is not a fully featured IO expander would be to just use a 74 series parallel-to-serial shift register (74HC165) to expand the input side, with external 68k pull-up resistors on each input pin. You can drive it by bit-banging, or PIO, or probably also with the SPI peripheral. You don't really need bidirectional IO expanders for a keyboard matrix, and it may be a few cents cheaper.
MMcM wrote:
13 Nov 2022, 04:45
  • Use spi_write to set one side (rows or columns) to outputs in IODIR.
  • In matrix_scan, set exactly one output low by spi_write to OLAT (or GPIO, they are equivalent).
No, don't do it like this! This is dangerous!
It's dangerous because if for example you are driving rows, and sensing columns, and you press two keys at the same time that are on different rows, but the same column, Then you will end up creating a short circuit through the membrane of the keyboard. And depending on the drive strength of the output, large currents could flow, and this could damage the GPIO, or maybe even the membrane over time.

The right way is to always keep all IOs (rows AND columns) as inputs, except only switch one single IO at a time to output driving 0. Never drive a strong "1" into a diode-less keyboard matrix.

MMcM

13 Nov 2022, 17:24

Oh, yes, you are absolutely right. I apologize for giving unsafe advice.

Just change the direction when scanning to set one low.

caberolo

13 Nov 2022, 19:01

pandrew wrote:
13 Nov 2022, 16:10
Not really exactly the answer you're looking for, but just an idea:
The Pico has 26 GPIOs available, but RP2040 has more, so if you're gonna build SMD-assembled PCB and not use the pico, then you don't need an expander. (At least definitely not for normal ModelM-size keyboard)
Also if you do want to use the pico, you may be able too hook into one of the extra GPIOS,
for example:
  • You may be able to knock off the led with a soldering iron, and solder a wire to TP5. This 'pin' will have less drive strength due to the presence of the led current limiting resistor. But it's good enough for a keyboard matrix.
  • You may be able to knock off Q1, and solder to pin 2 of it. (Losing the ability to measure VSYS voltage)
Soldering and desoldering at the pico is not (yet) an option; I'd rather do without the LEDs. On the other hand, as I said in the OP, the idea of this project is to learn how to handle io expanders as well.
pandrew wrote:
13 Nov 2022, 16:10
Yet another option that is not a fully featured IO expander would be to just use a 74 series parallel-to-serial shift register (74HC165) to expand the input side, with external 68k pull-up resistors on each input pin. You can drive it by bit-banging, or PIO, or probably also with the SPI peripheral. You don't really need bidirectional IO expanders for a keyboard matrix, and it may be a few cents cheaper.
I had also considered this possibility. I have seen some keyboards that use it (the Model H, for example). However, I have found even less documentation than with io expanders. In fact, I have several HC74xx. I would have to check if I have suitable resistors. In any case, from what I've seen the communication is done via SPI, so I'd be at a very similar point to where I am now (i.e. completely stuck).

Thanks for your interest.

Edit. Model H, not M. Link added.
Last edited by caberolo on 13 Nov 2022, 23:01, edited 2 times in total.

User avatar
Sheepless

13 Nov 2022, 19:41

If you mix the RP2040 with 74-series TTL, remember that the former will not tolerate 5V inputs.

pandrew

13 Nov 2022, 20:06

caberolo wrote:
13 Nov 2022, 19:01
I had also considered this possibility. I have seen some keyboards that use it (the model m, for example). However, I have found even less documentation than with io expanders. In fact, I have several HC74xx. I would have to check if I have suitable resistors. In any case, from what I've seen the communication is done via SPI, so I'd be at a very similar point to where I am now (i.e. completely stuck).
You meant to write the Model H controller, right? In that case one of the versions seems to use a 74XX595 chip, which is a serial in parallel out shift register, and it uses diodes to make sure that the short I mentioned doesn't happen. So if you go 74 route you either need a Parallel In shift register with 8 resistors, of a Parallel Out one with 8 diodes. I prefer the parallel in direction, cause in the parallel-out case the diodes will have some forward voltage drop, and that brings the measured voltage dangerously close to the RP2040's VILmax = 0.8V
Sheepless wrote:
13 Nov 2022, 19:41
If you mix the RP2040 with 74-series TTL, remember that the former will not tolerate 5V inputs.
Not all 74-series chips are TTL.
74HC chips are CMOS,
For example this parallel input shift register:
https://www.ti.com/lit/ds/symlink/sn74hc165.pdf
Can operate down to 2V.

These should absolutely be powered off of the 3.3V rail! And that is the same when it comes to any fully featured IO expanders, you must pick one that supports running at 3.3V and run it at 3.3V.

pandrew

13 Nov 2022, 20:10

In any case we're just discussing options here. Sounds like the simpler way is still for you to get that IO expander working since you already have the hardware. Just follow MMcM's advice, and take into account my correction as well.

Findecanor

13 Nov 2022, 20:27

caberolo wrote:
13 Nov 2022, 19:01
pandrew wrote:
13 Nov 2022, 16:10
Yet another option that is not a fully featured IO expander would be to just use a 74 series parallel-to-serial shift register (74HC165) ...
... I have found even less documentation than with io expanders.
I have found OpenMusicLabs' matrix scanning guide a great resource. Even though the site is about building musical keyboards rather than computer keyboards, the principles are the same.
There is a section on shift-registers.

User avatar
Sheepless

13 Nov 2022, 21:10

pandrew wrote:
13 Nov 2022, 20:06
Not all 74-series chips are TTL.
74HC chips are CMOS,
For example this parallel input shift register:
https://www.ti.com/lit/ds/symlink/sn74hc165.pdf
Can operate down to 2V.

These should absolutely be powered off of the 3.3V rail! And that is the same when it comes to any fully featured IO expanders, you must pick one that supports running at 3.3V and run it at 3.3V.
I'm so out of date! Somewhere I probably still have the big orange Texas Instruments TTL data book from the 1980s!

caberolo

13 Nov 2022, 22:54

MMcM wrote:
13 Nov 2022, 04:45
I assume you are following https://github.com/qmk/qmk_firmware/blo ... _driver.md and https://ww1.microchip.com/downloads/aem ... 001919.pdf.

Overall, I would expect:
  • Two address pins are hardwired to fix SPI address.
  • SCK, SI, and SO connected to corresponding pins on RP2020.
  • CS connected to some other GPIO.
  • Call spi_init in matrix_init.
  • I suppose for simplicity call spi_start once here, too, and keep the only connected device selected.
  • Use spi_write to set one side (rows or columns) to outputs in IODIR.
  • Set GPPU on the other side to keep them at a known state.
  • In matrix_scan, set exactly one output low by spi_write to OLAT (or GPIO, they are equivalent).
  • Use spi_write and spi_read to read GPIO; low means pressed.
The connections between the MCP and the pico are as follows:

Code: Select all

|-----+----------+-------------------+-----|
| pin | MCP23S08 | Raspberry pi pico | pin |
|-----+----------+-------------------+-----|
|   1 | SCK      | GP18              |  24 |
|   2 | SI       | GP16              |  21 |
|   3 | SO       | GP19              |  25 |
|   4 | A0       | GND               |     |
|   5 | A1       | GND               |     |
|   6 | RESET    | 3V3               |  36 |
|   7 | CS       | GP17              |  22 |
|   8 | INT      |                   |     |
|   9 | Vss      | GND               |     |
|  10 | GP0      | 3V3               |     |
|  11 | GP1      |                   |     |
|  12 | GP2      |                   |     |
|  13 | GP3      |                   |     |
|  14 | GP4      |                   |     |
|  15 | GP5      |                   |     |
|  16 | GP6      |                   |     |
|  17 | GP7      |                   |     |
|  18 | VDD      | 3V3               |  36 |
|-----+----------+-------------------+-----|
Grounded pins A0 and A1, set the expander ID to 0. GP0 is connected to 3v3 to simulate a key press, and see if it is able to read a 1. Raspberry pi pico pinout is described here.

And the configuration files are as follows (Some of the configuration has been borrowed from https://github.com/qmk/qmk_firmware/tre ... zarc/ghoul):

Code: Select all

/* config.h */

#pragma once

#include "config_common.h"

#define DEBUG_MATRIX_SCAN_RATE

#define QMK_WAITING_TEST_BUSY_PIN GP8
#define QMK_WAITING_TEST_YIELD_PIN GP9

#define RP2040_BOOTLOADER_DOUBLE_TAP_RESET
#define RP2040_BOOTLOADER_DOUBLE_TAP_RESET_LED GP25
#define RP2040_BOOTLOADER_DOUBLE_TAP_RESET_TIMEOUT 500U

// Matrix configuration
#define SPI_MATRIX_CHIP_SELECT_PIN GP17
#define SPI_MATRIX_DIVISOR 16

// SPI Configuration
#define SPI_DRIVER SPID0
#define SPI_SCK_PIN GP18
#define SPI_MOSI_PIN GP19
#define SPI_MISO_PIN GP16

Code: Select all

/* halconf.h */
#pragma once

#define HAL_USE_SPI TRUE

#include_next <halconf.h>

Code: Select all

/* mcuconf.h */
#pragma once

#define HAL_USE_SPI TRUE

#include_next <halconf.h>

Code: Select all

/* info.json */
{
    "keyboard_name": "Onekey RP2040-MCP23S08",
    "processor": "RP2040",
    "bootloader": "rp2040",
    "matrix_pins": {
        "cols": ["GP4"],
        "rows": ["NO_PIN","NO_PIN","NO_PIN","NO_PIN","NO_PIN","NO_PIN","NO_PIN","NO_PIN"]
    },
    "rgblight": {
        "pin": "A1"
    }
}

Code: Select all

/* matrix.c */
#include <stdint.h>
#include <stdbool.h>
#include "wait.h"
#include "action_layer.h"
#include "print.h"
#include "debug.h"
#include "util.h"
#include "matrix.h"
#include "debounce.h"

#include QMK_KEYBOARD_H
#include "analog.h"
#include "spi_master.h"

void matrix_init_custom(void) {
    // SPI Matrix
    setPinOutput(SPI_MATRIX_CHIP_SELECT_PIN);
    writePinHigh(SPI_MATRIX_CHIP_SELECT_PIN);
    spi_init();
}

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
    bool matrix_has_changed = false;
    static matrix_row_t temp_matrix[MATRIX_ROWS] = {0};

    // Read from SPI the matrix
    bool result = spi_start(SPI_MATRIX_CHIP_SELECT_PIN, false, 0, SPI_MATRIX_DIVISOR);
    uprintf("Value of spi_start: %d\n",result);
    spi_receive((uint8_t*)temp_matrix, MATRIX_ROWS * sizeof(matrix_row_t));
    spi_stop();

    return matrix_has_changed;
}
I'm not programming an actual scan of the matrix yet; I first want to check that the configuration is correct, that communication is established between the MCP23 and the pico, and that I am able to correctly read the values from the expander.

I must be doing something (very) wrong. The result variable (returned by spi_start()) takes a value of 1 even though the MCP is disconnected.

Thanks for the help.

Edit: Corrected MISO and MOSI pin assignments.
Last edited by caberolo on 21 Nov 2022, 00:03, edited 2 times in total.

caberolo

13 Nov 2022, 23:46

Rereading the MCP32S08 documentation, I have noticed an important detail. To read from the SPI bus, the CS pin must be set to low. I've changed the startup functions, and included some debugging code, but it still doesn't work.

Code: Select all

void matrix_init_custom(void) {
    // SPI Matrix
    setPinOutput(SPI_MATRIX_CHIP_SELECT_PIN);
    //writePinHigh(SPI_MATRIX_CHIP_SELECT_PIN);
    writePinLow(SPI_MATRIX_CHIP_SELECT_PIN);
    spi_init();

}

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
    bool matrix_has_changed = false;
    static matrix_row_t temp_matrix[MATRIX_ROWS] = {0};

    // Read from SPI the matrix
    spi_start(SPI_MATRIX_CHIP_SELECT_PIN, false, 0, SPI_MATRIX_DIVISOR);
    spi_receive((uint8_t*)temp_matrix, MATRIX_ROWS * sizeof(matrix_row_t));
    spi_stop();
    print("Content of temp_matrix: ");
    for (int i = 0; i < MATRIX_ROWS; i++){
      uprintf("%d ",temp_matrix[i]);
    }
    print("\n");

    return matrix_has_changed;
}

MMcM

13 Nov 2022, 23:54

spi_start doesn't actually communicate with the external device; it just configures the internal SPI to talk to it. So success just means no internal pin conflicts.

You should look at the return value from spi_receive. If I am not mistaken, the only time this device will send you anything is when you write a register that you want to read.

I think the next thing to do is write some subroutines to read and write the MCP registers. You can do that without the MCP being connected to the matrix at all. If you pick a read/write one, like the pull-up register, you can verify that you read back what you wrote. Then you can proceed to configure them for the actual scan.

It might be profitable to google around for some Arduino code that talks to one of these devices via SPI. The SPI routines are similar enough that you can probably crib off it.

caberolo

18 Nov 2022, 00:53

Hello all.

This is proving to be more complicated than it seemed (or I am more clumsy than I thought).

I think I've made some progress, but basically I'm still at the same point.

On the one hand, I've found [1] a keyboard driver among the qmk ones, which does something very similar to what I'm trying to do; it controls the rows from the pins of a promicro and reads the column values from three shift-registers (SN74HC165). It reads the shift-registers via SPI, as I want to do. The code in matrix.c is quite simple, almost to copy it directly.

On the other hand, following this advice:
MMcM wrote:
13 Nov 2022, 23:54
It might be profitable to google around for some Arduino code that talks to one of these devices via SPI. The SPI routines are similar enough that you can probably crib off it.
I found a page where it explains how to control a MCP23S08 like the one I have, from arduino [2].

Now for the bad news. Although everything seems to be well configured, I still don't have a correct reading of the MCP23S08 values. The code I have now is the following:

Code: Select all

void matrix_init_custom(void) {
    // init SPI
    spi_init();
    spi_start(SPI_MATRIX_CHIP_SELECT_PIN, false, 0, SPI_MATRIX_DIVISOR);
    setPinOutput(SPI_MATRIX_CHIP_SELECT_PIN);
    writePinHigh(SPI_MATRIX_CHIP_SELECT_PIN);

}

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
    bool changed = false;

    static matrix_row_t current_row_value = 0;

    // Read from SPI the matrix
    writePinLow(SPI_MATRIX_CHIP_SELECT_PIN);
    wait_ms(20); // delay needed (?)
    //matrix_output_select_delay();
    writePinHigh(SPI_MATRIX_CHIP_SELECT_PIN);

    spi_status_t read_result = spi_read();

    if (read_result >= 0) {
      /* only if SPI read successful: populate the matrix row with the
	 state of the 8 consecutive column bits */
      uprintf("read_result = %i\n",(uint8_t)read_result);
    }

    return changed;
}
Whether the pins have signal or not, it always reads the same values; 0 or 127, with some values in between (120,64,96) if I pass a high enough value to wait_ms() (20ms seems correct.). Also, I've noticed that if I don't include a slight delay, it always reads 0. But as I said, it's independent of whether the GPx pins have a signal or not.
MMcM wrote:
13 Nov 2022, 23:54
You should look at the return value from spi_receive. If I am not mistaken, the only time this device will send you anything is when you write a register that you want to read.

I think the next thing to do is write some subroutines to read and write the MCP registers. You can do that without the MCP being connected to the matrix at all. If you pick a read/write one, like the pull-up register, you can verify that you read back what you wrote. Then you can proceed to configure them for the actual scan.
And another thing that has surprised me is that, with the spi functions of qmk (spi_master.h), it is not clear how to select which io expander to read from in case of having several in series, nor how to control the registers... It may also be something above my level.

Thanks in advance

[1] https://github.com/qmk/qmk_firmware/tre ... d/dqz11n1g
[2] https://www.instructables.com/MCP23S08-With-Arduino/

caberolo

21 Nov 2022, 00:01

In I finally solved it. I have not yet programmed a complete matrix processing routine, but I have managed to read and write correctly from the MCP23S08.

I started from the scratch, with an arduino, to check that everything was working, and then passed it to qmk. The main problem was that I wasn't quite understanding how to control the spi devices with qmk. The documentation is not very newbie-friendly, but it is true that you must know well how to control each device, as each one is different from the others. Also, although this wasn't what was going wrong, I had an error in the pin assignment for MOSI and MISO (corrected in above post).

At the moment, the matrix.h code looks like this:

Code: Select all

/*matrix.c */
#include <stdint.h>
#include <stdbool.h>
#include "wait.h"
#include "action_layer.h"
#include "print.h"
#include "debug.h"
#include "util.h"
#include "matrix.h"
#include "debounce.h"

#include QMK_KEYBOARD_H
#include "analog.h"
#include "spi_master.h"

#define CHIP 0x40       // The chip's address (set by pins 4 & 5)
#define IO_DIR_REG 0x00 // The Input/Output Register
#define GPIO_REG 0x09   // The GPIO Register

void mcp_write(uint8_t device, uint8_t spiRegister, uint8_t value){
  /* This function sends data to the chip */
  /* It's a 5 step process */
  /* 1) Pull the Slave/Chip select LOW */
  writePinLow(SPI_MATRIX_CHIP_SELECT_PIN);
  
  /* 2) Send the chip's address to the chip */
  spi_write(0b01000000 | (device << 1)); // taken from jhawthorn

  /* 3) Send the register to the chip */
  spi_write(spiRegister);
  /* 4) Send the value to the chip */
  spi_write(value);
  /* 5) Pull the Slave/Chip select HIGH */
  //wait_ms(20);
  writePinHigh(SPI_MATRIX_CHIP_SELECT_PIN);
}

matrix_row_t mcp_read(uint8_t device, uint8_t addr){
  writePinLow(SPI_MATRIX_CHIP_SELECT_PIN);

  spi_write(0b01000001 | ((device) << 1));
  spi_write(addr);

  matrix_row_t result = spi_read();

  writePinHigh(SPI_MATRIX_CHIP_SELECT_PIN);

  return result;
}

void matrix_init_custom(void) {
  setPinOutput(SPI_MATRIX_CHIP_SELECT_PIN);
  writePinHigh(SPI_MATRIX_CHIP_SELECT_PIN);
  wait_ms(100); /* really needed? */

  spi_init();
  spi_start(SPI_MATRIX_CHIP_SELECT_PIN, false, 0, SPI_MATRIX_DIVISOR);

  wait_ms(20); /* really needed? */
  
  /* mcp_write(IO_DIR_REG,0x00); // Set all pins to OUTPUT */
  mcp_write(0,IO_DIR_REG,0xff); // Set all pins to INPUT
  mcp_write(0,GPIO_REG,0x00);   // Set all pins LOW
}

bool matrix_scan_custom(matrix_row_t current_matrix[]) {
    bool changed = false;

    matrix_row_t read_result =  mcp_read(0,GPIO_REG);

    if (read_result >= 0) {
      /* only if SPI read successful: populate the matrix row with the
    	 state of the 8 consecutive column bits */
      uprintf("read_result = %i\n",(uint8_t)read_result);

    }
    return changed;
}
A minor problem that has appeared is that the GPIO pins of the MCP give a slight input voltage (0.07v), even if nothing is connected to them, which ends up in false input readings. I don't know if it's because you don't have the actual matrix connected, because it's all mounted on a breadboard, or for some other reason that I don't know. It can be easily solved with some pull-down resistors (10k), but I don't know if they are really necessary. In some cases they are and in others they are not.

Thank you all for your help and interest.

MMcM

21 Nov 2022, 01:37

The data sheet seems to say that max output low (D080) is 0.6V, max input low (D031) is 0.2 Vdd = 0.6V @ 3V, min input high (D041) is 0.8 Vdd = 2.4V @3V. So it doesn't seem that 0.07V should be confusing anything.

Maybe just go ahead and code up the matrix to set input pullups and one output low and see whether that detects reliably.

Post Reply

Return to “Workshop”