From 3d0001560714de08aacccaafe6cb26ea36bb7c01 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Fri, 29 Apr 2022 09:00:46 +0700 Subject: [PATCH 01/12] Add joycon-python source code --- pycon/__init__.py | 25 +++ pycon/constants.py | 4 + pycon/device.py | 77 +++++++ pycon/event.py | 133 ++++++++++++ pycon/gyro.py | 84 +++++++ pycon/joycon.py | 530 +++++++++++++++++++++++++++++++++++++++++++++ pycon/wrappers.py | 142 ++++++++++++ 7 files changed, 995 insertions(+) create mode 100644 pycon/__init__.py create mode 100644 pycon/constants.py create mode 100644 pycon/device.py create mode 100644 pycon/event.py create mode 100644 pycon/gyro.py create mode 100644 pycon/joycon.py create mode 100644 pycon/wrappers.py diff --git a/pycon/__init__.py b/pycon/__init__.py new file mode 100644 index 0000000..3d23498 --- /dev/null +++ b/pycon/__init__.py @@ -0,0 +1,25 @@ +from .joycon import JoyCon +from .wrappers import PythonicJoyCon # as JoyCon +from .gyro import GyroTrackingJoyCon +from .event import ButtonEventJoyCon +from .device import get_device_ids, get_ids_of_type +from .device import is_id_L +from .device import get_R_ids, get_L_ids +from .device import get_R_id, get_L_id + + +__version__ = "0.2.4" + +__all__ = [ + "ButtonEventJoyCon", + "GyroTrackingJoyCon", + "JoyCon", + "PythonicJoyCon", + "get_L_id", + "get_L_ids", + "get_R_id", + "get_R_ids", + "get_device_ids", + "get_ids_of_type", + "is_id_L", +] diff --git a/pycon/constants.py b/pycon/constants.py new file mode 100644 index 0000000..7a72e89 --- /dev/null +++ b/pycon/constants.py @@ -0,0 +1,4 @@ +JOYCON_VENDOR_ID = 0x057E +JOYCON_L_PRODUCT_ID = 0x2006 +JOYCON_R_PRODUCT_ID = 0x2007 +JOYCON_PRODUCT_IDS = (JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID) diff --git a/pycon/device.py b/pycon/device.py new file mode 100644 index 0000000..273e587 --- /dev/null +++ b/pycon/device.py @@ -0,0 +1,77 @@ +import hid +from .constants import JOYCON_VENDOR_ID, JOYCON_PRODUCT_IDS +from .constants import JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID + + +def get_device_ids(debug=False): + """ + returns a list of tuples like `(vendor_id, product_id, serial_number)` + """ + devices = hid.enumerate(0, 0) + + out = [] + for device in devices: + vendor_id = device["vendor_id"] + product_id = device["product_id"] + product_string = device["product_string"] + serial = device.get('serial') or device.get("serial_number") + + if vendor_id != JOYCON_VENDOR_ID: + continue + if product_id not in JOYCON_PRODUCT_IDS: + continue + if not product_string: + continue + + out.append((vendor_id, product_id, serial)) + + if debug: + print(product_string) + print(f"\tvendor_id is {vendor_id!r}") + print(f"\tproduct_id is {product_id!r}") + print(f"\tserial is {serial!r}") + + return out + + +def is_id_L(id): + return id[1] == JOYCON_L_PRODUCT_ID + + +def get_ids_of_type(lr, **kw): + """ + returns a list of tuples like `(vendor_id, product_id, serial_number)` + + arg: lr : str : put `R` or `L` + """ + if lr.lower() == "l": + product_id = JOYCON_L_PRODUCT_ID + else: + product_id = JOYCON_R_PRODUCT_ID + return [i for i in get_device_ids(**kw) if i[1] == product_id] + + +def get_R_ids(**kw): + """returns a list of tuple like `(vendor_id, product_id, serial_number)`""" + return get_ids_of_type("R", **kw) + + +def get_L_ids(**kw): + """returns a list of tuple like `(vendor_id, product_id, serial_number)`""" + return get_ids_of_type("L", **kw) + + +def get_R_id(**kw): + """returns a tuple like `(vendor_id, product_id, serial_number)`""" + ids = get_R_ids(**kw) + if not ids: + return (None, None, None) + return ids[0] + + +def get_L_id(**kw): + """returns a tuple like `(vendor_id, product_id, serial_number)`""" + ids = get_L_ids(**kw) + if not ids: + return (None, None, None) + return ids[0] diff --git a/pycon/event.py b/pycon/event.py new file mode 100644 index 0000000..b72a44f --- /dev/null +++ b/pycon/event.py @@ -0,0 +1,133 @@ +from .wrappers import PythonicJoyCon + + +class ButtonEventJoyCon(PythonicJoyCon): + def __init__(self, *args, track_sticks=False, **kwargs): + super().__init__(*args, **kwargs) + + self._events_buffer = [] # TODO: perhaps use a deque instead? + + self._event_handlers = {} + self._event_track_sticks = track_sticks + + self._previous_stick_l_btn = 0 + self._previous_stick_r_btn = 0 + self._previous_stick_r = self._previous_stick_l = (0, 0) + self._previous_r = self._previous_l = 0 + self._previous_zr = self._previous_zl = 0 + self._previous_plus = self._previous_minus = 0 + self._previous_a = self._previous_right = 0 + self._previous_b = self._previous_down = 0 + self._previous_x = self._previous_up = 0 + self._previous_y = self._previous_left = 0 + self._previous_home = self._previous_capture = 0 + self._previous_right_sr = self._previous_left_sr = 0 + self._previous_right_sl = self._previous_left_sl = 0 + + if self.is_left(): + self.register_update_hook(self._event_tracking_update_hook_left) + else: + self.register_update_hook(self._event_tracking_update_hook_right) + + def joycon_button_event(self, button, state): # overridable + self._events_buffer.append((button, state)) + + def events(self): + while self._events_buffer: + yield self._events_buffer.pop(0) + + @staticmethod + def _event_tracking_update_hook_right(self): + if self._event_track_sticks: + pressed = self.stick_r_btn + if self._previous_stick_r_btn != pressed: + self._previous_stick_r_btn = pressed + self.joycon_button_event("stick_r_btn", pressed) + pressed = self.r + if self._previous_r != pressed: + self._previous_r = pressed + self.joycon_button_event("r", pressed) + pressed = self.zr + if self._previous_zr != pressed: + self._previous_zr = pressed + self.joycon_button_event("zr", pressed) + pressed = self.plus + if self._previous_plus != pressed: + self._previous_plus = pressed + self.joycon_button_event("plus", pressed) + pressed = self.a + if self._previous_a != pressed: + self._previous_a = pressed + self.joycon_button_event("a", pressed) + pressed = self.b + if self._previous_b != pressed: + self._previous_b = pressed + self.joycon_button_event("b", pressed) + pressed = self.x + if self._previous_x != pressed: + self._previous_x = pressed + self.joycon_button_event("x", pressed) + pressed = self.y + if self._previous_y != pressed: + self._previous_y = pressed + self.joycon_button_event("y", pressed) + pressed = self.home + if self._previous_home != pressed: + self._previous_home = pressed + self.joycon_button_event("home", pressed) + pressed = self.right_sr + if self._previous_right_sr != pressed: + self._previous_right_sr = pressed + self.joycon_button_event("right_sr", pressed) + pressed = self.right_sl + if self._previous_right_sl != pressed: + self._previous_right_sl = pressed + self.joycon_button_event("right_sl", pressed) + + @staticmethod + def _event_tracking_update_hook_left(self): + if self._event_track_sticks: + pressed = self.stick_l_btn + if self._previous_stick_l_btn != pressed: + self._previous_stick_l_btn = pressed + self.joycon_button_event("stick_l_btn", pressed) + pressed = self.l + if self._previous_l != pressed: + self._previous_l = pressed + self.joycon_button_event("l", pressed) + pressed = self.zl + if self._previous_zl != pressed: + self._previous_zl = pressed + self.joycon_button_event("zl", pressed) + pressed = self.minus + if self._previous_minus != pressed: + self._previous_minus = pressed + self.joycon_button_event("minus", pressed) + pressed = self.up + if self._previous_up != pressed: + self._previous_up = pressed + self.joycon_button_event("up", pressed) + pressed = self.down + if self._previous_down != pressed: + self._previous_down = pressed + self.joycon_button_event("down", pressed) + pressed = self.left + if self._previous_left != pressed: + self._previous_left = pressed + self.joycon_button_event("left", pressed) + pressed = self.right + if self._previous_right != pressed: + self._previous_right = pressed + self.joycon_button_event("right", pressed) + pressed = self.capture + if self._previous_capture != pressed: + self._previous_capture = pressed + self.joycon_button_event("capture", pressed) + pressed = self.left_sr + if self._previous_left_sr != pressed: + self._previous_left_sr = pressed + self.joycon_button_event("left_sr", pressed) + pressed = self.left_sl + if self._previous_left_sl != pressed: + self._previous_left_sl = pressed + self.joycon_button_event("left_sl", pressed) diff --git a/pycon/gyro.py b/pycon/gyro.py new file mode 100644 index 0000000..02157bc --- /dev/null +++ b/pycon/gyro.py @@ -0,0 +1,84 @@ +from .wrappers import PythonicJoyCon +from glm import vec2, vec3, quat, angleAxis, eulerAngles +from typing import Optional +import time + + +class GyroTrackingJoyCon(PythonicJoyCon): + """ + A specialized class based on PythonicJoyCon which tracks the gyroscope data + and deduces the current rotation of the JoyCon. Can be used to create a + pointer rotate an object or pointin a direction. Comes with the need to be + calibrated. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, simple_mode=False, **kwargs) + + # set internal state: + self.reset_orientation() + + # register the update callback + self.register_update_hook(self._gyro_update_hook) + + @property + def pointer(self) -> Optional[vec2]: + d = self.direction + if d.x <= 0: + return None + return vec2(d.y, -d.z) / d.x + + @property + def direction(self) -> vec3: + return self.direction_X + + @property + def rotation(self) -> vec3: + return -eulerAngles(self.direction_Q) + + is_calibrating = False + + def calibrate(self, seconds=2): + self.calibration_acumulator = vec3(0) + self.calibration_acumulations = 0 + self.is_calibrating = time.time() + seconds + + def _set_calibration(self, gyro_offset=None): + if not gyro_offset: + c = vec3(1, self._ime_yz_coeff, self._ime_yz_coeff) + gyro_offset = self.calibration_acumulator * c + gyro_offset /= self.calibration_acumulations + gyro_offset += vec3( + self._GYRO_OFFSET_X, + self._GYRO_OFFSET_Y, + self._GYRO_OFFSET_Z, + ) + self.is_calibrating = False + self.set_gyro_calibration(gyro_offset) + + def reset_orientation(self): + self.direction_X = vec3(1, 0, 0) + self.direction_Y = vec3(0, 1, 0) + self.direction_Z = vec3(0, 0, 1) + self.direction_Q = quat() + + @staticmethod + def _gyro_update_hook(self): + if self.is_calibrating: + if self.is_calibrating < time.time(): + self._set_calibration() + else: + for xyz in self.gyro: + self.calibration_acumulator += xyz + self.calibration_acumulations += 3 + + for gx, gy, gz in self.gyro_in_rad: + # TODO: find out why 1/86 works, and not 1/60 or 1/(60*30) + rotation \ + = angleAxis(gx * (-1/86), self.direction_X) \ + * angleAxis(gy * (-1/86), self.direction_Y) \ + * angleAxis(gz * (-1/86), self.direction_Z) + + self.direction_X *= rotation + self.direction_Y *= rotation + self.direction_Z *= rotation + self.direction_Q *= rotation diff --git a/pycon/joycon.py b/pycon/joycon.py new file mode 100644 index 0000000..3eba0a9 --- /dev/null +++ b/pycon/joycon.py @@ -0,0 +1,530 @@ +from .constants import JOYCON_VENDOR_ID, JOYCON_PRODUCT_IDS +from .constants import JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID +import hid +import time +import threading +from typing import Optional + +# TODO: disconnect, power off sequence + + +class JoyCon: + _INPUT_REPORT_SIZE = 49 + _INPUT_REPORT_PERIOD = 0.015 + _RUMBLE_DATA = b'\x00\x01\x40\x40\x00\x01\x40\x40' + + vendor_id : int + product_id : int + serial : Optional[str] + simple_mode: bool + color_body : (int, int, int) + color_btn : (int, int, int) + stick_cal : [int, int, int, int, int, int, int, int] + + def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_mode=False): + if vendor_id != JOYCON_VENDOR_ID: + raise ValueError(f'vendor_id is invalid: {vendor_id!r}') + + if product_id not in JOYCON_PRODUCT_IDS: + raise ValueError(f'product_id is invalid: {product_id!r}') + + self.vendor_id = vendor_id + self.product_id = product_id + self.serial = serial + self.simple_mode = simple_mode # TODO: It's for reporting mode 0x3f + + # setup internal state + self._input_hooks = [] + self._input_report = bytes(self._INPUT_REPORT_SIZE) + self._packet_number = 0 + self.set_accel_calibration((0, 0, 0), (1, 1, 1)) + self.set_gyro_calibration((0, 0, 0), (1, 1, 1)) + + # connect to joycon + self._joycon_device = self._open(vendor_id, product_id, serial=serial) + self._read_joycon_data() + self._setup_sensors() + + # start talking with the joycon in a daemon thread + self._update_input_report_thread \ + = threading.Thread(target=self._update_input_report) + self._update_input_report_thread.setDaemon(True) + self._update_input_report_thread.start() + + def _open(self, vendor_id, product_id, serial): + try: + if hasattr(hid, "device"): # hidapi + _joycon_device = hid.device() + _joycon_device.open(vendor_id, product_id, serial) + elif hasattr(hid, "Device"): # hid + _joycon_device = hid.Device(vendor_id, product_id, serial) + else: + raise Exception("Implementation of hid is not recognized!") + except IOError as e: + raise IOError('joycon connect failed') from e + return _joycon_device + + def _close(self): + if self._joycon_device: + self._joycon_device.close() + self._joycon_device = None + + def _read_input_report(self) -> bytes: + if self._joycon_device: + return bytes(self._joycon_device.read(self._INPUT_REPORT_SIZE)) + + def _write_output_report(self, command, subcommand, argument): + if not self._joycon_device: + return + + # TODO: add documentation + self._joycon_device.write(b''.join([ + command, + self._packet_number.to_bytes(1, byteorder='little'), + self._RUMBLE_DATA, + subcommand, + argument, + ])) + self._packet_number = (self._packet_number + 1) & 0xF + + def _send_subcmd_get_response(self, subcommand, argument) -> (bool, bytes): + # TODO: handle subcmd when daemon is running + self._write_output_report(b'\x01', subcommand, argument) + + report = self._read_input_report() + while report[0] != 0x21: # TODO, avoid this, await daemon instead + report = self._read_input_report() + + # TODO, remove, see the todo above + assert report[1:2] != subcommand, "THREAD carefully" + + # TODO: determine if the cut bytes are worth anything + + return report[13] & 0x80, report[13:] # (ack, data) + + def _spi_flash_read(self, address, size) -> bytes: + assert size <= 0x1d + argument = address.to_bytes(4, "little") + size.to_bytes(1, "little") + ack, report = self._send_subcmd_get_response(b'\x10', argument) + if not ack: + raise IOError("After SPI read @ {address:#06x}: got NACK") + + if report[:2] != b'\x90\x10': + raise IOError("Something else than the expected ACK was recieved!") + assert report[2:7] == argument, (report[2:5], argument) + + return report[7:size+7] + + def _update_input_report(self): # daemon thread + try: + while self._joycon_device: + report = self._read_input_report() + # TODO, handle input reports of type 0x21 and 0x3f + while report[0] != 0x30: + report = self._read_input_report() + + self._input_report = report + + for callback in self._input_hooks: + callback(self) + except OSError: + print('connection closed') + pass + + def _read_joycon_data(self): + color_data = self._spi_flash_read(0x6050, 6) + + self._read_stick_calibration_data() + + buf = self._spi_flash_read(0x6086 if self.is_left() else 0x6098, 16) + self.deadzone = (buf[4] << 8) & 0xF00 | buf[3] + + # user IME data + if self._spi_flash_read(0x8026, 2) == b"\xB2\xA1": + # print(f"Calibrate {self.serial} IME with user data") + imu_cal = self._spi_flash_read(0x8028, 24) + + # factory IME data + else: + # print(f"Calibrate {self.serial} IME with factory data") + imu_cal = self._spi_flash_read(0x6020, 24) + + self.color_body = tuple(color_data[:3]) + self.color_btn = tuple(color_data[3:]) + + self.set_accel_calibration(( + self._to_int16le_from_2bytes(imu_cal[ 0], imu_cal[ 1]), + self._to_int16le_from_2bytes(imu_cal[ 2], imu_cal[ 3]), + self._to_int16le_from_2bytes(imu_cal[ 4], imu_cal[ 5]), + ), ( + self._to_int16le_from_2bytes(imu_cal[ 6], imu_cal[ 7]), + self._to_int16le_from_2bytes(imu_cal[ 8], imu_cal[ 9]), + self._to_int16le_from_2bytes(imu_cal[10], imu_cal[11]), + ) + ) + self.set_gyro_calibration(( + self._to_int16le_from_2bytes(imu_cal[12], imu_cal[13]), + self._to_int16le_from_2bytes(imu_cal[14], imu_cal[15]), + self._to_int16le_from_2bytes(imu_cal[16], imu_cal[17]), + ), ( + self._to_int16le_from_2bytes(imu_cal[18], imu_cal[19]), + self._to_int16le_from_2bytes(imu_cal[20], imu_cal[21]), + self._to_int16le_from_2bytes(imu_cal[22], imu_cal[23]), + ) + ) + + def _read_stick_calibration_data(self): + user_stick_cal_addr = 0x8012 if self.is_left() else 0x801D + buf = self._spi_flash_read(user_stick_cal_addr, 9) + use_user_data = False + + for b in buf: + if b != 0xFF: + use_user_data = True + break + + if not use_user_data: + factory_stick_cal_addr = 0x603D if self.is_left() else 0x6046 + buf = self._spi_flash_read(factory_stick_cal_addr, 9) + + self.stick_cal = [0] * 6 + + # X Axis Max above center + self.stick_cal[0 if self.is_left() else 2] = (buf[1] << 8) & 0xF00 | buf[0] + # Y Axis Max above center + self.stick_cal[1 if self.is_left() else 3] = (buf[2] << 4) | (buf[1] >> 4) + # X Axis Center + self.stick_cal[2 if self.is_left() else 4] = (buf[4] << 8) & 0xF00 | buf[3] + # Y Axis Center + self.stick_cal[3 if self.is_left() else 5] = (buf[5] << 4) | (buf[4] >> 4) + # X Axis Min below center + self.stick_cal[4 if self.is_left() else 0] = (buf[7] << 8) & 0xF00 | buf[6] + # Y Axis Min below center + self.stick_cal[5 if self.is_left() else 1] = (buf[8] << 4) | (buf[7] >> 4) + + def _setup_sensors(self): + # Enable 6 axis sensors + self._write_output_report(b'\x01', b'\x40', b'\x01') + # It needs delta time to update the setting + time.sleep(0.02) + # Change format of input report + self._write_output_report(b'\x01', b'\x03', b'\x30') + + @staticmethod + def _to_int16le_from_2bytes(hbytebe, lbytebe): + uint16le = (lbytebe << 8) | hbytebe + int16le = uint16le if uint16le < 32768 else uint16le - 65536 + return int16le + + def _get_nbit_from_input_report(self, offset_byte, offset_bit, nbit): + byte = self._input_report[offset_byte] + return (byte >> offset_bit) & ((1 << nbit) - 1) + + def __del__(self): + self._close() + + def set_gyro_calibration(self, offset_xyz=None, coeff_xyz=None): + if offset_xyz: + self._GYRO_OFFSET_X, \ + self._GYRO_OFFSET_Y, \ + self._GYRO_OFFSET_Z = offset_xyz + if coeff_xyz: + cx, cy, cz = coeff_xyz + self._GYRO_COEFF_X = 0x343b / cx if cx != 0x343b else 1 + self._GYRO_COEFF_Y = 0x343b / cy if cy != 0x343b else 1 + self._GYRO_COEFF_Z = 0x343b / cz if cz != 0x343b else 1 + + def set_accel_calibration(self, offset_xyz=None, coeff_xyz=None): + if offset_xyz and coeff_xyz: + self._ACCEL_OFFSET_X, \ + self._ACCEL_OFFSET_Y, \ + self._ACCEL_OFFSET_Z = offset_xyz + + cx, cy, cz = coeff_xyz + self._ACCEL_COEFF_X = (1.0 / (cx - self._ACCEL_OFFSET_X)) * 4.0 + self._ACCEL_COEFF_Y = (1.0 / (cy - self._ACCEL_OFFSET_Y)) * 4.0 + self._ACCEL_COEFF_Z = (1.0 / (cz - self._ACCEL_OFFSET_Z)) * 4.0 + + + def get_actual_stick_value(self, pre_cal, orientation): # X/Horizontal = 0, Y/Vertical = 1 + diff = pre_cal - self.stick_cal[2 + orientation] + if (abs(diff) < self.deadzone): + return 0 + elif diff > 0: # Axis is above center + return diff / self.stick_cal[orientation] + else: + return diff / self.stick_cal[4 + orientation] + + def register_update_hook(self, callback): + self._input_hooks.append(callback) + return callback # this makes it so you could use it as a decorator + + def is_left(self): + return self.product_id == JOYCON_L_PRODUCT_ID + + def is_right(self): + return self.product_id == JOYCON_R_PRODUCT_ID + + def get_battery_charging(self): + return self._get_nbit_from_input_report(2, 4, 1) + + def get_battery_level(self): + return self._get_nbit_from_input_report(2, 5, 3) + + def get_button_y(self): + return self._get_nbit_from_input_report(3, 0, 1) + + def get_button_x(self): + return self._get_nbit_from_input_report(3, 1, 1) + + def get_button_b(self): + return self._get_nbit_from_input_report(3, 2, 1) + + def get_button_a(self): + return self._get_nbit_from_input_report(3, 3, 1) + + def get_button_right_sr(self): + return self._get_nbit_from_input_report(3, 4, 1) + + def get_button_right_sl(self): + return self._get_nbit_from_input_report(3, 5, 1) + + def get_button_r(self): + return self._get_nbit_from_input_report(3, 6, 1) + + def get_button_zr(self): + return self._get_nbit_from_input_report(3, 7, 1) + + def get_button_minus(self): + return self._get_nbit_from_input_report(4, 0, 1) + + def get_button_plus(self): + return self._get_nbit_from_input_report(4, 1, 1) + + def get_button_r_stick(self): + return self._get_nbit_from_input_report(4, 2, 1) + + def get_button_l_stick(self): + return self._get_nbit_from_input_report(4, 3, 1) + + def get_button_home(self): + return self._get_nbit_from_input_report(4, 4, 1) + + def get_button_capture(self): + return self._get_nbit_from_input_report(4, 5, 1) + + def get_button_charging_grip(self): + return self._get_nbit_from_input_report(4, 7, 1) + + def get_button_down(self): + return self._get_nbit_from_input_report(5, 0, 1) + + def get_button_up(self): + return self._get_nbit_from_input_report(5, 1, 1) + + def get_button_right(self): + return self._get_nbit_from_input_report(5, 2, 1) + + def get_button_left(self): + return self._get_nbit_from_input_report(5, 3, 1) + + def get_button_left_sr(self): + return self._get_nbit_from_input_report(5, 4, 1) + + def get_button_left_sl(self): + return self._get_nbit_from_input_report(5, 5, 1) + + def get_button_l(self): + return self._get_nbit_from_input_report(5, 6, 1) + + def get_button_zl(self): + return self._get_nbit_from_input_report(5, 7, 1) + + def get_stick_left_horizontal(self): + if not self.is_left(): + return 0 + + pre_cal = self._get_nbit_from_input_report(6, 0, 8) \ + | (self._get_nbit_from_input_report(7, 0, 4) << 8) + return self.get_actual_stick_value(pre_cal, 0) + + def get_stick_left_vertical(self): + if not self.is_left(): + return 0 + + pre_cal = self._get_nbit_from_input_report(7, 4, 4) \ + | (self._get_nbit_from_input_report(8, 0, 8) << 4) + return self.get_actual_stick_value(pre_cal, 1) + + def get_stick_right_horizontal(self): + if self.is_left(): + return 0 + + pre_cal = self._get_nbit_from_input_report(9, 0, 8) \ + | (self._get_nbit_from_input_report(10, 0, 4) << 8) + return self.get_actual_stick_value(pre_cal, 0) + + def get_stick_right_vertical(self): + if self.is_left(): + return 0 + + pre_cal = self._get_nbit_from_input_report(10, 4, 4) \ + | (self._get_nbit_from_input_report(11, 0, 8) << 4) + return self.get_actual_stick_value(pre_cal, 1) + + def get_accels(self): + input_report = bytes(self._input_report) + + x = self.get_accel_x(input_report) + y = self.get_accel_y(input_report) + z = self.get_accel_z(input_report) + + return (x, y, z) + + def get_accel_x(self, input_report=None, sample_idx=0): + if not input_report: + input_report = self._input_report + + if sample_idx not in (0, 1, 2): + raise IndexError('sample_idx should be between 0 and 2') + data = self._to_int16le_from_2bytes( + input_report[13 + sample_idx * 12], + input_report[14 + sample_idx * 12]) + return data * self._ACCEL_COEFF_X + + def get_accel_y(self, input_report=None, sample_idx=0): + if not input_report: + input_report = self._input_report + + if sample_idx not in (0, 1, 2): + raise IndexError('sample_idx should be between 0 and 2') + data = self._to_int16le_from_2bytes( + input_report[15 + sample_idx * 12], + input_report[16 + sample_idx * 12]) + return data * self._ACCEL_COEFF_Y * (1 if self.is_left() else -1) + + def get_accel_z(self, input_report=None, sample_idx=0): + if not input_report: + input_report = self._input_report + + if sample_idx not in (0, 1, 2): + raise IndexError('sample_idx should be between 0 and 2') + data = self._to_int16le_from_2bytes( + input_report[17 + sample_idx * 12], + input_report[18 + sample_idx * 12]) + return data * self._ACCEL_COEFF_Z * (1 if self.is_left() else -1) + + def get_gyro_x(self, sample_idx=0): + if sample_idx not in (0, 1, 2): + raise IndexError('sample_idx should be between 0 and 2') + data = self._to_int16le_from_2bytes( + self._input_report[19 + sample_idx * 12], + self._input_report[20 + sample_idx * 12]) + return (data - self._GYRO_OFFSET_X) * self._GYRO_COEFF_X + + def get_gyro_y(self, sample_idx=0): + if sample_idx not in (0, 1, 2): + raise IndexError('sample_idx should be between 0 and 2') + data = self._to_int16le_from_2bytes( + self._input_report[21 + sample_idx * 12], + self._input_report[22 + sample_idx * 12]) + return (data - self._GYRO_OFFSET_Y) * self._GYRO_COEFF_Y + + def get_gyro_z(self, sample_idx=0): + if sample_idx not in (0, 1, 2): + raise IndexError('sample_idx should be between 0 and 2') + data = self._to_int16le_from_2bytes( + self._input_report[23 + sample_idx * 12], + self._input_report[24 + sample_idx * 12]) + return (data - self._GYRO_OFFSET_Z) * self._GYRO_COEFF_Z + + def get_status(self) -> dict: + return { + "battery": { + "charging": self.get_battery_charging(), + "level": self.get_battery_level(), + }, + "buttons": { + "right": { + "y": self.get_button_y(), + "x": self.get_button_x(), + "b": self.get_button_b(), + "a": self.get_button_a(), + "sr": self.get_button_right_sr(), + "sl": self.get_button_right_sl(), + "r": self.get_button_r(), + "zr": self.get_button_zr(), + }, + "shared": { + "minus": self.get_button_minus(), + "plus": self.get_button_plus(), + "r-stick": self.get_button_r_stick(), + "l-stick": self.get_button_l_stick(), + "home": self.get_button_home(), + "capture": self.get_button_capture(), + "charging-grip": self.get_button_charging_grip(), + }, + "left": { + "down": self.get_button_down(), + "up": self.get_button_up(), + "right": self.get_button_right(), + "left": self.get_button_left(), + "sr": self.get_button_left_sr(), + "sl": self.get_button_left_sl(), + "l": self.get_button_l(), + "zl": self.get_button_zl(), + } + }, + "analog-sticks": { + "left": { + "horizontal": self.get_stick_left_horizontal(), + "vertical": self.get_stick_left_vertical(), + }, + "right": { + "horizontal": self.get_stick_right_horizontal(), + "vertical": self.get_stick_right_vertical(), + }, + }, + "accel": { + "x": self.get_accel_x(), + "y": self.get_accel_y(), + "z": self.get_accel_z(), + }, + "gyro": { + "x": self.get_gyro_x(), + "y": self.get_gyro_y(), + "z": self.get_gyro_z(), + }, + } + + def set_player_lamp_on(self, on_pattern: int): + self._write_output_report( + b'\x01', b'\x30', + (on_pattern & 0xF).to_bytes(1, byteorder='little')) + + def set_player_lamp_flashing(self, flashing_pattern: int): + self._write_output_report( + b'\x01', b'\x30', + ((flashing_pattern & 0xF) << 4).to_bytes(1, byteorder='little')) + + def set_player_lamp(self, pattern: int): + self._write_output_report( + b'\x01', b'\x30', + pattern.to_bytes(1, byteorder='little')) + + def disconnect_device(self): + self._write_output_report(b'\x01', b'\x06', b'\x00') + + +if __name__ == '__main__': + import pyjoycon.device as d + ids = d.get_L_id() if None not in d.get_L_id() else d.get_R_id() + + if None not in ids: + joycon = JoyCon(*ids) + lamp_pattern = 0 + while True: + print(joycon.get_status()) + joycon.set_player_lamp_on(lamp_pattern) + lamp_pattern = (lamp_pattern + 1) & 0xf + time.sleep(0.2) diff --git a/pycon/wrappers.py b/pycon/wrappers.py new file mode 100644 index 0000000..e8b5e08 --- /dev/null +++ b/pycon/wrappers.py @@ -0,0 +1,142 @@ +from .joycon import JoyCon + + +# Preferably, this class gets merged into the +# parent class if approved by the original author +class PythonicJoyCon(JoyCon): + """ + A wrapper class for the JoyCon parent class. + This creates a more pythonic interface by + * using properties instead of requiring java-style getters and setters, + * bundles related xy/xyz data in tuples + * bundles the multiple measurements of the + gyroscope and accelerometer into a list + * Adds the option to invert the y and z axis of the left joycon + to make it match the right joycon. This is enabled by default + """ + + def __init__(self, *a, invert_left_ime_yz=True, **kw): + super().__init__(*a, **kw) + self._ime_yz_coeff = -1 if invert_left_ime_yz and self.is_left() else 1 + + is_charging = property(JoyCon.get_battery_charging) + battery_level = property(JoyCon.get_battery_level) + + r = property(JoyCon.get_button_r) + zr = property(JoyCon.get_button_zr) + plus = property(JoyCon.get_button_plus) + a = property(JoyCon.get_button_a) + b = property(JoyCon.get_button_b) + x = property(JoyCon.get_button_x) + y = property(JoyCon.get_button_y) + stick_r_btn = property(JoyCon.get_button_r_stick) + home = property(JoyCon.get_button_home) + right_sr = property(JoyCon.get_button_right_sr) + right_sl = property(JoyCon.get_button_right_sl) + + l = property(JoyCon.get_button_l) # noqa: E741 + zl = property(JoyCon.get_button_zl) + minus = property(JoyCon.get_button_minus) + stick_l_btn = property(JoyCon.get_button_l_stick) + up = property(JoyCon.get_button_up) + down = property(JoyCon.get_button_down) + left = property(JoyCon.get_button_left) + right = property(JoyCon.get_button_right) + capture = property(JoyCon.get_button_capture) + left_sr = property(JoyCon.get_button_left_sr) + left_sl = property(JoyCon.get_button_left_sl) + + set_led_on = JoyCon.set_player_lamp_on + set_led_flashing = JoyCon.set_player_lamp_flashing + set_led = JoyCon.set_player_lamp + disconnect = JoyCon.disconnect_device + + @property + def stick_l(self): + return ( + self.get_stick_left_horizontal(), + self.get_stick_left_vertical(), + ) + + @property + def stick_r(self): + return ( + self.get_stick_right_horizontal(), + self.get_stick_right_vertical(), + ) + + @property + def accel(self): + c = self._ime_yz_coeff + return [ + ( + self.get_accel_x(i), + self.get_accel_y(i) * c, + self.get_accel_z(i) * c, + ) + for i in range(3) + ] + + @property + def accel_in_g(self): + c = 4.0 / 0x4000 + c2 = c * self._ime_yz_coeff + return [ + ( + self.get_accel_x(i) * c, + self.get_accel_y(i) * c2, + self.get_accel_z(i) * c2, + ) + for i in range(3) + ] + + @property + def gyro(self): + c = self._ime_yz_coeff + return [ + ( + self.get_gyro_x(i), + self.get_gyro_y(i) * c, + self.get_gyro_z(i) * c, + ) + for i in range(3) + ] + + @property + def gyro_in_deg(self): + c = 0.06103 + c2 = c * self._ime_yz_coeff + return [ + ( + self.get_gyro_x(i) * c, + self.get_gyro_y(i) * c2, + self.get_gyro_z(i) * c2, + ) + for i in range(3) + ] + + @property + def gyro_in_rad(self): + c = 0.0001694 * 3.1415926536 + c2 = c * self._ime_yz_coeff + return [ + ( + self.get_gyro_x(i) * c, + self.get_gyro_y(i) * c2, + self.get_gyro_z(i) * c2, + ) + for i in range(3) + ] + + @property + def gyro_in_rot(self): + c = 0.0001694 + c2 = c * self._ime_yz_coeff + return [ + ( + self.get_gyro_x(i) * c, + self.get_gyro_y(i) * c2, + self.get_gyro_z(i) * c2, + ) + for i in range(3) + ] From a3872b1f385a89dad4b32fe0131243f1ae81d62b Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Fri, 29 Apr 2022 09:06:38 +0700 Subject: [PATCH 02/12] Remove code related to gyro --- pycon/__init__.py | 11 ++----- pycon/device.py | 5 +-- pycon/gyro.py | 84 ----------------------------------------------- pycon/joycon.py | 76 ++++-------------------------------------- pycon/wrappers.py | 54 ------------------------------ 5 files changed, 12 insertions(+), 218 deletions(-) delete mode 100644 pycon/gyro.py diff --git a/pycon/__init__.py b/pycon/__init__.py index 3d23498..e0c8861 100644 --- a/pycon/__init__.py +++ b/pycon/__init__.py @@ -1,18 +1,13 @@ +from .device import (get_device_ids, get_ids_of_type, get_L_id, get_L_ids, + get_R_id, get_R_ids, is_id_L) +from .event import ButtonEventJoyCon from .joycon import JoyCon from .wrappers import PythonicJoyCon # as JoyCon -from .gyro import GyroTrackingJoyCon -from .event import ButtonEventJoyCon -from .device import get_device_ids, get_ids_of_type -from .device import is_id_L -from .device import get_R_ids, get_L_ids -from .device import get_R_id, get_L_id - __version__ = "0.2.4" __all__ = [ "ButtonEventJoyCon", - "GyroTrackingJoyCon", "JoyCon", "PythonicJoyCon", "get_L_id", diff --git a/pycon/device.py b/pycon/device.py index 273e587..e6c7e0f 100644 --- a/pycon/device.py +++ b/pycon/device.py @@ -1,6 +1,7 @@ import hid -from .constants import JOYCON_VENDOR_ID, JOYCON_PRODUCT_IDS -from .constants import JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID + +from .constants import (JOYCON_L_PRODUCT_ID, JOYCON_PRODUCT_IDS, + JOYCON_R_PRODUCT_ID, JOYCON_VENDOR_ID) def get_device_ids(debug=False): diff --git a/pycon/gyro.py b/pycon/gyro.py deleted file mode 100644 index 02157bc..0000000 --- a/pycon/gyro.py +++ /dev/null @@ -1,84 +0,0 @@ -from .wrappers import PythonicJoyCon -from glm import vec2, vec3, quat, angleAxis, eulerAngles -from typing import Optional -import time - - -class GyroTrackingJoyCon(PythonicJoyCon): - """ - A specialized class based on PythonicJoyCon which tracks the gyroscope data - and deduces the current rotation of the JoyCon. Can be used to create a - pointer rotate an object or pointin a direction. Comes with the need to be - calibrated. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, simple_mode=False, **kwargs) - - # set internal state: - self.reset_orientation() - - # register the update callback - self.register_update_hook(self._gyro_update_hook) - - @property - def pointer(self) -> Optional[vec2]: - d = self.direction - if d.x <= 0: - return None - return vec2(d.y, -d.z) / d.x - - @property - def direction(self) -> vec3: - return self.direction_X - - @property - def rotation(self) -> vec3: - return -eulerAngles(self.direction_Q) - - is_calibrating = False - - def calibrate(self, seconds=2): - self.calibration_acumulator = vec3(0) - self.calibration_acumulations = 0 - self.is_calibrating = time.time() + seconds - - def _set_calibration(self, gyro_offset=None): - if not gyro_offset: - c = vec3(1, self._ime_yz_coeff, self._ime_yz_coeff) - gyro_offset = self.calibration_acumulator * c - gyro_offset /= self.calibration_acumulations - gyro_offset += vec3( - self._GYRO_OFFSET_X, - self._GYRO_OFFSET_Y, - self._GYRO_OFFSET_Z, - ) - self.is_calibrating = False - self.set_gyro_calibration(gyro_offset) - - def reset_orientation(self): - self.direction_X = vec3(1, 0, 0) - self.direction_Y = vec3(0, 1, 0) - self.direction_Z = vec3(0, 0, 1) - self.direction_Q = quat() - - @staticmethod - def _gyro_update_hook(self): - if self.is_calibrating: - if self.is_calibrating < time.time(): - self._set_calibration() - else: - for xyz in self.gyro: - self.calibration_acumulator += xyz - self.calibration_acumulations += 3 - - for gx, gy, gz in self.gyro_in_rad: - # TODO: find out why 1/86 works, and not 1/60 or 1/(60*30) - rotation \ - = angleAxis(gx * (-1/86), self.direction_X) \ - * angleAxis(gy * (-1/86), self.direction_Y) \ - * angleAxis(gz * (-1/86), self.direction_Z) - - self.direction_X *= rotation - self.direction_Y *= rotation - self.direction_Z *= rotation - self.direction_Q *= rotation diff --git a/pycon/joycon.py b/pycon/joycon.py index 3eba0a9..3722b15 100644 --- a/pycon/joycon.py +++ b/pycon/joycon.py @@ -1,10 +1,12 @@ -from .constants import JOYCON_VENDOR_ID, JOYCON_PRODUCT_IDS -from .constants import JOYCON_L_PRODUCT_ID, JOYCON_R_PRODUCT_ID -import hid -import time import threading +import time from typing import Optional +import hid + +from .constants import (JOYCON_L_PRODUCT_ID, JOYCON_PRODUCT_IDS, + JOYCON_R_PRODUCT_ID, JOYCON_VENDOR_ID) + # TODO: disconnect, power off sequence @@ -38,7 +40,6 @@ def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_m self._input_report = bytes(self._INPUT_REPORT_SIZE) self._packet_number = 0 self.set_accel_calibration((0, 0, 0), (1, 1, 1)) - self.set_gyro_calibration((0, 0, 0), (1, 1, 1)) # connect to joycon self._joycon_device = self._open(vendor_id, product_id, serial=serial) @@ -162,16 +163,6 @@ def _read_joycon_data(self): self._to_int16le_from_2bytes(imu_cal[10], imu_cal[11]), ) ) - self.set_gyro_calibration(( - self._to_int16le_from_2bytes(imu_cal[12], imu_cal[13]), - self._to_int16le_from_2bytes(imu_cal[14], imu_cal[15]), - self._to_int16le_from_2bytes(imu_cal[16], imu_cal[17]), - ), ( - self._to_int16le_from_2bytes(imu_cal[18], imu_cal[19]), - self._to_int16le_from_2bytes(imu_cal[20], imu_cal[21]), - self._to_int16le_from_2bytes(imu_cal[22], imu_cal[23]), - ) - ) def _read_stick_calibration_data(self): user_stick_cal_addr = 0x8012 if self.is_left() else 0x801D @@ -223,17 +214,6 @@ def _get_nbit_from_input_report(self, offset_byte, offset_bit, nbit): def __del__(self): self._close() - def set_gyro_calibration(self, offset_xyz=None, coeff_xyz=None): - if offset_xyz: - self._GYRO_OFFSET_X, \ - self._GYRO_OFFSET_Y, \ - self._GYRO_OFFSET_Z = offset_xyz - if coeff_xyz: - cx, cy, cz = coeff_xyz - self._GYRO_COEFF_X = 0x343b / cx if cx != 0x343b else 1 - self._GYRO_COEFF_Y = 0x343b / cy if cy != 0x343b else 1 - self._GYRO_COEFF_Z = 0x343b / cz if cz != 0x343b else 1 - def set_accel_calibration(self, offset_xyz=None, coeff_xyz=None): if offset_xyz and coeff_xyz: self._ACCEL_OFFSET_X, \ @@ -414,30 +394,6 @@ def get_accel_z(self, input_report=None, sample_idx=0): input_report[18 + sample_idx * 12]) return data * self._ACCEL_COEFF_Z * (1 if self.is_left() else -1) - def get_gyro_x(self, sample_idx=0): - if sample_idx not in (0, 1, 2): - raise IndexError('sample_idx should be between 0 and 2') - data = self._to_int16le_from_2bytes( - self._input_report[19 + sample_idx * 12], - self._input_report[20 + sample_idx * 12]) - return (data - self._GYRO_OFFSET_X) * self._GYRO_COEFF_X - - def get_gyro_y(self, sample_idx=0): - if sample_idx not in (0, 1, 2): - raise IndexError('sample_idx should be between 0 and 2') - data = self._to_int16le_from_2bytes( - self._input_report[21 + sample_idx * 12], - self._input_report[22 + sample_idx * 12]) - return (data - self._GYRO_OFFSET_Y) * self._GYRO_COEFF_Y - - def get_gyro_z(self, sample_idx=0): - if sample_idx not in (0, 1, 2): - raise IndexError('sample_idx should be between 0 and 2') - data = self._to_int16le_from_2bytes( - self._input_report[23 + sample_idx * 12], - self._input_report[24 + sample_idx * 12]) - return (data - self._GYRO_OFFSET_Z) * self._GYRO_COEFF_Z - def get_status(self) -> dict: return { "battery": { @@ -490,28 +446,8 @@ def get_status(self) -> dict: "y": self.get_accel_y(), "z": self.get_accel_z(), }, - "gyro": { - "x": self.get_gyro_x(), - "y": self.get_gyro_y(), - "z": self.get_gyro_z(), - }, } - def set_player_lamp_on(self, on_pattern: int): - self._write_output_report( - b'\x01', b'\x30', - (on_pattern & 0xF).to_bytes(1, byteorder='little')) - - def set_player_lamp_flashing(self, flashing_pattern: int): - self._write_output_report( - b'\x01', b'\x30', - ((flashing_pattern & 0xF) << 4).to_bytes(1, byteorder='little')) - - def set_player_lamp(self, pattern: int): - self._write_output_report( - b'\x01', b'\x30', - pattern.to_bytes(1, byteorder='little')) - def disconnect_device(self): self._write_output_report(b'\x01', b'\x06', b'\x00') diff --git a/pycon/wrappers.py b/pycon/wrappers.py index e8b5e08..3c1258e 100644 --- a/pycon/wrappers.py +++ b/pycon/wrappers.py @@ -46,9 +46,6 @@ def __init__(self, *a, invert_left_ime_yz=True, **kw): left_sr = property(JoyCon.get_button_left_sr) left_sl = property(JoyCon.get_button_left_sl) - set_led_on = JoyCon.set_player_lamp_on - set_led_flashing = JoyCon.set_player_lamp_flashing - set_led = JoyCon.set_player_lamp disconnect = JoyCon.disconnect_device @property @@ -89,54 +86,3 @@ def accel_in_g(self): ) for i in range(3) ] - - @property - def gyro(self): - c = self._ime_yz_coeff - return [ - ( - self.get_gyro_x(i), - self.get_gyro_y(i) * c, - self.get_gyro_z(i) * c, - ) - for i in range(3) - ] - - @property - def gyro_in_deg(self): - c = 0.06103 - c2 = c * self._ime_yz_coeff - return [ - ( - self.get_gyro_x(i) * c, - self.get_gyro_y(i) * c2, - self.get_gyro_z(i) * c2, - ) - for i in range(3) - ] - - @property - def gyro_in_rad(self): - c = 0.0001694 * 3.1415926536 - c2 = c * self._ime_yz_coeff - return [ - ( - self.get_gyro_x(i) * c, - self.get_gyro_y(i) * c2, - self.get_gyro_z(i) * c2, - ) - for i in range(3) - ] - - @property - def gyro_in_rot(self): - c = 0.0001694 - c2 = c * self._ime_yz_coeff - return [ - ( - self.get_gyro_x(i) * c, - self.get_gyro_y(i) * c2, - self.get_gyro_z(i) * c2, - ) - for i in range(3) - ] From e42599bc7321aae71052021c20337663fcec07f9 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Fri, 29 Apr 2022 11:12:10 +0700 Subject: [PATCH 03/12] Remove device.py --- dance.py | 4 +-- pycon/__init__.py | 11 ------- pycon/device.py | 78 ----------------------------------------------- 3 files changed, 2 insertions(+), 91 deletions(-) delete mode 100644 pycon/device.py diff --git a/dance.py b/dance.py index d69391e..4be7356 100644 --- a/dance.py +++ b/dance.py @@ -11,8 +11,8 @@ import aiohttp import hid from aiohttp import WSMsgType, web -from pyjoycon import ButtonEventJoyCon, JoyCon -from pyjoycon.constants import JOYCON_PRODUCT_IDS, JOYCON_VENDOR_ID +from pycon import ButtonEventJoyCon, JoyCon +from pycon.constants import JOYCON_PRODUCT_IDS, JOYCON_VENDOR_ID from joydance import JoyDance, PairingState from joydance.constants import (DEFAULT_CONFIG, JOYDANCE_VERSION, diff --git a/pycon/__init__.py b/pycon/__init__.py index e0c8861..a5739fe 100644 --- a/pycon/__init__.py +++ b/pycon/__init__.py @@ -1,20 +1,9 @@ -from .device import (get_device_ids, get_ids_of_type, get_L_id, get_L_ids, - get_R_id, get_R_ids, is_id_L) from .event import ButtonEventJoyCon from .joycon import JoyCon from .wrappers import PythonicJoyCon # as JoyCon -__version__ = "0.2.4" - __all__ = [ "ButtonEventJoyCon", "JoyCon", "PythonicJoyCon", - "get_L_id", - "get_L_ids", - "get_R_id", - "get_R_ids", - "get_device_ids", - "get_ids_of_type", - "is_id_L", ] diff --git a/pycon/device.py b/pycon/device.py deleted file mode 100644 index e6c7e0f..0000000 --- a/pycon/device.py +++ /dev/null @@ -1,78 +0,0 @@ -import hid - -from .constants import (JOYCON_L_PRODUCT_ID, JOYCON_PRODUCT_IDS, - JOYCON_R_PRODUCT_ID, JOYCON_VENDOR_ID) - - -def get_device_ids(debug=False): - """ - returns a list of tuples like `(vendor_id, product_id, serial_number)` - """ - devices = hid.enumerate(0, 0) - - out = [] - for device in devices: - vendor_id = device["vendor_id"] - product_id = device["product_id"] - product_string = device["product_string"] - serial = device.get('serial') or device.get("serial_number") - - if vendor_id != JOYCON_VENDOR_ID: - continue - if product_id not in JOYCON_PRODUCT_IDS: - continue - if not product_string: - continue - - out.append((vendor_id, product_id, serial)) - - if debug: - print(product_string) - print(f"\tvendor_id is {vendor_id!r}") - print(f"\tproduct_id is {product_id!r}") - print(f"\tserial is {serial!r}") - - return out - - -def is_id_L(id): - return id[1] == JOYCON_L_PRODUCT_ID - - -def get_ids_of_type(lr, **kw): - """ - returns a list of tuples like `(vendor_id, product_id, serial_number)` - - arg: lr : str : put `R` or `L` - """ - if lr.lower() == "l": - product_id = JOYCON_L_PRODUCT_ID - else: - product_id = JOYCON_R_PRODUCT_ID - return [i for i in get_device_ids(**kw) if i[1] == product_id] - - -def get_R_ids(**kw): - """returns a list of tuple like `(vendor_id, product_id, serial_number)`""" - return get_ids_of_type("R", **kw) - - -def get_L_ids(**kw): - """returns a list of tuple like `(vendor_id, product_id, serial_number)`""" - return get_ids_of_type("L", **kw) - - -def get_R_id(**kw): - """returns a tuple like `(vendor_id, product_id, serial_number)`""" - ids = get_R_ids(**kw) - if not ids: - return (None, None, None) - return ids[0] - - -def get_L_id(**kw): - """returns a tuple like `(vendor_id, product_id, serial_number)`""" - ids = get_L_ids(**kw) - if not ids: - return (None, None, None) - return ids[0] From b94453f58fafdc3a368c40bd793c7fa6447da4b4 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Fri, 29 Apr 2022 16:25:00 +0700 Subject: [PATCH 04/12] Remove unused dependencies --- requirements.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0411586..0035c13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -https://github.com/redphx/joycon-python/archive/refs/tags/0.3.zip#egg=joycon-python websockets==10.2 aiohttp==3.8.1 hidapi==0.11.2 -pyglm==2.5.7 From 313c8b1ec305eddc5794f97357f31c1b72bd47fc Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Fri, 29 Apr 2022 16:25:09 +0700 Subject: [PATCH 05/12] Add pycon/README.md --- pycon/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pycon/README.md diff --git a/pycon/README.md b/pycon/README.md new file mode 100644 index 0000000..2469ec2 --- /dev/null +++ b/pycon/README.md @@ -0,0 +1,3 @@ +Simplified version of [tocoteron/joycon-python](https://github.com/tocoteron/joycon-python): +- Remove codes irrelevant to JoyDance. +- Fix bugs & improve stability. From 3c32efd6cffb6612ae7295fe243645053eb9873c Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Fri, 29 Apr 2022 17:10:00 +0700 Subject: [PATCH 06/12] Improve tracking --- joydance/__init__.py | 18 ++++++++++-------- joydance/constants.py | 2 +- pycon/joycon.py | 17 ++++++++--------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/joydance/__init__.py b/joydance/__init__.py index 82580ba..2083d21 100644 --- a/joydance/__init__.py +++ b/joydance/__init__.py @@ -69,7 +69,7 @@ def __init__( self.available_shortcuts = set() self.accel_data = [] - self.last_accel = (0, 0, 0) + self.last_accels = [] self.ws = None self.disconnected = False @@ -282,18 +282,20 @@ async def collect_accelerometer_data(self, frames): max_runtime = FRAME_DURATION * 0.5 while time.time() - start < max_runtime: # Make sure accelerometer axes are changed - accel = self.joycon.get_accels() # (x, y, z) - if accel != self.last_accel: - self.last_accel = accel + accels = self.joycon.get_accels() # [(x, y, z),...] + if accels != self.last_accels: + self.last_accels = accels break # Accelerator axes on phone & Joy-Con are different so we need to swap some axes # https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/imu_sensor_notes.md - x = accel[1] * -1 - y = accel[0] - z = accel[2] + for accel in accels: + x = accel[1] * -1 + y = accel[0] + z = accel[2] + + self.accel_data.append([x, y, z]) - self.accel_data.append([x, y, z]) await self.send_accelerometer_data(frames), except OSError: self.disconnect() diff --git a/joydance/constants.py b/joydance/constants.py index 2e5c275..ab8df8c 100644 --- a/joydance/constants.py +++ b/joydance/constants.py @@ -15,7 +15,7 @@ class WsSubprotocolVersion(Enum): WS_SUBPROTOCOLS[WsSubprotocolVersion.V2.value] = 'v2.phonescoring.jd.ubisoft.com' FRAME_DURATION = 1 / 60 -ACCEL_ACQUISITION_FREQ_HZ = 60 # Hz +ACCEL_ACQUISITION_FREQ_HZ = 200 # Hz ACCEL_ACQUISITION_LATENCY = 40 # ms ACCEL_MAX_RANGE = 8 # ±G diff --git a/pycon/joycon.py b/pycon/joycon.py index 3722b15..1ba4e9f 100644 --- a/pycon/joycon.py +++ b/pycon/joycon.py @@ -354,12 +354,15 @@ def get_stick_right_vertical(self): def get_accels(self): input_report = bytes(self._input_report) + accels = [] - x = self.get_accel_x(input_report) - y = self.get_accel_y(input_report) - z = self.get_accel_z(input_report) + for idx in range(3): + x = self.get_accel_x(input_report, sample_idx=idx) + y = self.get_accel_y(input_report, sample_idx=idx) + z = self.get_accel_z(input_report, sample_idx=idx) + accels.append((x, y, z)) - return (x, y, z) + return accels def get_accel_x(self, input_report=None, sample_idx=0): if not input_report: @@ -441,11 +444,7 @@ def get_status(self) -> dict: "vertical": self.get_stick_right_vertical(), }, }, - "accel": { - "x": self.get_accel_x(), - "y": self.get_accel_y(), - "z": self.get_accel_z(), - }, + "accel": self.get_accels(), } def disconnect_device(self): From 9e0a8ec5840d75ab34d44a3c8e636402c1f75966 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sat, 30 Apr 2022 10:10:45 +0700 Subject: [PATCH 07/12] Use asyncio instead of thread in pycon.joycon.py --- pycon/joycon.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/pycon/joycon.py b/pycon/joycon.py index 1ba4e9f..710ff3e 100644 --- a/pycon/joycon.py +++ b/pycon/joycon.py @@ -1,4 +1,4 @@ -import threading +import asyncio import time from typing import Optional @@ -47,10 +47,7 @@ def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_m self._setup_sensors() # start talking with the joycon in a daemon thread - self._update_input_report_thread \ - = threading.Thread(target=self._update_input_report) - self._update_input_report_thread.setDaemon(True) - self._update_input_report_thread.start() + asyncio.run(self._update_input_report()) def _open(self, vendor_id, product_id, serial): try: @@ -92,7 +89,7 @@ def _send_subcmd_get_response(self, subcommand, argument) -> (bool, bytes): # TODO: handle subcmd when daemon is running self._write_output_report(b'\x01', subcommand, argument) - report = self._read_input_report() + report = [0] while report[0] != 0x21: # TODO, avoid this, await daemon instead report = self._read_input_report() @@ -114,12 +111,12 @@ def _spi_flash_read(self, address, size) -> bytes: raise IOError("Something else than the expected ACK was recieved!") assert report[2:7] == argument, (report[2:5], argument) - return report[7:size+7] + return report[7:size + 7] - def _update_input_report(self): # daemon thread + async def _update_input_report(self): # daemon thread try: while self._joycon_device: - report = self._read_input_report() + report = [0] # TODO, handle input reports of type 0x21 and 0x3f while report[0] != 0x30: report = self._read_input_report() From f06503b6fbdc456f0394666a108abf91a91c9fcb Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sat, 30 Apr 2022 10:13:53 +0700 Subject: [PATCH 08/12] Linting --- dance.py | 4 ++-- pycon/joycon.py | 49 ++++++++++++++++++++++--------------------------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/dance.py b/dance.py index 4be7356..346b98e 100644 --- a/dance.py +++ b/dance.py @@ -11,12 +11,12 @@ import aiohttp import hid from aiohttp import WSMsgType, web -from pycon import ButtonEventJoyCon, JoyCon -from pycon.constants import JOYCON_PRODUCT_IDS, JOYCON_VENDOR_ID from joydance import JoyDance, PairingState from joydance.constants import (DEFAULT_CONFIG, JOYDANCE_VERSION, WsSubprotocolVersion) +from pycon import ButtonEventJoyCon, JoyCon +from pycon.constants import JOYCON_PRODUCT_IDS, JOYCON_VENDOR_ID logging.getLogger('asyncio').setLevel(logging.WARNING) diff --git a/pycon/joycon.py b/pycon/joycon.py index 710ff3e..e051ae7 100644 --- a/pycon/joycon.py +++ b/pycon/joycon.py @@ -1,6 +1,6 @@ import asyncio import time -from typing import Optional +from typing import Optional, Tuple import hid @@ -15,13 +15,13 @@ class JoyCon: _INPUT_REPORT_PERIOD = 0.015 _RUMBLE_DATA = b'\x00\x01\x40\x40\x00\x01\x40\x40' - vendor_id : int - product_id : int - serial : Optional[str] + vendor_id: int + product_id: int + serial: Optional[str] simple_mode: bool - color_body : (int, int, int) - color_btn : (int, int, int) - stick_cal : [int, int, int, int, int, int, int, int] + color_body: Tuple[int, int, int] + color_btn: Tuple[int, int, int] + stick_cal: Tuple[int, int, int, int, int, int, int, int] def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_mode=False): if vendor_id != JOYCON_VENDOR_ID: @@ -30,9 +30,9 @@ def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_m if product_id not in JOYCON_PRODUCT_IDS: raise ValueError(f'product_id is invalid: {product_id!r}') - self.vendor_id = vendor_id - self.product_id = product_id - self.serial = serial + self.vendor_id = vendor_id + self.product_id = product_id + self.serial = serial self.simple_mode = simple_mode # TODO: It's for reporting mode 0x3f # setup internal state @@ -85,7 +85,7 @@ def _write_output_report(self, command, subcommand, argument): ])) self._packet_number = (self._packet_number + 1) & 0xF - def _send_subcmd_get_response(self, subcommand, argument) -> (bool, bytes): + def _send_subcmd_get_response(self, subcommand, argument) -> Tuple[bool, bytes]: # TODO: handle subcmd when daemon is running self._write_output_report(b'\x01', subcommand, argument) @@ -131,6 +131,8 @@ async def _update_input_report(self): # daemon thread def _read_joycon_data(self): color_data = self._spi_flash_read(0x6050, 6) + self.color_body = tuple(color_data[:3]) + self.color_btn = tuple(color_data[3:]) self._read_stick_calibration_data() @@ -147,19 +149,15 @@ def _read_joycon_data(self): # print(f"Calibrate {self.serial} IME with factory data") imu_cal = self._spi_flash_read(0x6020, 24) - self.color_body = tuple(color_data[:3]) - self.color_btn = tuple(color_data[3:]) - self.set_accel_calibration(( - self._to_int16le_from_2bytes(imu_cal[ 0], imu_cal[ 1]), - self._to_int16le_from_2bytes(imu_cal[ 2], imu_cal[ 3]), - self._to_int16le_from_2bytes(imu_cal[ 4], imu_cal[ 5]), - ), ( - self._to_int16le_from_2bytes(imu_cal[ 6], imu_cal[ 7]), - self._to_int16le_from_2bytes(imu_cal[ 8], imu_cal[ 9]), - self._to_int16le_from_2bytes(imu_cal[10], imu_cal[11]), - ) - ) + self._to_int16le_from_2bytes(imu_cal[0], imu_cal[1]), + self._to_int16le_from_2bytes(imu_cal[2], imu_cal[3]), + self._to_int16le_from_2bytes(imu_cal[4], imu_cal[5]), + ), ( + self._to_int16le_from_2bytes(imu_cal[6], imu_cal[7]), + self._to_int16le_from_2bytes(imu_cal[8], imu_cal[9]), + self._to_int16le_from_2bytes(imu_cal[10], imu_cal[11]), + )) def _read_stick_calibration_data(self): user_stick_cal_addr = 0x8012 if self.is_left() else 0x801D @@ -213,16 +211,13 @@ def __del__(self): def set_accel_calibration(self, offset_xyz=None, coeff_xyz=None): if offset_xyz and coeff_xyz: - self._ACCEL_OFFSET_X, \ - self._ACCEL_OFFSET_Y, \ - self._ACCEL_OFFSET_Z = offset_xyz + self._ACCEL_OFFSET_X, self._ACCEL_OFFSET_Y, self._ACCEL_OFFSET_Z = offset_xyz cx, cy, cz = coeff_xyz self._ACCEL_COEFF_X = (1.0 / (cx - self._ACCEL_OFFSET_X)) * 4.0 self._ACCEL_COEFF_Y = (1.0 / (cy - self._ACCEL_OFFSET_Y)) * 4.0 self._ACCEL_COEFF_Z = (1.0 / (cz - self._ACCEL_OFFSET_Z)) * 4.0 - def get_actual_stick_value(self, pre_cal, orientation): # X/Horizontal = 0, Y/Vertical = 1 diff = pre_cal - self.stick_cal[2 + orientation] if (abs(diff) < self.deadzone): From 135f69bec501cbeea08fb586f52cded0297d773d Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sat, 30 Apr 2022 10:45:26 +0700 Subject: [PATCH 09/12] Reverse back to thread --- pycon/joycon.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pycon/joycon.py b/pycon/joycon.py index e051ae7..81040d6 100644 --- a/pycon/joycon.py +++ b/pycon/joycon.py @@ -1,4 +1,4 @@ -import asyncio +import threading import time from typing import Optional, Tuple @@ -47,7 +47,9 @@ def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_m self._setup_sensors() # start talking with the joycon in a daemon thread - asyncio.run(self._update_input_report()) + self._update_input_report_thread = threading.Thread(target=self._update_input_report) + self._update_input_report_thread.setDaemon(True) + self._update_input_report_thread.start() def _open(self, vendor_id, product_id, serial): try: @@ -113,7 +115,7 @@ def _spi_flash_read(self, address, size) -> bytes: return report[7:size + 7] - async def _update_input_report(self): # daemon thread + def _update_input_report(self): # daemon thread try: while self._joycon_device: report = [0] From 6e30040d81e760d856df07e7bcb36e24a646e4d1 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sat, 30 Apr 2022 16:36:07 +0700 Subject: [PATCH 10/12] Add SHORTCUT_DONT_SHOW_ANYMORE --- joydance/constants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/joydance/constants.py b/joydance/constants.py index ab8df8c..fc0c629 100644 --- a/joydance/constants.py +++ b/joydance/constants.py @@ -38,6 +38,7 @@ class Command(Enum): BACK = 'SHORTCUT_BACK' CHANGE_DANCERCARD = 'SHORTCUT_CHANGE_DANCERCARD' + DONT_SHOW_ANYMORE = 'SHORTCUT_DONT_SHOW_ANYMORE' FAVORITE = 'SHORTCUT_FAVORITE' GOTO_SONGSTAB = 'SHORTCUT_GOTO_SONGSTAB' SKIP = 'SHORTCUT_SKIP' @@ -109,6 +110,7 @@ class JoyConButton(Enum): Command.UPLAY, ], JoyConButton.PLUS: [ + Command.DONT_SHOW_ANYMORE, Command.FAVORITE, Command.PAUSE, Command.PLAYLIST_RENAME, From 1f98a9bf466a231f7f67489db1c483c9e8777a42 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sun, 1 May 2022 16:05:05 +0700 Subject: [PATCH 11/12] Joycon: call input hooks in a different thread --- pycon/joycon.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pycon/joycon.py b/pycon/joycon.py index 81040d6..8bc5b48 100644 --- a/pycon/joycon.py +++ b/pycon/joycon.py @@ -1,5 +1,5 @@ -import threading import time +from threading import Thread from typing import Optional, Tuple import hid @@ -47,9 +47,7 @@ def __init__(self, vendor_id: int, product_id: int, serial: str = None, simple_m self._setup_sensors() # start talking with the joycon in a daemon thread - self._update_input_report_thread = threading.Thread(target=self._update_input_report) - self._update_input_report_thread.setDaemon(True) - self._update_input_report_thread.start() + Thread(target=self._update_input_report, daemon=True).start() def _open(self, vendor_id, product_id, serial): try: @@ -125,12 +123,16 @@ def _update_input_report(self): # daemon thread self._input_report = report - for callback in self._input_hooks: - callback(self) + # Call input hooks in a different thread + Thread(target=self._input_hook_caller, daemon=True).start() except OSError: print('connection closed') pass + def _input_hook_caller(self): + for callback in self._input_hooks: + callback(self) + def _read_joycon_data(self): color_data = self._spi_flash_read(0x6050, 6) self.color_body = tuple(color_data[:3]) From a6d66b3418c4361b2d077ff188b02f16c2c2c0c0 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Tue, 3 May 2022 17:36:58 +0700 Subject: [PATCH 12/12] Reduce CPU usage --- joydance/__init__.py | 65 ++++++++++++++++++++++--------------------- joydance/constants.py | 3 +- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/joydance/__init__.py b/joydance/__init__.py index 2083d21..4c54945 100644 --- a/joydance/__init__.py +++ b/joydance/__init__.py @@ -69,7 +69,6 @@ def __init__( self.available_shortcuts = set() self.accel_data = [] - self.last_accels = [] self.ws = None self.disconnected = False @@ -242,34 +241,46 @@ async def send_hello(self): async for message in self.ws: await self.on_message(message) + async def sleep_approx(self, target_duration): + tmp_duration = target_duration + x = 0.3 + start = time.time() + while True: + tmp_duration = tmp_duration * x + await asyncio.sleep(tmp_duration) + + dt = time.time() - start + if dt >= target_duration: + break + + tmp_duration = target_duration - dt + async def tick(self): - sleep_duration = FRAME_DURATION * 0.75 - last_time = time.time() + sleep_duration = FRAME_DURATION frames = 0 while True: if self.disconnected: break - # Make sure it runs at exactly 60 FPS - while True: - time_now = time.time() - dt = time_now - last_time - if dt >= FRAME_DURATION: - break - last_time = time_now - frames = frames + 1 if frames < 3 else 1 - if not self.should_start_accelerometer: - await asyncio.sleep(sleep_duration), + frames = 0 + await asyncio.sleep(sleep_duration) continue + last_time = time.time() + frames = frames + 1 if frames < 3 else 1 + await asyncio.gather( - asyncio.sleep(sleep_duration), - self.collect_accelerometer_data(frames), + self.sleep_approx(sleep_duration), + self.collect_accelerometer_data(), ) + await self.send_accelerometer_data(frames) - async def collect_accelerometer_data(self, frames): + dt = time.time() - last_time + sleep_duration = FRAME_DURATION - (dt - sleep_duration) + + async def collect_accelerometer_data(self): if self.disconnected: return @@ -278,25 +289,15 @@ async def collect_accelerometer_data(self, frames): return try: - start = time.time() - max_runtime = FRAME_DURATION * 0.5 - while time.time() - start < max_runtime: - # Make sure accelerometer axes are changed - accels = self.joycon.get_accels() # [(x, y, z),...] - if accels != self.last_accels: - self.last_accels = accels - break + accels = self.joycon.get_accels() # (x, y, z) # Accelerator axes on phone & Joy-Con are different so we need to swap some axes # https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/imu_sensor_notes.md - for accel in accels: - x = accel[1] * -1 - y = accel[0] - z = accel[2] - - self.accel_data.append([x, y, z]) - - await self.send_accelerometer_data(frames), + accel = accels[2] + x = accel[1] * -1 + y = accel[0] + z = accel[2] + self.accel_data.append([x, y, z]) except OSError: self.disconnect() return diff --git a/joydance/constants.py b/joydance/constants.py index fc0c629..db4dacd 100644 --- a/joydance/constants.py +++ b/joydance/constants.py @@ -15,7 +15,8 @@ class WsSubprotocolVersion(Enum): WS_SUBPROTOCOLS[WsSubprotocolVersion.V2.value] = 'v2.phonescoring.jd.ubisoft.com' FRAME_DURATION = 1 / 60 -ACCEL_ACQUISITION_FREQ_HZ = 200 # Hz +SEND_FREQ_MS = 0.005 +ACCEL_ACQUISITION_FREQ_HZ = 60 # Hz ACCEL_ACQUISITION_LATENCY = 40 # ms ACCEL_MAX_RANGE = 8 # ±G