├── .gitignore ├── README.md ├── requirements.txt └── src └── mario.py /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | venv 4 | .idea 5 | .DS_Store 6 | *.pyc 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lego Mario Controller 2 | 3 | This is a small python script that connects to a Lego Mario toy and emits keystrokes for certaim movements of the figurine. I used it as a controller for Super Mario Brothers ([Video](https://twitter.com/r1ckp/status/1301074026975162368)). 4 | 5 | I only tested it on MacOS 10.15 - it may or may not work on other opererating systems. I had to run the NES emulator in a Linux VM (Parallels or VirtualBox) because the native emulators on MacOS did ignore the virtual keypresses from python. 6 | 7 | Unfortuately I have no time to support this but I'll accept pull requests. 8 | 9 | ## Dependencies 10 | 11 | You need python3 and some packages that can be installed from the project root with `pip3 install -r requirements.txt` 12 | 13 | ## Running it 14 | 15 | python3 src/mario.py 16 | 17 | ## Customization 18 | 19 | The keys can be configured in the top of the file `src/mario.py`. For special keys use the ones defined [here](https://pythonhosted.org/pynput/_modules/pynput/keyboard/_base.html#Key) (e.g. `key.enter`) 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bleak~=0.7.1 2 | pynput~=1.7.1 3 | wxPython~=4.1.0 4 | wxasync~=0.41 5 | -------------------------------------------------------------------------------- /src/mario.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | import wx 4 | from wxasync import WxAsyncApp, StartCoroutine 5 | from pynput.keyboard import Key, Controller 6 | from bleak import BleakScanner, BleakClient 7 | 8 | # Key assignments 9 | KEY_JUMP = 'a' 10 | KEY_LEAN_FORWARD = Key.right 11 | KEY_LEAN_BACKWARD = Key.left 12 | KEY_RED_TILE = 'b' 13 | KEY_GREEN_TILE = Key.down 14 | 15 | # Timing 16 | BUTTON_TIME_DEFAULT = 0.1 17 | BUTTON_TIME_JUMP = 1.5 18 | 19 | # BLE stuff 20 | LEGO_CHARACTERISTIC_UUID = "00001624-1212-efde-1623-785feabcd123" 21 | SUBSCRIBE_IMU_COMMAND = bytearray([0x0A, 0x00, 0x41, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01]) 22 | SUBSCRIBE_RGB_COMMAND = bytearray([0x0A, 0x00, 0x41, 0x01, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01]) 23 | 24 | # GUI class 25 | class MarioFrame(wx.Frame): 26 | 27 | def __init__(self, parent=None, id=-1, title="Lego Mario Keys"): 28 | wx.Frame.__init__(self, parent, id, title, size=(450, 100)) 29 | self.initGUI() 30 | self.controller = MarioController(self) 31 | StartCoroutine(self.controller.run(), self) 32 | 33 | def initGUI(self): 34 | 35 | panel = wx.Panel(self) 36 | 37 | font = wx.Font(15, wx.DEFAULT, wx.NORMAL, wx.DEFAULT) 38 | 39 | self.status_field = wx.StaticText(self, label="", style=wx.ALIGN_CENTER) 40 | self.status_field.SetFont(font) 41 | 42 | self.cam_field = wx.StaticText(self, label="", style=wx.ALIGN_LEFT, size=wx.Size(50, wx.DefaultCoord)) 43 | self.cam_field.SetFont(font) 44 | 45 | self.accel_field = wx.StaticText(self, label="", style=wx.ALIGN_LEFT, size=wx.Size(200, wx.DefaultCoord)) 46 | self.accel_field.SetFont(font) 47 | 48 | self.key_switch_label = wx.StaticText(self, label="Send keys: ", style=wx.ALIGN_RIGHT, size=wx.Size(100, wx.DefaultCoord)) 49 | self.key_switch_label.SetFont(font) 50 | 51 | self.key_switch = wx.CheckBox(self) 52 | 53 | vbox = wx.BoxSizer(wx.VERTICAL) 54 | vbox.Add(self.status_field, flag=wx.ALL, border=5, ) 55 | 56 | hbox = wx.BoxSizer(wx.HORIZONTAL) 57 | hbox.Add(self.cam_field, flag=wx.ALL|wx.FIXED_MINSIZE, border=5) 58 | hbox.Add(self.accel_field, flag=wx.ALL|wx.FIXED_MINSIZE, border=5) 59 | hbox.Add(self.key_switch_label, flag=wx.ALL|wx.FIXED_MINSIZE, border=5) 60 | hbox.Add(self.key_switch, flag=wx.ALL, border=5) 61 | 62 | vbox.Add(hbox, flag=wx.ALL, border=5) 63 | 64 | self.SetSizer(vbox) 65 | 66 | # Class for the controller 67 | class MarioController: 68 | 69 | def __init__(self, gui): 70 | self.gui = gui 71 | self.keyboard = Controller() 72 | self.current_tile = 0 73 | self.current_x = 0 74 | self.current_y = 0 75 | self.current_z = 0 76 | self.is_connected = False 77 | 78 | def signed(char): 79 | return char - 256 if char > 127 else char 80 | 81 | async def process_keys(self): 82 | if self.is_connected and self.gui.key_switch.GetValue(): 83 | if self.current_tile == 1: 84 | self.keyboard.press(KEY_RED_TILE) 85 | await asyncio.sleep(BUTTON_TIME_DEFAULT) 86 | self.keyboard.release(KEY_RED_TILE) 87 | self.current_tile = 0 88 | elif self.current_tile == 2: 89 | self.keyboard.press(KEY_GREEN_TILE) 90 | await asyncio.sleep(BUTTON_TIME_DEFAULT) 91 | self.keyboard.release(KEY_GREEN_TILE) 92 | self.current_tile = 0 93 | if self.current_z > 10: 94 | self.keyboard.press(KEY_LEAN_BACKWARD) 95 | elif self.current_z < -10: 96 | self.keyboard.press(KEY_LEAN_FORWARD) 97 | else: 98 | self.keyboard.release(KEY_LEAN_BACKWARD) 99 | self.keyboard.release(KEY_LEAN_FORWARD) 100 | if self.current_x > 5: 101 | self.keyboard.press(KEY_JUMP) 102 | await asyncio.sleep(BUTTON_TIME_JUMP) 103 | self.keyboard.release(KEY_JUMP) 104 | await asyncio.sleep(0.05) 105 | 106 | 107 | def notification_handler(self, sender, data): 108 | # Camera sensor data 109 | if data[0] == 8: 110 | 111 | # RGB code 112 | if data[5] == 0x0: 113 | if data[4] == 0xb8: 114 | self.gui.cam_field.SetLabel("Start tile") 115 | self.current_tile = 3 116 | if data[4] == 0xb7: 117 | self.gui.cam_field.SetLabel("Goal tile") 118 | self.current_tile = 4 119 | print("Barcode: " + " ".join(hex(n) for n in data)) 120 | 121 | # Red tile 122 | elif data[6] == 0x15: 123 | self.gui.cam_field.SetLabel("Red tile") 124 | self.current_tile = 1 125 | # Green tile 126 | elif data[6] == 0x25: 127 | self.gui.cam_field.SetLabel("Green tile") 128 | self.current_tile = 2 129 | # No tile 130 | elif data[6] == 0x1a: 131 | self.gui.cam_field.SetLabel("No tile") 132 | self.current_tile = 0 133 | 134 | 135 | # Accelerometer data 136 | elif data[0] == 7: 137 | self.current_x = int((self.current_x*0.5) + (MarioController.signed(data[4])*0.5)) 138 | self.current_y = int((self.current_y*0.5) + (MarioController.signed(data[5])*0.5)) 139 | self.current_z = int((self.current_z*0.5) + (MarioController.signed(data[6])*0.5)) 140 | self.gui.accel_field.SetLabel("X: %i | Y: %i | Z: %i" % (self.current_x, self.current_y, self.current_z)) 141 | 142 | 143 | async def run(self): 144 | while True: 145 | self.is_connected = False 146 | self.gui.status_field.SetLabel("Looking for Mario. Switch on and press Bluetooth key.") 147 | self.gui.cam_field.SetLabel("") 148 | self.gui.accel_field.SetLabel("") 149 | devices = await BleakScanner.discover() 150 | for d in devices: 151 | if d.name.lower().startswith("lego mario"): 152 | self.gui.status_field.SetLabel("Found Mario!") 153 | try: 154 | async with BleakClient(d.address) as client: 155 | await client.is_connected() 156 | self.gui.status_field.SetLabel("Mario is connected") 157 | self.is_connected = True 158 | await client.start_notify(LEGO_CHARACTERISTIC_UUID, self.notification_handler) 159 | await asyncio.sleep(0.1) 160 | await client.write_gatt_char(LEGO_CHARACTERISTIC_UUID, SUBSCRIBE_IMU_COMMAND) 161 | await asyncio.sleep(0.1) 162 | await client.write_gatt_char(LEGO_CHARACTERISTIC_UUID, SUBSCRIBE_RGB_COMMAND) 163 | while await client.is_connected(): 164 | await self.process_keys() 165 | except: 166 | pass 167 | 168 | 169 | # Run it 170 | if __name__ == "__main__": 171 | # The application object. 172 | app = WxAsyncApp() 173 | # The app frame 174 | frm = MarioFrame() 175 | # Drawing it 176 | frm.Show() 177 | 178 | # Start the main loop 179 | loop = asyncio.get_event_loop() 180 | loop.run_until_complete(app.MainLoop()) 181 | --------------------------------------------------------------------------------