Files
esp32-learning/06-joystick
2026-06-08 06:15:52 -04:00
..
WIP
2026-06-08 06:15:52 -04:00
WIP
2026-06-07 22:02:09 -04:00
WIP
2026-06-08 06:15:52 -04:00
WIP
2026-06-07 22:02:09 -04:00

Joystick demo

Lessons Learned

  • I still don't trust C++
  • How to drive an WS2812 RGB LED strip from the ESP32 using the Freenove library
  • How the RMT peripheral on the ESP32-S3 works and why it is involved when driving WS2812 LED strs
  • How to select pin assignments on the ESP32 without relying on the tutorial
  • How to calibrate joysticks

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.

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.

it's wrong

I chose instead to force the initializer for the global variable to happen during the init2812 routine, which is called during setup.

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 ...

read the datasheet

WS2812s

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.

wot

The WS2812 is controlled with a wierd single line protocol that's really efficient but very sensitive to timing. The whole thing works off of PWM signals on the wire, and there is no clock synchronization. You could do it yourself on a GPIO pin manually sending the PWM signals but it is very easy to get wrong. So the RMT module, which was originally designed to convert data to pulses and back again for sending over infrared, is abused for this purpose instead. We can just send the data and the CPU goes along its merry way.

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.

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.