This clock project uses USB gamepad input to control its time setting menu. The display uses TileGrid sprites that I made in Krita. The code demonstrates how to use timers and a state machine to build an event loop with gamepad input, I2C real time clock IO, and display updates.
Overview and Context
This clock is a step along the way on my quest towards learning how to build little games and apps in CircuitPython. The look for the display theme is about digital watches and alarm clocks from the 80's and 90's.
Some of the technical bits and pieces from this project that you might be able to reuse in your own projects include:
Menu system for manually setting time and date
USB gamepad input system with edge-triggered button press events and repeating timer-triggered button hold events
Data-watch style display theme with three display areas: 20 ASCII characters at the top, an eight digit 7-segment clock display in the middle, and another 20 ASCII character display at the bottom
Main event loop with gamepad button polling, real time clock polling, state machine updates and display updates
Clock Digit Sprites
This is a Krita screenshot showing a zoomed in view of the spritesheet I made
for 7-segment digits. When the spritesheet is loaded as CircuitPython bitmap
for displayio.TileGrid, the sprite numbers start at 0 for the "0" sprite.
The "9" sprite is number 9, the ":" sprite is 10, the "-" sprite is 11,
and the empty sprite is 12.
Each sprite is 30 pixels wide by 50 pixels high. The top pixel of each sprite is blank to work around a bug that currently affects bitmaps loaded from PNG files with adafruit_imageloader. By the time you read this, the bug may have been fixed (see https://github.com/adafruit/circuitpython/issues/9587).
The solid grid lines between sprites are Krita guides. The dotted lines are grid divisions (for details, refer to the Grid options tab in the screenshot).
The sprites are each 6 pixels wide by 8 pixels high. The look is based on dot matrix character LCD fonts used in digital watches and serial character displays.
Numbering for the character sprites starts with 0 for the ASCII space
character. The last sprite number is 95, which corresponds to ASCII DEL
character (127), which I used for a custom up/down arrows glyph. To translate
from a Python string or byte to the sprite number, you subtract 32 from the
character's ordinal number (ord()) or the byte's integer value.
To get from a Krita document to a BMP spritesheet, I did:
In Krita: File menu > Export... > (export PNG file: ASCII-font.png)
In Debian terminal shell:
gm convert ASCII-font.png BMP3:ASCII-font.bmp
The gm convert shell command requires that you have the Debian GraphicsMagick
package installed (sudo apt install graphicsmagick). ImageMagick would also
work.
State Machine
The clock's state machine is moderately complicated. So, I made a table to help me organize all the states along with actions and state transitions:
| State | UP | DOWN | LEFT | RIGHT | A | B | START |
|---|---|---|---|---|---|---|---|
| hhmm | nop | nop | mmss | mmss | nop | hhmm | setHMin |
| mmss | nop | nop | hhmm | hhmm | nop | hhmm | setHMin |
| setYr | year+1 | year-1 | setSec | setMDay | setMDay | hhmm | hhmm |
| setMDay | day+1 | day-1 | setYr | setHour | setHour | hhmm | hhmm |
| setHour | hour+1 | hour-1 | setMDay | setHMin | setHMin | hhmm | hhmm |
| setHMin | min+1 | min-1 | setHour | setSec | setSec | hhmm | hhmm |
| setSec | sec=0 | sec=0 | setHMin | setYr | setYr | hhmm | hhmm |
Major Modes and Sub-modes
The state machine has 2 major modes:
1) Clock Mode shows the current time or date. There are sub-modes for a minimal hour and minute display (hhmm) and a fancier display with full date and time including seconds (mmss).
2) Set Mode lets you set the clock's year, month, day, hour, minutes, and seconds. Set Mode has sub-modes for setting the year (setYr), the month and day (setMDay), hours (setHour), hours and minutes (setHMin), and seconds (setSec).
Button Actions
Clock Mode (all sub-modes):
- LEFT or RIGHT: switch between sub-modes
- B: Switch back to the hours and minutes sub-mode (hhmm)
- START: Switch to Set Mode
Set Mode (sub-modes: year, month-day, hours-minutes):
- UP: Add 1 to the value being set, or press and hold to increment the value faster
- DOWN: Subtract 1 from the value being set, or press and hold to decrement the value faster
- A or RIGHT: Advance to the next sub-mode
- LEFT: Switch to the previous sub-mode
- B or START: Switch back to Clock Mode
Set Mode (sub-mode: seconds):
- UP or DOWN: Set seconds to 00, rounding minutes to closest minute
- A or RIGHT: Advance to the next sub-mode
- LEFT: Switch to the previous sub-mode
- B or START: Switch back to Clock Mode
Tools and Consumables
Soldering iron
Solder
Fine point hobby knife with safety handle (X-ACTO or similar)
Solid-Core insulated 22AWG hookup wire (Adafruit #289 or similar)
Wire strippers (Adafruit #527 or similar)
Soldering Vise (Adafruit #3197 or similar)
Flush diagonal cutters (Adafruit #152 or similar)
Adhesive tape with clean-removable adhesive (Kapton tape, 3M Scotch 35 electrical tape, blue painter's tape, or whatever)
Assemble the Hardware
If you are unfamiliar with soldering headers, you might want to read:
Order of Soldering
The TFT Feather, USB Host Featherwing, and Adalogger FeatherWing each come with two strips of 16-position male header. Since feather boards have 16 holes on one side and 12 holes on the other, use your flush cutters to trim 4 pins off the header strips for the 12-hole sides.
Assemble the USB Host FeatherWing with pin headers on a breadboard, then solder the headers in place. (The breadboard will align your header pins at the right angle relative to the FeatherWing PCB. Once the FeatherWing pins are done, you can use the FeatherWing as a jig to help hold the Tripler's female headers while you solder them.)
Locate a set of female headers from your FeatherWing Tripler kit. Remove the USB host FeatherWing from the breadboard, then put female headers onto the pins of the USB host FeatherWing.
Using the USB host FeatherWing to hold the female headers in place, put the female header pins into one of the silkscreened Feather footprints of the Tripler. Tape the ends of the USB host FeatherWing to the Tripler, being careful not to cover any of the pins.
Clamp the Tripler in a vise and solder the female headers in place.
Locate another set of female headers from your Tripler kit. Remove the USB host FeatherWing from the Tripler, then put the female headers onto the pins of the USB host board.
Put the female header pins into one of the open silkscreen footprint of your Tripler board, then prepare the assembly as before with tape and a vise.
Solder the female header pins in place.
Repeat the previous 6 steps to solder the third set of female headers in place on the third silkscreen footprint of your Tripler.
Carefully assemble your ESP32-S3 TFT Feather with header pins on a breadboard. Leave the protective film in place to protect the display from flux splatter. Solder the header pins in place. You can use the solder wire to bend the pull tab of the protective film out of the way so it does not touch your soldering iron.
Remove the Feather TFT from the breadboard and set it aside.
Assemble the Adalogger FeatherWing with pin headers on a breadboard, then solder the headers in place.
-
IMPORTANT: The Adalogger FeatherWing's default SD card CS pin is D10, which conflicts with the CS pin for the USB Host FeatherWing, so the Adalogger's SDCS signal needs to be moved with a wire jumper. For more details, check out the SD & SPI Pins section of the Adalogger Learn Guide.
Locate the Adalogger's SDCS silkscreen label next to the corner of its micro SD card slot. Right next to the "CS" of the SDCS label, you should see a jumper (two rectangular pads joined by a thin trace) along with a round drilled pad. Use a fine point hobby knife to cut the trace between the jumper pads with a light scraping motion.
Cut and strip a piece of 22AWG insulated hookup wire long enough to reach from the SDCS drilled pad over to the inner pad for the Adalogger's D11 pin (one pad closer to the battery holder).
Clamp the Adalogger board in a vise, solder the jumper wire from the bottom of the board, then trim the excess wire ends with flush cutters. The end result should look like this:
Smoke Test and Final Assembly
(optional) Use nylon M2.5 standoffs to mount your Tripler board on a backplate, such as a Tamiya Universal Plate, so the board is easier to handle without shorts or static discharges.
Assemble the Tripler with your Feather TFT, USB Host FeatherWing, and Adalogger FeatherWing.
Try plugging your board into a USB charger to make sure the LEDs light up.
If the LEDs light up, unplug the USB power cable, install the CR1220 coin cell in your Adalogger's battery holder, then plug the USB gamepad into the Host FeatherWing's USB A port.
Updating CircuitPython
NOTE: To update CircuitPython on the ESP32-S3 TFT Feather with 2MB PSRAM and 4MB Flash, you need to use the .BIN file (combination bootloader and CircuitPython core)
Download the CircuitPython 9.1.3 .BIN file from the Feather ESP32-S3 TFT PSRAM page on circuitpython.org
Follow the instructions in the Web Serial ESPTool section of the "CircuitPython on ESP32 Quick Start" learn guide to update your board with CircuitPython 9.1.3. First erasing the board's contents, then programming it with the .BIN file.
If you encounter errors with the Adafruit ESPTool web application, you can also try Espressif's ESP32 Tool web application. But, if you do that, be sure to se the "Flash Address" field to "0" before using the "Program" button.
Installing CircuitPython Code
To copy the project bundle files to your CIRCUITPY drive:
Download the project bundle .zip file using the Download Project Bundle button below.
Expand the zip file by opening it, or use
unzipin a Terminal. The zip archive should expand to a folder. When you open the folder, it should contain aREADME.txtfile and aCircuitPython 9.xfolder.Open the CircuitPython 9.x folder and copy all of its contents to your CIRCUITPY drive.
To learn more about copying libraries to your CIRCUITPY drive, check out the CircuitPython Libraries section of the Welcome to CircuitPython! learn guide.
Running the Code
Connect the USB gamepad to the MAX3421E USB Host Featherwing.
Plug a computer or charger into the Feather TFT ESP32-S3 USB C port.
CAUTION: This code was tested with an 8BitDo SN30 Pro USB wired gamepad, which uses the XInput protocol and identifies itself with the vendor and product IDs of an Xbox 360 gamepad (045e:028e). This code may not work properly with other gamepads.
Understanding the Code
The
main()function incode.pyinitializes objects for the data-watch display theme, USB gamepad input, the real time clock, and the state machine. It also has the main event loop that coordinates gamepad polling and clock display updates. The timers for gamepad repeating timer-triggered button hold events are part of the main event loop.The
XInputGamepadclass fromgamepad.pyhandles low-level USB gamepad details.The
SevenSegandCharLCDclasses fromsevenseg.pyandcharldcd.pyimplement the 7-segment and ASCII character display withdisplayio.TileGridsprites.The
StateMachineclass fromstatemachine.pyimplements the logic to model the behavior of the clock's time display and time setting modes. The code for setting the Adalogger's PCF8523 Real Time Clock is inStateMachine.handleGamepad.
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# Hardware:
# - Adafruit ESP32-S3 TFT Feather - 4MB Flash, 2MB PSRAM (#5483)
# - Adafruit USB Host FeatherWing with MAX3421E (#5858)
# - 8BitDo SN30 Pro USB gamepad
#
# Pinouts:
# | TFT feather | USB Host | ST7789 TFT | Adalogger |
# | ----------- | -------- | ---------- | ------------------ |
# | SCK | SCK | | SCK (SD) |
# | MOSI | MOSI | | MOSI (SD) |
# | MISO | MISO | | MISO (SD) |
# | SDA | | | SDA (RTC) |
# | SCL | | | SCL (RTC) |
# | D9 | IRQ | | |
# | D10 | CS | | (Not SDCS!) |
# | D11 | | | SDCS (wire jumper) |
# | TFT_CS | | CS | |
# | TFT_DC | | DC | |
#
# Related Documentation:
# - https://learn.adafruit.com/adafruit-esp32-s3-tft-feather
# - https://learn.adafruit.com/adafruit-1-14-240x135-color-tft-breakout
# - https://learn.adafruit.com/adafruit-usb-host-featherwing-with-max3421e
# - https://learn.adafruit.com/adafruit-adalogger-featherwing/rtc-with-circuitpython
# - https://docs.circuitpython.org/en/latest/shared-bindings/time/index.html
#
from board import D9, D10, D11, I2C, SPI, TFT_CS, TFT_DC
from digitalio import DigitalInOut, Direction
from displayio import Bitmap, Group, Palette, TileGrid, release_displays
from fourwire import FourWire
import gc
from max3421e import Max3421E
from micropython import const
from supervisor import ticks_ms
from time import sleep, struct_time
from usb.core import USBError
from adafruit_pcf8523 import PCF8523
from adafruit_st7789 import ST7789
from charlcd import CharLCD
from gamepad import (
XInputGamepad, UP, DOWN, LEFT, RIGHT, START, SELECT, A, B, X, Y)
from sevenseg import SevenSeg
from statemachine import StateMachine
def handle_input(machine, prev, buttons, repeat):
# Respond to gamepad button state change events
diff = prev ^ buttons
mh = machine.handleGamepad
#print(f"{buttons:016b}")
if repeat:
# Check for hold-time triggered repeating events
if (buttons == UP): # UP held
mh(machine.UP, True)
elif (buttons == DOWN): # DOWN held
mh(machine.DOWN, True)
else:
# Check for edge-triggered events
if (diff & A) and (buttons == A): # A pressed
mh(machine.A, False)
elif (diff & B) and (buttons == B): # B pressed
mh(machine.B, False)
elif (diff & UP) and (buttons == UP): # UP pressed
mh(machine.UP, False)
elif (diff & DOWN) and (buttons == DOWN): # DOWN pressed
mh(machine.DOWN, repeat)
elif (diff & LEFT) and (buttons == LEFT): # LEFT pressed
mh(machine.LEFT, False)
elif (diff & RIGHT) and (buttons == RIGHT): # RIGHT pressed
mh(machine.RIGHT, False)
elif (diff & START) and (buttons == START): # START pressed
mh(machine.START, False)
def elapsed_ms(prev, now):
# Calculate elapsed ms between two timestamps from supervisor.ticks_ms().
# The ticks counter rolls over at 2**29, and (2**29)-1 = 0x3fffffff
MASK = const(0x3fffffff)
return (now - prev) & MASK
def main():
release_displays()
gc.collect()
spi = SPI()
# Initialize ST7789 display with native display size of 240x135px.
TFT_W = const(240)
TFT_H = const(135)
bus = FourWire(spi, command=TFT_DC, chip_select=TFT_CS)
display = ST7789(bus, rotation=270, width=TFT_W, height=TFT_H, rowstart=40,
colstart=53, auto_refresh=False)
gc.collect()
# Set up the 5 digit/dots sprites to build a 7-segment time display
# Each sprite is 5*8px wide by 6*8 px high (= 40x48px).
# Configure the character display areas at top and bottom of screen.
# This uses a 6x8 px spritesheet font for ASCII characters (32..127).
SCALE = 2
PAD = 2
COLS = 20
Y1 = (TFT_H // SCALE) - 8 - PAD
charLCD = CharLCD(cols=COLS, x=0, y0=PAD, y1=Y1, scale=SCALE)
gc.collect()
# Configure the 7-segment clock digits display area in the center of the
# screen. There are eight 7-segment sprites, and each sprite is 30px wide by
# 50px high.
X = (TFT_W - (8 * 30)) // 2
Y = (TFT_H - 50) // 2
digits = SevenSeg(x=X, y=Y)
gc.collect()
# Add the TileGrids to the display's root group
gc.collect()
grp = Group(scale=1)
grp.append(charLCD.group())
grp.append(digits.group())
display.root_group = grp
display.refresh()
# Initialize MAX3421E USB host chip which is needed by usb.core.
# The link between usb.core and Max3421E happens by way of invisible
# magic in the CircuitPython core, kinda like with displayio displays.
print("Initializing USB host port...")
gc.collect()
usbHost = Max3421E(spi, chip_select=D10, irq=D9)
gc.collect()
sleep(0.1)
# Initialize RTC
rtc = PCF8523.PCF8523(I2C())
gc.collect()
# Example, reset time to 2024-09-14 01:23:45:
# rtc.datetime = struct_time((2024, 9, 14, 1, 23, 45, 0, -1, -1))
# Initialize State Machine
machine = StateMachine(digits, charLCD, rtc)
# Gamepad status update strings
GP_FIND = 'Finding USB gamepad'
GP_READY = 'gamepad ready'
GP_DISCON = 'gamepad disconnected'
GP_ERR = 'gamepad connection error'
# Cache frequently used callables to save time on dictionary name lookups
# NOTE: rtc.datetime is a property, so we can't cache it here!
_collect = gc.collect
_elapsed = elapsed_ms
_ms = ticks_ms
_refresh = display.refresh
_setMsg = charLCD.setMsg
_updateDigits = machine.updateDigits
# Read RTC time and update display digits
prevST = rtc.datetime
_updateDigits(prevST)
# MAIN EVENT LOOP
# Establish and maintain a gamepad connection
gp = XInputGamepad()
print(GP_FIND)
_setMsg(GP_FIND, top=False)
_refresh()
need_refresh = False
# OUTER LOOP: Update clock and try to connect to a USB gamepad.
# Start timers for RTC polling and gamepad button hold detection. The point
# the RTC timer is to avoid burning unecessary clock cycles waiting for the
# I2C bus, which is slow.
RTC_MS = const(100) # RTC poll interval (ms)
DELAY_MS = const(900) # Gamepad button hold delay before repeat (ms)
REPEAT_MS = const(300) # Gamepad button interval between repeats (ms)
prev_ms = _ms()
rtc_ms = 0
hold_tmr = 0
repeat_tmr = 0
while True:
_collect()
now_ms = _ms()
if need_refresh or (_elapsed(rtc_ms, now_ms) >= RTC_MS):
# Check clock (RTC) and update time display if needed
rtc_ms = now_ms
nowST = rtc.datetime
if need_refresh or (nowST != prevST):
prevST = nowST
_updateDigits(prevST)
_refresh()
need_refresh = False
try:
# Attempt to connect to USB gamepad
if gp.find_and_configure():
print(gp.device_info_str())
connected = True
_setMsg(GP_READY, top=False)
_refresh()
# INNER LOOP: Update clock and poll gamepad for button events
prev_btn = 0
hold_tmr = 0
repeat_tmr = 0
for buttons in gp.poll():
# Update timers
now_ms = _ms()
interval = _elapsed(prev_ms, now_ms)
prev_ms = now_ms
if buttons == 0:
hold_tmr = 0
repeat_tmr = 0
elif prev_btn != buttons:
hold_tmr = 0
repeat_tmr = 0
else:
hold_tmr += interval
repeat_tmr += interval
# Check RTC and update display if needed
if need_refresh or (_elapsed(rtc_ms, now_ms) >= RTC_MS):
rtc_ms = now_ms
nowST = rtc.datetime
if need_refresh or (nowST != prevST):
prevST = nowST
_updateDigits(prevST)
_refresh()
_collect()
need_refresh = False
# Handle hold-time triggered gamepad input events
if hold_tmr >= DELAY_MS:
if hold_tmr == repeat_tmr:
# First re-trigger event after initial delay
repeat_tmr -= DELAY_MS
handle_input(machine, prev_btn, buttons, True)
need_refresh = True
elif repeat_tmr >= REPEAT_MS:
# Another re-trigger event after repeat interval
repeat_tmr -= REPEAT_MS
handle_input(machine, prev_btn, buttons, True)
need_refresh = True
# Handle edge-triggered gamepad input events
if prev_btn != buttons:
handle_input(machine, prev_btn, buttons, False)
need_refresh = True
# Save button values
prev_btn = buttons
# If loop stopped, gamepad connection was lost
print(GP_DISCON)
print(GP_FIND)
_setMsg(GP_FIND, top=False)
_refresh()
else:
# No connection yet, so sleep briefly then try again
sleep(0.1)
except USBError as e:
# This might mean gamepad was unplugged, or maybe some other
# low-level USB thing happened which this driver does not yet
# know how to deal with. So, log the error and keep going
print(e)
print(GP_ERR)
print(GP_FIND)
_setMsg(GP_FIND, top=False)
_refresh()
main()
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
from displayio import Bitmap, Group, Palette, TileGrid
import gc
from micropython import const
import adafruit_imageload
class CharLCD:
# Simulate a dot matrix character LCD using sprites
def __init__(self, cols=8, x=0, y0=0, y1=32, scale=2):
# Args:
# - cols: number of columns (monospace characters) in the display
# - x: x coordinate of both lines' top-left corners
# - y0: y coordinate of top line's top-left corner
# - y1: y coordinate of bottom lines's top-left corner
# - scale: scaling factor for the font (scale=2 means 2x zoom)
self.cols = cols
# Load font spritesheet into Bitmap and Palette objects
gc.collect()
(bmp, pal) = adafruit_imageload.load(
"ascii-font.bmp", bitmap=Bitmap, palette=Palette)
gc.collect()
# Make a Group with TileGrids for the top line, with top left corner at
# (x0, y0), and the bottom line, with top left corner at (x1, y1)
tg0 = TileGrid(
bmp, pixel_shader=pal, width=cols, height=1,
tile_width=6, tile_height=8, x=x, y=y0, default_tile=0)
gc.collect()
tg1 = TileGrid(
bmp, pixel_shader=pal, width=cols, height=1,
tile_width=6, tile_height=8, x=x, y=y1, default_tile=0)
gc.collect()
self.tg0 = tg0
self.tg1 = tg1
g = Group(scale=scale)
g.append(tg0)
g.append(tg1)
self.grp = g
def group(self):
return self.grp
def setMsg(self, msg, top=True):
# Show message left-aligned on the top or bottom character LCD.
# - msg: string or bytes (should have ASCII chars in range 32..127)
# - top: True: show msg on top line; False: show msg on bottom line
#
ASCII_SPACE = const(32) # first sprite: space (blank rectangle)
ASCII_DEL = const(127) # last sprite: DEL (up/down arrows)
_tg = self.tg0 if top else self.tg1
_cols = self.cols
# Set sprites for characters of the message (max length = self.cols)
for (i, char) in zip(range(_cols), msg):
# Convert the character to a sprite number and update the TileGrid
n = char if (type(char) == int) else ord(char)
if (n < ASCII_SPACE) or (ASCII_DEL < n):
n = ord('?') # Replace out of range chars with '?'
# Change sprite if current value differs from previous value
sprite = n - 32 # spritesheet starts at ' ', so subtract 32
if _tg[i] != sprite:
_tg[i] = sprite
# Clear right padding area with space characters
for i in range(len(msg), _cols):
if _tg[i] != 0:
_tg[i] = 0 # spritesheet starts at ' ', so 0 is space
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
from displayio import Bitmap, Group, Palette, TileGrid
import gc
from micropython import const
import adafruit_imageload
class SevenSeg:
# Simulate a 7-segment LED clock display using sprites
def __init__(self, x=0, y=0, cols=8):
# Args:
# - x: x coordinate of first digit's top-left corner
# - y: y coordinate of top line's top-left corner
# Load font spritesheet into Bitmap and Palette objects
#
# CAUTION 1: This uses adafruit_imageload.load() with a PNG file, and
# the PNG loader currently has a bug where sprites are misaligned by
# one pixel. (https://github.com/adafruit/circuitpython/issues/9587 )
# To work around the bug, I made the spritesheet with 1 pixel of
# vertical padding above and below the important area of each digit.
#
# CAUTION 2: When I tried this spritesheet with a BMP file, the image
# loaded without any exceptions, but the resulting bitmap was badly
# glitched (rows were skewed, colors were wrong, etc). The BMP file was
# about 14KB, so maybe it overflowed a buffer or something? Not sure.
#
gc.collect()
(bmp, pal) = adafruit_imageload.load(
"digit-sprites.png", bitmap=Bitmap, palette=Palette)
gc.collect()
# Make a Group with TileGrids with top left corner at (x, y)
tg = TileGrid(
bmp, pixel_shader=pal, width=cols, height=1,
tile_width=30, tile_height=50, x=x, y=y, default_tile=12)
gc.collect()
self.tg = tg
self.cols = cols
g = Group(scale=1)
g.append(tg)
self.grp = g
def group(self):
return self.grp
def setDigits(self, digits):
# Show message left-aligned on the top or bottom character LCD.
# - digits: string or bytes in the set: "0123456789: "
#
ASCII_SPACE = const(32)
ASCII_DASH = const(45)
ASCII_ZERO = const(48)
ASCII_COLON = const(58) # conveniently, ASCII ":" is right after "9"!
_DASH_SPRITE = const(11)
_SPACE_SPRITE = const(12)
_tg = self.tg
_cols = self.cols
# Set sprites for characters of the message (max length = self.cols)
for (i, char) in zip(range(_cols), digits):
# Convert the character to a sprite number and update the TileGrid
n = char if (type(char) == int) else ord(char)
sprite = _SPACE_SPRITE # default: ' '
if (ASCII_ZERO <= n) and (n <= ASCII_COLON): # '0'..'9' and ':'
sprite = n - ASCII_ZERO
elif n == ASCII_DASH: # '-'
sprite = _DASH_SPRITE
if _tg[i] != sprite: # Avoid triggering redundant repaints
_tg[i] = sprite
# Clear right padding area with space characters
for i in range(len(digits), _cols):
if _tg[i] != _SPACE_SPRITE:
_tg[i] = _SPACE_SPRITE
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# Table of states and state transitions:
#
# | State | UP | DOWN | LEFT | RIGHT | A | B | START |
# | ------- | ------ | ------ | ------- | ------- | ------- | ---- | ------- |
# | hhmm | nop | nop | mmss | mmss | nop | hhmm | setHMin |
# | mmss | nop | nop | hhmm | hhmm | nop | hhmm | setHMin |
# | setYr | year+1 | year-1 | setSec | setMDay | setMDay | hhmm | hhmm |
# | setMDay | day+1 | day-1 | setYr | setHour | setHour | hhmm | hhmm |
# | setHour | hour+1 | hour-1 | setMDay | setHMin | setHMin | hhmm | hhmm |
# | setHMin | min+1 | min-1 | setHour | setSec | setSec | hhmm | hhmm |
# | setSec | sec=0 | sec=0 | setHMin | setYr | setYr | hhmm | hhmm |
#
# Related documentation:
# - https://docs.circuitpython.org/projects/datetime/en/latest/api.html
# - https://docs.circuitpython.org/en/latest/shared-bindings/time/index.html#time.mktime
#
from micropython import const
from time import mktime
from adafruit_datetime import datetime, timedelta
# State Transition Constants (private)
# CAUTION: These values must match row indexes of StateMachine.TABLE
_HHMM = const(0)
_MMSS = const(1)
_SetYr = const(2)
_SetMDay = const(3)
_SetHour = const(4)
_SetHMin = const(5)
_SetSec = const(6)
# Action Constants (private)
_NOP = const(7)
_YrInc = const(8)
_YrDec = const(9)
_DayInc = const(10)
_DayDec = const(11)
_HrInc = const(12)
_HrDec = const(13)
_MinInc = const(14)
_MinDec = const(15)
_Sec00 = const(16)
class StateMachine:
# Button Press Constants (public)
# CAUTION: These values must match column indexes of StateMachine.TABLE
UP = const(0)
DOWN = const(1)
LEFT = const(2)
RIGHT = const(3)
A = const(4)
B = const(5)
START = const(6)
# LookUp Table (private) of actions (including NOP and state transitions)
# for possible button press events in each of the possible states. NOP is
# short for "No OPeration", and it means to do nothing.
_TABLE = (
# UP DOWN LEFT RIGHT A B START State
(_NOP, _NOP, _MMSS, _MMSS, _NOP, _HHMM, _SetHMin), # hhmm
(_NOP, _NOP, _HHMM, _HHMM, _NOP, _HHMM, _SetHMin), # mmss
(_YrInc, _YrDec, _SetSec, _SetMDay, _SetMDay, _HHMM, _HHMM ), # setYr
(_DayInc, _DayDec, _SetYr, _SetHour, _SetHour, _HHMM, _HHMM ), # setMDay
(_HrInc, _HrDec, _SetMDay, _SetHMin, _SetHMin, _HHMM, _HHMM ), # setHour
(_MinInc, _MinDec, _SetHour, _SetSec, _SetSec, _HHMM, _HHMM ), # setHMin
(_Sec00, _Sec00, _SetHMin, _SetYr, _SetYr, _HHMM, _HHMM ), # setSec
)
def __init__(self, digits, charLCD, rtc):
# Save references to character LCD display, digits display, and RTC
self.digits = digits
self.charLCD = charLCD
self.rtc = rtc
# Start in the state for Clock Mode with hours and minutes sub-mode
self.state = _HHMM
def updateDigits(self, st):
# Update clock digits from current state and struct_time object, st.
# struct_time(tm_year, tm_mon, tm_mday, tm_hour, tm_min, tm_sec,
# tm_wday, tm_yday, tm_isdst)
_setD = self.digits.setDigits
_setM = self.charLCD.setMsg
s = self.state
if (s == _HHMM):
# Simple clock like, "12:00"
_setD(' %02d:%02d' % (st.tm_hour, st.tm_min))
elif (s == _MMSS):
# Full date and time like, "2024-09-12 12:00:01"
_setM('%04d-%02d-%02d' % (st.tm_year, st.tm_mon, st.tm_mday))
_setD('%02d:%02d:%02d' % (st.tm_hour, st.tm_min, st.tm_sec))
elif (s == _SetHour) or (s == _SetHMin) or (s == _SetSec):
# for setting hours, minutes, or seconds like, "12:00:01"
_setD('%02d:%02d:%02d' % (st.tm_hour, st.tm_min, st.tm_sec))
elif (s == _SetYr):
# for setting the year
_setD(' %04d' % (st.tm_year))
elif (s == _SetMDay):
# for setting the month and day
_setD(' %02d-%02d' % (st.tm_mon, st.tm_mday))
def handleGamepad(self, button, repeat):
# Handle a button press event
# args:
# - button: one of the button constants
# - repeat: True if this is a hold-time triggered repeating event
# Check lookup table for the response code for this button event
if button < UP or button > START:
print("Button value out of range:", button)
return
r = self._TABLE[self.state][button]
# Cache frequently used names to reduce time used by dictionary lookups
_rtc = self.rtc
_setM = self.charLCD.setMsg
_fromtimestamp = datetime.fromtimestamp
# The help message for Set Mode uses a special up/down arrows sprite
# that is mapped in the sprite sheet to ASCII DEL (0x7f)
SET_HELP = b"\x7f:+/- B:Exit A:OK"
SET_HELP_SEC = b"\x7f:=00 B:Exit A:OK"
# Handle the response code
# First, check for state transition codes
if r == _HHMM:
self.state = r
_setM(b'')
_setM(b'', top=False)
elif r == _MMSS:
self.state = r
_setM(b'')
_setM(b'', top=False)
elif r == _SetYr:
self.state = r
_setM(b' SET YEAR')
_setM(SET_HELP, top=False)
elif r == _SetMDay:
self.state = r
_setM(b' SET MONTH-DAY')
_setM(SET_HELP, top=False)
elif r == _SetHour:
self.state = r
_setM(b' SET HOUR')
_setM(SET_HELP, top=False)
elif r == _SetHMin:
self.state = r
_setM(b' SET MINUTES')
_setM(SET_HELP, top=False)
elif r == _SetSec:
self.state = r
_setM(b' SET SECONDS')
_setM(SET_HELP_SEC, top=False)
# Second, check for action codes that don't change the state
elif r == _NOP:
return
# Third, check for action codes that modify the RTC date or time
else:
# To avoid surprising things like unintentionally changing the day
# when you're trying to set the minutes (e.g. crossing midnight),
# we need a struct_time object. But, to do math with time deltas in
# a way that accounts for leap years, days per month, etc., we need
# a datetime object. So, make both:
st = _rtc.datetime # struct_time
now = _fromtimestamp(mktime(st)) # datetime
# Unpack the struct_time with shorter names
(year, month, day) = (st.tm_year, st.tm_mon, st.tm_mday)
(hour, min_, sec) = (st.tm_hour, st.tm_min, st.tm_sec)
if r == _YrInc:
# Increment Year
n = 5 if repeat else 1
if year + n > 2037:
# Don't go above 2037 because attempting to do so causes
# a CircuitPython long int overflow error. Note that
# 19 January 2038 is the Unix time 32-bit overflow date.
# see https://en.wikipedia.org/wiki/Year_2038_problem
n = 2037 - year
_rtc.datetime = (now + timedelta(days=(n*365))).timetuple()
elif r == _YrDec:
# Decrement Year
n = -5 if repeat else -1
if year + n < 2001:
# Don't go below 2001 because adafruit_pcf8523 doesn't like
# years below 2000
n = 2001 - year
_rtc.datetime = (now + timedelta(days=(n*365))).timetuple()
elif r == _DayInc:
# Increment Day
n = 10 if repeat else 1
if (month == 12) and (day + n > 31):
# Do not go past December 31 (avoid changing year)
n = 31 - day
_rtc.datetime = (now + timedelta(days=n)).timetuple()
elif r == _DayDec:
# Decrement Day
n = -10 if repeat else -1
if (month == 1) and (day + n < 1):
# Do not go past January 1 (avoid changing year)
n = 1 - day
_rtc.datetime = (now + timedelta(days=n)).timetuple()
elif r == _HrInc:
# Increment Hour
n = 4 if repeat else 1
if hour + n > 23:
# Do not go past 23:xx (avoid changing day)
n = 23 - hour
_rtc.datetime = (now + timedelta(hours=n)).timetuple()
elif r == _HrDec:
# Decrement Hour
n = -4 if repeat else -1
if hour + n < 0:
# Do not go past 00:xx (avoid changing day)
n = 0 - hour
_rtc.datetime = (now + timedelta(hours=n)).timetuple()
elif r == _MinInc:
# Increment Minute
n = 10 if repeat else 1
if (hour == 23) and (min_ + n > 59):
# Do not go past 23:59 (avoid changing day)
n = 59 - min_
_rtc.datetime = (now + timedelta(minutes=n)).timetuple()
elif r == _MinDec:
# Decrement Minute
n = -10 if repeat else -1
if (hour == 0) and (min_ + n < 0):
# Do not go past 00:00 (avoid changing day)
n = 00 - min_
_rtc.datetime = (now + timedelta(minutes=n)).timetuple()
elif r == _Sec00:
# Round seconds to nearest minute
delta = -(sec) if (sec <= 30) else (60-sec)
_rtc.datetime = (now + timedelta(seconds=delta)).timetuple()
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# Gamepad driver for XInput compatible USB wired gamepad with MAX421E.
#
# The button names used here match the Nintendo SNES style button
# cluster layout, but the USB IDs and protocol match the Xbox 360 USB
# wired controller. This is meant to work with widely available USB
# wired xinput compatible gamepads for the retrogaming market. In
# particular, I tested this package using my 8BitDo SN30 Pro USB wired
# gamepad.
#
# CAUTION: If you try to use a USB adapter with a wireless xinput
# compatible gamepad, it probably won't work with this driver in its
# current form. In my testing, compared to wired gamepads, USB wireless
# adapters have extra delays and errors that require retries and
# low-level error handling. I haven't been able to get a USB wireless
# gamepad adapter working yet in CircuitPython yet.
#
from micropython import const
from struct import unpack
from time import sleep
from usb import core
from usb.core import USBError
# Gamepad button bitmask constants
UP = const(0x0001) # dpad: Up
DOWN = const(0x0002) # dpad: Down
LEFT = const(0x0004) # dpad: Left
RIGHT = const(0x0008) # dpad: Right
START = const(0x0010)
SELECT = const(0x0020)
L = const(0x0100) # Left shoulder button
R = const(0x0200) # Right shoulder button
B = const(0x1000) # button cluster: bottom button (Nintendo B, Xbox A)
A = const(0x2000) # button cluster: right button (Nintendo A, Xbox B)
Y = const(0x4000) # button cluster: left button (Nintendo Y, Xbox X)
X = const(0x8000) # button cluster: top button (Nintendo X, Xbox Y)
class XInputGamepad:
# Constants for USB device IO
_INTERFACE = const(0)
_TIMEOUT_MS = const(5)
_ENDPOINT = const(0x81)
def __init__(self):
# Initialize buffer used in polling USB gamepad events
self.buf64 = bytearray(64)
# Variable to hold the gamepad's usb.core.Device object
self.device = None
def find_and_configure(self):
# Connect to a USB wired Xbox 360 style gamepad (vid:pid=045e:028e)
#
# Returns: True = success, False = device not found or config failed
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
device = core.find(idVendor=0x045e, idProduct=0x028e)
sleep(0.1)
if device:
self._configure(device) # may raise usb.core.USBError
return True # end retry loop
else:
# No gamepad was found
self.reset()
return False
def _configure(self, device):
# Prepare USB gamepad for use (set configuration, drain buffer, etc)
#
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
try:
# Make sure CircuitPython core is not claiming the device
if device.is_kernel_driver_active(_INTERFACE):
device.detach_kernel_driver(_INTERFACE)
# Make sure that configuration is set
device.set_configuration()
except USBError as e:
self.reset()
raise e
# Initial reads may give old data, so drain gamepad's buffer. This
# may raise an exception (with no string description nor errno!)
# when buffer is already empty. If that happens, ignore it.
try:
sleep(0.1)
for _ in range(8):
__ = device.read(0x81, self.buf64, timeout=_TIMEOUT_MS)
except USBError as e:
if e.errno is None:
pass # this is okay
else:
self.reset()
raise e
# All good, so save a reference to the device object
self.device = device
def poll(self):
# Generator to poll gamepad for button changes (ignore sticks/triggers)
# Yields:
# buttons: Uint16 containing bitfield of individual button values
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
# This generator is meant to be used with a `for` loop. The point is to
# allow for faster polling by reducing the Python VM overhead spent on
# memory allocation, method calls, and dictionary lookups. To read more
# about generators, see https://peps.python.org/pep-0255/
#
# Expected endpoint 0x81 report format:
# bytes 0,1: prefix that doesn't change [ignored]
# bytes 2,3: button bitfield for dpad, ABXY, etc (uint16)
# byte 4: L2 left trigger (analog uint8) [ignored]
# byte 5: R2 right trigger (analog uint8) [ignored]
# bytes 6,7: LX left stick X axis (int16) [ignored]
# bytes 8,9: LY left stick Y axis (int16) [ignored]
# bytes 10,11: RX right stick X axis (int16) [ignored]
# bytes 12,13: RY right stick Y axis (int16) [ignored]
# bytes 14..19: ???, but they don't change
#
if self.device is None:
# Caller is trying to poll buttons when gamepad is not connected
return
# Caching frequently used objects saves time on dictionary name lookups
_devread = self.device.read
_buf = self.buf64
_unpack = unpack
# Generator loop (note how this uses yield instead of return)
prev = 0
while True:
try:
# Poll gamepad endpoint to get button and joystick status bytes
n = _devread(_ENDPOINT, _buf, timeout=_TIMEOUT_MS)
if n < 14:
# skip unexpected responses (too short to be a full report)
yield prev
# Only bytes 2 and 3 are interesting (ignore sticks/triggers)
(buttons,) = _unpack('<H', self.buf64[2:4])
prev = buttons
yield buttons
except USBError as e:
self.reset()
raise e
def device_info_str(self):
# Return string describing gamepad device (or lack thereof)
d = self.device
if d is None:
return "[Gamepad not connected]"
(v, pi, pr, m) = (d.idVendor, d.idProduct, d.product, d.manufacturer)
if (v is None) or (pi is None):
# Sometimes the usb.core or Max3421E will return 0000:0000 for
# reasons that I do not understand
return "[bad vid:pid]"
else:
return "Connected: %04x:%04x prod='%s' mfg='%s'" % (v, pi, pr, m)
def reset(self):
# Reset USB device and gamepad button polling state
self.device = None
This page (Feather TFT Clock with Gamepad Input) was last updated on September 18, 2024.
Text editor powered by tinymce.