Compare commits

4 Commits

42 changed files with 1604 additions and 49 deletions

View File

@@ -1,3 +1,5 @@
#include "Freenove_WS2812_Lib_for_ESP32.h"
/*********************************************************************************/ /*********************************************************************************/
// Constants // Constants
@@ -29,7 +31,7 @@
//#define PIN_JOY_Z 12 //#define PIN_JOY_Z 12
#define BUTTON_STABLETIME 100 #define BUTTON_STABLETIME 100
#define JOYSTICK_STABLETIME 1000 #define JOYSTICK_STABLETIME 3000
// Button states // Button states
#define BUTTON_STATE_BOUNCING (1 << 0) #define BUTTON_STATE_BOUNCING (1 << 0)
@@ -44,14 +46,14 @@
#define JOYSTICK_STATE_READY (1 << 3) #define JOYSTICK_STATE_READY (1 << 3)
// These aren't pins, they're just defines that help us identify the lights on the 2812 unit // These aren't pins, they're just defines that help us identify the lights on the 2812 unit
#define LED_2812_LEFT 1 // 2812 LEDs are ordered from connection point at left #define LED_2812_LEFT 0 // 2812 LEDs are ordered from connection point at left
#define LED_2812_TOPLEFT 2 // and go clockwise #define LED_2812_TOPLEFT 1 // and go clockwise
#define LED_2812_TOP 3 #define LED_2812_TOP 2
#define LED_2812_TOPRIGHT 4 #define LED_2812_TOPRIGHT 3
#define LED_2812_RIGHT 5 #define LED_2812_RIGHT 4
#define LED_2812_BOTTOMRIGHT 6 #define LED_2812_BOTTOMRIGHT 5
#define LED_2812_BOTTOM 7 #define LED_2812_BOTTOM 6
#define LED_2812_BOTTOMLEFT 8 #define LED_2812_BOTTOMLEFT 7
// Error flags // Error flags
@@ -97,7 +99,7 @@ typedef struct JoystickAxis {
uint8_t pin; uint8_t pin;
// Calibration data // Calibration data
Calibration calibration; Calibration calibration;
// Position as of the last read time // Position as of the last read time.
uint32_t position; uint32_t position;
} JoystickAxis; } JoystickAxis;
@@ -119,6 +121,9 @@ int errno;
Button calibration_button; Button calibration_button;
Joystick js; Joystick js;
alignas(Freenove_ESP32_WS2812) uint8_t strip_storage[sizeof(Freenove_ESP32_WS2812)];
Freenove_ESP32_WS2812 *strip;
/******************************************************************************/ /******************************************************************************/
// Initialization Functions // Initialization Functions
void initSerial() void initSerial()
@@ -126,8 +131,15 @@ void initSerial()
Serial.begin(115200); Serial.begin(115200);
} }
int init2812() int init2812(Freenove_ESP32_WS2812 **strip)
{ {
Freenove_ESP32_WS2812 *obj = new(strip_storage) Freenove_ESP32_WS2812(8, PIN_2812, 0, TYPE_GRB);
if ( obj == NULL ) {
ERROR(ERRNO_NULLPOINTER);
}
obj->setBrightness(10);
obj->begin();
*strip = obj;
return ERRNO_SUCCESS; return ERRNO_SUCCESS;
} }
@@ -175,7 +187,6 @@ int calibrateJoystick(Joystick *js)
ERROR(ERRNO_NULLPOINTER); ERROR(ERRNO_NULLPOINTER);
} }
curmillis = millis(); curmillis = millis();
if ( js->state == JOYSTICK_STATE_CALIBRATING ) {
if ( js->x.calibration.start_time == 0 || js->y.calibration.start_time == 0 ) { if ( js->x.calibration.start_time == 0 || js->y.calibration.start_time == 0 ) {
// Starting a new calibration cycle // Starting a new calibration cycle
Serial.printf("Starting a new calibration cycle\n"); Serial.printf("Starting a new calibration cycle\n");
@@ -210,7 +221,6 @@ int calibrateJoystick(Joystick *js)
} else if ( y > js->y.calibration.high ) { } else if ( y > js->y.calibration.high ) {
js->y.calibration.high = y; js->y.calibration.high = y;
} }
}
return ERRNO_SUCCESS; return ERRNO_SUCCESS;
} }
@@ -273,10 +283,16 @@ int readJoystick(Joystick *js)
adcvalue = analogRead(js->x.pin); adcvalue = analogRead(js->x.pin);
if ( adcvalue < js->x.calibration.low || adcvalue > js->x.calibration.high ) { if ( adcvalue < js->x.calibration.low || adcvalue > js->x.calibration.high ) {
js->x.position = adcvalue; js->x.position = adcvalue;
} else {
// Snap to center
js->x.position = 2048;
} }
adcvalue = analogRead(js->y.pin); adcvalue = analogRead(js->y.pin);
if ( adcvalue < js->y.calibration.low || adcvalue > js->y.calibration.high ) { if ( adcvalue < js->y.calibration.low || adcvalue > js->y.calibration.high ) {
js->y.position = adcvalue; js->y.position = adcvalue;
} else {
// Snap to center
js->y.position = 2048;
} }
//Serial.printf("Joystick x %d y %d\n", js->x.position, js->y.position); //Serial.printf("Joystick x %d y %d\n", js->x.position, js->y.position);
@@ -317,15 +333,43 @@ int displayLEDs(Joystick *js)
return ERRNO_SUCCESS; return ERRNO_SUCCESS;
} }
int display2812(Joystick *js) int display2812(Joystick *js, Freenove_ESP32_WS2812 *strip)
{ {
if ( js == NULL ) { if ( js == NULL ) {
ERROR(ERRNO_NULLPOINTER); ERROR(ERRNO_NULLPOINTER);
} }
strip->setLedColorData(LED_2812_LEFT, 0, 0, 0);
strip->setLedColorData(LED_2812_TOPLEFT, 0, 0, 0);
strip->setLedColorData(LED_2812_TOP, 0, 0, 0);
strip->setLedColorData(LED_2812_TOPRIGHT, 0, 0, 0);
strip->setLedColorData(LED_2812_RIGHT, 0, 0, 0);
strip->setLedColorData(LED_2812_BOTTOMRIGHT, 0, 0, 0);
strip->setLedColorData(LED_2812_BOTTOM, 0, 0, 0);
strip->setLedColorData(LED_2812_BOTTOMLEFT, 0, 0, 0);
// Is the joystick fully ready? Display direction on the 2812 // Is the joystick fully ready? Display direction on the 2812
if ( ( js->state & JOYSTICK_STATE_READY ) == JOYSTICK_STATE_READY ) { if ( ( js->state & JOYSTICK_STATE_READY ) == JOYSTICK_STATE_READY ) {
// Light up the correct LED_2812_* depending on the value of the X/Y axes on the joystick // Light up the correct LED_2812_* depending on the value of the X/Y axes on the joystick
if ( js->x.position < 1024 && (js->y.position < 3072 && js->y.position > 1024) ) {
strip->setLedColorData(LED_2812_LEFT, 128, 128, 128);
} else if ( js->x.position < 1024 && js->y.position < 1024 ) {
strip->setLedColorData(LED_2812_TOPLEFT, 128, 128, 128);
} else if ( ( js->x.position > 1024 && js->x.position < 3072 ) && js->y.position < 1024 ) {
strip->setLedColorData(LED_2812_TOP, 128, 128, 128);
} else if ( js->x.position > 3072 && js->y.position < 1024 ) {
strip->setLedColorData(LED_2812_TOPRIGHT, 128, 128, 128);
} else if ( js->x.position > 3072 && ( js->y.position < 3072 && js->y.position > 1024) ) {
strip->setLedColorData(LED_2812_RIGHT, 128, 128, 128);
} else if ( js->x.position > 3072 && js->y.position > 3072 ) {
strip->setLedColorData(LED_2812_BOTTOMRIGHT, 128, 128, 128);
} else if ( ( js->x.position > 1024 && js->x.position < 3072 ) && js->y.position > 3072 ) {
strip->setLedColorData(LED_2812_BOTTOM, 128, 128, 128);
} else if ( js->x.position < 1024 && js->y.position > 3072 ) {
strip->setLedColorData(LED_2812_BOTTOMLEFT, 128, 128, 128);
} }
}
strip->show();
return ERRNO_SUCCESS; return ERRNO_SUCCESS;
} }
@@ -342,7 +386,7 @@ void setup() {
calibration_button.pressedvalue = LOW; calibration_button.pressedvalue = LOW;
initSerial(); initSerial();
if ( init2812() != ERRNO_SUCCESS ) Serial.printf("Failed to initialize 2812 LED array : %d\n", errno); if ( init2812(&strip) != ERRNO_SUCCESS ) Serial.printf("Failed to initialize 2812 LED array : %d\n", errno);
if ( initLEDs() != ERRNO_SUCCESS ) Serial.printf("Failed to initialize LED GPIO pins : %d\n", errno); if ( initLEDs() != ERRNO_SUCCESS ) Serial.printf("Failed to initialize LED GPIO pins : %d\n", errno);
if ( initJoystick(&js) != ERRNO_SUCCESS ) Serial.printf("Failed to initialized Joystick datastructure : %d\n", errno); if ( initJoystick(&js) != ERRNO_SUCCESS ) Serial.printf("Failed to initialized Joystick datastructure : %d\n", errno);
if ( initButton(&calibration_button) != ERRNO_SUCCESS ) Serial.printf("Failed to initialize Calibration button and GPIO pin : %d\n", errno); if ( initButton(&calibration_button) != ERRNO_SUCCESS ) Serial.printf("Failed to initialize Calibration button and GPIO pin : %d\n", errno);
@@ -369,7 +413,7 @@ void loop() {
if ( displayLEDs(&js) != ERRNO_SUCCESS ) { if ( displayLEDs(&js) != ERRNO_SUCCESS ) {
Serial.printf("Failed to display LEDs : %d\n", errno); Serial.printf("Failed to display LEDs : %d\n", errno);
} }
if ( display2812(&js) != ERRNO_SUCCESS ) { if ( display2812(&js, strip) != ERRNO_SUCCESS ) {
Serial.printf("Failed to display 2812 LEDs : %d\n", errno); Serial.printf("Failed to display 2812 LEDs : %d\n", errno);
} }
} }

362
06-joystick/README.md Normal file
View File

@@ -0,0 +1,362 @@
# Joystick demo
![breadboard](breadboard.png)
This demo shows using a joystick peripheral to control the position of an LED light on a WS2812 light strip, as well as how to calibrate a joystick, while using PWM driven LEDs to show the relative position of a joystick on its axis and the status of the momentary joystick pushbutton. The breadboard picture above shows a WS2812 horizontal strip, but that's not what I used; mine was a circular LED arrangement, I just couldn't find the right part in Fritzing.
I won't cover the entirety of the code here, but you are welcome to peruse it for yourself: [06-joystick.ino](06-joystick.ino)
[Here is a video of the demonstration running](https://x.com/AKLabsDotNet/status/2063056777768239514)
# Lessons Learned
* Fritzing is time consuming but neat
* I still don't trust C++
* How the RMT peripheral on the ESP32-S3 works and why it is involved when driving WS2812 LED strips
* How to select pin assignments on the ESP32 without relying on the tutorial
* How to calibrate joysticks
## Fritzing
I've been using a breadboard software named [Fritzing](https://fritzing.org/) to do breadboard diagrams for these projects. If the breadboard is unchanged from a tutorial, I'll just copy and paste from the tutorial, but whenever I modify a breadboard, I make up the model in Fritzing. The Freenove folks were cool enough to send the Fritzing components for the devboard I'm using so making the breadboards is relatively easy. Still, translating the breadboard from reality to Fritzing *feels* slow. That's probably just in my head, but I have tended to dread it, kind of like writing documentation. It does have some amount of simulation ability, which I haven't explored yet, but you can apparently do some amount of arduino simulation from within Fritzing, which is neat.
I also haven't gotten the hang of Fritzing's schematic functionality. It can generate schematics from breadboards, which is pretty cool, but I'll be honest the schematics are soup. You have to do a ton of cleanup to make them readable, and I haven't devoted the time yet to figuring out the best way to manage that workflow.
![schematic](schematic.png)
That, uh ... That needs some work.
But if you're getting into this hobby, Fritzing is definitely worth the (small) money.
## I still don't trust C++
To be fair, I give C++ more hate than it deserves. But the way some people write it really does the language no favors.
The WS2812 tutorial(s) use a global variable with an initializer that is called outside of the scope of any function. This is something that you can do in C++ that you really can't do in C, and I tend to think C is right where C++ is wrong.
```arduino
Freenove_ESP32_WS2812 strip = Freenove_ESP32_WS2812(8, PIN_2812, 0, TYPE_GRB);
```
In C the code wouldn't compile, because you can't execute code (beyond setting initializer values with builtin types) outside of a function. In C++, this is valid for constructors. The compiler puts the objects in the data segment, which is nice, as the alternative might be to call `new()` on it and get storage from the heap, which is nasty. But the compiler will generate some magic unspecified initializer function wherein it will call the constructors on the object, before it calls `main()`. And the order of those initializers is not guaranteed, so if A depends on B, you may be shit out of luck. And I especially don't trust global initializers that seem to be touching hardware. The whole thing just feels *wrong*.
<center><img alt="it's wrong" width="320" src="cartmanitswrong.gif"/></center>
I chose instead to force the initializer for the global variable to happen during the `init2812` routine, which is called during `setup`.
```arduino
alignas(Freenove_ESP32_WS2812) uint8_t strip_storage[sizeof(Freenove_ESP32_WS2812)];
Freenove_ESP32_WS2812 *strip;
int init2812(Freenove_ESP32_WS2812 **strip)
{
Freenove_ESP32_WS2812 *obj = new(strip_storage) Freenove_ESP32_WS2812(8, PIN_2812, 0, TYPE_GRB);
if ( obj == NULL ) {
ERROR(ERRNO_NULLPOINTER);
}
obj->setBrightness(10);
obj->begin();
*strip = obj;
return ERRNO_SUCCESS;
}
```
With this pattern, I get the global variable, that lives in the data segment, that is initialized exactly when I want it to be initialized. The downside (some might say) is that I'm stuck with using a pointer to reference the object in memory. Someone who fears pointers could use `std::optional` and `emplace()` to do the same thing, assuming that their toolchain supports it. But I feel like that's adding complexity in layers of abstraction. It doesn't get much simpler than saying "This object lives in memory *right over there* and here is a pointer to it".
## Selecting pin assignments: read the datasheets
Up until now I've been using whatever pins the tutorial chose because, well, I had enough to figure out while getting my fundamental electronics lessons. But ...
<center><img alt="read the datasheet" width="320" src="readthedatasheet.jpg"/></center>
When you're following tutorials it's easy to assume "Pin X does Y thing because that's what the tutorial connected me to", and it's especially easy to think "Pin X **only** does Y thing", or "You **always** do Y thing with Pin X". This is almost never true. Each microcontroller has a datasheet from the manufacturer that tells you what all the different pins on the MCU do. The particular dev board you're using should also have a datasheet (or at least a pinout) that shows how the dev board maps all of the microcontroller pins to the devboard pins. Using these references will show you that, in fact, you can just lots of pins for lots of different functions, and that depending on how the chip is set up, a given pin may be overloaded with several different features - and that these usually can't be used together at the same time.
For example, here is the Freenove ESP32-S3 WROOM pinout from Freenove, the manufacturer of the dev board I'm using
<center><img alt="Freenove ESP32-S3 WROOM pinout" src="wroompinout.png" width="800px" /></center>
And here is some of the pin reference documentation from Espressif on the ESP32-S3 chip itself that lives on the devboard (click each image for higher resolution - this can alos be found in chapter 2 of the datasheet from Espressif). This should tell us that the pins on our devboard aren't on the same place as the ones on the ESP32-S3 itself, they may not even be numbered the same, and the board vendor may not actually choose to expose the entire package. At least the vendor and the chip manufacturer should be using the same *names* for the pins (`GPIOxx` for example) so we can cross reference them.
<img alt="Espressif ESP32-S3 pinout" src="esp32s3pinout.png" width="20%" />
<img alt="Espressif ESP32-S3 pin names" src="esp32s3pinnames.png" width="30%" />
<img alt="Espressif ESP32-S3 pin functions" src="esp32s3pinfunctions.png" width="40%" />
<br/>
<img alt="Espressif ESP32-S3 GPIO functions" src="esp32s3gpiofunctions.png" width="100%" />
... The datasheet goes on to explain that not only might these pins have multiple different functions, but that those functions:
* Might be input only, output only, or both
* May or may not be high impedance
* What the input signal of unused functions on a given pin will read when checkeed
* What is the default state of the pin after reset
* What is the drive strength of the signal on the pin (how many milliamps)
* What glitches occur on which pins at powerup (high level, low level, pull up, pull down) and how long does that glitch last
* Which pins have mappings to which in-package Flash/SRAM pins, which makes these pins unsuitable for other usage
It's also worth pointing out that, in order to get the most complete picture of the pin configuration, I actually had to consult THREE different datasheets:
* The Espressif ESP32-S3 datasheet
* The Espressif ESP32-S3 WROOM datasheet (the specific ESP32-S3 package my dev board is using, which has some differences vs a regular ESP32-S3)
* The Freenove devboard documentation (such as it is)
Using this information, I was able to make pin selections for my code, while noting some incompatibilities if we were to turn on other functions. I knew I was going to need:
* 2 input pins on an ADC controller for the joystick axes
* 2 output pins on a PWM controller for the joystick axes LED lights
* Something for the WS2812 signal
* 2 GPIOs, one for the calibration button, one for the joystick button
```
// Pin 21 is GPIO only
#define PIN_BUTTON 21 // Calibration button
// Apparently there is a dedicated 2812 controller in the ESP32-S3 that lives on this pin
// and this pin only.
#define PIN_2812 48 // Data bus for the 2812 LED
// These pins are shared with the second ADC unit. We're not using that unit in this project
// but for future projects as my boards become more dense I need to remember that each pin
// choice is a tradeoff. At a certain point we run out of available pins for adding stuff on,
// and have to start getting creative about how we use the pins we have left.
#define PIN_LED_X 16 // Red X axis LED
#define PIN_LED_Y 18 // Green Y axis LED
#define PIN_LED_Z 15 // Yellow Z axis LED (button)
// Pins 1 and 2 are on ADC channel 0 and 1.
#define PIN_JOY_X 1
#define PIN_JOY_Y 2
// Pin 47 is GPIO only
#define PIN_JOY_Z 47
```
Interstingly enough, the Freenove documentation labels pin 48 on their dev board as featuring some kind of dedicated WS2812 controller. However the text of their C tutorial says that the WS2812 is driven through the RMT controller (more on that in a minute). The ESP32-S3 WROOM MCU on this devboard doesn't even list 48 pins on the datasheet (though the parent ESP32-S3 datasheet does, so I guess the parent datasheet is authoritative? Maybe the missing pins are in the WROOM's "keep out zone"). When you're designing a board, you may need to ask questions like, "how will this dev board map to the real world when my code is driving the MCU on a different PCB? Will my assumptions about pins be correct? Will the peripherals be in the same place and work the same way? *Will this peripheral I'm using on the dev board even be there*?"
Hopefully the documentation makes all of that clear. Sometimes it does not, and you have to go spelunking, or ask the vendor questions directly. In the case of the WS2812 mystery pin 48, we can see in the ESP32-S3 datasheet chapter 3 (peripheral pin configurations) that the remote control peripheral (RMT) can be assigned to "any GPIO pin", depending on how it is initialized. So it looks like the Freenove WS2812 driver library is simply forcing the assignment of one of the RMT channels to pin 48, and that's why the tutorials and datasheets list pin 48 as the home for WS2812.
## WS2812s and the RMT
The WS2812 is a series of LEDs on a strip, which can be controlled by sending a compact stream of data to the strip, and each LED takes its own data before forwarding on the rest. This allows you to control each LED individually in a theoretically endless string of LEDs with very little hardware overhead. In the particular example here, the WS2812 strip is arranged as an octagon of LEDs on a square PCB package with one `V+` line, one `GND` line, and one `S`(ignal) line.
Oh, and you control it using the **infrared remote control module** on the ESP32.
<center><img alt="wot" width="320" src="../05-photoresistors/wot.jpeg"/></center>
The WS2812 is controlled with [a wierd single line protocol](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) that's really efficient but very sensitive to timing. The whole thing works off of PWM signals on the signal wire, and there is no clock synchronization. Basically every LED receives a 24 bit color value (in RGB or GRB or whatever format is appropriate), then there's a pause, then the next value is sent for the next LED. If you've got 8 LEDs in series, you get 8 24 bit values, in order.
```
LED8val LED7val LED6val LED5val LED4val LED3val LED2val LED1val
```
The 24 bits for each LED RGB/GBR value are sent in reverse order (bit 23 is sent first, down to bit 0). What's really wierd is that the bits in the value are sent using pulse width modulation. Each bit in a value consists of some portion of a full duty cycle being high, and some other portion being low. If the duty cycle is empty, then there is no bit on the wire during that time.
```
0 bit:
┌─┐
│ │
│ └─────────
└───────────
0.4µs 0.85µs
1 bit:
┌──────┐
│ │
│ └────
└───────────
0.8µs 0.45µs
```
Apparently this kind of task is what the remote control peripheral was made for. It's a pulse encoder, and what you do is you fill a buffer in RAM with the values you want sent, tell it to go, and it will take care of performing all the pulse width encoding of those values for you. (Or, in the reverse: it receives and decodes pulses, and puts them in memory for you.) You could do it yourself on a GPIO pin manually sending the PWM signals but it is very easy to get wrong, as you can see with the timing data; and even if your code was perfect, if you're running on a multitasking environment on the ESP32, you're basically screwed. Instead we can just send the data to the independent hardware peripheral, and the CPU goes along its merry way doing other things.
Unlike something like I2C or SPI where every LED would need its own address on the bus, and a lot more data lines, this protocol allows us to simply shove the data down the pipe and allow each LED to act as a filter for the one after it. FOr example, LED1 consumes the first 24 bits of information in the signal, and then passes everything else. LED2 consumes the next 24 and passes everything else, and so on, and so on. It's kind of odd but very efficient.
Anyway, with our circular array of 8 LEDs on the WS2812 strip, and with our ability to control every LED individually, we can light up a single LED on the array according to the X/Y values of the joystick, allowing us to move the light around the circle with the joystick.
All this happens inside the `display2812` function. First we turn off all the LEDs on the strip
```arduino
strip->setLedColorData(LED_2812_LEFT, 0, 0, 0);
strip->setLedColorData(LED_2812_TOPLEFT, 0, 0, 0);
strip->setLedColorData(LED_2812_TOP, 0, 0, 0);
strip->setLedColorData(LED_2812_TOPRIGHT, 0, 0, 0);
strip->setLedColorData(LED_2812_RIGHT, 0, 0, 0);
strip->setLedColorData(LED_2812_BOTTOMRIGHT, 0, 0, 0);
strip->setLedColorData(LED_2812_BOTTOM, 0, 0, 0);
strip->setLedColorData(LED_2812_BOTTOMLEFT, 0, 0, 0);
```
... then we turn on just the one we want according to the joystick position
```arduino
if ( ( js->state & JOYSTICK_STATE_READY ) == JOYSTICK_STATE_READY ) {
// Light up the correct LED_2812_* depending on the value of the X/Y axes on the joystick
if ( js->x.position < 1024 && (js->y.position < 3072 && js->y.position > 1024) ) {
strip->setLedColorData(LED_2812_LEFT, 128, 128, 128);
} else if ( js->x.position < 1024 && js->y.position < 1024 ) {
strip->setLedColorData(LED_2812_TOPLEFT, 128, 128, 128);
} else if ( ( js->x.position > 1024 && js->x.position < 3072 ) && js->y.position < 1024 ) {
strip->setLedColorData(LED_2812_TOP, 128, 128, 128);
} else if ( js->x.position > 3072 && js->y.position < 1024 ) {
strip->setLedColorData(LED_2812_TOPRIGHT, 128, 128, 128);
} else if ( js->x.position > 3072 && ( js->y.position < 3072 && js->y.position > 1024) ) {
strip->setLedColorData(LED_2812_RIGHT, 128, 128, 128);
} else if ( js->x.position > 3072 && js->y.position > 3072 ) {
strip->setLedColorData(LED_2812_BOTTOMRIGHT, 128, 128, 128);
} else if ( ( js->x.position > 1024 && js->x.position < 3072 ) && js->y.position > 3072 ) {
strip->setLedColorData(LED_2812_BOTTOM, 128, 128, 128);
} else if ( js->x.position < 1024 && js->y.position > 3072 ) {
strip->setLedColorData(LED_2812_BOTTOMLEFT, 128, 128, 128);
}
}
strip->show();
```
The code complexity is about the same as if we were using individual LEDs on the board, because I can still (effectively) control each one in the strip individually, thanks to the WS2812 driver code from Freenove that I'm using here. But the ability to do it all with one `+5v`, one `GND` and one `Data` wire on the board is really cool.
## Calibrating joysticks
I implemented calibration logic in my code, but it was a bit lazy, and to be honest, it doesn't do what real calibration typically does. But by the time I got here, I was just ready to move on to the next project. When you press the calibration button, the calibration routine starts which continually samples over a period of time. It creates a deadzone according to the lowest and highest values seen on each axes during this time. When future joystick reads occur, anything within this deadzone are counted as being at the center of the axis. Really I did this to account for potential wobble in the joystick I hooked to the arduino, but that was unnecessary, it was remarkably stable in the center.
The calibration data lives on the JoystickAxis datastructure
```arduino
typedef struct Calibration {
// When did calibration start
uint32_t start_time;
// Low calibration range
uint32_t low;
// High calibration range
uint32_t high;
// How long we should sample values on this joystick
uint32_t stable_time;
} Calibration;
typedef struct JoystickAxis {
// What ADC pin is this joystick axis connected to
uint8_t pin;
// Calibration data
Calibration calibration;
// Position as of the last read time.
uint32_t position;
} JoystickAxis;
```
A Joystick itself is represented as a collection of axis measurements and a button
```arduino
typedef struct Joystick {
// X axis data
JoystickAxis x;
// Y axis data
JoystickAxis y;
// This is the Z axis switch on the joystick control
Button button;
// What is the state of this joystick (bitmask of JOYSTICK_STATE_*)
uint8_t state;
} Joystick;
```
The calibration method manages the state of the joystick in question, performing calibration over a given period of time
```arduino
// Calibrate a given joystick
int calibrateJoystick(Joystick *js)
{
uint32_t x = 0;
uint32_t y = 0;
uint32_t curmillis = 0;
if ( js == NULL ) {
ERROR(ERRNO_NULLPOINTER);
}
curmillis = millis();
if ( js->x.calibration.start_time == 0 || js->y.calibration.start_time == 0 ) {
// Starting a new calibration cycle
Serial.printf("Starting a new calibration cycle\n");
js->x.calibration.low = 65535;
js->x.calibration.high = 0;
js->y.calibration.low = 65535;
js->y.calibration.high = 0;
js->x.calibration.start_time = curmillis;
js->y.calibration.start_time = curmillis;
return ERRNO_SUCCESS;
}
if ( (curmillis - js->x.calibration.start_time) >= js->x.calibration.stable_time ) {
js->state = JOYSTICK_STATE_CALIBRATED;
js->x.calibration.start_time = 0;
js->y.calibration.start_time = 0;
Serial.printf("Calibrated joystick to x: <%d, %d> y <%d, %d>\n",
js->x.calibration.low,
js->x.calibration.high,
js->y.calibration.low,
js->y.calibration.high);
return ERRNO_SUCCESS;
}
x = analogRead(js->x.pin);
if ( x < js->x.calibration.low ) {
js->x.calibration.low = x;
} else if ( x > js->x.calibration.high ) {
js->x.calibration.high = x;
}
y = analogRead(js->y.pin);
if ( y < js->y.calibration.low ) {
js->y.calibration.low = y;
} else if ( y > js->y.calibration.high ) {
js->y.calibration.high = y;
}
return ERRNO_SUCCESS;
}
```
The method for reading joystick axes and buttons is only called when the joystick has a `CALIBRATED` status
```arduino
if ( ( js.state & JOYSTICK_STATE_CALIBRATED) == JOYSTICK_STATE_CALIBRATED ) {
if ( readJoystick(&js) != ERRNO_SUCCESS ) {
Serial.printf("Failed to read joystick state : %d\n", errno);
}
}
```
... and the joystick read function performs the primitive snap-to-center lazy calibration:
```arduino
int readJoystick(Joystick *js)
{
uint32_t adcvalue = 0;
if ( js == NULL ) {
ERROR(ERRNO_NULLPOINTER);
}
js->state = js->state | JOYSTICK_STATE_READING;
adcvalue = analogRead(js->x.pin);
if ( adcvalue < js->x.calibration.low || adcvalue > js->x.calibration.high ) {
js->x.position = adcvalue;
} else {
// Snap to center
js->x.position = 2048;
}
adcvalue = analogRead(js->y.pin);
if ( adcvalue < js->y.calibration.low || adcvalue > js->y.calibration.high ) {
js->y.position = adcvalue;
} else {
// Snap to center
js->y.position = 2048;
}
//Serial.printf("Joystick x %d y %d\n", js->x.position, js->y.position);
readButton(&js->button);
js->state = js->state ^ JOYSTICK_STATE_READING;
if ( errno != ERRNO_SUCCESS ) {
Serial.printf("Failed to read joystick button : %d\n", errno);
return errno;
}
js->state = js->state | JOYSTICK_STATE_READY;
return ERRNO_SUCCESS;
}
```
A real calibration routine is more advanced and allows the center to actually move. As it samples, it figures out where the center of the sample average range is, and future joystick reads return the axis movement relative to that center location. My calibration routine here can't do that. I considered changing this, but again, that was outside of the original design goal for the calibration routine.
## Closing Thoughts
This project was fun. It was the first one I designed fully independently of tutorials (except for the PIN 48 assignment and library syntax for the WS2812), and I got a lot of reusable code out of the deal. The entire joystick and button subsystem can be reused in future projects. And I think one of my next projects is going to be connecting the joystick to a Linux PC as a human interface device, and seeing if I can control some [`libakgl`](https://source.starfort.tech/andrew/libakgl) demos with it.

BIN
06-joystick/breadboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
06-joystick/schematic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
06-joystick/wroompinout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

140
07-LM339N-ADC/README.md Normal file
View File

@@ -0,0 +1,140 @@
# Breadboard
![breadboard](breadboard.png)
This project uses an LM339N comparator chip, a voltage ladder, a potentiometer and 4 LEDs to create a Flash Analog to Digital Converter.
[Here is a video of it working](https://x.com/AKLabsDotNet/status/2064431239013048425?s=20)
It's worth pointing out that this ADC outputs thermometer code (unary code), not binary. I would have needed to use an additional priority encoder to get it converted down to binary. I didn't have a priority encoder on hand (though I could've built one with quite a few other chips I *did* have on hand), so I just left it at unary output.
# Lessons Learned
* What is a voltage ladder
* How pull up and pull down resistors work
* Breadboards are small finnicky things
* How USB power delivery dummies work
## Voltage ladder
Not a lot to say here. A voltage ladder is a series of connected voltage dividers. Each junction between resistors in the series is a step on the "ladder".
```
Supply
|
R1 220
|
>----- Step 1
|
R2 220
|
>----- Step 2
|
R3 220
|
>----- Step 3
|
R4 220
|
>----- Step 4
|
R5 220
|
GROUND
```
... and so on. The voltage is (assuming an equal dispersion of resistor values) equally divided at every step on the ladder, allowing you to take different voltages at different parts of the ladder. Varying the resistance values in between steps of the ladder will give you different divisions along the ladder.
For this project, I used 220 ohm resistors all the way through, for an equal 25% division at each step.
## Pull Up and Pull Down Resistors
This circuit uses LM339N chips to perform the comparison operations on the input voltage vs the reference voltage at various points along the voltage ladder. The LM339N output pins require a pull up resistor to do their job correctly.
[From the LM339N datasheet](https://www.ti.com/lit/ds/symlink/lm339-n.pdf?ts=1781077876487):
> The output is HIGH when the voltage on the non-inverting (+IN) input is greater than the inverting (-IN) input. The output is LOW when the voltage on the noninverting (+IN) input is less than the inverting (-IN) input
The voltage ladder is going to `-IN` and the potentiometer is going to `+IN`. So when the potentiometer is greater than a given step of the voltage ladder, the output for that step is supposed to be `LOW`; otherwise it should be `HIGH`. However, if we just wire the LEDs up directly to the output pins, we get unpredictable readings on the output pin - I got `0.25v` when I was expecting `5v`.
<center><img alt="Okay but why" src="butwhy.webp" width="320px"/></center>
When working with electronics, you might see something called a "pull up" or "pull down" resistor. For example,
* "... We connect this pin to a pull up resistor which pulls the voltage up to 5v"
* "... We connect this pin to a pull down resistor which pulls the voltage down to 0v"
Electricity, like water, always wants to take the path of least resistance possible. So if there is a resistor of any value in a circuit path, the electrons will prefer to go a different way until the path of least resistance is found. Pull up and pull down resistors use this property of electricity, in cooperation with [transistors](../02-buzzers_with_transistors#transistors), to control the flow of voltage between a supply and a ground.
In lesson 2 I talked about [how transistors work](../02-buzzers_with_transistors#transistors)), having a collector, an emitter, and a base. They work by a charge coming into the base and changing the amount of charge carriers that can pass through depletion zone in the `P/N` materials at the `BC` and `BE` junction. Crudely speaking, the base connector is used like a switch to turn the flow from one side of the transistor to the other (depending on how the transistor is configured) on or off.
If we look at the block diagram for the LM339N [from the datasheet](https://www.ti.com/lit/ds/symlink/lm339-n.pdf?ts=1781077876487), we can see that the integrated circuit is made up of (among other things) a number of transistors, especially transistors that sit right at the output pins:
<center><img alt="TI LM339N block diagram" src="lm339nblock.png"/></center>
This tells us that the output pin is actually one leg of a transistor, and that the transistor is set up as a low side switch. Makes sense - we have comparator that will be HIGH or LOW depending on the operation, so we have an output that will be turned on or off depending on the output of that comparison. The LM339N datasheet further clarifies this:
> The output of the LMx39-N series is the uncommitted collector of a grounded-emitter NPN output transistor.
What they're saying here is that the collector is floating, but the transistor is setup as a low-side switch. *The collector is actually the output pin*; the emitter is going to ground. Remember what I said about low side switching:
> ... with low side switching, the load is always connected to +V and the load just floats when it's turned off.
This is why we see `0.25v` without the pull up resistor. The output is just floating, so when the base goes high, there's no voltage there, because the LM339N isn't actually providing any voltage of its own. It essentially produces unpredictable noise, because the charge from base was allowing charge carriers to move but there were no charge carriers around and they had nowhere to go anyway. I got `0.25v` you might get something else, but either way it's not usable voltage.
So how do we solve this? How do we get 5v out of our pin like we expect? We have to **supply the 5v to the output pin ourself**, and use a `pull up resistor` to make the 5v supply current flow on the output path whenever that transistor is activated.
```
+---------+
| |
| | OUTPUT
| |-----+--> 220R ---> LED
| LM339N | ^
| | |
| | |
| | +--- 10kR <--- +5v
+---------+
```
What this does is weakly connects 5v onto the output pin. When the comparator returns a false result, the transistor provides a strong connection to ground that provides an easier path than for the +5v to travel through the resistor out to the output, and the transistor sinks the `+5v` supply voltage coming from the output pin directly to ground. So we get a `+5v` when the comparator says "your potentiometer is above this step on the voltage ladder", and we get ground reference voltage (`0v`) in the opposite case. However when the comparator returns a true result, the transistor ensures the easiest path for the `+5v` supply voltage is out to the LED rather than back towards ground.
A pull down resistor works in the opposite way.
```
+---------+
| |
| | OUTPUT
| |------+--> 220R ---> LED
+5v --> | DEVICE | |
| | |
| | |
| | +--- 10kR ---> GROUND
+---------+
```
Instead of weakly connecting the output pin to supply voltage, it weakly connects the output pin to ground. When the transistor is open, the pull down resistor keeps the output (which would otherwise float) down at ground reference voltage (`0v`). When the transistor is closed, the `10k` resistor allows only a very small amount of current to flow, which keeps the output at supply voltage (depending on the device).
<center><img alt="That's just on and off with extra steps" src="extrasteps.jpeg" /></center>
Part of the confusion here is not understanding how the LM339N in particular works. The LM339 never outputs a logic 1. It only outputs a logic 0 or "nothing." The pull-up resistor converts that "nothing" state into a valid logic 1 by gently connecting the output to the supply voltage. The datasheet explains this, but if you're like me, that point might WHOOSH past you.
Open collector outputs like you see on the LM339N do have some uses. For example:
* You can combine multiple outputs onto a single pullup resistor to make a `wired-AND` or `wired-OR` (depending on the logic of your particular chip). This way, if any of the comparator outputs go low, they all go low.
* You can pretty easily set different outputs to different voltage levels. For example you can supply the LM339N with 5v but drop the output pins to 3.3v.
And I'm sure there are others besides this.
## Breadboards are small and finicky things
Once you start getting into integrated circuits, a breadboard starts feeling really small really fast. I've seen some folks who have breadboarding setups that are essentially dozens of breadboards glued together, and I'm definitely seeing why. Initially I had planned to use several of these LM339N chips to make a 4-bit ADC, but after seeing how hairy the wiring got on a single one, I quickly ditched that plan.
Also they are not the best at forming strong connections, especially if your components stand proud of the board. If your resistors have long legs, for example, they may bend and move and momentarily lose contact with the breadboard. You may need to poke things to make them work. On simple circuits that's not a huge issue, but even at this level of complexity, there were several times I thought I had a bug in my design only to find I needed to touch a loose resistor or an LED to make things work again.
## How USB power delivery dummies work
This was my first exposure to a USB-C power delivery dummy device. Basically it's just a little USB plug with an IC, a few resistors, a `VCC` and `GND` connection, and (on some models like mine) a switch that tells the IC what output voltage you want.
The device acts by pretending to be a USB device, and asks for the voltage you've requested. If the power delivery device can provide the granted voltage, that's what you get. Otherwise you will get 5 volts. Some devices, no matter what you ask for or what they agree to, will always provide 5 volts and you just have to get bent if you don't like that. But these things were a really neat way to power this board, and I'm sure I'll be using them again soon.
They can be had very cheaply - I'm sure there are cheaper options, but [these are the ones I ordered](https://www.amazon.com/dp/B0FXL6DLB9).

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

BIN
07-LM339N-ADC/butwhy.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,380 @@
#include <Arduino.h>
#include "controls.h"
#include "error.h"
#include "IC74HC595.h"
#include "7Segment.h"
#include "matrix.h"
#define PIN_BUZZER 21
#define PWM_FREQUENCY 1000
#define PWM_BITWIDTH 12
#define PWM_BUZZER_CHANNEL 0
#define ENTITY_ALIVE 1 << 0
#define ENTITY_FOOD 1 << 1
#define ENTITY_SNAKE 1 << 2
#define ENTITY_HIGH_MASK 248 // 11111000
#define ENTITY_LOW_MASK 7 // 00000111
#define ENTITY_SHIFT_LIFETIME 3
int8_t player_x;
int8_t player_y;
int8_t delta_x;
int8_t delta_y;
// Entities represent a lit pixel on the LED matrix. Each entity is an 8 bit mask that consists of:
//
// 8 0
// 0 0 0 0 0 0 0 0
// ^ ^ ^ ^ ^
// | | | | `-- Alive or dead. When 1, display this entity.
// | | | `---- Food (1)
// | | `------ Snake (1)
// | |
// `-------+-------- Lifetime. How many game cycles should this entity remain for? 0-248.
// The lifetime gets counted down each cycle!
#define GAMESTATE_SCREENCAL 1
#define GAMESTATE_PLAY 2
#define GAMESTATE_LOSE 3
#define GAMESTATE_WIN 4
uint8_t entities[64] = {0};
int found_food = 0;
int8_t tail_length = 2;
uint32_t gamespeed;
uint32_t lastupdate;
uint32_t difficulty = 0;
uint8_t pad1 = 0;
uint8_t score = 0;
uint8_t pad2 = 0;
uint8_t open_indexes[64] = {0};
Button btn_left = {PIN_BUTTON_LEFT, 0, LOW, BUTTON_STABLETIME, 0};
Button btn_up = {PIN_BUTTON_UP, 0, LOW, BUTTON_STABLETIME, 0};
Button btn_down = {PIN_BUTTON_DOWN, 0, LOW, BUTTON_STABLETIME, 0};
Button btn_right = {PIN_BUTTON_RIGHT, 0, LOW, BUTTON_STABLETIME, 0};
uint8_t gamestate = 0;
void initSerial()
{
Serial.begin(115200);
Serial.printf("\nSERIAL READY\n");
}
void initBuzzer() {
ledcAttachChannel(PIN_BUZZER, 1000, 10, PWM_BUZZER_CHANNEL);
ledcWriteTone(PIN_BUZZER, 2000);
delay(1000);
ledcWriteTone(PIN_BUZZER, 0);
}
void move_player()
{
player_x += delta_x;
player_y += delta_y;
if ( player_x > 7) {
player_x = 0;
} else if ( player_x < 0 ) {
player_x = 7;
}
if ( player_y > 7 ) {
player_y = 0;
} else if ( player_y < 0 ) {
player_y = 7;
}
}
void collide_player()
{
uint8_t entity;
entity = entities[(player_x * 8) + player_y];
// Is there an entity where we're going?
if ( entity > 0 ) {
// We die if we hit our own body
if ( (entity & ENTITY_SNAKE) == ENTITY_SNAKE ) {
ledcWriteTone(PIN_BUZZER, 2000);
delay(1000);
ledcWriteTone(PIN_BUZZER, 0);
init_game();
} else if ( (entity & ENTITY_FOOD) == ENTITY_FOOD ) {
found_food = 1;
score += 1;
}
}
}
void draw_entities()
{
uint8_t entity;
resetMatrix();
if ( gamestate == GAMESTATE_SCREENCAL ) {
for ( int i = 0 ; i < 64 ; i++ ) {
if ( pokeMatrixPixel((i / 8), (i % 8)) != ERRNO_SUCCESS ) {
Serial.printf("Failed to poke pixel : %d\n", i, errno);
return;
}
}
} else {
// Poke matrix pixels for every living entity
for ( int i = 0 ; i < 64; i++ ) {
entity = entities[i];
if ( ( entity & ENTITY_ALIVE ) == ENTITY_ALIVE ) {
if ( pokeMatrixPixel((i / 8), (i % 8)) != ERRNO_SUCCESS ) {
Serial.printf("Failed to poke pixel for entity %d : %d\n", i, errno);
return;
}
}
}
}
displayMatrix();
}
void update_matrix_entities()
{
uint8_t entity;
int8_t elife = 0;
// Poke matrix pixels for every living entity
for ( int i = 0 ; i < 64; i++ ) {
entity = entities[i];
if ( (entity & ENTITY_ALIVE) == ENTITY_ALIVE && i != ((player_x * 8) + player_y) ) {
// Snake parts age (except the head)
if ( (entity & ENTITY_SNAKE) == ENTITY_SNAKE ) {
elife = (entity & ENTITY_HIGH_MASK) >> ENTITY_SHIFT_LIFETIME;
if ( found_food == 0 ) {
// Count down their lifetime
if ( elife == 0 ) {
entity = 0;
} else {
elife -= 1;
entity = (elife << ENTITY_SHIFT_LIFETIME) | (entity & ENTITY_LOW_MASK);
}
entities[i] = entity;
} /*else {
elife += 1;
entity = (elife << ENTITY_SHIFT_LIFETIME) | (entity & ENTITY_LOW_MASK);
entities[i] = entity;
}*/
}
}
}
}
void place_random_entity(int8_t eflags, int8_t set_player)
{
uint8_t index = 0;
uint8_t entity = 0;
uint8_t open_positions = 0;
memset((void *)&open_indexes, 0x00, 64);
// Count how many positions are open on the board
for ( int i = 0 ; i < 64; i++ ) {
entity = entities[i];
if ( entity == 0) {
open_indexes[open_positions] = i;
open_positions += 1;
} else {
}
}
if ( open_positions == 0 ) {
// This is a win condition!
gamestate = GAMESTATE_WIN;
return;
}
index = random(0, open_positions-1);
if ( index > 63 ) {
Serial.printf("Index out of bounds!\n");
return;
}
index = open_indexes[index];
// Place a random piece of food on the board
entities[index] = (eflags);
if ( set_player == 1 ) {
player_x = (index / 8);
player_y = (index % 8);
}
}
void init_game()
{
tail_length = 2;
memset((void *)&entities, 0x00, 64);
gamespeed = 1000;
lastupdate = millis();
score = 0;
delta_x = 0;
delta_y = 0;
place_random_entity(ENTITY_HIGH_MASK | ENTITY_ALIVE | ENTITY_FOOD, 0);
// Place the player away from the food
place_random_entity(((tail_length << ENTITY_SHIFT_LIFETIME) | ENTITY_ALIVE | ENTITY_SNAKE), 1);
}
void read_controls()
{
difficulty = readDifficulty();
if ( readButton(&btn_up) != ERRNO_SUCCESS ||
readButton(&btn_left) != ERRNO_SUCCESS ||
readButton(&btn_right) != ERRNO_SUCCESS ||
readButton(&btn_down) != ERRNO_SUCCESS ) {
Serial.printf("Failed to read buttons\n");
return;
}
if ( gamestate == GAMESTATE_SCREENCAL ) {
if ( btn_up.state + btn_down.state + btn_left.state + btn_right.state != 0 ) {
init_game();
gamestate = GAMESTATE_PLAY;
}
} else {
// We don't let the player reverse direction back on themselves.
if ( (delta_x == 0) && (btn_up.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_x = 1;
delta_y = 0;
}
if ( (delta_x == 0) && (btn_down.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_x = -1;
delta_y = 0;
}
if ( (delta_y == 0) && (btn_right.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_y = -1;
delta_x = 0;
}
if ( (delta_y == 0) && (btn_left.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_y = 1;
delta_x = 0;
}
}
}
void display_score()
{
uint8_t integerValues[] = {
CHAR_7SEG_0,
CHAR_7SEG_1,
CHAR_7SEG_2,
CHAR_7SEG_3,
CHAR_7SEG_4,
CHAR_7SEG_5,
CHAR_7SEG_6,
CHAR_7SEG_7,
CHAR_7SEG_8,
CHAR_7SEG_9,
};
uint8_t score_cur = 0;
int rc = 0;
if ( gamestate == GAMESTATE_SCREENCAL ) {
select7SegDisplay(PIN_7SEGMENT_DIGIT4);
rc = writeIC74HC595Char(&scoreboard, CHAR_7SEG_C, MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
select7SegDisplay(PIN_7SEGMENT_DIGIT3);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_A, MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
select7SegDisplay(PIN_7SEGMENT_DIGIT2);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_L, MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
}
return;
}
if ( difficulty > 0 ) {
gamespeed = 1000 / difficulty;
} else {
gamespeed = 1000;
}
score_cur = (score % 10);
select7SegDisplay(PIN_7SEGMENT_DIGIT1);
rc = writeIC74HC595Char(&scoreboard, integerValues[score_cur], MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
}
score_cur = (score / 10);
select7SegDisplay(PIN_7SEGMENT_DIGIT2);
rc = writeIC74HC595Char(&scoreboard, integerValues[score_cur], MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
}
select7SegDisplay(PIN_7SEGMENT_DIGIT4);
rc = writeIC74HC595Char(&scoreboard, integerValues[difficulty], MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
}
}
void gamelogic()
{
uint32_t updatetime = millis();
found_food = 0;
Serial.printf("Score is %d\n", score);
if ( (updatetime - lastupdate) > gamespeed ) {
lastupdate = updatetime;
// Skip all of this if we have no delta (the player isn't playing yet)
if ( (delta_x + delta_y) != 0 ) {
move_player();
collide_player();
// Add a snake entity to the new location
if ( found_food == 1 ) {
tail_length += 1;
place_random_entity(ENTITY_HIGH_MASK | ENTITY_ALIVE | ENTITY_FOOD, 0);
}
entities[(player_x * 8) + player_y] = ((tail_length << ENTITY_SHIFT_LIFETIME) | ENTITY_ALIVE | ENTITY_SNAKE );
}
update_matrix_entities();
found_food = 0;
}
}
void setup() {
initSerial();
score = 0;
if ( init74HC595(&matrixcols) != ERRNO_SUCCESS ) {
Serial.printf("failed to initialize matrix series 74HC595");
}
if ( initButton(&btn_up) != ERRNO_SUCCESS ||
initButton(&btn_left) != ERRNO_SUCCESS ||
initButton(&btn_right) != ERRNO_SUCCESS ||
initButton(&btn_down) != ERRNO_SUCCESS ) {
Serial.printf("Failed to initialize buttons\n");
}
if ( init74HC595(&scoreboard) != ERRNO_SUCCESS ) {
Serial.printf("Failed to initialize scoreboard 74HC595");
}
if ( init7Segment() != ERRNO_SUCCESS ) {
Serial.printf("Failed to initialize 7 segment display");
}
initBuzzer();
init_game();
gamestate = GAMESTATE_SCREENCAL;
Serial.printf("\nSETUP READY\n");
}
void loop() {
int rc = 0;
read_controls();
display_score();
gamelogic();
draw_entities();
}

View File

@@ -0,0 +1,42 @@
#include <Arduino.h>
#include "7Segment.h"
#include "IC74HC595.h"
#include "error.h"
int init7Segment() {
pinMode(PIN_7SEGMENT_DIGIT1, OUTPUT);
pinMode(PIN_7SEGMENT_DIGIT2, OUTPUT);
pinMode(PIN_7SEGMENT_DIGIT3, OUTPUT);
pinMode(PIN_7SEGMENT_DIGIT4, OUTPUT);
return ERRNO_SUCCESS;
}
void test7Segment()
{
uint8_t segments[] = {
PIN_7SEGMENT_DIGIT1,
PIN_7SEGMENT_DIGIT2,
PIN_7SEGMENT_DIGIT3,
PIN_7SEGMENT_DIGIT4
};
for ( int j = 0; j < 4 ; j++ ) {
select7SegDisplay(segments[j]);
for ( int i = 0 ; i < 8; i++ ) {
if ( i > 0 ) {
writeIC74HC595Char(&scoreboard, ((uint8_t) ~(SEG_DP | (1 << i))), MSBFIRST);
} else {
writeIC74HC595Char(&scoreboard, ((uint8_t) ~(SEG_DP | 1)), MSBFIRST);
}
delay(1000);
}
}
}
int select7SegDisplay(uint8_t pin)
{
digitalWrite(PIN_7SEGMENT_DIGIT1, LOW);
digitalWrite(PIN_7SEGMENT_DIGIT2, LOW);
digitalWrite(PIN_7SEGMENT_DIGIT3, LOW);
digitalWrite(PIN_7SEGMENT_DIGIT4, LOW);
digitalWrite(pin, HIGH);
return ERRNO_SUCCESS;
}

View File

@@ -0,0 +1,48 @@
#ifndef _7SEGMENT_H_
#define _7SEGMENT_H_
#define SEG_A 1
#define SEG_B 1 << 1
#define SEG_C 1 << 2
#define SEG_D 1 << 3
#define SEG_E 1 << 4
#define SEG_F 1 << 5
#define SEG_G 1 << 6
#define SEG_DP 1 << 7
// 3, 5, A, F don't properly handle segment D (bottom). Not sure why.
// Other digits that include the bottom segment (like 2, 0, 8) and that don't include it
// (like 7, 9, 4) work fine.
#define CHAR_7SEG_0 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_1 (uint8_t) ~( SEG_B | SEG_C)
#define CHAR_7SEG_2 (uint8_t) ~( SEG_A | SEG_B | SEG_G | SEG_E | SEG_D)
#define CHAR_7SEG_3 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_D | SEG_G )
#define CHAR_7SEG_4 (uint8_t) ~( SEG_F | SEG_G | SEG_B | SEG_C)
#define CHAR_7SEG_5 (uint8_t) ~( SEG_A | SEG_F | SEG_G | SEG_C | SEG_D)
#define CHAR_7SEG_6 (uint8_t) ~( SEG_A | SEG_F | SEG_E | SEG_D | SEG_C | SEG_G)
#define CHAR_7SEG_7 (uint8_t) ~( SEG_A | SEG_B | SEG_C)
#define CHAR_7SEG_8 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_9 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_F | SEG_G)
#define CHAR_7SEG_A (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_B (uint8_t) ~( SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_C (uint8_t) ~( SEG_A | SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_D (uint8_t) ~( SEG_C | SEG_D | SEG_E | SEG_B | SEG_G)
#define CHAR_7SEG_E (uint8_t) ~( SEG_A | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_F (uint8_t) ~( SEG_A | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_L (uint8_t) ~( SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_DP (uint8_t) ~SEG_DP
#define CHAR_7SEG_OFF 0xFF
#define PIN_7SEGMENT_DIGIT1 7
#define PIN_7SEGMENT_DIGIT2 6
#define PIN_7SEGMENT_DIGIT3 5
#define PIN_7SEGMENT_DIGIT4 4
int init7Segment(void);
void test7Segment(void);
int select7SegDisplay(uint8_t num);
#endif // _7SEGMENT_H_

View File

@@ -0,0 +1,21 @@
#include <Arduino.h>
#include "IC74HC595.h"
#include "error.h"
IC74HC595 matrixcols = {PIN_74HC595_MATRIX_UPDATE, PIN_74HC595_MATRIX_DATA, PIN_74HC595_MATRIX_CLOCK};
IC74HC595 scoreboard = {PIN_74HC595_SCOREBOARD_UPDATE, PIN_74HC595_SCOREBOARD_DATA, PIN_74HC595_SCOREBOARD_CLOCK};
int init74HC595(IC74HC595 *sr) {
pinMode(sr->updatepin, OUTPUT);
pinMode(sr->clockpin, OUTPUT);
pinMode(sr->datapin, OUTPUT);
return ERRNO_SUCCESS;
}
int writeIC74HC595Char(IC74HC595 *sr, uint8_t value, uint8_t order)
{
digitalWrite(sr->updatepin, LOW);
shiftOut(sr->datapin, sr->clockpin, order, value);
digitalWrite(sr->updatepin, HIGH);
return ERRNO_SUCCESS;
}

View File

@@ -0,0 +1,28 @@
#ifndef _IC74HC595_H_
#define _IC74HC595_H_
// We save 4 pins by updating all 3 shift registers at once
// with one clock and one update signal. Each shift register
// only needs its own data line.
#define PIN_74HC595_SCOREBOARD_CLOCK 11
#define PIN_74HC595_MATRIX_CLOCK 12
#define PIN_74HC595_MATRIX_UPDATE 8
#define PIN_74HC595_SCOREBOARD_UPDATE 17
#define PIN_74HC595_MATRIX_DATA 18
#define PIN_74HC595_SCOREBOARD_DATA 14
typedef struct IC74HC595 {
uint8_t updatepin;
uint8_t datapin;
uint8_t clockpin;
} IC74HC595;
extern IC74HC595 matrixcols;
extern IC74HC595 scoreboard;
int init74HC595(IC74HC595 *sr);
int writeIC74HC595Char(IC74HC595 *sr, uint8_t value, uint8_t order);
#endif // _IC74HC595_H_

285
08-74HC595-Snake/README.md Normal file
View File

@@ -0,0 +1,285 @@
# Breadboard
![breadboard](breadboard.png)
This project uses 3 74HC595 serial to parallel shift registers, a 7-segment display multi unit, an 8x8 LED matrix, an active buzzer, an NPN transistor, a joystick (later replaced with 4 buttons), a handful of resistors and a potentiometer to make a playable Snake game.
This was easily the most challenging project so far, taking me almost two weeks to get it working right. I wound up having to break the project out across 4 separate breadboards. The breadboards look as gross in real life as it does in Fritzing.
# Lessons Learned
* C has admitted design flaws
* Hardware debouncing is definitely the way to go
* The Arduino IDE debugger really sucks
* It's been a long time since I had to think this hard about what my code was doing to the hardware
* Compiling, uploading, and debugging Arduino code from the CLI
* Breadboards probably introduce just as many bugs as I do
* How to drive a 7-segment display and an 8x8 LED matrix using shift registers
* Embedded platforms have undocumented limits that may surprise you
* You don't need lots of pixels, or even colors, to make it fun
## C design flaw : the binary operators in comparison
If you've followed my blog much at all, you will know that I am of the opinion that, as a programming language, [C is basically perfect. (Fight me)](https://aklabs.net/2026/01/10/libakerror/#There-is-no-problem-C-is-not-broken). But ... it does have quirks. And one of those quirks had me scratching my head from the time I wrote the game logic to the very end.
If you haven't played a snake game before, it is a simple thing. You have a snake, and you have food. The snake moves around the board and eats the food. The food stays on the board until the snake eats it. When the snake eats the food, the snake gets longer. If the snake runs into its own body, the game ends. If the snake gets so big that there is no longer space for food to spawn on the game board, you win.
In my snake game there are two kinds of entities on the game board: snake and food. Entities have a certain lifetime, and when they reach the end of that lifetime, they disapear from the screen. Food, however, is never supposed to disappear. It stays until the snake eats it.
Initially my code was counting down the lifetime on food entities, and I couldn't figure out why. Can you spot the bug?
```arduino
if ( entity & ENTITY_ALIVE == ENTITY_ALIVE && i != ((player_x * 8) + player_y) ) {
// Snake parts age (except the head)
if ( entity & ENTITY_SNAKE == ENTITY_SNAKE ) {
```
The intent is obvious:
- If an entity is alive
- AND the entity is not the player's current position
- AND the entity is a snake body part
- ... Decrement its lifetime
As it turns out, I had fallen victim to a classic blunder without realizing it. The `&` operator has a lower precedence than the `==` operator. So the compiler was actually generating an evaluation that looked like this:
```arduino
if ( entity && (ENTITY_SNAKE == ENTITY_SNAKE) ) {
```
... what the hell? That's not what I meant! But according to [The Man Himself](https://www.lysator.liu.se/c/dmr-on-or.html), this is a long standing design flaw in the C language:
> Early C had no separate operators for & and && or | and ||. [...] Instead it used the notion (inherited from B and BCPL) of "truth-value context": where a Boolean value was expected, after "if" and "while" and so forth, the & and | operators were interpreted as && and || are now; in ordinary expressions, the bitwise interpretations were used.
In other words, inside of a comparison operation (`if` or `switch` or `for` or `while`), `&` and `|` get evaluated AFTER the comparison operators (`<, >, <=, >=, ==, !=` etc). He admitted and lamented this design choice, but hey, it's already out in the wild, so we aren't changing it.
Parenthetize your bitwise operations inside of comparisons, and you'll be just fine. Don't let this happen to you, kids.
## Compiling, uploading, and debugging from the CLI
The Arduino IDE is ... not great. Its editor is crummy; I edit my code in Emacs and just use the IDE to upload, monitor and debug it. And frankly its debugger is not that great - I find most GUI debuggers to be kind of clunky, and this one especially so.It *works*, but I would say, *just barely*. The IDE is great for finding and adding libraries and things like that, but I don't like being constrained inside of it. Luckily the IDE is mostly just calling `arduino-cli` behind the scenes so everything the IDE does, we can do from the CLI. Setting up the arduino-cli is reasonably simple, but it quickly falls apart when you want to start debugging.
To set up the ESP32S3 dev board:
```
$ arduino-cli core update-index \
--additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
$ arduino-cli core install esp32:esp32 \
--additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
$ arduino-cli board list
Port Protocol Type Board Name FQBN Core
/dev/ttyACM0 serial Serial Port (USB) Unknown
```
To attach to the dev board when it's connected over USB Serial
```
# Get the board FQBN from the board list
$ arduino-cli board list all esp32
$ arduino-cli board attach -b esp32:esp32:esp32s3 -p serial:///dev/ttyACM0 .
```
To compile a project in the current directory
```
arduino-cli compile -b esp32:esp32:esp32s3 .
```
.. Then watch lots and lots of errors because the Arduino IDE has hidden the knowledge of header files and compiler flags from you.
* Apparently `.ino` files are magic, and get a bunch of stuff added to them for free, like the correct header files (like `Arduino.h`) for whatever you're using.
* Beware the fact that Arduino is C++ by default - if you make a `.c` file and expect to be able to call `Serial.xxx()`, that's not gonna work. Your code has to be `.cpp` files, like it or not.
* Your board has hidden compile options that you're used to setting in the Arduino IDE. For example USB CDC on boot options. If you don't set this at all, you'll get mysterious errors about Serial missing, etc. You need to inspect the board parameters and pass them to your build command appropriately.
* You don't get to control the link order on the output files.
* If you don't specify the output directory, it goes to some cache directory off in the middle of nowhere
```
$ mkdir -p build
$ arduino-cli compile --fqbn esp32:esp32:esp32s3:CDCOnBoot=cdc --output-dir build/ .
Sketch uses 320680 bytes (24%) of program storage space. Maximum is 1310720 bytes.
Global variables use 22504 bytes (6%) of dynamic memory, leaving 305176 bytes for local variables. Maximum is 327680 bytes.
$ ls build/
08-74HC595-Snake.ino.bin 08-74HC595-Snake.ino.elf 08-74HC595-Snake.ino.merged.bin esp32.esp32.esp32s3
08-74HC595-Snake.ino.bootloader.bin 08-74HC595-Snake.ino.map 08-74HC595-Snake.ino.partitions.bin
```
Sweet. Now to upload it
```
$ arduino-cli upload -p /dev/ttyACM0 -b esp32:esp32:esp32s3:CDCOnBoot=cdc --build-path build/
esptool v5.3.0
Serial port /dev/ttyACM0:
Connecting....
Connected to ESP32-S3 on /dev/ttyACM0:
Chip type: ESP32-S3 (QFN56) (revision v0.2)
Features: Wi-Fi, BT 5 (LE), Dual Core + LP Core, 240MHz, Embedded PSRAM 8MB (AP_3v3)
Crystal frequency: 40MHz
MAC: 1c:db:d4:59:f8:c4
Uploading stub flasher...
.... snip ...
Verifying written data...
Hash of data verified.
Hard resetting via RTS pin...
New upload port: /dev/ttyACM0 (serial)
```
Now let's monitor the serial:
```
$ arduino-cli monitor -p /dev/ttyACM0
Using default monitor configuration for board: esp32:esp32:esp32s3
Monitor port settings:
baudrate=9600
bits=8
dtr=on
parity=none
rts=on
stop_bits=1
Connecting to /dev/ttyACM0. Press CTRL-C to exit.
```
Let's debug it. The process requires spinning up OpenOCD and connecting GDB to it. Figuring out where OpenOCD got installed is not necessarily straightforward ... it depends on your toolchain, version, etc ... There is some googling involved. The easiest way is to fire up a debugger session from inside of `arduino-ide` and check `ps -aufx` to find the openocd process being executed. But once you find it:
```
cd ~/.arduino15/packages/esp32/tools/openocd-esp32/v0.12.0-esp32-20251215/share/openocd/scripts/
../../../bin/openocd -f interface/esp_usb_jtag.cfg -f target/esp32s3.cfg
```
Now you can connect gdb to it. Right?
<center><img alt="This is where we would run the debugger, if we had one" src="nodebugger.jpeg" width="320px"/></center>
Not really.
The Arduino IDE hides some complexity in how to hook up the debugger. The openocd server serves as a bridge between gdb and the jtag interface on the ESP32, and you can't just hook gdb up to it. Even once you find the right gdb binary to connect to it (it's buried somewhere in your arduino install), there is some configuration magic buried in the arduino IDE installation that tells gdb and openocd how to talk to each other. I tried several times to make this work outside of the Arduino IDE and to be perfectly frank it was not worth the effort.
You can install the Espressif ESP32 toolchain separately, and run the debugger from that, but again you're going to have some trouble getting it to work exactly right. There's a strong change the upstream Espressif toolchain you downloaded has a different gcc version than what your Arduino IDE is running.
As long as I'm working with Arduino, I'll just use the Arduino IDE to debug the code. When I move on to STM32 after these projects, I'll drop out to my beloved command line gdb.
## More breadboard bugs
My biggest frustration with these breadboards so far has been resistors. Resistors have really long legs out of the package, and I think for breadboard usage, much shorter resistor legs would be better. With their long legs, they short into other nodes, bend over, come loose, and get caught on other wires as they get moved around. I hate to cut down resistor legs since I might need them longer in other projects, but with this project, I had to start cutting them down so they would lie flush on the board.
For example I had a problem with the LCD 7 segment display where segment D (the very bottom light in a cell) and segment E (the bottom left light in a cell) would only light together. So 2, 6, 8, 0, b, and d all appeared correctly. But 3, 5, A, and F would not. I traced every wire, every connection, checked every voltage on every pin of the display and the shift register. Best I can tell, it was due to a short somewhere in the breadboard. But I made the rookie mistake of wiring *everything* up before starting to work on the code, so by the time I saw the error, I was retiscent to pull it apart. But I'm glad I did; cutting down the resistor legs for flush mounting solved the problem.
By Day 4 of the project the breadboard had defeated me. The LED matrix had a short of some kind very similar to what the 7 segment LCD had. Some columns didn't work, some rows didn't work, and bumping the boards and resistors caused things to behave strangely. With great reluctance, I pulled everything apart, rewired the LED matrix and 7 segment display on their own breadboards away from the main ESP32 breakout and the potentiometer, cut down all the resistor wires for flush mounting, and that resolved all the issues.
The issues with loose connections persisted. I had the game basically done but I was still struggling with rows or columns on the 8x8 mysteriously disappearing. i would bump the breadboard, and it would come back. The worst part was that sometimes the game would start with a dead row or column and you would have no idea until your snake entered it, or you found some food in it by accident. I wound up introducing a calibration state at the beginning of the game that did nothing more than light up all the pixels on the matrix, so you could make sure they were all lighting up before playing the game.
## Joysticks and ADC and wierd outputs
Once I had the 7 segment and LED matrix driver finally working (able to plot arbitrary pixels on the LED matrix), I attached the joystick so the user could control the snake. Attached the joystick X and Y to pins 1 and 2, power and ground, Z to 47, and ... the shift registers driving the 7 segment and matrix would see ADC values output to them from their signal pins whenever joystick X or Y went positive.
<center><img alt="Something's fucky" src="somethingsfucky.jpg" width="320px" /></center>
For example, the potentiometer that I use as a difficulty selector was on pin 9. The joystick X and Y were on pins 1 and 2. The serial data signals for the shift registers were on pins 14 and 18. The Freenove ESP32-S3-WROOM pinout shows:
* 1 : `GPIO1, ADC1_CH0, T1`
* 2 : `GPIO2, ADC1_CH1, LED_ON, T2`
* 9 : `GPIO9, CAM_Y3, ADC1_CH8, T9`
* 14 : `GPIO14, ADC2_CH3, T14`
* 18 : `GPIO18, CAM_Y7, ADC2_CH7, U1RXD`
Essentially this means that the shift registers were using GPIO pins that were doubled up on ADC2, and the joystick & potentiometer were using pins set up for ADC1. Why in the world would providing analog input values on pins 1 and 2 affect the outputs read on pins 2 and 9 - they are different ADC channels - let alone 14 and 18, when they are a separate ADC device entirely? I even tried moving pin 9 directly to ground - so there was never any signal coming to that pin, it was pinned to ground - and I still read values off that pin corresponding to the joystick values coming in to the ADC.
I didn't actually wind up figuring this out. I wrestled with it for a bit, checked the TRM to see if maybe I could disable ADC channels I wasn't using, tried multisampling the ADC, tried attenuating it ... Nothing helped. I considered moving the shift register serial and clock lines to different GPIO pins without ADC, but almost every pin on this thing is connected to the ADC. There are no pins that are *just* GPIO with nothing else. I knew there must have been SOMETHING I did wrong, but at this point I'd been struggling with the LED matrix wiring and logic for days, and I was just tapped out with figuring out hardware wierdness. I threw in the towel on the joystick, and changed my design to use a small controller that used 4 buttons instead.
... Once I got all that working, I discovered that the wire between the potentiometer and pin 10 was bad. No continuity through the wire. Had it been that way all along? I don't think so. But sure as shit it's dead now. Replaced that and the wierd analog readings on the potentiometer went away. I had already ripped out the joystick handling code, so I didn't try putting it back in. There's no turning back now.
## Debouncing is a much bigger problem than I thought
When I made the switch from a joystick to 4 d-pad style buttons, I reused my button debounce code from previous projects. It seemed to work pretty well. However, when I wired up 4 buttons on a breadboard, and got into a situation where I needed to have my fingers resting on them all the time to be ready to change the direction of the snake, I figured out how much of a problem software debouncing really is. Those buttons were *constantly* firing when I didn't want them to. Even just holding my fingers NEAR the buttons would sometimes cause them to start bouncing. Sometimes interference from neighboring wires would do it on their own! Eventually I just learned to live with it, but the next time I do a project like this, I think I'm going to use a 74HC14 or something similar as a hardware debouncer before my software even sees that signal. Super noisy! I really wanted to integrate them on this project, but honestly I didn't want to touch the breadboards again once I had all the wires working, so I just learned to live with it.
## Arduino IDE debugger woes and ESP32 limits
I spent about an hour struggling with a `Stack smashing protection` fault. Try as I might, I couldn't get the Arduino IDE debugger to show me where the actual crash was in my code. All I was getting was a fault with some addresses in memory for a backtrace. Sure, I could objdump the binary, figure out the addresses, and figure it out that way. But if I'm gonna do that, WTF is the point of having a GUI debugger? There are some GUI tools for decoding ESP32 stacktraces, but the Arduino IDE integration for them hasn't worked in at least one whole major version, and the newer versions want to run in some IDE I don't want to use.
I wound up figuring it out essentially through trial and error. The `place_random_entity()` function uses an array of 64 bytes to store references to entries on the LED matrix where there are currently no actors. I had originally allocated that inside of the function itself - on the stack. Apparently this was the *wrong* place to put it. Moving this to a global variable resolved my problem. I have to assume that the default stack size was something ridiculously small, even though the scant documentation I could find said it would be something like 8Kb.
I'm sure there was a more elegant solution than just moving it in to a global variable. But at this point, 9 days in to the project, lol bitch please, you're getting a global variable and we're making this damn thing run.
## Driving 7-segment displays and 8x8 matrixes with shift registers
These 7 segment LCD displays have an odd relationship between their pins and the segments of the LCD. You might think that you would start at pin 1 and proceed in some kind of logical fashion, clockwise or counterclockwise, around the device. Nope. The pins are all over the place; I assume because that's the most logical place for the physical connections to be, and putting them somewhere else would be inconvenient for the chip designer. Wiring them is kind of a pain, especially when you have a lot on the breadboard - especially depending on the kind of jumper wires you're using. Then there's the fact that you would need 8 individual GPIO lines to light up a single 7 segment LED; if you wanted to run a 4-cell unit (like I have on this game), then you need 12 GPIO pins. Needless to say, we can't sacrifice that many GPIOs.
That's where the 74HC595 shift register comes in. It's a delightful little IC that takes in 8 bits over serial input, and reproduces them in parallel on the output. So you can use 3 pins (one data, one clock, and one latch) to do the job that used to require 8. So we throw a 74HC595 between the ESP32 and the 7 segment matrix, wire up the output enable pin to ground (because we essentially always want it to be enabled), give it some voltage, and voila - we're pushing 8 bits of parallel data with 8 bits of serial input.
When I started this project, I had initially planned to write my own driver for the 74HC595's serial protocol, instead of using the builtin Arduino `shiftOut()` function. However there was so much time spent chasing other gremlins in this project that I ultimately abandoned this idea. I'll have to tinker with that some other time.
One caveat to working with these 7 segment LED matrixes (beyond the wierd pin layout): when working with multiple cells, you need to turn the cell OFF immediately after you write to it, before going to the next one. If you don't, you get really wierd ghosting artifacts across the matrix. I'm not sure why that happens; but it does.
One neat thing about these 74HC595 units is that you can daisy chain them together to create larger shift registers. By hooking the QH pin of the first IC to the serial in of the next IC, whenever we overflow past bit 7 on the first shift register, we begin pushing bits out of the first shift register into the second one. This allows us to essentially treat the daisy chain of 74HC595s like one big register of 16, or 24, or 32, or whatever bits width. And we never have to use more than 3 GPIOs; they all use the same clock, the same latch enable, and the same data pin. The ones wired further downstream in series just pull their data from the QH pin of the IC before them.
When I was originally designing the circuit for this, I intentionally didn't look at the upstream tutorial for using 74HC595s and tried to just figure it out from the datasheet. The datasheet did *not* make it clear to me that they could be wired in series like that - even going back now with that knowledge I struggle to see it in the datasheet. So I wound up with them sharing clock and latch signals, but they each had their own data pin, and I would push data to them individually. Obviously, this caused all kinds of problems; since I would only push to 1 of the 2 at a time, the one not receiving data would see the latch tripped, pull data from its serial pin, get noise, and corrupt itself. I struggled with this for a lot longer than I care to admit before I finally gave up, went and read the tutorial, wired it up exactly the way they had it, then stared at it for a while before realising how they had done the series wiring. When the light bulb finally came on, it was blinding.
Wiring them in series is how the 8x8 LED matrix is driven. In this project, the 8x8 LED matrix is used like a very small screen, with each position on it treated like a pixel. Again, the wiring I have is what was in the tutorial, because I spent so much time struggling with my own wiring that I just accepted what the tutorial had and went with it. Unfortunately that leads to an odd little convention in the code for displaying a row on the matrix:
```arduino
void writeLEDMatrix(uint8_t row, uint8_t col)
{
digitalWrite(matrixcols.updatepin, LOW);
shiftOut(matrixcols.datapin, matrixcols.clockpin, LSBFIRST, row);
shiftOut(matrixcols.datapin, matrixcols.clockpin, MSBFIRST, col);
digitalWrite(matrixcols.updatepin, HIGH);
}
```
You might notice that I'm reversing the bit order on the two `shiftOut` calls. No, that's not a bug, and until the very end o the project, I didn't really understand why it's doing it that way. Apparently the wiring tutorial I followed, for some reason, reversed the order of the wiring for columns, such that pin 1 of the shift register went to column 8 of the matrix. I presume they did this because, on their breadboard, that made the wiring make more sense. That wasn't necessary in my case, but since I followed their wiring, I adopted the convention without really realizing it. Because the wiring on those columns is backwards, the bit ordering on the columns has to be flipped.
I didn't figure this out until almost all other bugs were resolved. And by that time, I'll be damned if I'm gonna go in there and rip out wiring. It works this way, and it'll live this way.
Regardless, now that we had a pair of 8 bit registers that would allow us to control all the pixels on the 8x8 matrix, we could treat it like a tiny framebuffer. It might seem intuitive to say "we will select row X in the row shift register, and column Y in the column shift register, and away we go". But no... That's not how this hardware works. When you're driving hardware with software, you have to remember that the interface you're working with was designed primarily with the HARDWARE engineer's set of tradeoffs in mind. It's the software's job to adapt to the hardware interface, not the other way around. And in this case, the 8x8 matrix is either a common anode or a common cathode array of LEDs, meaning that all the LEDs on a row or column either share a common `+` or `-` connection. This means you can only turn on or off ROWS or COLUMNS at a time, and you control which elements in that row or column are lit by individual bits inside of the byte you send to the shift register.
This is why the matrix code only uses 8 bytes to control 64 individual pixels.
```arduino
uint8_t matrixcolumns[8] = {0};
int pokeMatrixPixel(uint8_t x, uint8_t y)
{
if (x > 7 || y > 7) {
ERROR(ERRNO_OUTOFBOUNDS);
}
// Because of the alignment of the screen in our demo, we treat columns as the X axis
matrixcolumns[x] = matrixcolumns[x] | (1 << y);
return ERRNO_SUCCESS;
}
```
We are actually storing 8 columns represented by 8 bytes. Each bit inside of those bytes represents a single pixel in the column (row id). We treat columns as X and rows as Y because of the orientation of the matrix on the breadboard relative to the player and other controls. When we are poking a pixel, we just turn on the bit inside of the column (X) for the row (Y). Then, when we go to draw pixels on the matrix (think like flipping a page buffer on a VGA display or populating a framebuffer), we just send each entire column one by one, using the row shift register as a selector:
```arduino
void displayMatrix()
{
for (int i = 0; i < 8; i++) {
writeLEDMatrix(matrixcolumns[i], ~(1 << i));
}
}
```
## Fun within constraints
I had never written a snake game before. (Wild, right?) So this was a fun project but it was also complicated by the fact that not only was I figuring out all the ins and outs of the 7 segment displays, the LED matrix, and the shift registers, but I was also figuring out how a snake game works. And I was doing it on a device that is (by modern standards) tiny - the ESP32-S3 has 512kB of internal RAM, with about 340kB of that being usable by your application. Granted, you can do A LOT with 512kB - the entire Amiga OS Kernel was written in 512kB of ROM, for example. But for average developers like us, that are used to working with gigabytes of ram at our disposal, this is essentially a starvation diet.
Further, the hardware components themselves introduced a ton of constraints on the project:
* The screen has only 1 color
* The screen is tiny. Only 64 possible pixels.
* I only have four 7 segment readouts to display information to the player
* The speaker can only make 1 sound
* The use of breadboards instead of protoboards required that all my D-Pad buttons were on the same horizontal plane
* One player
* 1 difficulty adjuster, which controls the speed of the game clock
... But the product was (for me, at least) still really fun. Part of the reason this took me so long is that, when diagnosing a bug, I would just sit and play with it for long periods of time. Before I had game logic working, I would just watch the snae scroll off the side of the matrix and back on to the other side. I would play with the potentiometer and watch numbers change. Once I had the basic game logic working, and was diagnosing an issue with the scoreboard not displaying properly, I would just play snake for several rounds before getting back to work.
It is very, very rare that I feel any kind of *fun* in the work I do professionally. I'm usually *glad that it's done* more than I am *excited to see it working*. This was absolutely not that way. Working through it was very frustrating at times, and there was more than once that I thought "This is too ambitious. Step back and do something smaller." But I'm glad I didn't. Because every breakthrough was a delightful little reward.
And being able to play a hardware Snake game with my wife and kids, on a tiny little microcontroller, on a bunch of breadboards, that I (mostly) designed and implemented myself, was really fun. 10/10, highly recommended.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

View File

@@ -0,0 +1,4 @@
#!/bin/bash
arduino-cli compile --clean --optimize-for-debug --fqbn esp32:esp32:esp32s3:CDCOnBoot=cdc --output-dir build/ .

View File

@@ -0,0 +1,64 @@
#include <Arduino.h>
#include <stddef.h>
#include <stdint.h>
#include "controls.h"
#include "error.h"
// For initializing simple buttons
int initButton(Button *button)
{
pinMode(button->pin, INPUT);
return ERRNO_SUCCESS;
}
// Read the state of a given button
int readButton(Button *button)
{
uint32_t pinvalue;
uint32_t curmillis;
uint32_t buttonvalue;
if ( button == NULL ) {
ERROR(ERRNO_NULLPOINTER);
}
curmillis = millis();
buttonvalue = digitalRead(button->pin);
if ( (button->state & BUTTON_STATE_BOUNCING) == BUTTON_STATE_BOUNCING ) {
if ( (curmillis - button->debounce_start_time) >= button->debouncetime ) {
if ( buttonvalue == button->pressedvalue ) {
button->state = BUTTON_STATE_DOWN;
} else {
button->state = BUTTON_STATE_UP;
}
Serial.printf("Button on pin %d has debounced state %d\n", button->pin, button->state);
button->debounce_start_time = 0;
return ERRNO_SUCCESS;
}
return ERRNO_SUCCESS;
}
if ( buttonvalue != button->pressedvalue && (
(button->state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ||
(button->state & BUTTON_STATE_HELD) == BUTTON_STATE_HELD)){
// We are beginning to release. Debounce.
Serial.printf("Debouncing button on pin %d for release\n", button->pin);
button->state = button->state | BUTTON_STATE_BOUNCING;
button->debounce_start_time = curmillis;
}
if ( buttonvalue == button->pressedvalue && (
(button->state & BUTTON_STATE_DOWN) != BUTTON_STATE_DOWN &&
(button->state & BUTTON_STATE_HELD) != BUTTON_STATE_HELD)){
// We are beginning to press. Debounce.
Serial.printf("Debouncing button on pin %d for press\n", button->pin);
button->state = button->state | BUTTON_STATE_BOUNCING;
button->debounce_start_time = curmillis;
}
return ERRNO_SUCCESS;
}
uint32_t readDifficulty()
{
analogRead(PIN_DIFFICULTY);
delay(1);
uint32_t setting = analogRead(PIN_DIFFICULTY);
uint32_t divisor = 4096/9;
return setting/divisor;
}

View File

@@ -0,0 +1,40 @@
#ifndef _JOYSTICK_H_
#define _JOYSTICK_H_
#include <Arduino.h>
#include <stdint.h>
#define PIN_BUTTON_LEFT 1 // Red
#define PIN_BUTTON_UP 2 // Green
#define PIN_BUTTON_DOWN 47 // Yellow
#define PIN_BUTTON_RIGHT 13 // Blue
#define PIN_DIFFICULTY 10
#define BUTTON_STABLETIME 100
// Button states
#define BUTTON_STATE_BOUNCING (1 << 0)
#define BUTTON_STATE_DOWN (1 << 1)
#define BUTTON_STATE_UP (1 << 2)
#define BUTTON_STATE_HELD (1 << 3)
typedef struct Button {
// What pin is this button on
uint8_t pin;
// What is the state of this button (bitmask of BUTTON_STATE_*)
uint8_t state;
// Is this button pressed when it is LOW or HIGH?
uint32_t pressedvalue;
// How long do we wait when debouncing this button
uint32_t debouncetime;
// When did we start debouncing
uint32_t debounce_start_time;
} Button;
int initButton(Button *button);
int readButton(Button *button);
uint32_t readDifficulty();
#endif // _JOYSTICK_H_

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
#!/bin/bash
(cd ~/.arduino15/packages/esp32/tools/openocd-esp32/v0.12.0-esp32-20251215/share/openocd/scripts/ ; ../../../bin/openocd -f board/esp32s3-builtin.cfg) &
/home/andrew/.espressif/tools/xtensa-esp-elf-gdb/16.3_20250913/xtensa-esp-elf-gdb/bin/xtensa-esp32-elf-gdb -x .gdbinit ./build/08-74HC595-Snake.ino.elf

View File

@@ -0,0 +1 @@
#include "error.h"

15
08-74HC595-Snake/error.h Normal file
View File

@@ -0,0 +1,15 @@
#ifndef _ERROR_H_
#define _ERROR_H_
#define ERRNO_SUCCESS 0
#define ERRNO_NULLPOINTER 1
#define ERRNO_IO 2
#define ERRNO_TIMEOUT 3
#define ERRNO_CALIBRATION 4
#define ERRNO_OUTOFBOUNDS 5
#define ERROR(x) errno=x; return x;
extern int errno;
#endif // _ERROR_H_

View File

@@ -0,0 +1,56 @@
#include <Arduino.h>
#include <stdint.h>
#include "error.h"
#include "IC74HC595.h"
#include "matrix.h"
uint8_t matrixcolumns[8] = {0};
int pokeMatrixPixel(uint8_t x, uint8_t y)
{
if (x > 7 || y > 7) {
ERROR(ERRNO_OUTOFBOUNDS);
}
// Because of the alignment of the screen in our demo, we treat columns as the X axis
matrixcolumns[x] = matrixcolumns[x] | (1 << y);
return ERRNO_SUCCESS;
}
void resetMatrix()
{
matrixcolumns[0] = 0;
matrixcolumns[1] = 0;
matrixcolumns[2] = 0;
matrixcolumns[3] = 0;
matrixcolumns[4] = 0;
matrixcolumns[5] = 0;
matrixcolumns[6] = 0;
matrixcolumns[7] = 0;
}
void displayMatrix()
{
for (int i = 0; i < 8; i++) {
writeLEDMatrix(matrixcolumns[i], ~(1 << i));
}
}
void writeLEDMatrix(uint8_t row, uint8_t col)
{
digitalWrite(matrixcols.updatepin, LOW);
shiftOut(matrixcols.datapin, matrixcols.clockpin, LSBFIRST, row);
shiftOut(matrixcols.datapin, matrixcols.clockpin, MSBFIRST, col);
digitalWrite(matrixcols.updatepin, HIGH);
}
void testMatrix()
{
uint8_t row = 0;
uint8_t col = 0;
for ( int i = 0; i < 8; i++ ) {
row = (1 << i);
col = ~(1 << i);
Serial.printf("%d = (%d, %d)\n", i, row, col);
writeLEDMatrix(row, col);
}
};

12
08-74HC595-Snake/matrix.h Normal file
View File

@@ -0,0 +1,12 @@
#ifndef _MATRIX_H_
#define _MATRIX_H_
#include <stdint.h>
int pokeMatrixPixel(uint8_t x, uint8_t y);
void resetMatrix();
void displayMatrix();
void writeLEDMatrix(uint8_t row, uint8_t col);
void testMatrix();
#endif // _MATRIX_H_

View File

@@ -0,0 +1,3 @@
#!/bin/bash
arduino-cli monitor -p /dev/ttyACM0

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1,2 @@
default_fqbn: esp32:esp32:esp32s3
default_port: serial:///dev/ttyACM0

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,3 @@
#!/bin/bash
arduino-cli upload -p /dev/ttyACM0 -b esp32:esp32:esp32s3:CDCOnBoot=cdc --build-path build/