joydance-improved/joydance/__init__.py

455 lines
18 KiB
Python
Raw Normal View History

2022-04-15 10:47:05 +00:00
import asyncio
import json
import random
import socket
import ssl
import time
from enum import Enum
from urllib.parse import urlparse
2022-04-15 10:47:05 +00:00
import aiohttp
import websockets
from .constants import (ACCEL_ACQUISITION_FREQ_HZ, ACCEL_ACQUISITION_LATENCY,
ACCEL_MAX_RANGE, ACCEL_SEND_RATE, JOYCON_UPDATE_RATE,
SHORTCUT_MAPPING, UBI_APP_ID, UBI_SKU_ID,
WS_SUBPROTOCOLS, Command, JoyConButton,
WsSubprotocolVersion)
2022-04-15 10:47:05 +00:00
class PairingState(Enum):
IDLE = 0
GETTING_TOKEN = 1
PAIRING = 2
CONNECTING = 3
CONNECTED = 4
DISCONNECTING = 5
DISCONNECTED = 10
ERROR_JOYCON = 101
ERROR_CONNECTION = 102
ERROR_INVALID_PAIRING_CODE = 103
ERROR_PUNCH_PAIRING = 104
ERROR_HOLE_PUNCHING = 105
ERROR_CONSOLE_CONNECTION = 106
class JoyDance:
def __init__(
self,
joycon,
protocol_version,
2022-04-15 10:47:05 +00:00
pairing_code=None,
host_ip_addr=None,
console_ip_addr=None,
accel_acquisition_freq_hz=ACCEL_ACQUISITION_FREQ_HZ,
accel_acquisition_latency=ACCEL_ACQUISITION_LATENCY,
accel_max_range=ACCEL_MAX_RANGE,
on_state_changed=None):
self.joycon = joycon
self.joycon_is_left = joycon.is_left()
self.protocol_version = protocol_version
2022-04-15 10:47:05 +00:00
if on_state_changed:
self.on_state_changed = on_state_changed
self.pairing_code = pairing_code
self.host_ip_addr = host_ip_addr
self.console_ip_addr = console_ip_addr
self.host_port = self.get_random_port()
self.tls_certificate = None
2022-04-15 10:47:05 +00:00
self.accel_acquisition_freq_hz = accel_acquisition_freq_hz
self.accel_acquisition_latency = accel_acquisition_latency
self.accel_max_range = accel_max_range
self.number_of_accels_sent = 0
self.should_start_accelerometer = False
self.is_input_allowed = False
self.available_shortcuts = set()
self.ws = None
self.disconnected = False
self.headers = {
'Ubi-AppId': UBI_APP_ID,
'X-SkuId': UBI_SKU_ID,
}
self.console_conn = None
def get_random_port(self):
''' Randomize a port number, to be used in hole_punching() later '''
return random.randrange(39000, 39999)
async def on_state_changed(state):
pass
async def get_access_token(self):
''' Log in using a guest account, pre-defined by Ubisoft '''
headers = {
'Authorization': 'UbiMobile_v1 t=NTNjNWRjZGMtZjA2Yy00MTdmLWJkMjctOTNhZTcxNzU1OTkyOlcwM0N5eGZldlBTeFByK3hSa2hhQ05SMXZtdz06UjNWbGMzUmZaVzB3TjJOYTpNakF5TVMweE1DMHlOMVF3TVRvME5sbz0=',
'Ubi-AppId': UBI_APP_ID,
'User-Agent': 'UbiServices_SDK_Unity_Light_Mobile_2018.Release.16_ANDROID64_dynamic',
'Ubi-RequestedPlatformType': 'ubimobile',
'Content-Type': 'application/json',
}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.post('https://public-ubiservices.ubi.com/v1/profiles/sessions', json={}, ssl=False) as resp:
if resp.status != 200:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_CONNECTION)
raise Exception('ERROR: Couldn\'t get access token!')
# Add ticket to headers
json_body = await resp.json()
self.headers['Authorization'] = 'Ubi_v1 ' + json_body['ticket']
async def send_pairing_code(self):
''' Send pairing code to JD server '''
url = 'https://prod.just-dance.com/sessions/v1/pairing-info'
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(url, params={'code': self.pairing_code}, ssl=False) as resp:
if resp.status != 200:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_INVALID_PAIRING_CODE)
raise Exception('ERROR: Invalid pairing code!')
json_body = await resp.json()
import pprint
pprint.pprint(json_body)
self.pairing_url = json_body['pairingUrl'].replace('https://', 'wss://')
if not self.pairing_url.endswith('/'):
self.pairing_url += '/'
self.pairing_url += 'smartphone'
self.tls_certificate = json_body['tlsCertificate']
print(self.pairing_url)
2022-04-15 10:47:05 +00:00
self.requires_punch_pairing = json_body.get('requiresPunchPairing', False)
async def send_initiate_punch_pairing(self):
''' Tell console which IP address & port to connect to '''
url = 'https://prod.just-dance.com/sessions/v1/initiate-punch-pairing'
json_payload = {
'pairingCode': self.pairing_code,
'mobileIP': self.host_ip_addr,
'mobilePort': self.host_port,
}
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.post(url, json=json_payload, ssl=False) as resp:
body = await resp.text()
if body != 'OK':
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_PUNCH_PAIRING)
raise Exception('ERROR: Couldn\'t initiate punch pairing!')
async def hole_punching(self):
''' Open a port on this machine so the console can connect to it '''
try:
conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
conn.settimeout(10)
2022-04-15 10:47:05 +00:00
conn.bind(('0.0.0.0', self.host_port))
conn.listen(5)
# Accept incoming connection from console
console_conn, addr = conn.accept()
self.console_conn = console_conn
print('Connected with {}:{}'.format(addr[0], addr[1]))
except Exception as e:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_HOLE_PUNCHING)
raise e
async def send_message(self, __class, data={}):
''' Send JSON message to server '''
if __class != 'JD_PhoneScoringData':
print('>>>', __class, data)
msg = {'root': {'__class': __class}}
if data:
msg['root'].update(data)
# Remove extra spaces from JSON to reduce size
try:
await self.ws.send(json.dumps(msg, separators=(',', ':')))
except Exception:
await self.disconnect(close_ws=False)
async def on_message(self, message):
message = json.loads(message)
print('<<<', message)
__class = message['__class']
if __class == 'JD_PhoneDataCmdHandshakeContinue':
await self.send_message('JD_PhoneDataCmdSync', {'phoneID': message['phoneID']})
elif __class == 'JD_PhoneDataCmdSyncEnd':
await self.send_message('JD_PhoneDataCmdSyncEnd', {'phoneID': message['phoneID']})
await self.on_state_changed(self.joycon.serial, PairingState.CONNECTED)
elif __class == 'JD_EnableAccelValuesSending_ConsoleCommandData':
self.should_start_accelerometer = True
self.number_of_accels_sent = 0
elif __class == 'JD_DisableAccelValuesSending_ConsoleCommandData':
self.should_start_accelerometer = False
elif __class == 'InputSetup_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'EnableCarousel_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'JD_EnableLobbyStartbutton_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'ShortcutSetup_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'JD_PhoneUiShortcutData':
shortcuts = set()
for item in message.get('shortcuts', []):
if item['__class'] == 'JD_PhoneAction_Shortcut':
try:
shortcuts.add(Command(item['shortcutType']))
except Exception as e:
print('Unknown Command: ', e)
self.available_shortcuts = shortcuts
elif __class == 'JD_OpenPhoneKeyboard_ConsoleCommandData':
await asyncio.sleep(1)
await self.send_message('JD_CancelKeyboard_PhoneCommandData')
elif __class == 'JD_PhoneUiSetupData':
self.is_input_allowed = True
self.available_shortcuts = set()
2022-04-15 10:47:05 +00:00
if message.get('setupData', {}).get('gameplaySetup', {}).get('pauseSlider', {}):
self.available_shortcuts.add(Command.PAUSE)
if message['isPopup'] == 1:
self.is_input_allowed = True
else:
self.is_input_allowed = (message.get('inputSetup', {}).get('isEnabled', 0) == 1)
async def send_hello(self):
print('Pairing...')
await self.send_message('JD_PhoneDataCmdHandshakeHello', {
'accelAcquisitionFreqHz': float(self.accel_acquisition_freq_hz),
'accelAcquisitionLatency': float(self.accel_acquisition_latency),
'accelMaxRange': float(self.accel_max_range),
})
async for message in self.ws:
await self.on_message(message)
async def send_accelerometer_data(self):
accel_data = []
delta_time = 0
end = time.time()
while True:
if self.disconnected:
break
if not self.should_start_accelerometer:
await asyncio.sleep(0.5)
continue
start = time.time()
if delta_time > ACCEL_SEND_RATE:
delta_time = 0
while len(accel_data) > 0:
accels_num = min(len(accel_data), 10)
await self.send_message('JD_PhoneScoringData', {
'accelData': accel_data[:accels_num],
'timeStamp': self.number_of_accels_sent,
})
self.number_of_accels_sent += accels_num
accel_data = accel_data[accels_num:]
try:
await asyncio.sleep(JOYCON_UPDATE_RATE)
joycon_status = self.joycon.get_status()
except OSError:
self.disconnect()
return
# 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
accel = joycon_status['accel']
x = accel['y'] * -1
y = accel['x']
z = accel['z']
accel_data.append([x, y, z])
end = time.time()
delta_time += (end - start) * 1000
async def send_command(self):
''' Capture Joycon's input and send to console. Only works on protocol v2 '''
if self.protocol_version == WsSubprotocolVersion.V1:
return
2022-04-15 10:47:05 +00:00
while True:
try:
if self.disconnected:
return
if not self.is_input_allowed and not self.should_start_accelerometer:
await asyncio.sleep(JOYCON_UPDATE_RATE * 5)
continue
await asyncio.sleep(JOYCON_UPDATE_RATE * 5)
cmd = None
# Get pressed button
for event_type, status in self.joycon.events():
if status == 0: # 0 = pressed, 1 = released
continue
joycon_button = JoyConButton(event_type)
if self.should_start_accelerometer: # Can only send Pause command while playing
if joycon_button == JoyConButton.PLUS or joycon_button == JoyConButton.MINUS:
cmd = Command.PAUSE
else:
if joycon_button == JoyConButton.A or joycon_button == JoyConButton.RIGHT:
cmd = Command.ACCEPT
elif joycon_button == JoyConButton.B or joycon_button == JoyConButton.DOWN:
cmd = Command.BACK
elif joycon_button in SHORTCUT_MAPPING:
# Get command depends on which button was pressed & which shortcuts are available
for shortcut in SHORTCUT_MAPPING[joycon_button]:
if shortcut in self.available_shortcuts:
cmd = shortcut
break
# Get joystick direction
if not self.should_start_accelerometer and not cmd:
status = self.joycon.get_status()
# Check which Joycon (L/R) is being used
stick = status['analog-sticks']['left'] if self.joycon_is_left else status['analog-sticks']['right']
vertical = stick['vertical']
horizontal = stick['horizontal']
if vertical < -0.5:
cmd = Command.DOWN
elif vertical > 0.5:
cmd = Command.UP
elif horizontal < -0.5:
cmd = Command.LEFT
elif horizontal > 0.5:
cmd = Command.RIGHT
# Send command to server
if cmd:
if cmd == Command.PAUSE:
__class = 'JD_Pause_PhoneCommandData'
data = {}
elif type(cmd.value) == str:
__class = 'JD_Custom_PhoneCommandData'
data = {
'identifier': cmd.value,
}
else:
__class = 'JD_Input_PhoneCommandData'
data = {
'input': cmd.value,
}
# Only send input when it's allowed to, otherwise we might get a disconnection
if self.is_input_allowed:
await self.send_message(__class, data)
await asyncio.sleep(0.01)
except Exception as e:
print(e)
import traceback
traceback.print_exc()
2022-04-15 10:47:05 +00:00
await self.disconnect()
async def connect_ws(self):
server_hostname = None
if self.protocol_version == WsSubprotocolVersion.V1:
ssl_context = None
else:
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.set_ciphers('ALL')
ssl_context.options &= ~ssl.OP_NO_SSLv3
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
if self.tls_certificate:
ssl_context.load_verify_locations(cadata=self.tls_certificate)
if self.pairing_url.startswith('192.168.') or self.pairing_url.startswith('10.'):
if self.console_conn:
server_hostname = self.console_conn.getpeername()[0]
else:
# Stadia
tmp = urlparse(self.pairing_url)
server_hostname = tmp.hostname
subprotocol = WS_SUBPROTOCOLS[self.protocol_version.value]
2022-04-15 10:47:05 +00:00
try:
async with websockets.connect(
self.pairing_url,
subprotocols=[subprotocol],
2022-04-15 10:47:05 +00:00
sock=self.console_conn,
ssl=ssl_context,
ping_timeout=None,
server_hostname=server_hostname
) as websocket:
try:
self.ws = websocket
await asyncio.gather(
self.send_hello(),
self.send_accelerometer_data(),
self.send_command(),
)
except websockets.ConnectionClosed:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_CONSOLE_CONNECTION)
await self.disconnect(close_ws=False)
except Exception as e:
print(e)
2022-04-15 10:47:05 +00:00
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_CONSOLE_CONNECTION)
await self.disconnect(close_ws=False)
async def disconnect(self, close_ws=True):
print('disconnected')
self.disconnected = True
self.joycon.__del__()
if close_ws and self.ws:
await self.ws.close()
async def pair(self):
try:
if self.console_ip_addr:
await self.on_state_changed(self.joycon.serial, PairingState.CONNECTING)
if self.protocol_version == WsSubprotocolVersion.V1:
self.pairing_url = 'ws://{}:8080/smartphone'.format(self.console_ip_addr)
else:
self.pairing_url = 'wss://{}:8080/smartphone'.format(self.console_ip_addr)
2022-04-15 10:47:05 +00:00
else:
await self.on_state_changed(self.joycon.serial, PairingState.GETTING_TOKEN)
print('Getting authorication token...')
await self.get_access_token()
await self.on_state_changed(self.joycon.serial, PairingState.PAIRING)
print('Sending pairing code...')
await self.send_pairing_code()
await self.on_state_changed(self.joycon.serial, PairingState.CONNECTING)
print('Connecting with console...')
if self.requires_punch_pairing:
await self.send_initiate_punch_pairing()
await self.hole_punching()
await self.connect_ws()
except Exception as e:
await self.disconnect()
print(e)