|
| 1 | +# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | + |
| 5 | +""" |
| 6 | +USB Typewriter Feather-side Script |
| 7 | +Converts incoming keystrokes to solenoid clicks |
| 8 | +""" |
| 9 | + |
| 10 | +import time |
| 11 | +import struct |
| 12 | +import usb_cdc |
| 13 | +import board |
| 14 | +from adafruit_mcp230xx.mcp23017 import MCP23017 |
| 15 | + |
| 16 | +# Typewriter configuration |
| 17 | +KEYSTROKE_BELL_INTERVAL = 25 # Ring bell every 25 keystrokes |
| 18 | +SOLENOID_STRIKE_TIME = 0.03 # Duration in seconds for solenoid activation |
| 19 | +ENTER_KEY_CODE = 0x28 # HID code for Enter key |
| 20 | +ESCAPE_KEY_CODE = 0x29 # HID code for Escape key |
| 21 | +BACKSPACE_KEY_CODE = 0x2A # HID code for Backspace key |
| 22 | +TAB_KEY_CODE = 0x2B # HID code for Tab key |
| 23 | + |
| 24 | +# Key name mapping for debug output |
| 25 | +key_names = { |
| 26 | + 0x04: "A", 0x05: "B", 0x06: "C", 0x07: "D", |
| 27 | + 0x08: "E", 0x09: "F", 0x0A: "G", 0x0B: "H", |
| 28 | + 0x0C: "I", 0x0D: "J", 0x0E: "K", 0x0F: "L", |
| 29 | + 0x10: "M", 0x11: "N", 0x12: "O", 0x13: "P", |
| 30 | + 0x14: "Q", 0x15: "R", 0x16: "S", 0x17: "T", |
| 31 | + 0x18: "U", 0x19: "V", 0x1A: "W", 0x1B: "X", |
| 32 | + 0x1C: "Y", 0x1D: "Z", |
| 33 | + 0x1E: "1", 0x1F: "2", 0x20: "3", 0x21: "4", |
| 34 | + 0x22: "5", 0x23: "6", 0x24: "7", 0x25: "8", |
| 35 | + 0x26: "9", 0x27: "0", |
| 36 | + 0x28: "ENTER", 0x29: "ESC", 0x2A: "BACKSPACE", |
| 37 | + 0x2B: "TAB", 0x2C: "SPACE", 0x2D: "MINUS", |
| 38 | + 0x2E: "EQUAL", 0x2F: "LBRACKET", 0x30: "RBRACKET", |
| 39 | + 0x31: "BACKSLASH", 0x33: "SEMICOLON", 0x34: "QUOTE", |
| 40 | + 0x35: "GRAVE", 0x36: "COMMA", 0x37: "PERIOD", |
| 41 | + 0x38: "SLASH", 0x39: "CAPS_LOCK", |
| 42 | + 0x4F: "RIGHT", 0x50: "LEFT", 0x51: "DOWN", 0x52: "UP", |
| 43 | +} |
| 44 | + |
| 45 | +# Add F1-F12 keys |
| 46 | +for i in range(12): |
| 47 | + key_names[0x3A + i] = f"F{i + 1}" |
| 48 | + |
| 49 | +# Set up I2C and MCP23017 |
| 50 | +i2c = board.STEMMA_I2C() |
| 51 | +mcp = MCP23017(i2c) |
| 52 | + |
| 53 | +# Configure solenoid pins |
| 54 | +noid_1 = mcp.get_pin(0) # Bell solenoid |
| 55 | +noid_2 = mcp.get_pin(1) # Key strike solenoid |
| 56 | +noid_1.switch_to_output(value=False) |
| 57 | +noid_2.switch_to_output(value=False) |
| 58 | + |
| 59 | +# Typewriter state tracking |
| 60 | +keystroke_count = 0 |
| 61 | +current_keys = set() # Track currently pressed keys |
| 62 | + |
| 63 | +# Check if USB CDC data is available |
| 64 | +if usb_cdc.data is None: |
| 65 | + print("ERROR: USB CDC data not enabled!") |
| 66 | + print("Please create a boot.py file with:") |
| 67 | + print(" import usb_cdc") |
| 68 | + print(" usb_cdc.enable(console=True, data=True)") |
| 69 | + print("\nThen reset the board.") |
| 70 | + while True: |
| 71 | + time.sleep(1) |
| 72 | + |
| 73 | +serial = usb_cdc.data |
| 74 | + |
| 75 | +def strike_key_solenoid(): |
| 76 | + """Activate the key strike solenoid briefly""" |
| 77 | + noid_2.value = True |
| 78 | + time.sleep(SOLENOID_STRIKE_TIME) |
| 79 | + noid_2.value = False |
| 80 | + |
| 81 | +def ring_bell_solenoid(): |
| 82 | + """Activate the bell solenoid briefly""" |
| 83 | + noid_1.value = True |
| 84 | + time.sleep(SOLENOID_STRIKE_TIME) |
| 85 | + noid_1.value = False |
| 86 | + |
| 87 | +def process_key_event(mod, code, p): # pylint: disable=too-many-branches |
| 88 | + """Process a key event from the computer""" |
| 89 | + global keystroke_count # pylint: disable=global-statement |
| 90 | + |
| 91 | + # Debug output |
| 92 | + key_name = key_names.get(code, f"0x{code:02X}") |
| 93 | + action = "pressed" if p else "released" |
| 94 | + |
| 95 | + # Handle modifier display |
| 96 | + if mod > 0: |
| 97 | + mod_str = [] |
| 98 | + if mod & 0x01: |
| 99 | + mod_str.append("L_CTRL") |
| 100 | + if mod & 0x02: |
| 101 | + mod_str.append("L_SHIFT") |
| 102 | + if mod & 0x04: |
| 103 | + mod_str.append("L_ALT") |
| 104 | + if mod & 0x08: |
| 105 | + mod_str.append("L_GUI") |
| 106 | + if mod & 0x10: |
| 107 | + mod_str.append("R_CTRL") |
| 108 | + if mod & 0x20: |
| 109 | + mod_str.append("R_SHIFT") |
| 110 | + if mod & 0x40: |
| 111 | + mod_str.append("R_ALT") |
| 112 | + if mod & 0x80: |
| 113 | + mod_str.append("R_GUI") |
| 114 | + print(f"[{'+'.join(mod_str)}] {key_name} {action}") |
| 115 | + else: |
| 116 | + print(f"{key_name} {action}") |
| 117 | + |
| 118 | + # Only process key presses (not releases) for solenoid activation |
| 119 | + if p and code > 0: # key_code 0 means modifier-only update |
| 120 | + # Check if this is a new key press |
| 121 | + if code not in current_keys: |
| 122 | + current_keys.add(code) |
| 123 | + |
| 124 | + # Increment keystroke counter |
| 125 | + keystroke_count += 1 |
| 126 | + |
| 127 | + # Strike the key solenoid |
| 128 | + strike_key_solenoid() |
| 129 | + |
| 130 | + # Check for special keys |
| 131 | + if code == ENTER_KEY_CODE: |
| 132 | + ring_bell_solenoid() |
| 133 | + keystroke_count = 0 # Reset counter for new line |
| 134 | + elif code == ESCAPE_KEY_CODE: |
| 135 | + ring_bell_solenoid() |
| 136 | + keystroke_count = 0 # Reset counter |
| 137 | + elif code == TAB_KEY_CODE: |
| 138 | + ring_bell_solenoid() |
| 139 | + keystroke_count = 0 # Reset counter |
| 140 | + elif code == BACKSPACE_KEY_CODE: |
| 141 | + keystroke_count = 0 # Reset counter but no bell |
| 142 | + elif keystroke_count % KEYSTROKE_BELL_INTERVAL == 0: |
| 143 | + print(f"\n*** DING! ({keystroke_count} keystrokes) ***\n") |
| 144 | + ring_bell_solenoid() |
| 145 | + |
| 146 | + print(f"Total keystrokes: {keystroke_count}") |
| 147 | + |
| 148 | + elif not p and code > 0: |
| 149 | + # Remove key from pressed set when released |
| 150 | + current_keys.discard(code) |
| 151 | + |
| 152 | +print("USB Typewriter Receiver starting...") |
| 153 | +print(f"Bell will ring every {KEYSTROKE_BELL_INTERVAL} keystrokes or on special keys") |
| 154 | +print("Waiting for key events from computer...") |
| 155 | +print("-" * 40) |
| 156 | + |
| 157 | +# Buffer for incoming data |
| 158 | +buffer = bytearray(4) |
| 159 | +buffer_pos = 0 |
| 160 | + |
| 161 | +while True: |
| 162 | + # Check for incoming serial data |
| 163 | + if serial.in_waiting > 0: |
| 164 | + # Read available bytes |
| 165 | + data = serial.read(serial.in_waiting) |
| 166 | + |
| 167 | + for byte in data: |
| 168 | + # Look for start marker |
| 169 | + if buffer_pos == 0: |
| 170 | + if byte == 0xAA: |
| 171 | + buffer[0] = byte |
| 172 | + buffer_pos = 1 |
| 173 | + else: |
| 174 | + # Fill buffer |
| 175 | + buffer[buffer_pos] = byte |
| 176 | + buffer_pos += 1 |
| 177 | + |
| 178 | + # Process complete message |
| 179 | + if buffer_pos >= 4: |
| 180 | + # Unpack the message |
| 181 | + _, modifier, key_code, pressed = struct.unpack('BBBB', buffer) |
| 182 | + |
| 183 | + # Process the key event |
| 184 | + process_key_event(modifier, key_code, pressed) |
| 185 | + |
| 186 | + # Reset buffer |
| 187 | + buffer_pos = 0 |
| 188 | + |
| 189 | + # Small delay to prevent busy-waiting |
| 190 | + time.sleep(0.001) |
0 commit comments