Monday, 16 July 2018

CircuitPython 101: Basic Builtin Data Structures

Overview


With the introduction of the new SAMD51 (M4) based boards, CircuitPython gets far more interesting. Not only do they have a clock speed almost three times faster than the SAMD21 (M0 boards) and they have six times as much RAM. Not only do CircuitPython programs run significantly faster, they can be much larger and/or work with much more data. With this space comes the capability to move beyond simple scripts to more elaborate programs.
The goal of this series of guides is to explore Python's mechanisms and techniques that will help make your more ambitious CircuitPython programs more manageable, understandable, and maintainable.
Are you new to using CircuitPython? No worries, there is a full getting started guide here.
Adafruit suggests using the Mu editor to edit your code and have an interactive REPL in CircuitPython. You can learn about Mu and installation in this tutorial.
In this guide, we'll look at several basic ways to organize data in Python: Tuples, Lists, and Dictionaries.
We'll use examples that deal with playing tunes using the Adafruit pulseio library. Below is the circuit used featuring an Adafruit ItsyBitsy M4 Express. With a few minimal tweaks to the wiring and code, this will work with any of the M4 Express boards. Note that only the final example makes use of the buttons and an OLED display.
Parts
Any of these M4 boards will do nicely.
1 x ItsyBitsy M4 Express
ATSAMD51 based ItsyBitsy with extra flash.

1 x Feather M4 Express
ATSAMD51 based Feather with extra flash.

1 x Metro M4 Express
ATSAMD51 based Metro with extra flash.

To play the notes, you'll need a buzzer. For the interface you'll need an OLED display and a couple buttons.
1 x Piezo buzzer
Piezo buzzer that you can drive with any frequency square wave.

1 x Breadboardable tactile button
Little clicky switches are standard input "buttons" on electronic projects.

1 x 128x32 I2C OLED display
Small, but very readable OLED display with an I2C interface.

Tools and Materials

  • A small solderless breakboard
  • wires for use of the breadboard
  • microUSB data cable fro connecting the M4 board to your computer

Tuple


Tuples are a lightweight way to group information together. They are created using a comma separated sequence of items in parentheses:
  1. >>> t = (1, 2, 'three')
Notice that there is no need for the parts of a tuple to be the same type of thing. E.g. in the example above we have a tuple with two integers and a string.
Once you have a tuple, you can access the parts of it using an indexing notation:
  1. >>> t[0]
  2. 1
  3. >>> t[1]
  4. 2
  5. >>> t[2]
  6. 'three'
Notice that tuples (and lists) in Python are 0 based. That is, the index of the first item is 0, the index of the second item is 1, and so on. You can think of this as being how far from the first item you want to access.
Tuples are immutable. That means that once one is created, it can't be changed: you can't add or delete items, or change the values in the tuple.
Let's use tuples to represent notes in a song. Each note has a frequency and a duration. Frequency is in hertz and duration is in seconds. A C4 for quarter of a second would be

  1. (261, 0.25)
We can use this to write a play_note function using pulseio:

  1. def play_note(note):
  2. pwm = pulseio.PWMOut(board.D12, duty_cycle = 0, frequency=note[0])
  3. pwm.duty_cycle = 0x7FFF
  4. time.sleep(note[1])
  5. pwm.deinit()
From this, we can write some simple code to play notes.

  1. import time
  2. import board
  3. import pulseio
  4.  
  5. C4 = 261
  6. C_SH_4 = 277
  7. D4 = 293
  8. D_SH_4 = 311
  9. E4 = 329
  10. F4 = 349
  11. F_SH_4 = 369
  12. G4 = 392
  13. G_SH_4 = 415
  14. A4 = 440
  15. A_SH_4 = 466
  16. B4 = 493
  17.  
  18. def play_note(note):
  19. if note[0] != 0:
  20. pwm = pulseio.PWMOut(board.D12, duty_cycle = 0, frequency=note[0])
  21. # Hex 7FFF (binary 0111111111111111) is half of the largest value for a 16-bit int,
  22. # i.e. 50%
  23. pwm.duty_cycle = 0x7FFF
  24. time.sleep(note[1])
  25. if note[0] != 0:
  26. pwm.deinit()
  27.  
  28.  
  29. a4_quarter = (A4, 0.25)
  30. c4_half = (C4, 0.5)
  31.  
  32. play_note(a4_quarter)
  33. play_note(c4_half)

List

A list in Python is just that: a list of things. We use lists all the time: shopping lists, TO-DO lists. Even Santa uses lists.
Lists in Python have features that jive with our general idea of lists:
  • They can be empty.
  • They can have any number of things in them.
  • They can have different kinds of things in them.
  • You can append (add to the end) new things to them.
  • You can insert new things anywhere in them (this is easier with lists that aren't written on paper)
  • You can sort them to put them in some particular order (if everything in them can be compared).
  • You can see how long they are.
  • You can remove things from them.
  • You can replace things in them.
  • You can check if something is in them.
  • You can combine them.
  • You can throw them out when you're done with them.
Making a list looks a lot like making a tuple, except that square brackets are used:

  1. >>> my_list = [1, 2, "three"]
In Python, lists and tuples are both a kind of sequence, which means that they have many capabilities in common. For example, you can find their length:

  1. >>> len(my_list)
  2. 3
Accessing items is the same:

  1. >>> my_list[0]
  2. 1
  3. >>> my_list[1]
  4. 2
If you use negative indices, they are from the end rather than the start. That makes sense, but -1 is the last item, -2 is the second to last, etc. There really isn't a way around this since -0 isn't really a thing. You can use negative indices with tuples as well as with lists, but it isn't generally as useful. Tuples tend to be small, and for a specific purpose they tend to be the same size with the same type of information in each position. In our tuple example, the frequency was always at position 0, and the duration at position 1.
Lists are more dynamic, both in size and content, unlike tuples.
You can change the contents/size of lists but not tuples! In exchange, tuples use less memory and are great when you want 'immutable' data
Changing the thing at a specific location of a list is much like accessing whatever is there: you simply give it a new value.

  1. >>> my_list[2] = 42
  2. >>> my_list
  3. [1, 2, 42]
Appending new items to a list is easy:

  1. >>> my_list.append(3)
  2. >>> my_list
  3. [1, 2, 42, 3]
As is inserting something anywhere in the list:

  1. >>> my_list.insert(1, 99)
  2. >>> my_list
  3. [1, 99, 2, 42, 3]
The first argument to insert is where in the list to put the new item, at index 1 in the above example (i.e. the second position). Everything else will get moved to one position larger to make room.
Lists have many more capabilities that we won't consider here. See the python documentation for more information.

Making songs

We used tuples to form notes that can be played by combining a frequency and a duration, and we wrote a function to pull that information out of a tuple and play the tone on the CircuitPlayground Express' built-in speaker. The next step is to put those individual notes together into songs. Before we can do that we need to make a slight adjustment to the play_note function to add rests. We can make the decision to use a frequency of 0 Hertz indicate a rest, or silence:

  1. def play_note(note):
  2. if note[0] != 0:
  3. pwm = pulseio.PWMOut(board.D12, duty_cycle = 0, frequency=note[0])
  4. pwm.duty_cycle = 0x7FFF
  5. time.sleep(note[1])
  6. if note[0] != 0:
  7. pwm.deinit()
With that we can now construct a list of note tuples.

  1. import time
  2. import board
  3. import pulseio
  4.  
  5. C4 = 261
  6. C_SH_4 = 277
  7. D4 = 293
  8. D_SH_4 = 311
  9. E4 = 329
  10. F4 = 349
  11. F_SH_4 = 369
  12. G4 = 392
  13. G_SH_4 = 415
  14. A4 = 440
  15. A_SH_4 = 466
  16. B4 = 493
  17.  
  18. twinkle = [(C4, 0.5), (C4, 0.5), (G4, 0.5), (G4, 0.5), (A4, 0.5), (A4, 0.5), (G4, 0.5), (0, 0.5),
  19. (F4, 0.5), (F4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (D4, 0.5), (C4, 0.5)]
  20.  
  21. def play_note(note):
  22. if note[0] != 0:
  23. pwm = pulseio.PWMOut(board.D12, duty_cycle = 0, frequency=note[0])
  24. # Hex 7FFF (binary 0111111111111111) is half of the largest value for a 16-bit int,
  25. # i.e. 50%
  26. pwm.duty_cycle = 0x7FFF
  27. time.sleep(note[1])
  28. if note[0] != 0:
  29. pwm.deinit()
  30.  
  31. def play_song(song):
  32. for note in song:
  33. play_note(note)
  34.  
  35. play_song(twinkle)

Dictionary

Dictionaries allow us to associate a value with a name (generally called a key). It's more general than that, but that's probably the most common use.
A common way to construct a dictionary to use the brace notation:

  1. >>> d = {'one': 1, 'two': 2, 'three': 3}
  2. >>> d
  3. {'one': 1, 'three': 3, 'two': 2}
Notice that dictionaries are not ordered. That's fine, because they don't use numeric indices, they use keys.
As with lists, we can find out how many key-value pairs are in a dictionary using:

  1. >>> len(d)
  2. 3
Accessing data in a dictionary is done similarly to lists:

  1. >>> d['three']
  2. 3
as is changing it:

  1. >>> d['three'] = 'not3'
  2. >>> d
  3. {'one': 1, 'three': 'not3', 'two': 2}
Adding a key/value pair to a dictionary is the same as modifying one:

  1. >>> d['four'] = 4
  2. >>> d
  3. {'four': 4, 'one': 1, 'two': 2, 'three': 'not3'}
To remove from a dictionary we use the del function:

  1. >>> del(d['three'])
  2. >>> d
  3. {'four': 4, 'one': 1, 'two': 2}
Finally, we can check if a dictionary contains a specific key:

  1. >>> 'one' in d
  2. True
  3. >>> 'three' not in d
  4. True
  5. >>> 'three' in d
  6. False

Formats - Songbook

So we have notes represented by tuples that we can put together in a list to make a song. What if we want multiple songs? We could have each in a separate variable. Let's add the additional requirement that we want to connect an I2C OLED display to show a list of songs that we can select from. Having songs in separate variables means that everything has to be hardcoded. That's seldom a good idea. What we'd like is to have things stored in such a way that we just have to add a song and have the menu automatically be updated to reflect it. We can do this by storing them in a dictionary, keyed by name (edited for brevity):

  1. songbook = {'Twinkle Twinkle': [(C4, 0.5), (C4, 0.5), (G4, 0.5), (G4, 0.5), (A4, 0.5), ...],
  2. 'ItsyBitsy Spider': [(G4, 0.5), (C4, 0.5), (C4, 0.5), (C4, 0.5), (D4, 0.5), ...],
  3.  
  4. 'Old MacDonald': [(G4, 0.5), (G4, 0.5), (G4, 0.5), (D4, 0.5), (E4, 0.5), ...]
  5. }
With that done, we can get the names of the songs by songbook.keys(). This isn't a list, although it can be used for some list-like things. It can't be indexed, however. We need to convert it to a list in order to be able to do that: list(songbook.keys()). While we're at it, we should go ahead and sort it so that it will display in alphabetical order: sorted(list(songbook.keys())).
Here is the complete code:

  1. import time
  2. import board
  3. import debouncer
  4. import busio as io
  5. import digitalio
  6. import pulseio
  7. import adafruit_ssd1306
  8.  
  9. i2c = io.I2C(board.SCL, board.SDA)
  10. reset_pin = digitalio.DigitalInOut(board.D11)
  11. oled = adafruit_ssd1306.SSD1306_I2C(128, 32, i2c, reset=reset_pin)
  12. button_select = debouncer.Debouncer(board.D7, mode=digitalio.Pull.UP)
  13. button_play = debouncer.Debouncer(board.D9, mode=digitalio.Pull.UP)
  14.  
  15. C4 = 261
  16. C_SH_4 = 277
  17. D4 = 293
  18. D_SH_4 = 311
  19. E4 = 329
  20. F4 = 349
  21. F_SH_4 = 369
  22. G4 = 392
  23. G_SH_4 = 415
  24. A4 = 440
  25. A_SH_4 = 466
  26. B4 = 493
  27.  
  28. # pylint: disable=line-too-long
  29. songbook = {'Twinkle Twinkle': [(C4, 0.5), (C4, 0.5), (G4, 0.5), (G4, 0.5), (A4, 0.5), (A4, 0.5), (G4, 1.0), (0, 0.5),
  30. (F4, 0.5), (F4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (D4, 0.5), (C4, 0.5), (0, 0.5),
  31. (G4, 0.5), (G4, 0.5), (F4, 0.5), (F4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (0, 0.5),
  32. (G4, 0.5), (G4, 0.5), (F4, 0.5), (F4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (0, 0.5),
  33. (C4, 0.5), (C4, 0.5), (G4, 0.5), (G4, 0.5), (A4, 0.5), (A4, 0.5), (G4, 1.0), (0, 0.5),
  34. (F4, 0.5), (F4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (D4, 0.5), (C4, 0.5), (0, 0.5)],
  35.  
  36. 'ItsyBitsy Spider': [(G4, 0.5), (C4, 0.5), (C4, 0.5), (C4, 0.5), (D4, 0.5), (E4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (C4, 0.5), (D4, 0.5), (E4, 0.5), (C4, 0.5), (0, 0.5),
  37. (E4, 0.5), (E4, 0.5), (F4, 0.5), (G4, 0.5), (G4, 0.5), (F4, 0.5), (E4, 0.5), (F4, 0.5), (G4, 0.5), (E4, 0.5), (0, 0.5)],
  38.  
  39. 'Old MacDonald': [(G4, 0.5), (G4, 0.5), (G4, 0.5), (D4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (0, 0.5),
  40. (B4, 0.5), (B4, 0.5), (A4, 0.5), (A4, 0.5), (G4, 0.5), (0, 0.5),
  41. (D4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (D4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (0, 0.5),
  42. (B4, 0.5), (B4, 0.5), (A4, 0.5), (A4, 0.5), (G4, 0.5), (0, 0.5),
  43. (D4, 0.5), (D4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (D4, 0.5), (D4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (0, 0.5),
  44. (G4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (0, 0.5),
  45. (G4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (G4, 0.5), (0, 0.5),
  46. (G4, 0.5), (G4, 0.5), (G4, 0.5), (D4, 0.5), (E4, 0.5), (E4, 0.5), (D4, 0.5), (0, 0.5),
  47. (B4, 0.5), (B4, 0.5), (A4, 0.5), (A4, 0.5), (G4, 0.5), (0, 0.5)]
  48. }
  49. # pylint: enable=line-too-long
  50.  
  51. def play_note(note):
  52. if note[0] != 0:
  53. pwm = pulseio.PWMOut(board.D12, duty_cycle = 0, frequency=note[0])
  54. # Hex 7FFF (binary 0111111111111111) is half of the largest value for a 16-bit int,
  55. # i.e. 50%
  56. pwm.duty_cycle = 0x7FFF
  57. time.sleep(note[1])
  58. if note[0] != 0:
  59. pwm.deinit()
  60.  
  61.  
  62. def play_song(songname):
  63. for note in songbook[songname]:
  64. play_note(note)
  65.  
  66.  
  67. def update(songnames, selected):
  68. oled.fill(0)
  69. line = 0
  70. for songname in songnames:
  71. if line == selected:
  72. oled.text(">", 0, line * 8)
  73. oled.text(songname, 10, line * 8)
  74. line += 1
  75. oled.show()
  76.  
  77.  
  78. selected_song = 0
  79. song_names = sorted(list(songbook.keys()))
  80. while True:
  81. button_select.update()
  82. button_play.update()
  83. update(song_names, selected_song)
  84. if button_select.fell:
  85. print("select")
  86. selected_song = (selected_song + 1) % len(songbook)
  87. elif button_play.fell:
  88. print("play")
  89. play_song(song_names[selected_song])
And the switch debouncer:

  1. """
  2. The MIT License (MIT)
  3.  
  4. Copyright (c) 2018 Dave Astels
  5.  
  6. Permission is hereby granted, free of charge, to any person obtaining a copy
  7. of this software and associated documentation files (the "Software"), to deal
  8. in the Software without restriction, including without limitation the rights
  9. to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. copies of the Software, and to permit persons to whom the Software is
  11. furnished to do so, subject to the following conditions:
  12.  
  13. The above copyright notice and this permission notice shall be included in
  14. all copies or substantial portions of the Software.
  15.  
  16. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  22. THE SOFTWARE.
  23.  
  24. --------------------------------------------------------------------------------
  25. Debounce an input pin.
  26. """
  27.  
  28. import time
  29. import digitalio
  30.  
  31. class Debouncer(object):
  32. """Debounce an input pin"""
  33.  
  34. DEBOUNCED_STATE = 0x01
  35. UNSTABLE_STATE = 0x02
  36. CHANGED_STATE = 0x04
  37.  
  38.  
  39. def __init__(self, pin, mode=None, interval=0.010):
  40. """Make an instance.
  41. :param int pin: the pin (from board) to debounce
  42. :param int mode: digitalio.Pull.UP or .DOWN (default is no pull up/down)
  43. :param int interval: bounce threshold in seconds (default is 0.010, i.e. 10 milliseconds)
  44. """
  45. self.state = 0x00
  46. self.pin = digitalio.DigitalInOut(pin)
  47. self.pin.direction = digitalio.Direction.INPUT
  48. if mode != None:
  49. self.pin.pull = mode
  50. if self.pin.value:
  51. self.__set_state(Debouncer.DEBOUNCED_STATE | Debouncer.UNSTABLE_STATE)
  52. self.previous_time = 0
  53. if interval is None:
  54. self.interval = 0.010
  55. else:
  56. self.interval = interval
  57.  
  58.  
  59. def __set_state(self, bits):
  60. self.state |= bits
  61.  
  62.  
  63. def __unset_state(self, bits):
  64. self.state &= ~bits
  65.  
  66.  
  67. def __toggle_state(self, bits):
  68. self.state ^= bits
  69.  
  70.  
  71. def __get_state(self, bits):
  72. return (self.state & bits) != 0
  73.  
  74.  
  75. def update(self):
  76. """Update the debouncer state. Must be called before using any of the properties below"""
  77. self.__unset_state(Debouncer.CHANGED_STATE)
  78. current_state = self.pin.value
  79. if current_state != self.__get_state(Debouncer.UNSTABLE_STATE):
  80. self.previous_time = time.monotonic()
  81. self.__toggle_state(Debouncer.UNSTABLE_STATE)
  82. else:
  83. if time.monotonic() - self.previous_time >= self.interval:
  84. if current_state != self.__get_state(Debouncer.DEBOUNCED_STATE):
  85. self.previous_time = time.monotonic()
  86. self.__toggle_state(Debouncer.DEBOUNCED_STATE)
  87. self.__set_state(Debouncer.CHANGED_STATE)
  88.  
  89.  
  90. @property
  91. def value(self):
  92. """Return the current debounced value of the input."""
  93. return self.__get_state(Debouncer.DEBOUNCED_STATE)
  94.  
  95.  
  96. @property
  97. def rose(self):
  98. """Return whether the debounced input went from low to high at the most recent update."""
  99. return self.__get_state(self.DEBOUNCED_STATE) and self.__get_state(self.CHANGED_STATE)
  100.  
  101.  
  102. @property
  103. def fell(self):
  104. """Return whether the debounced input went from high to low at the most recent update."""
  105. return (not self.__get_state(self.DEBOUNCED_STATE)) and self.__get_state(self.CHANGED_STATE)

Closing

Tuples, Lists, and Dictionaries provide three different ways to organize information. Choosing the best way to do this can make the difference between a program that is awkward to change and one that is a pleasure to work with.
Next time we'll have a look at functions and some guidelines for writing good ones.

 

No comments:

Post a Comment