Last active
October 23, 2025 18:01
-
-
Save ndeadly/7d27aa63e2f653a902a2474dbcbc08b3 to your computer and use it in GitHub Desktop.
A python script for connecting Nintendo Switch 2 controllers, viewing their input data and sending commands to the controller
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import os | |
| import sys | |
| import time | |
| import struct | |
| import asyncio | |
| import platform | |
| import pickle | |
| from bleak import BleakScanner, BleakClient | |
| from PyQt5 import QtCore, QtGui, QtWidgets | |
| import qasync | |
| import numpy as np | |
| import pyqtgraph as pg | |
| from matplotlib import rcParams | |
| # NOTE: for some reason all the handles are one less than these in bleak | |
| INPUT_HANDLES = [0x000A, 0x000E] | |
| COMMAND_HANDLES = [0x0014, 0x0016] | |
| COMMAND_RESPONSE_HANDLES = [0x001A, 0x001E] | |
| DEFAULT_FEATURE_FLAGS = 0x03 # 0x2f | |
| DEFAULT_LED_PATTERN = 0b0110 | |
| def hexdump(data, width=16): | |
| lines = [] | |
| for i in range(0, len(data), width): | |
| chunk = data[i:i+width] | |
| hex_bytes = ' '.join(f'{b:02X}' for b in chunk) | |
| ascii_repr = ''.join((chr(b) if 32 <= b < 127 else '.') for b in chunk) | |
| lines.append(f'{i:04X} {hex_bytes:<{width*3}} |{ascii_repr}|') | |
| return '\n'.join(lines) | |
| def unpack_12bit_triplet(data): | |
| a = (data[0] | ((data[1] & 0x0F) << 8)) | |
| b = ((data[1] >> 4) | (data[2] << 4)) | |
| return a, b | |
| def unpack_12bit_sequence(data): | |
| out = [] | |
| view = memoryview(data).cast('B') | |
| for i in range(0, len(view), 3): | |
| out.extend(unpack_12bit_triplet(view[i:i+3])) | |
| return out | |
| class FeatureFlagWidget(QtWidgets.QWidget): | |
| stateChanged = QtCore.pyqtSignal(int) | |
| BIT_FLAGS = { | |
| 0: "Bit 0: Enable button state reporting", | |
| 1: "Bit 1: Enable analog stick reporting", | |
| 2: "Bit 2: Enable IMU reporting", | |
| 3: "Bit 3: Unknown", | |
| 4: "Bit 4: Enable mouse reporting", | |
| 5: "Bit 5: Enable current reporting", | |
| 6: "Bit 6: Unknown", | |
| 7: "Bit 7: Enable magnetometer reporting" | |
| } | |
| def __init__(self): | |
| super().__init__() | |
| # Style for inset black square with white margin | |
| self.setStyleSheet(""" | |
| QCheckBox::indicator { | |
| border: 1px solid gray; | |
| background-color: white; | |
| } | |
| QCheckBox::indicator:checked { | |
| background-color: black; | |
| border: 1px solid gray; | |
| padding: 3px; | |
| } | |
| """) | |
| self.checkboxes = [] | |
| label_layout = QtWidgets.QHBoxLayout() | |
| checkbox_layout = QtWidgets.QHBoxLayout() | |
| for i in reversed(range(8)): | |
| label = QtWidgets.QLabel("Bit " + str(i)) | |
| #label.setAlignment(QtCore.Qt.AlignCenter) | |
| label_layout.addWidget(label) | |
| checkbox = QtWidgets.QCheckBox() | |
| checkbox.setToolTip(FeatureFlagWidget.BIT_FLAGS[i]) | |
| checkbox.setTristate(False) | |
| checkbox.stateChanged.connect(self.on_state_changed) | |
| self.checkboxes.insert(0, checkbox) | |
| checkbox_layout.addWidget(checkbox) | |
| self.hex_label = QtWidgets.QLabel("Flags: 0x00") | |
| self.hex_label.setAlignment(QtCore.Qt.AlignCenter) | |
| main_layout = QtWidgets.QVBoxLayout(self) | |
| main_layout.addLayout(label_layout) | |
| main_layout.addLayout(checkbox_layout) | |
| main_layout.addWidget(self.hex_label) | |
| def on_state_changed(self, state): | |
| value = self.get_state() | |
| self.hex_label.setText(f"Flags: 0x{value:02X}") | |
| self.stateChanged.emit(value) | |
| def set_state(self, state): | |
| for i in range(len(self.checkboxes)): | |
| self.checkboxes[i].setChecked((state >> i) & 1) | |
| self.stateChanged.emit(state & 0xff) | |
| def get_state(self): | |
| state = 0 | |
| for i in range(len(self.checkboxes)): | |
| state |= self.checkboxes[i].isChecked() << i | |
| return state | |
| class StickWidget(QtWidgets.QWidget): | |
| def __init__(self): | |
| super().__init__() | |
| self.x = 0x800 | |
| self.y = 0x800 | |
| self.setMinimumSize(100, 100) | |
| self.calibration = None | |
| def set_calibration(self, calibration): | |
| self.calibration = calibration | |
| def set_position(self, x, y): | |
| self.x = x | |
| self.y = y | |
| self.update() | |
| def paintEvent(self, event): | |
| painter = QtGui.QPainter(self) | |
| painter.setRenderHint(QtGui.QPainter.Antialiasing) | |
| center = self.rect().center() | |
| radius = min(self.width(), self.height()) / 2 - 10 | |
| painter.setPen(QtGui.QPen(QtCore.Qt.gray, 1)) | |
| painter.drawLine(center.x(), int(center.y() - radius), center.x(), int(center.y() + radius)) | |
| painter.drawLine(int(center.x() - radius), center.y(), int(center.x() + radius), center.y()) | |
| painter.drawEllipse(center, int(radius), int(radius)) | |
| if self.calibration: | |
| x = self.x - self.calibration[0] | |
| x /= self.calibration[4] if x < 0 else self.calibration[2] | |
| y = self.y - self.calibration[1] | |
| y /= self.calibration[5] if y < 0 else self.calibration[3] | |
| dot_x = center.x() + x * radius | |
| dot_y = center.y() - y * radius | |
| else: | |
| x = (self.x - 0x800) / 0x800 | |
| y = (self.y - 0x800) / 0x800 | |
| dot_x = center.x() + x * radius | |
| dot_y = center.y() - y * radius | |
| if self.isEnabled(): | |
| painter.setBrush(QtGui.QBrush(QtCore.Qt.red)) | |
| painter.setPen(QtCore.Qt.NoPen) | |
| painter.drawEllipse(QtCore.QRectF(dot_x - 4, dot_y - 4, 8, 8)) | |
| def changeEvent(self, event): | |
| if event.type() == QtCore.QEvent.EnabledChange: | |
| self.update() | |
| super().changeEvent(event) | |
| class ButtonGridWidget(QtWidgets.QWidget): | |
| BUTTON_GRID_FORMATS = [ | |
| # Handle 0x000A, Common | |
| [ | |
| [(0, 0x80, "ZR"), (0, 0x40, "R"), (0, 0x20, "SL Right"), (0, 0x10, "SR Right"), (0, 0x08, "A"), (0, 0x04, "B"), (0, 0x02, "X"), (0, 0x01, "Y")], | |
| [(1, 0x80, ""), (1, 0x40, "C"), (1, 0x20, "Capture"), (1, 0x10, "Home"), (1, 0x08, "L‑Stick"), (1, 0x04, "R‑Stick"), (1, 0x02, "+"), (1, 0x01, "-")], | |
| [(2, 0x80, "ZL"), (2, 0x40, "L"), (2, 0x20, "SL Left"), (2, 0x10, "SR Left"), (2, 0x08, "Left"), (2, 0x04, "Right"), (2, 0x02, "Up"), (2, 0x01, "Down")], | |
| [(3, 0x80, ""), (3, 0x40, ""), (3, 0x20, ""), (3, 0x10, "Headset"), (3, 0x08, ""), (3, 0x04, ""), (3, 0x02, "GL"), (3, 0x01, "GR")], | |
| ], | |
| # Handle 0x000E, JoyConR | |
| [ | |
| [(0, 0x80, "Stick"), (0, 0x40, "+"), (0, 0x20, "ZR"), (0, 0x10, "R"), (0, 0x08, "X"), (0, 0x04, "Y"), (0, 0x02, "A"), (0, 0x01, "B")], | |
| [(1, 0x80, "SL"), (1, 0x40, "SR"), (1, 0x20, ""), (1, 0x10, "C"), (1, 0x08, ""), (1, 0x04, ""), (1, 0x02, ""), (1, 0x01, "Home")], | |
| ], | |
| # Handle 0x000E, JoyConL | |
| [ | |
| [(0, 0x80, "Stick"), (0, 0x40, "-"), (0, 0x20, "ZL"), (0, 0x10, "L"), (0, 0x08, "Up"), (0, 0x04, "Left"), (0, 0x02, "Right"), (0, 0x01, "Down")], | |
| [(1, 0x80, "SL"), (1, 0x40, "SR"), (1, 0x20, ""), (1, 0x10, ""), (1, 0x08, ""), (1, 0x04, ""), (1, 0x02, ""), (1, 0x01, "Capture")], | |
| ], | |
| # Handle 0x000E, Pro/GCN | |
| [ | |
| [(0, 0x80, "R-Stick"), (0, 0x40, "+"), (0, 0x20, "ZR"), (0, 0x10, "R"), (0, 0x08, "X"), (0, 0x04, "Y"), (0, 0x02, "A"), (0, 0x01, "B")], | |
| [(1, 0x80, "L-Stick"), (1, 0x40, "-"), (1, 0x20, "ZL"), (1, 0x10, "L"), (1, 0x08, "Up"), (1, 0x04, "Left"), (1, 0x02, "Right"), (1, 0x01, "Down")], | |
| [(2, 0x80, ""), (2, 0x40, ""), (2, 0x20, ""), (2, 0x10, "C"), (2, 0x08, "GL"), (2, 0x04, "GR"), (2, 0x02, "Capture"), (2, 0x01, "Home")], | |
| ] | |
| ] | |
| def __init__(self, format=0): | |
| super().__init__() | |
| self.labels = {} | |
| self.layout_format = format | |
| self.button_grid = ButtonGridWidget.BUTTON_GRID_FORMATS[format] | |
| self.setup_ui() | |
| def setup_ui(self): | |
| # Create input buttons | |
| layout = QtWidgets.QGridLayout(self) | |
| # grid.setContentsMargins(0, 0, 0, 0) | |
| layout.setHorizontalSpacing(2) | |
| layout.setVerticalSpacing(2) | |
| layout.setSpacing(2) | |
| def make_label(txt, is_placeholder=False): | |
| label = QtWidgets.QLabel(txt) | |
| label.setAlignment(QtCore.Qt.AlignCenter) | |
| label.setFixedSize(40, 20) | |
| if is_placeholder: | |
| label.setStyleSheet("background:#333;color:#666;border:1px solid #444;border-radius:3px;") | |
| else: | |
| label.setStyleSheet(self._style(False)) | |
| return label | |
| for row, entries in enumerate(self.button_grid): | |
| for col, (byte_idx, bit_mask, name) in enumerate(entries): | |
| if not name: | |
| label = make_label("", is_placeholder=True) | |
| layout.addWidget(label, row, col) | |
| continue | |
| label = make_label(name) | |
| layout.addWidget(label, row, col) | |
| self.labels[name] = label | |
| def _style(self, pressed): | |
| return ( | |
| "background:#2b2b2b;color:" + ("white" if pressed else "#bbb") + | |
| ";border:1px solid #555;border-radius:3px;" | |
| ) | |
| def set_button_state(self, buttons): | |
| for row in self.button_grid: | |
| for byte_idx, bit_mask, name in row: | |
| if not name or byte_idx is None or bit_mask is None: | |
| continue | |
| pressed = bool(buttons[byte_idx] & bit_mask) | |
| self.labels[name].setStyleSheet(self._style(pressed)) | |
| def setEnabled(self, enabled: bool): | |
| super().setEnabled(enabled) | |
| for label in self.labels.values(): | |
| if enabled: | |
| # Use normal style (not pressed) | |
| label.setStyleSheet(self._style(False)) | |
| else: | |
| # Greyed-out style | |
| label.setStyleSheet( | |
| "background:#444;color:#777;border:1px solid #555;border-radius:3px;" | |
| ) | |
| class TriggerBarWidget(QtWidgets.QWidget): | |
| def __init__(self, parent=None): | |
| super().__init__(parent) | |
| self.value = 0 | |
| self.setMaximumWidth(20) | |
| self.setMinimumHeight(90) | |
| def setValue(self, val): | |
| self.value = max(0, min(255, val)) | |
| self.update() | |
| def paintEvent(self, event): | |
| painter = QtGui.QPainter(self) | |
| rect = self.rect() | |
| painter.fillRect(rect, QtCore.Qt.transparent) | |
| bg_color = QtGui.QColor(230, 230, 230) if self.isEnabled() else QtGui.QColor(210, 210, 210) | |
| painter.setBrush(bg_color) | |
| painter.setPen(QtCore.Qt.NoPen) | |
| painter.drawRect(rect) | |
| if self.isEnabled(): | |
| fill_height = int((self.value / 255) * rect.height()) | |
| fill_rect = QtCore.QRect(0, rect.height() - fill_height, rect.width(), fill_height) | |
| gradient = QtGui.QLinearGradient(fill_rect.topLeft(), fill_rect.bottomLeft()) | |
| gradient.setColorAt(0.0, QtGui.QColor(255, 100, 100)) | |
| gradient.setColorAt(1.0, QtGui.QColor(150, 0, 0)) | |
| painter.setBrush(gradient) | |
| painter.setPen(QtCore.Qt.NoPen) | |
| painter.drawRect(fill_rect) | |
| border_color = QtCore.Qt.black if self.isEnabled() else QtGui.QColor(160, 160, 160) | |
| painter.setPen(border_color) | |
| painter.setBrush(QtCore.Qt.NoBrush) | |
| painter.drawRect(rect.adjusted(0, 0, -1, -1)) | |
| class TriggersWidget(QtWidgets.QWidget): | |
| def __init__(self): | |
| super().__init__() | |
| self.calibration = None | |
| self.setup_ui() | |
| def setup_ui(self): | |
| layout = QtWidgets.QVBoxLayout(self) | |
| self.left_trigger = TriggerBarWidget() | |
| self.right_trigger = TriggerBarWidget() | |
| triggers_layout = QtWidgets.QHBoxLayout() | |
| triggers_layout.setContentsMargins(0, 0, 0, 0) | |
| triggers_layout.addWidget(self.left_trigger) | |
| triggers_layout.addWidget(self.right_trigger) | |
| label = QtWidgets.QLabel('Analog Triggers') | |
| label.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) | |
| label.setAlignment(QtCore.Qt.AlignHCenter) | |
| layout.addWidget(label) | |
| layout.addLayout(triggers_layout) | |
| def set_calibration(self, calibration): | |
| self.calibration = calibration | |
| def set_trigger_state(self, triggers): | |
| if triggers: | |
| if self.calibration: | |
| LT = 255 * max(0, (triggers[0] - self.calibration[0]) / (0xff - self.calibration[0])) | |
| RT = 255 * max(0, (triggers[1] - self.calibration[1]) / (0xff - self.calibration[1])) | |
| else: | |
| LT = triggers[0] | |
| RT = triggers[1] | |
| self.left_trigger.setValue(LT) | |
| self.right_trigger.setValue(RT) | |
| class MouseWidget(QtWidgets.QWidget): | |
| def __init__(self): | |
| super().__init__() | |
| self.input_handle = None | |
| self.labels = [] | |
| self.setup_ui() | |
| def setup_ui(self): | |
| layout = QtWidgets.QFormLayout() | |
| layout.setContentsMargins(0, 0, 0, 0) | |
| layout.setLabelAlignment(QtCore.Qt.AlignRight) | |
| for field in ['ΔX', 'ΔY', 'SQUAL', 'LOD']: | |
| label = QtWidgets.QLabel('-') | |
| layout.addRow(field + ':', label) | |
| self.labels.append(label) | |
| label = QtWidgets.QLabel('Mouse') | |
| label.setAlignment(QtCore.Qt.AlignHCenter) | |
| main_layout = QtWidgets.QVBoxLayout(self) | |
| main_layout.addWidget(label) | |
| main_layout.addLayout(layout) | |
| def configure_input_format(self, input_handle): | |
| self.input_handle = input_handle | |
| def set_mouse_state(self, mouse_data): | |
| if mouse_data: | |
| if self.input_handle == 0x000A: | |
| position_x = struct.unpack('<H', mouse_data[0:2])[0] | |
| position_y = struct.unpack('<H', mouse_data[2:4])[0] | |
| squal = struct.unpack('<H', mouse_data[4:6])[0] | |
| lod = struct.unpack('<H', mouse_data[6:8])[0] | |
| self.labels[0].setText('0x{:04X}'.format(position_x)) | |
| self.labels[1].setText('0x{:04X}'.format(position_y)) | |
| self.labels[2].setText('0x{:04X}'.format(squal)) | |
| self.labels[3].setText('0x{:04X}'.format(lod)) | |
| else: | |
| delta_x = struct.unpack('<h', mouse_data[0:2])[0] | |
| delta_y = struct.unpack('<h', mouse_data[2:4])[0] | |
| lod = struct.unpack('<B', mouse_data[4:5])[0] | |
| self.labels[0].setText('0x{:04X}'.format(delta_x)) | |
| self.labels[1].setText('0x{:04X}'.format(delta_y)) | |
| self.labels[2].setText('') | |
| self.labels[3].setText('0x{:02X}'.format(lod)) | |
| class MotionPlotWidget(QtWidgets.QWidget): | |
| def __init__(self): | |
| super().__init__() | |
| # Timer for motion plot updates | |
| self.timer = QtCore.QTimer() | |
| self.timer.timeout.connect(self.update_motion_plot) | |
| self.setup_ui() | |
| self.configure_motion_plot(0) | |
| def setup_ui(self): | |
| self.plot_widget = pg.PlotWidget() | |
| self.plot_widget.setFrameShape(QtWidgets.QFrame.StyledPanel) | |
| self.plot_widget.setFrameShadow(QtWidgets.QFrame.Sunken) | |
| self.plot_widget.setMaximumHeight(250) | |
| self.plot_widget.setYRange(-32768, 32768) | |
| self.plot_widget.setBackground('w') | |
| plot_item = self.plot_widget.getPlotItem() | |
| plot_item.getAxis('left').setPen(pg.mkPen(color='k')) | |
| plot_item.getAxis('bottom').setPen(pg.mkPen(color='k')) | |
| plot_item.getAxis('left').setTextPen('k') | |
| plot_item.getAxis('bottom').setTextPen('k') | |
| plot_item.showGrid(x=True, y=True, alpha=0.3) | |
| layout = QtWidgets.QVBoxLayout(self) | |
| layout.setContentsMargins(0, 0, 0, 0) | |
| layout.addWidget(self.plot_widget) | |
| # Overlay frame for greyed out effect | |
| self.overlay = QtWidgets.QFrame(self.plot_widget) | |
| self.overlay.setStyleSheet("background-color: rgba(230, 230, 230, 150);") | |
| self.overlay.hide() | |
| self.overlay.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) | |
| self.plot_widget.installEventFilter(self) | |
| def configure_motion_plot(self, format): | |
| # Motion data buffer | |
| self.buffer_length = 500 #200 | |
| self.buffer_width = 14 if format == 0 else 40 | |
| self.counter = 0 | |
| self.xdata = np.arange(self.buffer_length) | |
| self.motion_buffer = np.zeros((self.buffer_length, self.buffer_width), dtype=np.uint8) | |
| self.motion_dtype = np.dtype('<i2') | |
| # self.motion_dtype = np.dtype([ | |
| # ('temperature', '<i2'), | |
| # ('accel_x', '<i2'), | |
| # ('accel_y', '<i2'), | |
| # ('accel_z', '<i2'), | |
| # ('gyro_x', '<i2'), | |
| # ('gyro_y', '<i2'), | |
| # ('gyro_z', '<i2'), | |
| # ]) | |
| self.plot_widget.clear() | |
| colours = rcParams['axes.prop_cycle'].by_key()['color'] | |
| motion_view = self.motion_buffer.view(self.motion_dtype) | |
| self.plot_widget.addLegend(labelTextSize='6pt') | |
| self.curves = [] | |
| # for i in range(motion_view.shape[1]): | |
| for i in range(3): | |
| pen = pg.mkPen(color=colours[i%len(colours)], width=1) | |
| curve = self.plot_widget.plot(self.xdata, motion_view[:, i], pen=pen, name='p{:d}'.format(i)) | |
| self.curves.append(curve) | |
| def start_motion_timer(self): | |
| self.timer.start(20) | |
| def stop_motion_timer(self): | |
| if self.timer.isActive(): | |
| self.timer.stop() | |
| def update_motion_plot(self): | |
| motion_view = self.motion_buffer.view(self.motion_dtype) | |
| for i, curve in enumerate(self.curves): | |
| # curve.setData(self.xdata, motion_view[:, i]) | |
| curve.setData(self.xdata, motion_view[:, i+1 ]) | |
| def set_motion_data(self, motion): | |
| self.motion_buffer[self.counter, :] = motion | |
| self.counter = (self.counter + 1) % self.buffer_length | |
| def resizeEvent(self, event): | |
| super().resizeEvent(event) | |
| self.overlay.setGeometry(self.plot_widget.rect()) | |
| def setEnabled(self, enabled): | |
| super().setEnabled(enabled) | |
| self.overlay.setVisible(not enabled) | |
| def setDisabled(self, disabled): | |
| super().setDisabled(disabled) | |
| self.overlay.setVisible(disabled) | |
| def eventFilter(self, source, event): | |
| if source == self.plot_widget and event.type() == QtCore.QEvent.Resize: | |
| self.overlay.setGeometry(self.plot_widget.rect()) | |
| return super().eventFilter(source, event) | |
| class PlayerLedWidget(QtWidgets.QWidget): | |
| stateChanged = QtCore.pyqtSignal(int) | |
| def __init__(self): | |
| super().__init__() | |
| self.setToolTip('Player LEDs') | |
| self.setStyleSheet(""" | |
| QCheckBox::indicator { | |
| width: 6px; | |
| height: 6px; | |
| border: 1px solid #444; | |
| background-color: #111; | |
| } | |
| QCheckBox::indicator:checked { | |
| background-color: #8CFB05; | |
| border: 1px solid #0a0; | |
| box-shadow: 0 0 5px #0f0; | |
| } | |
| """) | |
| self.checkboxes = [] | |
| layout = QtWidgets.QHBoxLayout(self) | |
| layout.setContentsMargins(5, 5, 5, 5) | |
| layout.setAlignment(QtCore.Qt.AlignHCenter) | |
| for i in range(4): | |
| checkbox = QtWidgets.QCheckBox() | |
| checkbox.setChecked(False) | |
| checkbox.stateChanged.connect(lambda: self.stateChanged.emit(self.get_state())) | |
| self.checkboxes.append(checkbox) | |
| layout.addWidget(checkbox) | |
| def set_state(self, state): | |
| for i in range(len(self.checkboxes)): | |
| self.checkboxes[i].setChecked((state >> i) & 1) | |
| self.stateChanged.emit(state & 0xf) | |
| def get_state(self): | |
| state = 0 | |
| for i in range(len(self.checkboxes)): | |
| state |= self.checkboxes[i].isChecked() << i | |
| return state | |
| class BatteryWidget(QtWidgets.QWidget): | |
| def __init__(self): | |
| super().__init__() | |
| self.input_handle = None | |
| self.setup_ui() | |
| def setup_ui(self): | |
| layout = QtWidgets.QHBoxLayout(self) | |
| layout.setAlignment(QtCore.Qt.AlignHCenter) | |
| layout.setContentsMargins(0, 0, 0, 0) | |
| self.voltage_label = QtWidgets.QLabel('- V') | |
| self.voltage_label.setToolTip('Battery Voltage') | |
| self.current_label = QtWidgets.QLabel('- mA') | |
| self.current_label.setToolTip('Battery Current') | |
| self.status_label = QtWidgets.QLabel('-') | |
| self.status_label.setToolTip('Charge Status') | |
| layout.addWidget(self.voltage_label) | |
| layout.addWidget(self.current_label) | |
| layout.addWidget(self.status_label) | |
| def configure_input_format(self, input_handle): | |
| self.input_handle = input_handle | |
| def update_state(self, report): | |
| if self.input_handle == 0x000A: | |
| voltage = struct.unpack('<H', report[0x1f:0x21])[0] | |
| current = struct.unpack('<H', report[0x22:0x24])[0] | |
| charge_status = struct.unpack('<B', report[0x21:0x22])[0] | |
| self.voltage_label.setText('{:.3f} V'.format(voltage / 1000)) | |
| if current > 0: | |
| self.current_label.setText('{:.02f} mA'.format(current / 100)) | |
| else: | |
| self.current_label.setText('--.-- mA') | |
| self.status_label.setText('0x{:02X}'.format(charge_status)) | |
| else: | |
| self.voltage_label.setText('') | |
| self.current_label.setText('') | |
| self.status_label.setText('') | |
| class InputWidget(QtWidgets.QWidget): | |
| def __init__(self): | |
| super().__init__() | |
| self.setStyleSheet("QLabel{font-size:9px;}") | |
| self.input_format = 0 | |
| self.device_type = 0 | |
| self.primary_stick_calibration = None | |
| self.secondary_stick_calibration = None | |
| self.motion_calibration = None | |
| self.setup_ui() | |
| def setup_ui(self): | |
| font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont) | |
| font.setPointSize(10) | |
| # Editor to display incoming hid data | |
| self.text_edit = QtWidgets.QPlainTextEdit() | |
| self.text_edit.setFont(font) | |
| self.text_edit.setMaximumHeight(100) | |
| self.text_edit.setReadOnly(True) | |
| # Create button grid widget | |
| self.button_grid_widget = ButtonGridWidget() | |
| # Create primary stick widget | |
| self.primary_stick_widget = StickWidget() | |
| primary_stick_label = QtWidgets.QLabel('Primary Stick') | |
| primary_stick_label.setAlignment(QtCore.Qt.AlignCenter) | |
| primary_stick_layout = QtWidgets.QVBoxLayout() | |
| primary_stick_layout.setContentsMargins(0, 0, 0, 0) | |
| primary_stick_layout.addWidget(primary_stick_label) | |
| primary_stick_layout.addWidget(self.primary_stick_widget) | |
| # Create secondary stick widget | |
| self.secondary_stick_widget = StickWidget() | |
| secondary_stick_label = QtWidgets.QLabel('Secondary Stick') | |
| secondary_stick_label.setAlignment(QtCore.Qt.AlignCenter) | |
| secondary_stick_layout = QtWidgets.QVBoxLayout() | |
| secondary_stick_layout.setContentsMargins(0, 0, 0, 0) | |
| secondary_stick_layout.addWidget(secondary_stick_label) | |
| secondary_stick_layout.addWidget(self.secondary_stick_widget) | |
| sticks_layout = QtWidgets.QHBoxLayout() | |
| # sticks_layout.setContentsMargins(0, 0, 0, 0) | |
| sticks_layout.addLayout(primary_stick_layout) | |
| sticks_layout.addLayout(secondary_stick_layout) | |
| self.triggers_widget = TriggersWidget() | |
| self.triggers_widget.setHidden(True) | |
| self.mouse_widget = MouseWidget() | |
| self.mouse_widget.setHidden(True) | |
| # Plot for motion data | |
| self.motion_plot_widget = MotionPlotWidget() | |
| self.input_layout = QtWidgets.QHBoxLayout() | |
| self.input_layout.addWidget(self.button_grid_widget) | |
| self.input_layout.addLayout(sticks_layout) | |
| self.input_layout.addWidget(self.triggers_widget) | |
| self.input_layout.addWidget(self.mouse_widget) | |
| main_layout = QtWidgets.QVBoxLayout(self) | |
| main_layout.addWidget(self.text_edit) | |
| main_layout.addLayout(self.input_layout) | |
| main_layout.addWidget(self.motion_plot_widget) | |
| def configure_input_format(self, input_handle, device_type): | |
| self.device_type = device_type | |
| if input_handle == 0x000A: | |
| self.input_format = 0 | |
| else: | |
| if device_type == 0x2066: | |
| self.input_format = 1 | |
| elif device_type == 0x2067: | |
| self.input_format = 2 | |
| else: | |
| self.input_format = 3 | |
| # Swap in new button grid widget | |
| button_grid_widget = ButtonGridWidget(self.input_format) | |
| self.input_layout.replaceWidget(self.button_grid_widget, button_grid_widget) | |
| old_widget = self.button_grid_widget | |
| self.button_grid_widget = button_grid_widget | |
| old_widget.deleteLater() | |
| # Reconfigure motion widget | |
| self.motion_plot_widget.configure_motion_plot(self.input_format) | |
| self.mouse_widget.configure_input_format(input_handle) | |
| # Hide unused widgets | |
| if device_type in [0x2066, 0x2067]: | |
| self.primary_stick_widget.setHidden(False) | |
| self.secondary_stick_widget.setHidden(True) | |
| self.triggers_widget.setHidden(True) | |
| self.mouse_widget.setHidden(False) | |
| else: | |
| self.primary_stick_widget.setHidden(False) | |
| self.secondary_stick_widget.setHidden(False) | |
| self.mouse_widget.setHidden(True) | |
| self.triggers_widget.setHidden(device_type != 0x2073) | |
| def set_primary_stick_calibration(self, calibration): | |
| self.primary_stick_widget.set_calibration(calibration) | |
| def set_secondary_stick_calibration(self, calibration): | |
| self.secondary_stick_widget.set_calibration(calibration) | |
| def set_gc_trigger_calibration(self, calibration): | |
| self.triggers_widget.set_calibration(calibration) | |
| def start_motion_timer(self): | |
| self.motion_plot_widget.start_motion_timer() | |
| def stop_motion_timer(self): | |
| self.motion_plot_widget.stop_motion_timer() | |
| def update_state(self, report): | |
| self.text_edit.setPlainText(hexdump(report)) | |
| # Extract raw values from report | |
| if self.input_format == 0: | |
| buttons = report[0x4:0x8] | |
| stick1 = report[0xA:0xD] | |
| stick2 = report[0xD:0x10] | |
| motion = report[0x2E:0x3C] | |
| mouse = report[0x10:0x18] | |
| triggers = report[0x3C:0x3E] | |
| elif self.input_format == 3: | |
| buttons = report[0x2:0x5] | |
| stick1 = report[0x5:0x8] | |
| stick2 = report[0x8:0xB] | |
| motion = report[0xF:0x37] | |
| mouse = None | |
| triggers = report[0xC:0xE] | |
| else: | |
| buttons = report[0x2:0x4] | |
| stick1 = report[0x5:0x8] | |
| stick2 = None | |
| motion = report[0x10:0x38] | |
| mouse = report[0x9:0xE] | |
| triggers = None | |
| # Update buttons | |
| self.button_grid_widget.set_button_state(buttons) | |
| # Update sticks | |
| if self.input_format == 0 and self.device_type == 0x2066: | |
| x2, y2 = unpack_12bit_triplet(stick2) | |
| self.primary_stick_widget.set_position(x2, y2) | |
| else: | |
| x1, y1 = unpack_12bit_triplet(stick1) | |
| self.primary_stick_widget.set_position(x1, y1) | |
| if self.input_format not in [1, 2]: | |
| x2, y2 = unpack_12bit_triplet(stick2) | |
| self.secondary_stick_widget.set_position(x2, y2) | |
| # Update analog triggers | |
| self.triggers_widget.set_trigger_state(triggers) | |
| # Update mouse data | |
| self.mouse_widget.set_mouse_state(mouse) | |
| # Update motion | |
| self.motion_plot_widget.set_motion_data(motion) | |
| def setEnabled(self, enabled): | |
| super().setEnabled(enabled) | |
| self.button_grid_widget.setEnabled(enabled) | |
| self.motion_plot_widget.setEnabled(enabled) | |
| def setDisabled(self, disabled): | |
| super().setDisabled(disabled) | |
| self.button_grid_widget.setDisabled(disabled) | |
| self.motion_plot_widget.setDisabled(disabled) | |
| class ReportRateWidget(QtWidgets.QWidget): | |
| BUFFER_SIZE = 200 | |
| def __init__(self): | |
| super().__init__() | |
| self.setToolTip('Report Rate') | |
| self.counter = 0 | |
| self.timestamp_buffer = np.zeros(ReportRateWidget.BUFFER_SIZE) | |
| layout = QtWidgets.QHBoxLayout(self) | |
| layout.setAlignment(QtCore.Qt.AlignHCenter) | |
| layout.setContentsMargins(0, 0, 0, 0) | |
| self.label = QtWidgets.QLabel('- Hz') | |
| layout.addWidget(self.label) | |
| self.timer = QtCore.QTimer() | |
| self.timer.timeout.connect(self.update_report_rate) | |
| self.timer.start(1000) | |
| def add_timestamp(self): | |
| self.timestamp_buffer[self.counter] = time.time() | |
| self.counter = (self.counter + 1) % ReportRateWidget.BUFFER_SIZE | |
| def update_report_rate(self): | |
| dt = np.median(np.diff(self.timestamp_buffer)) | |
| if dt > 0: | |
| freq = 1.0 / dt | |
| if freq > 1.0: | |
| self.label.setText('{:.03f} Hz'.format(freq)) | |
| class ColourGrid(QtWidgets.QWidget): | |
| def __init__(self, colors, parent=None): | |
| super().__init__(parent) | |
| self.setMinimumSize(50, 50) | |
| self.colors = [QtGui.QColor(int.from_bytes(c, byteorder="big")) for c in colors] | |
| def paintEvent(self, event): | |
| painter = QtGui.QPainter(self) | |
| painter.setRenderHint(QtGui.QPainter.Antialiasing) | |
| w = self.width() // 2 | |
| h = self.height() // 2 | |
| positions = [ | |
| QtCore.QRect(0, 0, w, h), # Top-left | |
| QtCore.QRect(w, 0, w, h), # Top-right | |
| QtCore.QRect(0, h, w, h), # Bottom-left | |
| QtCore.QRect(w, h, w, h) # Bottom-right | |
| ] | |
| # Draw each square | |
| for rect, color in zip(positions, self.colors): | |
| painter.fillRect(rect, color) | |
| painter.drawRect(rect) # Draw border | |
| class MainWindow(QtWidgets.QMainWindow): | |
| def __init__(self): | |
| super().__init__() | |
| self.setWindowTitle('Switch 2 BLE Input Viewer') | |
| self.input_handle = INPUT_HANDLES[0] - 1 | |
| self.command_handle = COMMAND_HANDLES[0] - 1 | |
| self.command_response_handle = COMMAND_RESPONSE_HANDLES[0] - 1 | |
| self.command_buffer = bytearray(0x60) | |
| self.command_event = asyncio.Event() | |
| self.current_flags = 0x00 | |
| self.device_info = {} | |
| self.client = None | |
| self.setup_ui() | |
| self.loop = asyncio.get_event_loop() | |
| self.start() | |
| def setup_ui(self): | |
| # Configuration | |
| input_handle_combo = QtWidgets.QComboBox() | |
| input_handle_combo.addItems(['0x{:04X}'.format(h) for h in INPUT_HANDLES]) | |
| input_handle_combo.currentIndexChanged.connect(self.on_input_handle_combo_change) | |
| command_handle_combo = QtWidgets.QComboBox() | |
| command_handle_combo.addItems(['0x{:04X}'.format(h) for h in COMMAND_HANDLES]) | |
| command_handle_combo.currentIndexChanged.connect(self.on_command_handle_combo_change) | |
| command_response_handle_combo = QtWidgets.QComboBox() | |
| command_response_handle_combo.addItems(['0x{:04X}'.format(h) for h in COMMAND_RESPONSE_HANDLES]) | |
| command_response_handle_combo.currentIndexChanged.connect(self.on_command_response_handle_combo_change) | |
| handle_groupbox = QtWidgets.QGroupBox('GATT Attribute Handles') | |
| handle_groupbox_layout = QtWidgets.QFormLayout(handle_groupbox) | |
| handle_groupbox_layout.setSpacing(5) | |
| handle_groupbox_layout.addRow('Input Notification:', input_handle_combo) | |
| handle_groupbox_layout.addRow('Command:', command_handle_combo) | |
| handle_groupbox_layout.addRow('Command Response Notification:', command_response_handle_combo) | |
| self.feature_flags_widget = FeatureFlagWidget() | |
| self.feature_flags_widget.set_state(DEFAULT_FEATURE_FLAGS) | |
| feature_groupbox = QtWidgets.QGroupBox('Feature Flags') | |
| feature_groupbox_layout = QtWidgets.QVBoxLayout(feature_groupbox) | |
| feature_groupbox_layout.addWidget(self.feature_flags_widget) | |
| config_groupbox = QtWidgets.QGroupBox('Configuration') | |
| config_groupbox_layout = QtWidgets.QHBoxLayout(config_groupbox) | |
| config_groupbox_layout.addWidget(handle_groupbox) | |
| config_groupbox_layout.addWidget(feature_groupbox) | |
| font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont) | |
| font.setPointSize(10) | |
| # Top menu | |
| menu_bar = self.menuBar() | |
| self.file_menu = menu_bar.addMenu('File') | |
| self.tools_menu = menu_bar.addMenu('Tools') | |
| exit_action = QtWidgets.QAction('Exit', self) | |
| exit_action.triggered.connect(self.close) | |
| self.file_menu.addAction(exit_action) | |
| info_action = QtWidgets.QAction('Controller Info...', self) | |
| info_action.triggered.connect(self.display_controller_info) | |
| self.tools_menu.addAction(info_action) | |
| pair_action = QtWidgets.QAction('Pair Controller...', self) | |
| pair_action.triggered.connect(self.pair_device) | |
| pair_action.setDisabled(True) | |
| self.tools_menu.addAction(pair_action) | |
| dump_action = QtWidgets.QAction('Dump Memory...', self) | |
| dump_action.triggered.connect(self.dump_memory) | |
| self.tools_menu.addAction(dump_action) | |
| save_action = QtWidgets.QAction('Save Motion Buffer...', self) | |
| save_action.triggered.connect(self.save_motion_buffer) | |
| self.tools_menu.addAction(save_action) | |
| vibration_action = QtWidgets.QAction('Test Vibratrion...', self) | |
| vibration_action.triggered.connect(self.test_vibration) | |
| self.tools_menu.addAction(vibration_action) | |
| # self.tools_menu.setDisabled(True) | |
| # Editor to display commands and responses | |
| self.text_edit = QtWidgets.QPlainTextEdit() | |
| self.text_edit.setFont(font) | |
| self.text_edit.setReadOnly(True) | |
| # Line edit for entering hex commands | |
| self.line_edit = QtWidgets.QLineEdit() | |
| self.line_edit.setPlaceholderText('Enter command...') | |
| hex_regex = QtCore.QRegExp("[0-9A-Fa-f ]*") | |
| validator = QtGui.QRegExpValidator(hex_regex) | |
| self.line_edit.setValidator(validator) | |
| self.line_edit.returnPressed.connect(self.on_command_button_click) | |
| # Button to send hex commands | |
| self.command_button = QtWidgets.QPushButton('Send Command') | |
| self.command_button.clicked.connect(self.on_command_button_click) | |
| # Add line edit and button to their own layout | |
| command_layout = QtWidgets.QHBoxLayout() | |
| command_layout.addWidget(self.line_edit) | |
| command_layout.addWidget(self.command_button) | |
| # Groupbox for command widgets | |
| self.command_groupbox = QtWidgets.QGroupBox('Commands') | |
| command_groupbox_layout = QtWidgets.QVBoxLayout() | |
| command_groupbox_layout.addWidget(self.text_edit) | |
| command_groupbox_layout.addLayout(command_layout) | |
| self.command_groupbox.setLayout(command_groupbox_layout) | |
| self.input_widget = InputWidget() | |
| self.input_groupbox = QtWidgets.QGroupBox('Inputs') | |
| input_groupbox_layout = QtWidgets.QVBoxLayout() | |
| input_groupbox_layout.setContentsMargins(0, 0, 0, 0) | |
| input_groupbox_layout.addWidget(self.input_widget) | |
| self.input_groupbox.setLayout(input_groupbox_layout) | |
| # Create a central widget | |
| central = QtWidgets.QWidget() | |
| self.setCentralWidget(central) | |
| # Add grouped widgets to the main layout | |
| main_layout = QtWidgets.QVBoxLayout(central) | |
| main_layout.addWidget(config_groupbox) | |
| main_layout.addWidget(self.command_groupbox) | |
| main_layout.addWidget(self.input_groupbox) | |
| # Set groups initially disabled | |
| self.command_groupbox.setEnabled(False) | |
| self.input_widget.setEnabled(False) | |
| # Add battery level indicator to status bar | |
| self.battery_widget = BatteryWidget() | |
| self.battery_widget.setHidden(True) | |
| self.statusBar().addPermanentWidget(self.battery_widget) | |
| # Add report rate indicator to status bar | |
| self.report_rate_widget = ReportRateWidget() | |
| self.report_rate_widget.setHidden(True) | |
| self.statusBar().addPermanentWidget(self.report_rate_widget) | |
| # Add player LED widget to status bar | |
| self.player_led_widget = PlayerLedWidget() | |
| self.player_led_widget.setHidden(True) | |
| self.statusBar().addPermanentWidget(self.player_led_widget) | |
| def closeEvent(self, event): | |
| if self.client and self.client.is_connected: | |
| self.client.disconnect() | |
| self.input_widget.stop_motion_timer() | |
| event.accept() | |
| def display_controller_info(self): | |
| dialog = QtWidgets.QDialog() | |
| dialog.setWindowFlags(dialog.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) | |
| dialog.setWindowModality(QtCore.Qt.WindowModal) | |
| dialog.setWindowTitle("Controller Information") | |
| device_groupbox = QtWidgets.QGroupBox("Device") | |
| device_layout = QtWidgets.QFormLayout(device_groupbox) | |
| device_layout.addRow('Name:', QtWidgets.QLabel(self.device_info['remote_name'].decode())) | |
| device_layout.addRow('Hardware ID:', QtWidgets.QLabel('{:04X}:{:04X}'.format(self.device_info['vendor_id'], self.device_info['product_id']))) | |
| device_layout.addRow('Address:', QtWidgets.QLabel(self.device_info['remote_address'])) | |
| device_layout.addRow('Serial:', QtWidgets.QLabel(self.device_info['serial'].decode())) | |
| device_layout.addRow('Firmware:', QtWidgets.QLabel(self.device_info['firmware_version'])) | |
| device_layout.addRow('Colours:', ColourGrid(self.device_info['colours'])) | |
| pairing_groupbox = QtWidgets.QGroupBox("Pairing") | |
| pairing_layout = QtWidgets.QFormLayout(pairing_groupbox) | |
| pairing_layout.addRow('Host Address:', QtWidgets.QLabel(':'.join('{:02X}'.format(c) for c in self.device_info['host_address1']))) | |
| pairing_layout.addRow('LTK:', QtWidgets.QLabel(self.device_info['ltk'].hex())) | |
| stick_calibration_groupbox = QtWidgets.QGroupBox("Analog Stick") | |
| stick_calibration_layout = QtWidgets.QHBoxLayout(stick_calibration_groupbox) | |
| if self.device_info['factory_primary_stick_calibration']: | |
| primary_stick_calibration_groupbox = QtWidgets.QGroupBox("Primary") | |
| primary_stick_calibration_layout = QtWidgets.QFormLayout(primary_stick_calibration_groupbox) | |
| primary_stick_calibration_layout.addRow('X Center:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_primary_stick_calibration'][0], self.device_info['factory_primary_stick_calibration'][0]))) | |
| primary_stick_calibration_layout.addRow('Y Center:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_primary_stick_calibration'][1], self.device_info['factory_primary_stick_calibration'][1]))) | |
| primary_stick_calibration_layout.addRow('X Max:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_primary_stick_calibration'][0]+self.device_info['factory_primary_stick_calibration'][2], self.device_info['factory_primary_stick_calibration'][2]))) | |
| primary_stick_calibration_layout.addRow('Y Max:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_primary_stick_calibration'][1]+self.device_info['factory_primary_stick_calibration'][3], self.device_info['factory_primary_stick_calibration'][3]))) | |
| primary_stick_calibration_layout.addRow('X Min:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_primary_stick_calibration'][0]-self.device_info['factory_primary_stick_calibration'][4], self.device_info['factory_primary_stick_calibration'][4]))) | |
| primary_stick_calibration_layout.addRow('Y Min:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_primary_stick_calibration'][1]-self.device_info['factory_primary_stick_calibration'][5], self.device_info['factory_primary_stick_calibration'][5]))) | |
| stick_calibration_layout.addWidget(primary_stick_calibration_groupbox) | |
| if self.device_info['factory_secondary_stick_calibration']: | |
| secondary_stick_calibration_groupbox = QtWidgets.QGroupBox("Secondary") | |
| secondary_stick_calibration_layout = QtWidgets.QFormLayout(secondary_stick_calibration_groupbox) | |
| secondary_stick_calibration_layout.addRow('X Center:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_secondary_stick_calibration'][0], self.device_info['factory_secondary_stick_calibration'][0]))) | |
| secondary_stick_calibration_layout.addRow('Y Center:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_secondary_stick_calibration'][1], self.device_info['factory_secondary_stick_calibration'][1]))) | |
| secondary_stick_calibration_layout.addRow('X Max:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_secondary_stick_calibration'][0]+self.device_info['factory_secondary_stick_calibration'][2], self.device_info['factory_secondary_stick_calibration'][2]))) | |
| secondary_stick_calibration_layout.addRow('Y Max:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_secondary_stick_calibration'][1]+self.device_info['factory_secondary_stick_calibration'][3], self.device_info['factory_secondary_stick_calibration'][3]))) | |
| secondary_stick_calibration_layout.addRow('X Min:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_secondary_stick_calibration'][0]-self.device_info['factory_secondary_stick_calibration'][4], self.device_info['factory_secondary_stick_calibration'][4]))) | |
| secondary_stick_calibration_layout.addRow('Y Min:', QtWidgets.QLabel('{:d} ({:d})'.format(self.device_info['factory_secondary_stick_calibration'][1]-self.device_info['factory_secondary_stick_calibration'][5], self.device_info['factory_secondary_stick_calibration'][5]))) | |
| stick_calibration_layout.addWidget(secondary_stick_calibration_groupbox) | |
| motion_calibration_groupbox = QtWidgets.QGroupBox("Motion Controls") | |
| motion_calibration_layout = QtWidgets.QFormLayout(motion_calibration_groupbox) | |
| motion_calibration_layout.addRow('Temperature:', QtWidgets.QLabel('{:f}'.format(self.device_info['motion_calibration_temperature']))) | |
| motion_calibration_layout.addRow('Accelerometer Bias:', QtWidgets.QLabel('{:f}, {:f}, {:f}'.format(*self.device_info['accelerometer_bias']))) | |
| motion_calibration_layout.addRow('Gyroscope Bias:', QtWidgets.QLabel('{:f}, {:f}, {:f}'.format(*self.device_info['gyro_bias']))) | |
| motion_calibration_layout.addRow('Magnetometer Bias:', QtWidgets.QLabel('{:f}, {:f}, {:f}'.format(*self.device_info['magnetometer_bias']))) | |
| factory_calibration_groupbox = QtWidgets.QGroupBox("Factory Calibration") | |
| factory_calibration_layout = QtWidgets.QVBoxLayout(factory_calibration_groupbox) | |
| factory_calibration_layout.addWidget(stick_calibration_groupbox) | |
| factory_calibration_layout.addWidget(motion_calibration_groupbox) | |
| main_layout = QtWidgets.QVBoxLayout(dialog) | |
| main_layout.addWidget(device_groupbox) | |
| main_layout.addWidget(pairing_groupbox) | |
| main_layout.addWidget(factory_calibration_groupbox) | |
| dialog.exec_() | |
| def pair_device(self): | |
| pass | |
| @qasync.asyncSlot() | |
| async def dump_memory(self): | |
| await self.client.stop_notify(self.input_handle) | |
| max_read_size = 0x4f | |
| total_bytes = 0x200000 | |
| buffer = np.zeros(total_bytes, dtype=np.uint8) | |
| dialog = QtWidgets.QProgressDialog( "Dumping controller memory...", "Cancel", 0, total_bytes, self) | |
| dialog.setWindowFlags(dialog.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) | |
| dialog.setWindowModality(QtCore.Qt.WindowModal) | |
| dialog.setWindowTitle("Memory Dump Progress") | |
| dialog.setMinimumDuration(0) | |
| num_chunks, remaining = divmod(total_bytes, max_read_size) | |
| read_address = 0 | |
| for i in range(num_chunks): | |
| if dialog.wasCanceled(): | |
| await self.client.start_notify(self.input_handle, self.handle_input_report_notification) | |
| return | |
| # Read next memory chunk from controller | |
| data = await self.read_spi_memory(read_address, max_read_size) | |
| # Add data to buffer | |
| buffer[read_address:read_address+max_read_size] = data | |
| read_address += max_read_size | |
| dialog.setValue(read_address) | |
| # Read remaining bytes | |
| data = await self.read_spi_memory(read_address, remaining) | |
| # Add data to buffer | |
| buffer[read_address:read_address+remaining] = data | |
| dialog.setValue(total_bytes) | |
| with open('controller_memory.bin', 'wb') as f: | |
| f.write(buffer) | |
| QtWidgets.QMessageBox.information(self, "Done", "Memory dump completed successfully.") | |
| await self.client.start_notify(self.input_handle, self.handle_input_report_notification) | |
| def save_motion_buffer(self): | |
| # Store a copy of the current buffer | |
| buffer = self.input_widget.motion_plot_widget.motion_buffer.copy() | |
| # Default directory and filename | |
| default_dir = os.path.expanduser("~") # Home directory | |
| default_filename = "motion_buffer.pkl" | |
| default_path = os.path.join(default_dir, default_filename) | |
| # Open file save dialog | |
| file_path, _ = QtWidgets.QFileDialog.getSaveFileName(self,"Save Motion Buffer", default_path, "Pickle Files (*.pkl);;All Files (*)" ) | |
| if file_path: | |
| try: | |
| with open(file_path, "wb") as f: | |
| pickle.dump(buffer, f) | |
| QtWidgets.QMessageBox.information(self, "Success", f"Motion buffer saved to:\n{file_path}") | |
| except Exception as e: | |
| QtWidgets.QMessageBox.critical(self, "Error", f"Could not save array:\n{e}") | |
| def test_vibration(self): | |
| dialog = QtWidgets.QDialog() | |
| dialog.setWindowFlags(dialog.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) | |
| dialog.setWindowModality(QtCore.Qt.WindowModal) | |
| dialog.setWindowTitle("Vibration Test") | |
| notification_groupbox = QtWidgets.QGroupBox("Vibration Notifications") | |
| layout = QtWidgets.QGridLayout(notification_groupbox) | |
| layout.setHorizontalSpacing(2) | |
| layout.setVerticalSpacing(2) | |
| layout.setSpacing(2) | |
| for i in range(7): | |
| row, col = divmod(i, 4) | |
| button = QtWidgets.QPushButton("Notification #{}".format(i+1)) | |
| button.clicked.connect(lambda checked, x=i+1: self.play_vibration_sample(x)) | |
| layout.addWidget(button, row, col) | |
| main_layout = QtWidgets.QVBoxLayout(dialog) | |
| main_layout.addWidget(notification_groupbox) | |
| dialog.exec_() | |
| @qasync.asyncSlot() | |
| async def start(self): | |
| try: | |
| # Search for an advertising device | |
| device = await self.scan_devices() | |
| # Connect our discovered device | |
| self.client = await self.connect_device(device) | |
| self.device_info['remote_address'] = self.client.address | |
| self.device_info['remote_name'] = await self.client.read_gatt_char('2a00') | |
| # Locate the UUID of the attributes we care about | |
| print('Remote attributes:') | |
| for serv in self.client.services: | |
| print(serv.uuid, '0x{:04X}'.format(serv.handle)) | |
| for char in serv.characteristics: | |
| print(' ' + char.uuid, '0x{:04X}'.format(char.handle)) | |
| for desc in char.descriptors: | |
| print(' ' + desc.uuid, '0x{:04X}'.format(desc.handle)) | |
| # data = await self.client.read_gatt_char(0x0003- 1) | |
| # hexdump(data) | |
| data = bytearray.fromhex('0100') | |
| await self.client.write_gatt_char(0x0005 - 1, data, True) | |
| # Activate notifications for hid command replies | |
| await self.client.start_notify(self.command_response_handle, self.handle_command_reply_notification) | |
| # data = bytearray.fromhex('0791010100000000') | |
| # await self.send_command(data) | |
| # Read device information | |
| await self.read_spi_memory(0x13000, 0x40) | |
| # data = bytearray.fromhex('1691010100000000') | |
| # await self.send_command(data) | |
| # Play pre-defined connection vibration | |
| await self.play_vibration_sample(0x03) | |
| # Set player LEDs | |
| led_state = DEFAULT_LED_PATTERN | |
| await self.set_player_leds(led_state) | |
| self.player_led_widget.set_state(led_state) | |
| # Configure feature | |
| self.current_flags = self.feature_flags_widget.get_state() | |
| # await self.configure_features(current_feature_flags) | |
| await self.configure_features(0xFF) | |
| # Read primary stick factory configuration | |
| await self.read_spi_memory(0x13080, 0x40) | |
| # Read secondary stick factory configuration | |
| await self.read_spi_memory(0x130C0, 0x40) | |
| # Read user calibrations | |
| await self.read_spi_memory(0x1FC040, 0x40) | |
| # Read gyro calibration? | |
| await self.read_spi_memory(0x13040, 0x10) | |
| # Read accelerometer/magnetometer calibration? | |
| await self.read_spi_memory(0x13100, 0x18) | |
| # Read Gamecube specific data | |
| if self.device_info['product_id'] == 0x2073: | |
| # Read analog trigger calibration | |
| await self.read_spi_memory(0x13140, 0x2) | |
| # Read some other calibration? | |
| await self.read_spi_memory(0x13160, 0x20) | |
| # Read pairing data | |
| await self.read_spi_memory(0x1FA000, 0x40) | |
| # Enable features | |
| await self.enable_features(self.current_flags) | |
| # Get firmware version info | |
| await self.get_version_info() | |
| # Write report rate | |
| data = bytearray.fromhex('8500') | |
| await self.client.write_gatt_descriptor(self.input_handle+3, data) | |
| print('writing {} to 0x{:04x}'.format(data.hex(), self.input_handle+3)) | |
| # Set better connection parameters on Windows 11 where the API supports it | |
| if platform.system() == 'Windows': | |
| version = platform.version() | |
| build_number = int(version.split('.')[-1]) | |
| if build_number >= 22000: | |
| from bleak.backends.winrt.client import BleakClientWinRT | |
| from winrt.windows.devices.bluetooth import BluetoothLEPreferredConnectionParameters | |
| backend = self.client._backend | |
| if isinstance(backend, BleakClientWinRT): | |
| backend._requester.request_preferred_connection_parameters(BluetoothLEPreferredConnectionParameters.throughput_optimized) | |
| # Activate hid input reports | |
| await self.client.start_notify(self.input_handle, self.handle_input_report_notification) | |
| # Start motion plot update timer | |
| self.input_widget.start_motion_timer() | |
| # Connect stateChanged signals | |
| self.feature_flags_widget.stateChanged.connect(lambda state: self.update_feature_flags(state)) | |
| self.player_led_widget.stateChanged.connect(lambda state: self.set_player_leds(state)) | |
| # Loop until we exit | |
| while True: | |
| await asyncio.sleep(1) | |
| except KeyboardInterrupt: | |
| print('Exiting...') | |
| async def scan_devices(self): | |
| self.statusBar().showMessage('Scanning for devices...') | |
| found = None | |
| stop_event = asyncio.Event() | |
| def callback(device, adv_data): | |
| nonlocal found | |
| manu_data = adv_data.manufacturer_data.get(0x0553) | |
| if manu_data: | |
| vid = struct.unpack('<H', manu_data[3:5])[0] | |
| pid = struct.unpack('<H', manu_data[5:7])[0] | |
| adv = manu_data[12] == 0 | |
| if vid == 0x057E and pid in [0x2066, 0x2067, 0x2069, 0x2073] and adv: | |
| found = device | |
| stop_event.set() | |
| self.statusBar().showMessage('Found device {:04X}:{:04X} {}'.format(vid, pid, device.address)) | |
| async with BleakScanner(callback) as scanner: | |
| await stop_event.wait() | |
| return found | |
| async def connect_device(self, device): | |
| client = BleakClient(device, disconnected_callback=self.handle_disconnect) | |
| await client.connect() | |
| self.tools_menu.setEnabled(True) | |
| self.statusBar().showMessage(f"Connected to {device.address}") | |
| self.command_groupbox.setEnabled(True) | |
| self.input_widget.setEnabled(True) | |
| self.battery_widget.configure_input_format(INPUT_HANDLES[0]) | |
| self.battery_widget.setHidden(False) | |
| self.report_rate_widget.setHidden(False) | |
| self.player_led_widget.setHidden(False) | |
| return client | |
| def handle_disconnect(self, client): | |
| self.tools_menu.setDisabled(True) | |
| self.statusBar().showMessage("Disconnected") | |
| self.command_groupbox.setEnabled(False) | |
| self.input_widget.setEnabled(False) | |
| self.battery_widget.setHidden(True) | |
| self.report_rate_widget.setHidden(True) | |
| self.player_led_widget.setHidden(True) | |
| self.client = None | |
| async def handle_input_report_notification(self, sender, data): | |
| # Add timestamp for report rate estimation | |
| self.report_rate_widget.add_timestamp() | |
| self.input_widget.update_state(data) | |
| self.battery_widget.update_state(data) | |
| async def handle_command_reply_notification(self, sender, data): | |
| self.text_edit.appendPlainText('[0x{:04x}] <- : {}'.format(sender.handle+1, data.hex())) | |
| scrollbar = self.text_edit.verticalScrollBar() | |
| scrollbar.setValue(scrollbar.maximum()) | |
| if self.command_response_handle+1 == 0x001E: | |
| response = data[14:] | |
| else: | |
| response = data | |
| self.command_buffer[:len(data)] = response | |
| self.command_event.set() | |
| async def handle_read_response(self, response): | |
| read_address = struct.unpack('<I', response[12:16])[0] | |
| data_len = response[8] | |
| data = response[16:16+data_len] | |
| if read_address == 0x13000: | |
| self.device_info['serial'] = struct.unpack('16s', data[0x2:0x12])[0] | |
| self.device_info['vendor_id'] = struct.unpack('<H', data[0x12:0x14])[0] | |
| self.device_info['product_id'] = struct.unpack('<H', data[0x14:0x16])[0] | |
| self.device_info['colours'] = [data[0x19:0x1C], data[0x1C:0x1F], data[0x1F:0x22], data[0x22:0x25]] | |
| self.input_widget.configure_input_format(self.input_handle+1, self.device_info['product_id']) | |
| elif read_address == 0x13080: | |
| self.device_info['factory_primary_stick_calibration'] = unpack_12bit_sequence(data[0x28:0x31]) | |
| self.input_widget.set_primary_stick_calibration(self.device_info['factory_primary_stick_calibration']) | |
| elif read_address == 0x130C0: | |
| self.device_info['factory_secondary_stick_calibration'] = unpack_12bit_sequence(data[0x28:0x31]) | |
| self.input_widget.set_secondary_stick_calibration(self.device_info['factory_secondary_stick_calibration']) | |
| elif read_address == 0x13040: | |
| self.device_info['motion_calibration_temperature'] = struct.unpack('f', data[0:4])[0] | |
| self.device_info['gyro_bias'] = struct.unpack('3f', data[4:16]) | |
| elif read_address == 0x13100: | |
| self.device_info['magnetometer_bias'] = struct.unpack('3f', data[0:12]) | |
| self.device_info['accelerometer_bias'] = struct.unpack('3f', data[12:24]) | |
| elif read_address == 0x13140: | |
| self.device_info['gc_analog_trigger_calibration'] = data[0x0:0x2] | |
| print('gc_analog_trigger_calibration:', self.device_info['gc_analog_trigger_calibration']) | |
| self.input_widget.set_gc_trigger_calibration(self.device_info['gc_analog_trigger_calibration']) | |
| elif read_address == 0x1FC040: | |
| self.device_info['user_primary_stick_calibration'] = unpack_12bit_sequence(data[0x2:0xB]) if data[0x0:0x2] == b'\xa2\xb2' else None | |
| self.device_info['user_secondary_stick_calibration'] = unpack_12bit_sequence(data[0x22:0x2B]) if data[0x20:0x22] == b'\xa2\xb2' else None | |
| print('user_primary_stick_calibration:', self.device_info['user_primary_stick_calibration']) | |
| print('user_secondary_stick_calibration:', self.device_info['user_secondary_stick_calibration']) | |
| if self.device_info['user_primary_stick_calibration']: | |
| self.input_widget.set_primary_stick_calibration(self.device_info['user_primary_stick_calibration']) | |
| if self.device_info['user_secondary_stick_calibration']: | |
| self.input_widget.set_secondary_stick_calibration(self.device_info['user_secondary_stick_calibration']) | |
| elif read_address == 0x1FA000: | |
| self.device_info['host_address1'] = data[0x8:0xE] | |
| self.device_info['host_address2'] = data[0x30:0x36] | |
| self.device_info['ltk'] = data[0x1A:0x2A][::-1] | |
| print('host address #1:', self.device_info['host_address1'].hex()) | |
| print('host address #2:', self.device_info['host_address2'].hex()) | |
| print('LTK:', self.device_info['ltk'].hex()) | |
| @qasync.asyncSlot(int) | |
| async def update_feature_flags(self, flags): | |
| disabled = self.current_flags & ~flags | |
| if disabled: | |
| await self.disable_features(disabled) | |
| enabled = ~self.current_flags & flags | |
| if enabled: | |
| await self.enable_features(enabled) | |
| self.current_flags = flags | |
| @qasync.asyncSlot() | |
| async def on_command_button_click(self): | |
| command = bytearray.fromhex(self.line_edit.text().strip()) | |
| await self.send_command(command) | |
| @qasync.asyncSlot(int) | |
| async def on_input_handle_combo_change(self, index): | |
| if self.client: | |
| await self.client.stop_notify(self.input_handle) | |
| self.input_handle = INPUT_HANDLES[index] - 1 | |
| self.input_widget.configure_input_format(INPUT_HANDLES[index], self.device_info['product_id']) | |
| self.battery_widget.configure_input_format(INPUT_HANDLES[index]) | |
| if self.client: | |
| await self.client.start_notify(self.input_handle, self.handle_input_report_notification) | |
| @qasync.asyncSlot(int) | |
| async def on_command_handle_combo_change(self, index): | |
| self.command_handle = COMMAND_HANDLES[index] - 1 | |
| @qasync.asyncSlot(int) | |
| async def on_command_response_handle_combo_change(self, index): | |
| if self.client: | |
| await self.client.stop_notify(self.command_response_handle) | |
| self.command_response_handle = COMMAND_RESPONSE_HANDLES[index] - 1 | |
| if self.client: | |
| await self.client.start_notify(self.command_response_handle, self.handle_command_reply_notification) | |
| @qasync.asyncSlot(bytearray) | |
| async def send_command(self, command): | |
| if self.command_handle+1 == 0x0016: | |
| #command = b'\x00'*17 + command | |
| command = b'\x00'*33 + command | |
| self.text_edit.appendPlainText('[0x{:04x}] -> : {}'.format(self.command_handle+1, command.hex())) | |
| await self.client.write_gatt_char(self.command_handle, command, response=False) | |
| await self.command_event.wait() | |
| response = self.command_buffer.copy() | |
| self.command_event.clear() | |
| return response | |
| @qasync.asyncSlot(int, int) | |
| async def read_spi_memory(self, address, size): | |
| read_command = bytearray( [0x02, 0x91, 0x01, 0x04, 0x00, 0x08, 0x00, 0x00, size, 0x7E, 0x00, 0x00, address & 0xFF, (address >> 8) & 0xFF, (address >> 16) & 0xFF, (address >> 24) & 0xFF]) | |
| response = await self.send_command(read_command) | |
| await self.handle_read_response(response) | |
| return response[0x10:0x10+response[8]] | |
| # @qasync.asyncSlot(int, int) | |
| async def write_spi_memory(self, address, data): | |
| write_command = bytearray( [0x02, 0x91, 0x01, 0x04, 0x00, 0x08+len(data), 0x00, 0x00, len(data), 0x7E, 0x00, 0x00, address & 0xFF, (address >> 8) & 0xFF, (address >> 16) & 0xFF, (address >> 24) & 0xFF]) + data | |
| await self.send_command(write_command) | |
| @qasync.asyncSlot(int) | |
| async def set_player_leds(self, led_mask): | |
| led_command = bytearray([0x09, 0x91, 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, led_mask, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) | |
| await self.send_command(led_command) | |
| @qasync.asyncSlot(int) | |
| async def play_vibration_sample(self, index): | |
| vibration_command = bytearray([0x0a, 0x91, 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, index, 0x00, 0x00, 0x00]) | |
| await self.send_command(vibration_command) | |
| @qasync.asyncSlot(int, int) | |
| async def configure_features(self, flags): | |
| feature_command = bytearray([0x0c, 0x91, 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, flags, 0x00, 0x00, 0x00]) | |
| await self.send_command(feature_command) | |
| @qasync.asyncSlot(int, int) | |
| async def enable_features(self, flags): | |
| feature_command = bytearray([0x0c, 0x91, 0x01, 0x04, 0x00, 0x04, 0x00, 0x00, flags, 0x00, 0x00, 0x00]) | |
| await self.send_command(feature_command) | |
| @qasync.asyncSlot(int, int) | |
| async def disable_features(self, flags): | |
| feature_command = bytearray([0x0c, 0x91, 0x01, 0x05, 0x00, 0x04, 0x00, 0x00, flags, 0x00, 0x00, 0x00]) | |
| await self.send_command(feature_command) | |
| async def get_version_info(self): | |
| cmd = bytearray([0x10, 0x91, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00]) | |
| response = await self.send_command(cmd) | |
| response_data = response[8:20] | |
| fw_type_prefixes = ['OJL','OJR', 'OFK', 'LG'] | |
| fw_str = '{}.{:02d}.{:02d}.{:02d}'.format(fw_type_prefixes[response_data[3]], response_data[0], response_data[1], response_data[2]) | |
| if response_data[3] in [1, 2]: | |
| fw_str += '-{:02d}'.format(response_data[4]+1) | |
| if int.from_bytes(response_data[8:], byteorder='big') != 0xffffffff: | |
| fw_str += '-{:02d}.{:02d}.{:02d}'.format(response_data[8], response_data[9], response_data[10]) | |
| self.device_info['firmware_version'] = fw_str | |
| if __name__ == '__main__': | |
| app = QtWidgets.QApplication(sys.argv) | |
| loop = qasync.QEventLoop(app) | |
| asyncio.set_event_loop(loop) | |
| window = MainWindow() | |
| window.setFixedSize(800, 800) | |
| window.show() | |
| with loop: | |
| loop.run_forever() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment