|
| 1 | +# SPDX-FileCopyrightText: Adafruit Industries |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | + |
| 5 | +""" |
| 6 | +CHEEKMATE: secret message receiver using WiFi, Adafruit IO and a haptic |
| 7 | +buzzer. Periodically polls an Adafruit IO dashboard, converting new messages |
| 8 | +to Morse code. |
| 9 | +
|
| 10 | +secrets.py file must be present and contain WiFi & Adafruit IO credentials. |
| 11 | +""" |
| 12 | + |
| 13 | +import gc |
| 14 | +import time |
| 15 | +import ssl |
| 16 | +import adafruit_drv2605 |
| 17 | +import adafruit_requests |
| 18 | +import board |
| 19 | +import busio |
| 20 | +import neopixel |
| 21 | +import socketpool |
| 22 | +import supervisor |
| 23 | +import wifi |
| 24 | +from adafruit_io.adafruit_io import IO_HTTP |
| 25 | + |
| 26 | +try: |
| 27 | + from secrets import secrets |
| 28 | +except ImportError: |
| 29 | + print("WiFi secrets are kept in secrets.py, please add them there!") |
| 30 | + raise |
| 31 | + |
| 32 | +# CONFIGURABLE GLOBALS ----------------------------------------------------- |
| 33 | + |
| 34 | +FEED_KEY = "cheekmate" # Adafruit IO feed name |
| 35 | +POLL = 10 # Feed polling interval in seconds |
| 36 | +REPS = 3 # Max number of times to repeat new message |
| 37 | +WPM = 15 # Morse code words-per-minute |
| 38 | +BUZZ = 255 # Haptic buzzer amplitude, 0-255 |
| 39 | +LED_BRIGHTNESS = 0.2 # NeoPixel brightness 0.0-1.0, or 0 to disable |
| 40 | +LED_COLOR = (255, 0, 0) # NeoPixel color (R, G, B), 0-255 ea. |
| 41 | + |
| 42 | +# These values are derived from the 'WPM' setting above and do not require |
| 43 | +# manual editing. The dot, dash and gap times are set according to accepted |
| 44 | +# Morse code procedure. |
| 45 | +DOT_LENGTH = 1.2 / WPM # Duration of one Morse dot |
| 46 | +DASH_LENGTH = DOT_LENGTH * 3.0 # Duration of one Morse dash |
| 47 | +SYMBOL_GAP = DOT_LENGTH # Duration of gap between dot or dash |
| 48 | +CHARACTER_GAP = DOT_LENGTH * 3 # Duration of gap between characters |
| 49 | +MEDIUM_GAP = DOT_LENGTH * 7 # Duraction of gap between words |
| 50 | + |
| 51 | +# Morse code symbol-to-mark conversion dictionary. This contains the |
| 52 | +# standard A-Z and 0-9, and extra symbols "+" and "=" sometimes used |
| 53 | +# in chess. If other symbols are needed for this or other games, they |
| 54 | +# can be added to the end of the list. |
| 55 | +MORSE = { |
| 56 | + "A": ".-", |
| 57 | + "B": "-...", |
| 58 | + "C": "-.-.", |
| 59 | + "D": "-..", |
| 60 | + "E": ".", |
| 61 | + "F": "..-.", |
| 62 | + "G": "--.", |
| 63 | + "H": "....", |
| 64 | + "I": "..", |
| 65 | + "J": ".---", |
| 66 | + "K": "-.-", |
| 67 | + "L": ".-..", |
| 68 | + "M": "--", |
| 69 | + "N": "-.", |
| 70 | + "O": "---", |
| 71 | + "P": ".--.", |
| 72 | + "Q": "--.-", |
| 73 | + "R": ".-.", |
| 74 | + "S": "...", |
| 75 | + "T": "-", |
| 76 | + "U": "..-", |
| 77 | + "V": "...-", |
| 78 | + "W": ".--", |
| 79 | + "X": "-..-", |
| 80 | + "Y": "-.--", |
| 81 | + "Z": "--..", |
| 82 | + "0": "-----", |
| 83 | + "1": ".----", |
| 84 | + "2": "..---", |
| 85 | + "3": "...--", |
| 86 | + "4": "....-", |
| 87 | + "5": ".....", |
| 88 | + "6": "-....", |
| 89 | + "7": "--...", |
| 90 | + "8": "---..", |
| 91 | + "9": "----.", |
| 92 | + "+": ".-.-.", |
| 93 | + "=": "-...-", |
| 94 | +} |
| 95 | + |
| 96 | +# SOME FUNCTIONS ----------------------------------------------------------- |
| 97 | + |
| 98 | + |
| 99 | +def buzz_on(): |
| 100 | + """Turn on LED and haptic motor.""" |
| 101 | + pixels[0] = LED_COLOR |
| 102 | + drv.mode = adafruit_drv2605.MODE_REALTIME |
| 103 | + |
| 104 | + |
| 105 | +def buzz_off(): |
| 106 | + """Turn off LED and haptic motor.""" |
| 107 | + pixels[0] = 0 |
| 108 | + drv.mode = adafruit_drv2605.MODE_INTTRIG |
| 109 | + |
| 110 | + |
| 111 | +def play(string): |
| 112 | + """Convert a string to Morse code, output to both the onboard LED |
| 113 | + and the haptic motor.""" |
| 114 | + gc.collect() |
| 115 | + for symbol in string.upper(): |
| 116 | + if code := MORSE.get(symbol): # find Morse code for character |
| 117 | + for mark in code: |
| 118 | + buzz_on() |
| 119 | + time.sleep(DASH_LENGTH if mark == "-" else DOT_LENGTH) |
| 120 | + buzz_off() |
| 121 | + time.sleep(SYMBOL_GAP) |
| 122 | + time.sleep(CHARACTER_GAP - SYMBOL_GAP) |
| 123 | + else: |
| 124 | + time.sleep(MEDIUM_GAP) |
| 125 | + |
| 126 | + |
| 127 | +# NEOPIXEL INITIALIZATION -------------------------------------------------- |
| 128 | + |
| 129 | +# This assumes there is a board.NEOPIXEL, which is true for QT Py ESP32-S2 |
| 130 | +# and some other boards, but not ALL CircuitPython boards. If adapting the |
| 131 | +# code to another board, you might use digitalio with board.LED or similar. |
| 132 | +pixels = neopixel.NeoPixel( |
| 133 | + board.NEOPIXEL, 1, brightness=LED_BRIGHTNESS, auto_write=True |
| 134 | +) |
| 135 | + |
| 136 | +# HAPTIC MOTOR CONTROLLER INIT --------------------------------------------- |
| 137 | + |
| 138 | +# board.SCL1 and SDA1 are the "extra" I2C interface on the QT Py ESP32-S2's |
| 139 | +# STEMMA connector. If adapting to a different board, you might want |
| 140 | +# board.SCL and SDA as the sole or primary I2C interface. |
| 141 | +i2c = busio.I2C(board.SCL1, board.SDA1) |
| 142 | +drv = adafruit_drv2605.DRV2605(i2c) |
| 143 | + |
| 144 | +# "Real-time playback" (RTP) is an unusual mode of the DRV2605 that's not |
| 145 | +# handled in the library by default, but is desirable here to get accurate |
| 146 | +# Morse code timing. This requires bypassing the library for a moment and |
| 147 | +# writing a couple of registers directly... |
| 148 | +while not i2c.try_lock(): |
| 149 | + pass |
| 150 | +i2c.writeto(0x5A, bytes([0x1D, 0xA8])) # Amplitude will be unsigned |
| 151 | +i2c.writeto(0x5A, bytes([0x02, BUZZ])) # Buzz amplitude |
| 152 | +i2c.unlock() |
| 153 | + |
| 154 | +# WIFI CONNECT ------------------------------------------------------------- |
| 155 | + |
| 156 | +try: |
| 157 | + print("Connecting to {}...".format(secrets["ssid"]), end="") |
| 158 | + wifi.radio.connect(secrets["ssid"], secrets["password"]) |
| 159 | + print("OK") |
| 160 | + print("IP:", wifi.radio.ipv4_address) |
| 161 | + |
| 162 | + pool = socketpool.SocketPool(wifi.radio) |
| 163 | + requests = adafruit_requests.Session(pool, ssl.create_default_context()) |
| 164 | + # WiFi uses error messages, not specific exceptions, so this is "broad": |
| 165 | +except Exception as error: # pylint: disable=broad-except |
| 166 | + print("error:", error, "\nBoard will reload in 15 seconds.") |
| 167 | + time.sleep(15) |
| 168 | + supervisor.reload() |
| 169 | + |
| 170 | +# ADAFRUIT IO INITIALIZATION ----------------------------------------------- |
| 171 | + |
| 172 | +aio_username = secrets["aio_username"] |
| 173 | +aio_key = secrets["aio_key"] |
| 174 | +io = IO_HTTP(aio_username, aio_key, requests) |
| 175 | + |
| 176 | +# SUCCESSFUL STARTUP, PROCEED INTO MAIN LOOP ------------------------------- |
| 177 | + |
| 178 | +buzz_on() |
| 179 | +time.sleep(0.75) # Long buzz indicates everything is OK |
| 180 | +buzz_off() |
| 181 | + |
| 182 | +current_message = "" # No message on startup |
| 183 | +rep = REPS # Act as though message is already played out |
| 184 | +last_time = -POLL # Force initial Adafruit IO polling |
| 185 | + |
| 186 | +while True: # Repeat forever... |
| 187 | + |
| 188 | + now = time.monotonic() |
| 189 | + if now - last_time >= POLL: # Time to poll Adafruit IO feed? |
| 190 | + last_time = now # Do it! Do it now! |
| 191 | + feed = io.get_feed(FEED_KEY) |
| 192 | + new_message = feed["last_value"] |
| 193 | + if new_message != current_message: # If message has changed, |
| 194 | + current_message = new_message # Save it, |
| 195 | + rep = 0 # and reset the repeat counter |
| 196 | + |
| 197 | + # Play last message up to REPS times. If a new message has come along in |
| 198 | + # the interim, old message may repeat less than this, and new message |
| 199 | + # resets the count. |
| 200 | + if rep < REPS: |
| 201 | + play(current_message) |
| 202 | + time.sleep(MEDIUM_GAP) |
| 203 | + rep += 1 |
0 commit comments