We're taking a deeper dive into DIY I2C devices this week in The Factory. The PiicoDev buzzer is under development and we thought we'd fill you in on how we build these projects using a really extensible framework. We'll cover how data flows on the bus, how the device code is structured, and how the API is written to interact with our custom device.

Transcript

G'day, welcome back to the factory. We're taking a deep dive on DIY I2C devices this week. We're working on the PiicoDev buzzer module and this is a like a roll-it-yourself I2C device because of course you can't attach a buzzer to an I2C bus so we need a microcontroller in the mix to do the translation.

We're going to do a deep dive on both ends of the firmware. Of course this device needs firmware so it can behave as a device and we also need a really easy to use API in say MicroPython so that you can do really simple calls to like buzzer.tone1000hz and have it just work really easily. Let's get started.

First a bit of a production update. We have launched the Glowbit Matrix and the Glowbit Stick module so those are now available on Core Electronics. If you're leaving YouTube comments on like oh when are these going to be available? They're done, they're up, the guides are shot, the articles are written, the matrices are available.

This is the development environment at the moment. It does look a little bit complicated but quite simply we just have our device that we're programming in this programming fixture with a programmer connected to it and a Raspberry Pi P code that we can develop the MicroPython on. So in parallel we can develop the C++ code that drives the module and also the Python API so that we can interact with it.

And so this is what that looks like and a little more simply we have a Raspberry Pi Pico connected to our buzzer module and they just share a two-wire I2C bus so that the Pico can send commands to the buzzer.

Just briefly if you're not that familiar with I2C, I2C is just a data interface similar to UART or SPI. It's just another.Data interface for devices to send messages back and forth. I've got the data sheet open for another device. This is the light sensor that we use in the PiicoDev range. If I jump down to the register format, this is how a lot of I2C devices work. They're usually arranged into a structure of registers or command codes. You can kind of think of them as the same thing.

On this device, for instance, we have command codes from 0 to 6, so there are seven different command codes. These command codes can be thought of as memory locations that can be written into or read from. The first three command codes address different size byte registers. The first command code accesses a 16-bit register, the next one is another 16-bit register from 0 to 7, and the third one is from 8 to 15. These registers set the most significant byte and the least significant byte of a certain parameter, such as a high threshold. These are all write registers.

We also have a bunch of read registers. For example, if you just want to read the light intensity, there is a 16-bit register number four. So, the idea of memory locations and the size of that memory location is how a lot of I2C devices work.

Now, let's jump straight into a little two-tone demo. Here we have the user code for how you would make an E or Siren where you have buzzer.tone at a thousand Hz to create a high tone and then again at 500 Hz to create a low tone. If I run this script, we have a suitably annoying two-tone. This is why we write APIs because we want people to be able to just import.PKDev buzzer and then create a buzzer object. In this case, I'm calling it "buzz" and then call something like "buzzer.tone". And here, there are two arguments: the frequency in Hz and the duration of the tone in milliseconds.

There's a short delay and then we play the second tone. So that means that every call to "tone" is going to do something with two parameters: the frequency and the duration.

Taking a look at the definition of "tone", I've just spaced it out from all the other code so that it's on its own here. When we look at "tone", we can see that we have two arguments coming in: the frequency and the duration, just as before. The duration can default to zero in this case.

We first take that frequency argument and convert it into a byte string so that it can be easily passed along a data bus. And we do the same with the duration. This byte string for frequency is going to be two bytes long always, and of course, that's going to set a maximum frequency that you can count to, but that's okay.

Just a little bit of debugging here: we first print the frequency in Hz and then we print the frequency as it is packed into a byte string. Then we make a call to the "i2c_write_to_mem" function and push out those frequency bytes and those data bytes.

Now, recall we were talking about registers before. This data has to go somewhere, just as if we were going to write, say, a high threshold to this light sensor device, that would go to location number one. This frequency and duration information has to go to some location on our buzzer module. And so, in that function, we have the register as one of the arguments, and the very first argument is the device address.You can have multiple devices on an I2C bus, and so, of course, they all need a unique address, and that's how this function works. So if we were to look at this I2C data in the raw byte values, we would have the device address going out first, and for this device, that's just hex 8. We would have the tone command, and I've actually set that to be hex 5 because, of course, we're making this device, so we get to choose what the registers mean. And then we have our two bytes for frequency data, and that's in hertz, and two bytes for duration data that's actually in milliseconds. And so that means that every call to the tone function will create traffic for one, two, three, four, five, six bytes on the data bus, no matter what. We always need to address a device, and we always need to tell the device what the command we're going to send is, and then we always need four bytes of data for this command.

We have this debug string that I have in the works here, and you can see those prints coming out in the shell. Remember, we're printing the frequency in Hz and also the byte string that that is being converted to. So in our first call to tone, we call tone with 1000 hertz, 1000 in decimal and convert to hex. So a thousand in decimal converted to hex is 03E8, and that is exactly what we get here, a thousand in decimal converts to 3E8. Nice! And of course, the same is true converting 500 to hex 1F4. That means that we know that when we call tone with a thousand hertz, our first four bytes of this message are 8, 5, and then what is that 3 and E8. I haven't bothered debugging the duration, but it's basically the same, so if you're wondering where that address argumentAnd that register came from they're just defined in this class. We have the base address, which is the device address. In this case, that's the hex 8, and we have the register to create a tone, which is hex 5.

Okay, so we have this nicely formatted I2C packet of data coming out of our MicroPython device, and that gives us the structure of what we want to catch on the other end with this I2C device.

Jumping over to the C code, I've ported this from a Sparkfun project for their quick open log, and the structure is just beautiful. I think this is by Nate Seidel from Sparkfun, so on your Nate, appreciate your work.

We get to create our own memory map. Remember the register definitions for that light sensor, which were all laid out in some specific order. We get to define those here, so we create a memory map which is full of things like the tone command, maybe setting the volume, even toggling an on-board LED. And it's in the register map instance that we get to set the value for those addresses. Remember in our API, the address for tone was hex 5. Well, that is reflected here in the register map definition too.

So this device is always sitting on the I2C bus waiting for I2C transactions to come through. The main loop is basically empty. We're basically just checking if there's an update flag set, and if so, we can play some tone. So the device actually spends most of its time asleep, waiting for interrupts, and those interrupts come through on the I2C bus where we call wire begin. We then have attached receive and request events, and these are the events that handle dequeuing that data that's coming in and sending it to the right place.

So when we receive...Some data, we call a function called receive event. So here it is, we have receive event, and it also has the number of bytes received as one of its arguments, so we know how much data has come through on the I2C transaction. Basically, it just sits there looping, dequeuing however much data is on the I2C bus. We take that first byte, and we know for certain that that is going to be the register number that's being written to because we make the rules.

So, this first wire.read goes straight into a variable called register number. In a nested while wire is available loop, we just repeatedly call wire.read and dequeue that into some array, which is just all the incoming data. Once all the data is dequeued off the bus, we just go through a loop that checks for a valid register number and looks for an attached function.

In this case, the register number or the command number is five. So, we loop through some list of functions looking for that register number. If we find that number in that list, then it means we have a valid command, and we're going to call that function. We call that function and we also pass it all the remaining incoming data.

What this means is if the first byte we receive is a five, which is the tone command, and the function that is called from the function map is the set tone function. Remember, the function gets passed into it all the remaining data from that I2C transaction, and that's the four bytes that describe frequency and duration. So, we take those first two bytes and pack them into a 16-bit number for frequency, and we take those second two bytes and pack them into a 16-bit number for duration.We set something called an update flag to true, and then the function returns. It just goes straight into the main loop. The main loop sees that the update flag is true, and so it calls to play the tone. All play tone does is call the tone function, which plays a tone with a certain frequency and duration on a certain pin connected to the buzzer. These frequency and duration values are global variables that were populated from the set tone function. So, this is the journey of data from one end to the other. This is how you can make an I2C device, all the way from creating commands in Python on the left edge to dequeuing and sending data to the correct functions on the right edge. In this case, the data is unpacked into two numbers and the tone function is called.

Okay, that's all great. Let's look at how we can implement a new command. The onboard LED is not just a power LED; it's user-controllable. So, let's implement the command that will turn it on and off. We already have an associated command in our functions table called "set power LED". "Set power LED" takes in I2C data and basically calls the power LED function, passing in a true or false value. This is done by checking if the data byte is equal to one or zero. If the data byte is equal to one, it resolves as true. If it's equal to anything else, it resolves as false. So, it's basically calling power LED with true or false to turn the power LED on and off. This is the logic for that.

Therefore, our command to turn the power LED off would look like this: the device address, which is 08, and then the appropriate register number, which is 0x07. Remember...7 in hex was our LED control register. And then just a single byte to turn the LED on or off. To turn it off, we'll just send it a zero. So we need to send to device 8 address 7 the data 0.

So I'll create that function in MicroPython. We'll define power_led that takes the self command and x will be our input which could be say true or false for on or off. All we need to do is send over I2C to the device address the LED control register which I already have defined which is reg_led reg_led and we'll take our input and convert that to a byte string by calling bytes on x.

And now we've created that functionality in our API. Let's modify the example and call buzz.power_led put in a false and then on the other tone we'll call buzz.power_led true. Nice.

Now the moment of truth if I run this you can see that we have a blinking power LED on board. How good.

Now we're just talking about a buzzer here, a simple buzzer you know just play some tones. This is obviously extensible to so many other projects. Now we've already got the PiicoDev RGB module set up in the same way so rather than writing to a tone rather than writing to a frequency and duration location we're writing to nine individual locations for the RGB data for each of these LEDs. You know the sky's the limit and so big problems just being lots of small problems that's basically how you make an I2C device.

On the device side, you just create your the functions that you want in functions like the tone and the power LED function you associate them with a register number or a command number and on the MicroPython side of town you take those.Definitions and you just throw data at them or read data from them. There's obviously a lot more going on in this project than just what I've shown you, but this is really the basic foundations of how to make your own I2C device.

So that was a bit of a deeper dive than I had initially intended, but this is something that I had always wanted to do, and I'm sure there are a lot of people out there who were curious about this idea themselves.

So thanks so much Nate for this beautiful and extensible project template that we can all use to make our own perfect I2C devices. Of course, Nate's original project, the Quick Open Log, is available on GitHub already. This will go up once it's tidied up a bit and the product is live.

Yeah, thanks for joining me on that deep dive into homebrew I2C devices, and I hope you make something cool with it. Until next time, thanks for watching.

Comments


Loading...
Feedback

Please continue if you would like to leave feedback for any of these topics:

  • Website features/issues
  • Content errors/improvements
  • Missing products/categories
  • Product assignments to categories
  • Search results relevance

For all other inquiries (orders status, stock levels, etc), please contact our support team for quick assistance.

Note: click continue and a draft email will be opened to edit. If you don't have an email client on your device, then send a message via the chat icon on the bottom left of our website.

Makers love reviews as much as you do, please follow this link to review the products you have purchased.