Added remote control functionality
This commit is contained in:
parent
0ff822a0cf
commit
a7a468aa48
1 changed files with 428 additions and 0 deletions
428
nxbt/tui.py
Normal file
428
nxbt/tui.py
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import psutil
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from blessed import Terminal
|
||||||
|
|
||||||
|
from .nxbt import Nxbt, PRO_CONTROLLER
|
||||||
|
|
||||||
|
|
||||||
|
class LoadingSpinner():
|
||||||
|
|
||||||
|
SPINNER_CHARS = ['⢸', '⣰', '⣤', '⣆', '⡇', '⠏', '⠛', '⠹']
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.creation_time = time.perf_counter()
|
||||||
|
self.last_update_time = self.creation_time
|
||||||
|
self.current_char_index = 0
|
||||||
|
|
||||||
|
def get_spinner_char(self):
|
||||||
|
|
||||||
|
current_time = time.perf_counter()
|
||||||
|
delta = current_time - self.last_update_time
|
||||||
|
|
||||||
|
if delta > 0.07:
|
||||||
|
self.last_update_time = current_time
|
||||||
|
|
||||||
|
if self.current_char_index == 7:
|
||||||
|
self.current_char_index = 0
|
||||||
|
else:
|
||||||
|
self.current_char_index += 1
|
||||||
|
|
||||||
|
return self.SPINNER_CHARS[self.current_char_index]
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerTUI():
|
||||||
|
|
||||||
|
CONTROLS = {
|
||||||
|
"ZL": "◿□□□□",
|
||||||
|
"L": "◿□□□□",
|
||||||
|
"ZR": "□□□□◺",
|
||||||
|
"R": "□□□□◺",
|
||||||
|
"LS_UP": ".─.",
|
||||||
|
"LS_LEFT": "(",
|
||||||
|
"LS_RIGHT": ")",
|
||||||
|
"LS_DOWN": "`─'",
|
||||||
|
"RS_UP": ".─.",
|
||||||
|
"RS_LEFT": "(",
|
||||||
|
"RS_RIGHT": ")",
|
||||||
|
"RS_DOWN": "`─'",
|
||||||
|
"DP_UP": "△",
|
||||||
|
"DP_LEFT": "◁",
|
||||||
|
"DP_RIGHT": "▷",
|
||||||
|
"DP_DOWN": "▽",
|
||||||
|
"MINUS": "◎",
|
||||||
|
"PLUS": "◎",
|
||||||
|
"HOME": "□",
|
||||||
|
"CAPTURE": "□",
|
||||||
|
"A": "○",
|
||||||
|
"B": "○",
|
||||||
|
"X": "○",
|
||||||
|
"Y": "○",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, term):
|
||||||
|
|
||||||
|
self.term = term
|
||||||
|
# Save a copy of the controls we can restore the
|
||||||
|
# control text on deactivation
|
||||||
|
self.DEFAULT_CONTROLS = self.CONTROLS.copy()
|
||||||
|
|
||||||
|
self.CONTROL_RELEASE_TIMERS = self.CONTROLS.copy()
|
||||||
|
for control in self.CONTROL_RELEASE_TIMERS.keys():
|
||||||
|
self.CONTROL_RELEASE_TIMERS[control] = False
|
||||||
|
|
||||||
|
def activate_control(self, key, activated_text=None):
|
||||||
|
|
||||||
|
if activated_text:
|
||||||
|
self.CONTROLS[key] = activated_text
|
||||||
|
else:
|
||||||
|
self.CONTROLS[key] = self.term.bold_black_on_white(self.CONTROLS[key])
|
||||||
|
|
||||||
|
# Keep track of when the key was pressed so we can release later
|
||||||
|
self.CONTROL_RELEASE_TIMERS[key] = time.perf_counter()
|
||||||
|
|
||||||
|
def deactivate_control(self, key):
|
||||||
|
|
||||||
|
self.CONTROLS[key] = self.DEFAULT_CONTROLS[key]
|
||||||
|
|
||||||
|
def render_controller(self):
|
||||||
|
|
||||||
|
# Release any overdue timers
|
||||||
|
for control in self.CONTROL_RELEASE_TIMERS.keys():
|
||||||
|
pressed_time = self.CONTROL_RELEASE_TIMERS[control]
|
||||||
|
current_time = time.perf_counter()
|
||||||
|
if pressed_time is not False and current_time - pressed_time > 0.25:
|
||||||
|
self.deactivate_control(control)
|
||||||
|
|
||||||
|
ZL = self.CONTROLS['ZL']
|
||||||
|
L = self.CONTROLS['L']
|
||||||
|
ZR = self.CONTROLS['ZR']
|
||||||
|
R = self.CONTROLS['R']
|
||||||
|
LU = self.CONTROLS['LS_UP']
|
||||||
|
LL = self.CONTROLS['LS_LEFT']
|
||||||
|
LR = self.CONTROLS['LS_RIGHT']
|
||||||
|
LD = self.CONTROLS['LS_DOWN']
|
||||||
|
RU = self.CONTROLS['RS_UP']
|
||||||
|
RL = self.CONTROLS['RS_LEFT']
|
||||||
|
RR = self.CONTROLS['RS_RIGHT']
|
||||||
|
RD = self.CONTROLS['RS_DOWN']
|
||||||
|
DU = self.CONTROLS['DP_UP']
|
||||||
|
DL = self.CONTROLS['DP_LEFT']
|
||||||
|
DR = self.CONTROLS['DP_RIGHT']
|
||||||
|
DD = self.CONTROLS['DP_DOWN']
|
||||||
|
MN = self.CONTROLS['MINUS']
|
||||||
|
PL = self.CONTROLS['PLUS']
|
||||||
|
HM = self.CONTROLS['HOME']
|
||||||
|
CP = self.CONTROLS['CAPTURE']
|
||||||
|
A = self.CONTROLS['A']
|
||||||
|
B = self.CONTROLS['B']
|
||||||
|
X = self.CONTROLS['X']
|
||||||
|
Y = self.CONTROLS['Y']
|
||||||
|
|
||||||
|
print(self.term.home + self.term.move_y((self.term.height // 2) - 9))
|
||||||
|
print(self.term.center(f" {ZL} {ZR} "))
|
||||||
|
print(self.term.center(f" ─{L}──────────{R}─ ┌─────────────┬────────────┐"))
|
||||||
|
print(self.term.center(" ╱ ╲ │ Controls │ Keys │"))
|
||||||
|
print(self.term.center(f" ╱ {LU} {MN} {PL} {X} ╲ └─────────────┴────────────┘")) # noqa
|
||||||
|
print(self.term.center(f"│ {LL} {LR} {CP} {HM} {Y} {A} │ Left Stick ─ ─ ─ ▷ W/A/S/D ")) # noqa
|
||||||
|
print(self.term.center(f"│ {LD} {B} │ DPad ─ ─ ─ ─ ─ ─ ▷ G/V/B/N "))
|
||||||
|
print(self.term.center(f"│ {DU} {RU} │ Capture/Home ─ ─ ─ ─ ▷ [/] "))
|
||||||
|
print(self.term.center(f"│╲ {DL} □ {DR} {RL} {RR} ╱│ +/- ─ ─ ─ ─ ─ ─ ─ ─ ─▷ 6/7 ")) # noqa
|
||||||
|
print(self.term.center(f"│░░╲ {DD} {RD} ╱░░│ X/Y/B/A ─ ─ ─ ─ ─▷ J/I/K/L "))
|
||||||
|
print(self.term.center("│░░░░╲ ──────────────── ╱░░░░│ L/ZL ─ ─ ─ ─ ─ ─ ─ ─ ▷ 1/2 "))
|
||||||
|
print(self.term.center("│░░░░╱ ╲░░░░│ R/ZR ─ ─ ─ ─ ─ ─ ─ ─ ▷ 8/9 "))
|
||||||
|
print(self.term.center("│░░╱ ╲░░│ Right Stick - - - ▷ Arrows "))
|
||||||
|
print(self.term.center("│╱ ╲│ "))
|
||||||
|
|
||||||
|
|
||||||
|
class InputTUI():
|
||||||
|
|
||||||
|
CONTROLS = {
|
||||||
|
"ZL": "◿□□□□",
|
||||||
|
"L": "◿□□□□",
|
||||||
|
"ZR": "□□□□◺",
|
||||||
|
"R": "□□□□◺",
|
||||||
|
"LS_UP": ".─.",
|
||||||
|
"LS_LEFT": "(",
|
||||||
|
"LS_RIGHT": ")",
|
||||||
|
"LS_DOWN": "`─'",
|
||||||
|
"RS_UP": ".─.",
|
||||||
|
"RS_LEFT": "(",
|
||||||
|
"RS_RIGHT": ")",
|
||||||
|
"RS_DOWN": "`─'",
|
||||||
|
"DP_UP": "△",
|
||||||
|
"DP_LEFT": "◁",
|
||||||
|
"DP_RIGHT": "▷",
|
||||||
|
"DP_DOWN": "▽",
|
||||||
|
"MINUS": "◎",
|
||||||
|
"PLUS": "◎",
|
||||||
|
"HOME": "□",
|
||||||
|
"CAPTURE": "□",
|
||||||
|
"A": "○",
|
||||||
|
"B": "○",
|
||||||
|
"X": "○",
|
||||||
|
"Y": "○",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, reconnect_target=None):
|
||||||
|
|
||||||
|
self.reconnect_target = reconnect_target
|
||||||
|
self.term = Terminal()
|
||||||
|
self.remote_connection = self.detect_remote_connection()
|
||||||
|
self.controller = ControllerTUI(self.term)
|
||||||
|
|
||||||
|
def detect_remote_connection(self):
|
||||||
|
"""Traverse up the parent processes and check if any
|
||||||
|
have their parent as a remote daemon. If so, the python
|
||||||
|
script is running under a remote connection.
|
||||||
|
|
||||||
|
Remote shell detection is required for this TUI, due to
|
||||||
|
keyboard input limitations on most remote connections.
|
||||||
|
Specifically, no "keyup" events are sent when a key is
|
||||||
|
released. Keyup events are required for proper input to
|
||||||
|
the Switch, thus, we need to detect if the shell is a remote
|
||||||
|
session and workaround this.
|
||||||
|
|
||||||
|
:return: Returns a boolean value indicating whether or not
|
||||||
|
the current script is running as SSH
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
|
||||||
|
remote_connection = False
|
||||||
|
remote_process_names = ['sshd', 'mosh-server']
|
||||||
|
ppid = os.getppid()
|
||||||
|
while ppid > 0:
|
||||||
|
process = psutil.Process(ppid)
|
||||||
|
if process.name() in remote_process_names:
|
||||||
|
remote_connection = True
|
||||||
|
break
|
||||||
|
ppid = process.ppid()
|
||||||
|
|
||||||
|
return remote_connection
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
|
||||||
|
self.mainloop(self.term)
|
||||||
|
|
||||||
|
def mainloop(self, term):
|
||||||
|
|
||||||
|
# Initializing a controller
|
||||||
|
self.nx = Nxbt(disable_logging=True)
|
||||||
|
self.controller_index = self.nx.create_controller(
|
||||||
|
PRO_CONTROLLER,
|
||||||
|
reconnect_address=self.reconnect_target)
|
||||||
|
|
||||||
|
state = None
|
||||||
|
spinner = LoadingSpinner()
|
||||||
|
try:
|
||||||
|
with term.cbreak(), term.keypad(), term.location(), term.hidden_cursor():
|
||||||
|
print(term.home + term.clear)
|
||||||
|
self.render_top_bar(term)
|
||||||
|
self.render_bottom_bar(term)
|
||||||
|
self.render_start_screen(term, "Loading")
|
||||||
|
inp = term.inkey(timeout=0)
|
||||||
|
|
||||||
|
# Loading Screen
|
||||||
|
while inp != chr(113): # Checking for q press
|
||||||
|
# Check key at 15hz
|
||||||
|
inp = term.inkey(timeout=1/15)
|
||||||
|
new_state = self.nx.state[self.controller_index]["state"]
|
||||||
|
|
||||||
|
if new_state != state:
|
||||||
|
state = new_state
|
||||||
|
|
||||||
|
loading_text = "Loading"
|
||||||
|
if state == "initializing":
|
||||||
|
loading_text = "Initializing Controller"
|
||||||
|
elif state == "connecting":
|
||||||
|
loading_text = "Connecting to any Nintendo Switch"
|
||||||
|
elif state == "reconnecting":
|
||||||
|
loading_text = "Reconnecting to Nintendo Switch"
|
||||||
|
elif state == "connected":
|
||||||
|
loading_text = "Connected!"
|
||||||
|
self.render_start_screen(term, loading_text)
|
||||||
|
|
||||||
|
print(term.move_y((term.height // 2) + 6))
|
||||||
|
if state != "connected":
|
||||||
|
print(term.bold(term.center(spinner.get_spinner_char())))
|
||||||
|
else:
|
||||||
|
print(term.center(""))
|
||||||
|
|
||||||
|
if state == "connected":
|
||||||
|
time.sleep(1)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Main Gamepad Input Loop
|
||||||
|
if state == "connected":
|
||||||
|
if self.remote_connection:
|
||||||
|
self.remote_input_loop(term)
|
||||||
|
else:
|
||||||
|
self.direct_input_loop(term)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
print(term.clear())
|
||||||
|
|
||||||
|
def remote_input_loop(self, term):
|
||||||
|
|
||||||
|
inp = term.inkey(timeout=0)
|
||||||
|
while inp != chr(113): # Checking for q press
|
||||||
|
# Cutoff large buffered input from the deque
|
||||||
|
# so that we avoid spamming the Switch after
|
||||||
|
# a key releases from being held.
|
||||||
|
# Increasing the size of the buffer does not
|
||||||
|
# smooth out the jagginess of input.
|
||||||
|
if len(term._keyboard_buf) > 1:
|
||||||
|
term._keyboard_buf = deque([term._keyboard_buf.pop()])
|
||||||
|
|
||||||
|
inp = term.inkey(1/60)
|
||||||
|
|
||||||
|
pressed_key = None
|
||||||
|
if inp.is_sequence:
|
||||||
|
pressed_key = inp.name
|
||||||
|
elif inp:
|
||||||
|
pressed_key = inp
|
||||||
|
|
||||||
|
if pressed_key == 'w':
|
||||||
|
self.controller.activate_control('LS_UP')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@+000+100 0.1s")
|
||||||
|
elif pressed_key == 'a':
|
||||||
|
self.controller.activate_control('LS_LEFT')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@-100+000 0.1s")
|
||||||
|
elif pressed_key == 'd':
|
||||||
|
self.controller.activate_control('LS_RIGHT')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@+100+000 0.1s")
|
||||||
|
elif pressed_key == 's':
|
||||||
|
self.controller.activate_control('LS_DOWN')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@+000-100 0.1s")
|
||||||
|
|
||||||
|
elif pressed_key == 'g':
|
||||||
|
self.controller.activate_control('DP_UP')
|
||||||
|
self.nx.macro(self.controller_index, "DPAD_UP 0.1s")
|
||||||
|
elif pressed_key == 'v':
|
||||||
|
self.controller.activate_control('DP_LEFT')
|
||||||
|
self.nx.macro(self.controller_index, "DPAD_LEFT 0.1s")
|
||||||
|
elif pressed_key == 'n':
|
||||||
|
self.controller.activate_control('DP_RIGHT')
|
||||||
|
self.nx.macro(self.controller_index, "DPAD_RIGHT 0.1s")
|
||||||
|
elif pressed_key == 'b':
|
||||||
|
self.controller.activate_control('DP_DOWN')
|
||||||
|
self.nx.macro(self.controller_index, "DPAD_DOWN 0.1s")
|
||||||
|
|
||||||
|
elif pressed_key == '[':
|
||||||
|
self.controller.activate_control('CAPTURE')
|
||||||
|
self.nx.macro(self.controller_index, "CAPTURE 0.1s")
|
||||||
|
elif pressed_key == ']':
|
||||||
|
self.controller.activate_control('HOME')
|
||||||
|
self.nx.macro(self.controller_index, "HOME 0.1s")
|
||||||
|
|
||||||
|
elif pressed_key == '6':
|
||||||
|
self.controller.activate_control('MINUS')
|
||||||
|
self.nx.macro(self.controller_index, "- 0.1s")
|
||||||
|
elif pressed_key == '7':
|
||||||
|
self.controller.activate_control('PLUS')
|
||||||
|
self.nx.macro(self.controller_index, "+ 0.1s")
|
||||||
|
|
||||||
|
elif pressed_key == 'i':
|
||||||
|
self.controller.activate_control('X')
|
||||||
|
self.nx.macro(self.controller_index, "X 0.1s")
|
||||||
|
elif pressed_key == 'j':
|
||||||
|
self.controller.activate_control('Y')
|
||||||
|
self.nx.macro(self.controller_index, "Y 0.1s")
|
||||||
|
elif pressed_key == 'l':
|
||||||
|
self.controller.activate_control('A')
|
||||||
|
self.nx.macro(self.controller_index, "A 0.1s")
|
||||||
|
elif pressed_key == 'k':
|
||||||
|
self.controller.activate_control('B')
|
||||||
|
self.nx.macro(self.controller_index, "B 0.1s")
|
||||||
|
|
||||||
|
elif pressed_key == '1':
|
||||||
|
self.controller.activate_control('L')
|
||||||
|
self.nx.macro(self.controller_index, "L 0.1s")
|
||||||
|
elif pressed_key == '2':
|
||||||
|
self.controller.activate_control('ZL')
|
||||||
|
self.nx.macro(self.controller_index, "ZL 0.1s")
|
||||||
|
|
||||||
|
elif pressed_key == '8':
|
||||||
|
self.controller.activate_control('R')
|
||||||
|
self.nx.macro(self.controller_index, "R 0.1s")
|
||||||
|
elif pressed_key == '9':
|
||||||
|
self.controller.activate_control('ZR')
|
||||||
|
self.nx.macro(self.controller_index, "ZR 0.1s")
|
||||||
|
|
||||||
|
elif pressed_key == 'KEY_UP':
|
||||||
|
self.controller.activate_control('RS_UP')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@+000+100 0.1s")
|
||||||
|
elif pressed_key == 'KEY_LEFT':
|
||||||
|
self.controller.activate_control('RS_LEFT')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@-100+000 0.1s")
|
||||||
|
elif pressed_key == 'KEY_RIGHT':
|
||||||
|
self.controller.activate_control('RS_RIGHT')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@+100+000 0.1s")
|
||||||
|
elif pressed_key == 'KEY_DOWN':
|
||||||
|
self.controller.activate_control('RS_DOWN')
|
||||||
|
self.nx.macro(self.controller_index, "L_STICK@+000-100 0.1s")
|
||||||
|
|
||||||
|
self.controller.render_controller()
|
||||||
|
|
||||||
|
self.check_for_disconnect(term)
|
||||||
|
|
||||||
|
def direct_input_loop(self, term):
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
def render_start_screen(self, term, loading_text):
|
||||||
|
|
||||||
|
print(term.home + term.move_y((term.height // 2) - 8))
|
||||||
|
print(term.center("___╲╱___"))
|
||||||
|
print(term.center("│╲ ╱╲ ╱│"))
|
||||||
|
print(term.center("│ ╲╱__╲╱ │"))
|
||||||
|
print(term.center("│╱ ╲│"))
|
||||||
|
print(term.center("┌──────────────────┐"))
|
||||||
|
print(term.center("│ NXBT TUI │"))
|
||||||
|
print(term.center("└──────────────────┘"))
|
||||||
|
print(term.center(""))
|
||||||
|
print(term.black_on_white(term.center("")))
|
||||||
|
print(term.bold_black_on_white(term.center(loading_text)))
|
||||||
|
print(term.black_on_white(term.center("")))
|
||||||
|
|
||||||
|
def render_top_bar(self, term):
|
||||||
|
|
||||||
|
print(term.move_y(1))
|
||||||
|
if self.remote_connection:
|
||||||
|
print(term.bold_black_on_white(term.center(term.bold_black_on_red(" REMOTE MODE "))))
|
||||||
|
warning = " WARNING: MACROS WILL BE USED ON KEYPRESS DUE TO REMOTE CLI LIMITATIONS "
|
||||||
|
print(term.center(term.black_on_red(warning)))
|
||||||
|
else:
|
||||||
|
print(term.bold_black_on_white(term.center("DIRECT INPUT MODE")))
|
||||||
|
print(term.move_y(1))
|
||||||
|
print(term.white_on_black(" NXBT TUI 🎮 "))
|
||||||
|
|
||||||
|
def render_bottom_bar(self, term):
|
||||||
|
|
||||||
|
print(term.move_y(term.height))
|
||||||
|
print(term.center(term.bold_black_on_white(" <Press q to quit> ")))
|
||||||
|
|
||||||
|
def check_for_disconnect(self, term):
|
||||||
|
|
||||||
|
state = self.nx.state[self.controller_index]["state"]
|
||||||
|
if state != 'connected':
|
||||||
|
print(term.home + term.move_y((term.height // 2) - 4))
|
||||||
|
print(term.bold_black_on_red(term.center("")))
|
||||||
|
print(term.bold_black_on_red(term.center(state.title())))
|
||||||
|
print(term.bold_black_on_red(term.center("")))
|
||||||
|
self.nx.wait_for_connection(self.controller_index)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Program entry point."""
|
||||||
|
tui = InputTUI()
|
||||||
|
tui.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue