|
| 1 | +### larson-scanner.py v2.0 |
| 2 | +### Larson scanner for Cytron Maker Pi Pico |
| 3 | + |
| 4 | +### Tested with Maker Pi Pico and CircuitPython 6.2.0-beta.4 |
| 5 | +### with audio daughterboard as workaround for |
| 6 | +### https://github.com/adafruit/circuitpython/issues/4208 |
| 7 | + |
| 8 | +### copy this file to Cytron Maker Pi Pico as code.py |
| 9 | + |
| 10 | +### MIT License |
| 11 | + |
| 12 | +### Copyright (c) 2021 Kevin J. Walters |
| 13 | + |
| 14 | +### Permission is hereby granted, free of charge, to any person obtaining a copy |
| 15 | +### of this software and associated documentation files (the "Software"), to deal |
| 16 | +### in the Software without restriction, including without limitation the rights |
| 17 | +### to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 18 | +### copies of the Software, and to permit persons to whom the Software is |
| 19 | +### furnished to do so, subject to the following conditions: |
| 20 | + |
| 21 | +### The above copyright notice and this permission notice shall be included in all |
| 22 | +### copies or substantial portions of the Software. |
| 23 | + |
| 24 | +### THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 25 | +### IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 26 | +### FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 27 | +### AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 28 | +### LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 29 | +### OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 30 | +### SOFTWARE. |
| 31 | + |
| 32 | + |
| 33 | +import time |
| 34 | + |
| 35 | +import board |
| 36 | +import busio ### for UART |
| 37 | +import pwmio |
| 38 | +import digitalio |
| 39 | +from audiopwmio import PWMAudioOut as AudioOut |
| 40 | +from audiocore import WaveFile |
| 41 | + |
| 42 | + |
| 43 | +debug = 1 |
| 44 | + |
| 45 | +def d_print(level, *args, **kwargs): |
| 46 | + """A simple conditional print for debugging based on global debug level.""" |
| 47 | + if not isinstance(level, int): |
| 48 | + print(level, *args, **kwargs) |
| 49 | + elif debug >= level: |
| 50 | + print(*args, **kwargs) |
| 51 | + |
| 52 | + |
| 53 | +AUDIO_DAUGHTERBOARD = True |
| 54 | + |
| 55 | +if AUDIO_DAUGHTERBOARD: |
| 56 | + ### Using pins presented on ESP-01 to talk to Feather nRF52840 |
| 57 | + adb_uart = busio.UART(board.GP16, board.GP17, baudrate=115200) |
| 58 | + scanner_left = 1 |
| 59 | + scanner_right = 2 |
| 60 | + PWMAUDIO_CLASH_PINS = () |
| 61 | +else: |
| 62 | + ### TODO - PIO PWM would be nice to avoid losing GP2 GP3 for audio |
| 63 | + AUDIO_PIN_L = board.GP18 |
| 64 | + AUDIO_PIN_R = board.GP19 |
| 65 | + audio_out = AudioOut(AUDIO_PIN_L, right_channel=AUDIO_PIN_R) |
| 66 | + left_file = open("scanner-left-16k.wav", "rb") |
| 67 | + right_file = open("scanner-right-16k.wav", "rb") |
| 68 | + |
| 69 | + ### These crackle very unpleasantly and/or CP just crashes |
| 70 | + #scanner_left = WaveFile(left_file) |
| 71 | + #scanner_right = WaveFile(right_file) |
| 72 | + |
| 73 | + ### This blows up on first play during PWM animation |
| 74 | + ### but at least it doesn't crackle!! |
| 75 | + ### https://github.com/adafruit/circuitpython/issues/4431 |
| 76 | + buffer = bytearray(80 * 1024) ### TODO - remove this |
| 77 | + scanner_left = WaveFile(left_file, buffer) |
| 78 | + scanner_right = WaveFile(right_file, buffer) |
| 79 | + |
| 80 | + ### The use of GP18 also effectively reserves GP19 |
| 81 | + ### and the RP2040 hardware cannot then offer PWM on GP2 and GP3 |
| 82 | + ### PIO PWM could be a solution here when it works in CircuitPython |
| 83 | + PWMAUDIO_CLASH_PINS = (board.GP2, board.GP3, |
| 84 | + board.GP18, board.GP19) |
| 85 | + |
| 86 | +### Pins and vertical displacement |
| 87 | +left_pins = ((board.GP0, 0), |
| 88 | + (board.GP1, 1), |
| 89 | + # gap |
| 90 | + (board.GP2, 3), ### clash with GP18 audio due to PWM architecture |
| 91 | + (board.GP3, 4), ### clash with GP18 audio due to PWM architecture |
| 92 | + (board.GP4, 5), |
| 93 | + (board.GP5, 6), |
| 94 | + # gap |
| 95 | + (board.GP6, 8), |
| 96 | + (board.GP7, 9), |
| 97 | + (board.GP8, 10), |
| 98 | + (board.GP9, 11), |
| 99 | + # gap |
| 100 | + (board.GP10, 13), |
| 101 | + (board.GP11, 14), |
| 102 | + (board.GP12, 15), |
| 103 | + (board.GP13, 16), |
| 104 | + # gap |
| 105 | + (board.GP14, 18), |
| 106 | + (board.GP15, 19)) |
| 107 | + |
| 108 | +### GP28 is NeoPixel - will be interesting... |
| 109 | +right_pins = (# 6 absences (green LED for 3v3) |
| 110 | + (board.GP28, 6), |
| 111 | + # gap |
| 112 | + (board.GP27, 8), |
| 113 | + (board.GP26, 9), |
| 114 | + # gap |
| 115 | + (board.GP27, 8), |
| 116 | + (board.GP26, 9), |
| 117 | + # gap |
| 118 | + (board.GP22, 11), |
| 119 | + # gap |
| 120 | + (board.GP21, 13), |
| 121 | + (board.GP20, 14), |
| 122 | + (board.GP19, 15), ### clash with GP18 audio due to PWM architecture |
| 123 | + (board.GP18, 16), ### clash with GP18 audio |
| 124 | + # gap |
| 125 | + (board.GP17, 18), |
| 126 | + (board.GP16, 19)) |
| 127 | + |
| 128 | + |
| 129 | +PIN_SPACING_M = 2.54 / 10 / 100 |
| 130 | +BOARD_LENGTH_M = 20 * PIN_SPACING_M |
| 131 | + |
| 132 | +### This is a duty_cycle value |
| 133 | +MAX_BRIGHTNESS = 65535 |
| 134 | +PWM_LED_FREQUENCY = 7000 |
| 135 | + |
| 136 | + |
| 137 | +class FakePWMOut: |
| 138 | + """A basic, fixed-brightness emulation of the PWMOut object used for |
| 139 | + variable brightness LED driving.""" |
| 140 | + |
| 141 | + def __init__(self, pin, |
| 142 | + frequency=None, ### pylint: disable=unused-argument |
| 143 | + duty_cycle=32767): |
| 144 | + self._pin = pin |
| 145 | + self._duty_cycle = duty_cycle |
| 146 | + self._digout = digitalio.DigitalInOut(self._pin) |
| 147 | + self._digout.direction = digitalio.Direction.OUTPUT |
| 148 | + |
| 149 | + self.duty_cycle = duty_cycle ### set value using property |
| 150 | + |
| 151 | + |
| 152 | + @property |
| 153 | + def duty_cycle(self): |
| 154 | + return self._duty_cycle |
| 155 | + |
| 156 | + @duty_cycle.setter |
| 157 | + def duty_cycle(self, value): |
| 158 | + self._duty_cycle = value |
| 159 | + self._digout.value = (value >= 32768) |
| 160 | + |
| 161 | + |
| 162 | +def show_points(pwms, pin_posis, pnts): |
| 163 | + levels = [0] * len(pwms) |
| 164 | + |
| 165 | + ### Iterate over points accumulating the brightness value |
| 166 | + ### for the pin positions they cover |
| 167 | + for pos, rad, bri in pnts: |
| 168 | + top = pos - rad |
| 169 | + bottom = pos + rad |
| 170 | + for idx, pin_pos in enumerate(pin_posis): |
| 171 | + if top <= pin_pos <= bottom: |
| 172 | + levels[idx] += bri |
| 173 | + |
| 174 | + for idx, level in enumerate(levels): |
| 175 | + ### Use of min() saturates and |
| 176 | + ### caps the value within legal duty cycle range |
| 177 | + pwms[idx].duty_cycle = min(level, MAX_BRIGHTNESS) |
| 178 | + |
| 179 | + |
| 180 | +def start_sound(sample): |
| 181 | + if sample is not None: |
| 182 | + if AUDIO_DAUGHTERBOARD: |
| 183 | + adb_uart.write(bytes([sample])) |
| 184 | + else: |
| 185 | + audio_out.play(sample) |
| 186 | + |
| 187 | + |
| 188 | +def wait_sound(): |
| 189 | + if AUDIO_DAUGHTERBOARD: |
| 190 | + pass ### not implemented |
| 191 | + else: |
| 192 | + while audio_out.playing: |
| 193 | + pass |
| 194 | + |
| 195 | + |
| 196 | +def pwm_init(pins, duty_cycle=0): |
| 197 | + pwms = [] |
| 198 | + for p in pins: |
| 199 | + if p in PWMAUDIO_CLASH_PINS: |
| 200 | + pwms.append(FakePWMOut(p, duty_cycle=duty_cycle)) |
| 201 | + else: |
| 202 | + pwms.append(pwmio.PWMOut(p, frequency=PWM_LED_FREQUENCY, |
| 203 | + duty_cycle=duty_cycle)) |
| 204 | + return pwms |
| 205 | + |
| 206 | + |
| 207 | +points = [] |
| 208 | + |
| 209 | +left_pwms = pwm_init([p for p, d in left_pins]) |
| 210 | +left_pin_pos = tuple([d * PIN_SPACING_M for p, d in left_pins]) |
| 211 | + |
| 212 | +### Indices for point fields |
| 213 | +POS = 0 |
| 214 | +RAD = 1 |
| 215 | +BRI = 2 |
| 216 | + |
| 217 | +start_radius = PIN_SPACING_M / 2.0 |
| 218 | + |
| 219 | +### GP8 is about the middle |
| 220 | +lead_pos = left_pin_pos[8] |
| 221 | +### position, size, brightness |
| 222 | +points.append([lead_pos, start_radius, 65535]) |
| 223 | +for _ in range(5): |
| 224 | + points.append([lead_pos, points[-1][RAD], points[-1][BRI] >> 1]) |
| 225 | +trail_spc = 12 |
| 226 | +last_pos = [0] * ((len(points) - 1) * trail_spc) |
| 227 | + |
| 228 | +### Track if sound effect has been started |
| 229 | +left_se = False |
| 230 | +right_se = False |
| 231 | + |
| 232 | +left_se_pos = left_pin_pos[4] |
| 233 | +right_se_pos = left_pin_pos[-4] |
| 234 | + |
| 235 | +### The real thing goes "out of bounds" |
| 236 | +far_left = left_pin_pos[0] - PIN_SPACING_M * 2 |
| 237 | +far_right = left_pin_pos[-1] + PIN_SPACING_M * 2 |
| 238 | + |
| 239 | +### 1.333 seconds to go from one side to other |
| 240 | +target_speed_mpns = (far_right - far_left) / 1.333 / 1e9 |
| 241 | +speed_mpns = 0.0 |
| 242 | +accel_mpns2 = target_speed_mpns / 15.0 / 1e9 |
| 243 | + |
| 244 | +direction = -1 ### to the left |
| 245 | + |
| 246 | +start_ns = time.monotonic_ns() |
| 247 | +last_move_ns = start_ns |
| 248 | + |
| 249 | + |
| 250 | +while True: |
| 251 | + ### Move the main dot and trailing dots |
| 252 | + while far_left <= lead_pos <= far_right: |
| 253 | + show_points(left_pwms, left_pin_pos, points) |
| 254 | + |
| 255 | + now_ns = time.monotonic_ns() |
| 256 | + elapsed_ns = now_ns - last_move_ns |
| 257 | + if speed_mpns < target_speed_mpns: |
| 258 | + speed_mpns += accel_mpns2 * elapsed_ns |
| 259 | + |
| 260 | + lead_pos += direction * speed_mpns * elapsed_ns |
| 261 | + last_move_ns = now_ns |
| 262 | + |
| 263 | + ### Start the scanner return sound as it approaches either end |
| 264 | + if direction > 0 and not right_se and lead_pos >= right_se_pos: |
| 265 | + start_sound(scanner_right) |
| 266 | + right_se = True |
| 267 | + elif direction < 0 and not left_se and lead_pos <= left_se_pos: |
| 268 | + start_sound(scanner_left) |
| 269 | + left_se = True |
| 270 | + |
| 271 | + ### Move the trailing points and set main point's new position |
| 272 | + for tr_idx in range(1, len(points)): |
| 273 | + points[tr_idx][POS] = last_pos[tr_idx * trail_spc - 1] |
| 274 | + points[0][POS] = lead_pos |
| 275 | + |
| 276 | + ### Shuffle the point position history along and add new position |
| 277 | + last_pos = [lead_pos] + last_pos[:-1] |
| 278 | + |
| 279 | + ### Put position back into bounds and change direction |
| 280 | + lead_pos = far_right if direction > 0 else far_left |
| 281 | + direction = 0 - direction |
| 282 | + |
| 283 | + ### Clear sound start flags |
| 284 | + left_se = right_se = False |
0 commit comments