SelkiesGamepad
One virtual gamepad slot: interposer socket servers plus an optional kernel uinput device.
Serves the joydev-style and evdev-style Unix sockets the Joystick Interposer preload connects applications to, fanning queued events out to every connected client and (when enabled) mirroring them onto a kernel uinput device.
Instances live in _persistent_gamepads and outlive every per-service
input handler: applications open the interposer sockets once at their own
startup (the .so presents them as /dev/input devices), and a transport
mode switch (websockets \<-> webrtc) tears one service down and starts the
other, so closing or rebinding the sockets there would leave every
running app holding a dead fd until it restarts. Process exit reclaims
the fds; the next server start unlinks stale socket files before binding.
Attributes
attributejs_sock_path= js_interposer_socket_pathattributeevdev_sock_path= evdev_interposer_socket_pathattributeloop= loop or asyncio.get_running_loop()attributeuinput_enabled= uinput_enabledKernel gamepads are on for this slot.
attributeuinput= NoneThe kernel device, created on first use so an unused slot is not a phantom controller in every application.
attributemapper= NoneButton/axis mapper for this pad, built by set_config.
attributeconfig_payload_cache= Nonejs_config_t payload handed to interposer clients, built by set_config.
attributejs_server= Noneattributeevdev_server= Noneattributejs_clients= {}attributeevdev_clients= {}attributeevents_queue= asyncio.Queue(maxsize=4096)Bounded so one stalled client cannot grow it without limit and wedge delivery for every client; overflow drops the oldest event (see send_event).
attributerunning= Falseattribute_event_processor_task= Noneattribute_held_controls= set()Client controls (is_button, index) currently driven non-neutral, so reset_state releases exactly what is held.
attribute_js_state= {}Last queued js value per (ev_type, number), the source for init_state_burst; updated at queue time so the snapshot stays truthful even for events the bounded queue drops.
Functions
func__init__(self, js_interposer_socket_path, evdev_interposer_socket_path, loop=None, uinput_enabled=False) -> NoneSource Code
def __init__(self, js_interposer_socket_path: str,
evdev_interposer_socket_path: str,
loop: Optional[asyncio.AbstractEventLoop] = None,
uinput_enabled: bool = False) -> None:
self.js_sock_path = js_interposer_socket_path
self.evdev_sock_path = evdev_interposer_socket_path
self.loop = loop or asyncio.get_running_loop()
self.uinput_enabled = uinput_enabled
self.uinput = None
self.mapper = None
self.config_payload_cache = None
self.js_server = None
self.evdev_server = None
self.js_clients = {}
self.evdev_clients = {}
self.events_queue = asyncio.Queue(maxsize=4096)
self.running = False
self._event_processor_task = None
self._held_controls = set()
self._js_state = {}paramselfparamjs_interposer_socket_pathstrparamevdev_interposer_socket_pathstrparamloopOptional[asyncio.AbstractEventLoop]= Noneparamuinput_enabledbool= FalseReturns
Nonefuncset_config(self, client_input_name, client_num_btns, client_num_axes) -> NoneBuild the mapper and cache the js_config_t payload served to interposer clients.
Source Code
def set_config(self, client_input_name: str, client_num_btns: int,
client_num_axes: int) -> None:
"""Build the mapper and cache the js_config_t payload served to interposer clients."""
self.mapper = GamepadMapper(STANDARD_XPAD_CONFIG, client_input_name, client_num_btns, client_num_axes)
js_idx = 0
match = re.search(r"selkies_js(\d+)\.sock$", self.js_sock_path)
if match:
js_idx = int(match.group(1))
else:
logger_selkies_gamepad.warning(
f"Failed to parse js_index from {self.js_sock_path}, "
f"defaulting to 0 for payload name generation if needed."
)
payload_controller_config = {
"name": STANDARD_XPAD_CONFIG.get("name", f"Selkies Virtual JS{js_idx}"),
"vendor_id": STANDARD_XPAD_CONFIG.get("vendor_id", 0x0000),
"product_id": STANDARD_XPAD_CONFIG.get("product_id", 0x0000),
"version": STANDARD_XPAD_CONFIG.get("version", 0x0114),
"buttons": STANDARD_XPAD_CONFIG.get("btn_map", []),
"axes": STANDARD_XPAD_CONFIG.get("axes_map", [])
}
self.config_payload_cache = self._make_interposer_config_payload(js_idx, payload_controller_config)
logger_selkies_gamepad.info(
f"Gamepad configured. JS socket: {self.js_sock_path}, EVDEV socket: {self.evdev_sock_path}. "
f"Using fixed config: {STANDARD_XPAD_CONFIG['name']}"
)paramselfparamclient_input_namestrparamclient_num_btnsintparamclient_num_axesintReturns
Nonefuncensure_uinput(self) -> NoneBring this slot's kernel device up, once, if kernel gamepads are on. A failure downgrades the slot to the interposer sockets rather than killing input.
Source Code
def ensure_uinput(self) -> None:
"""Bring this slot's kernel device up, once, if kernel gamepads are on.
A failure downgrades the slot to the interposer sockets rather than
killing input."""
if not self.uinput_enabled or self.uinput is not None:
return
device = UInputGamepad(os.path.basename(self.js_sock_path))
try:
nodes = device.create()
except OSError as e:
self.uinput_enabled = False
logger_selkies_gamepad.error(
f"Gamepad {self.js_sock_path}: could not create a kernel device ({e}); "
"this slot now reaches applications only through the Joystick Interposer."
)
return
self.uinput = device
logger_selkies_gamepad.info(
f"Gamepad {self.js_sock_path}: kernel device ready ({', '.join(nodes) or 'node path unknown'})."
)
unreadable = [node for node in nodes if not os.access(node, os.R_OK)]
if unreadable:
logger_selkies_gamepad.warning(
f"Gamepad {self.js_sock_path}: {', '.join(unreadable)} is not readable by this user, "
"so applications cannot open it. Add the account to the 'input' group."
)paramselfReturns
Nonefunc_emit_uinput(self, ev_type, ev_code, ev_value) -> NoneMirror one event onto the kernel device, tearing it down on write failure.
Source Code
def _emit_uinput(self, ev_type: int, ev_code: int, ev_value: float) -> None:
"""Mirror one event onto the kernel device, tearing it down on write failure."""
self.ensure_uinput()
if self.uinput is None:
return
try:
self.uinput.emit(ev_type, ev_code, ev_value)
except OSError as e:
logger_selkies_gamepad.error(
f"Gamepad {self.js_sock_path}: kernel device write failed ({e}); tearing it down."
)
self.uinput.destroy()
self.uinput = None
self.uinput_enabled = Falseparamselfparamev_typeintparamev_codeintparamev_valuefloatReturns
Nonefunc_make_interposer_config_payload(self, js_index, controller_config) -> bytesCreate the js_config_t payload sent to the C interposer.
The payload is always exactly C_INTERPOSER_STRUCT_SIZE bytes; every failure path returns a zeroed buffer of that size rather than raising, so a client handshake never dies on a malformed config.
Source Code
def _make_interposer_config_payload(self, js_index: int, controller_config: dict) -> bytes:
"""Create the js_config_t payload sent to the C interposer.
The payload is always exactly C_INTERPOSER_STRUCT_SIZE bytes; every
failure path returns a zeroed buffer of that size rather than raising,
so a client handshake never dies on a malformed config.
"""
struct_fmt = base_struct_fmt = "undefined"
try:
name_str = controller_config.get("name", f"Selkies Virtual JS{js_index}")
name_bytes_utf8 = name_str.encode('utf-8')
if len(name_bytes_utf8) >= CONTROLLER_NAME_MAX_LEN:
name_bytes_for_pack = name_bytes_utf8[:CONTROLLER_NAME_MAX_LEN - 1] + b'\0'
else:
name_bytes_for_pack = name_bytes_utf8.ljust(CONTROLLER_NAME_MAX_LEN, b'\0')
if len(name_bytes_for_pack) != CONTROLLER_NAME_MAX_LEN:
logging.error(f"CRITICAL: name_bytes_for_pack is not {CONTROLLER_NAME_MAX_LEN} bytes long! Got {len(name_bytes_for_pack)}")
return b'\0' * C_INTERPOSER_STRUCT_SIZE
raw_vendor = controller_config.get("vendor_id")
if isinstance(raw_vendor, str):
vendor_id = int(raw_vendor, 16)
elif isinstance(raw_vendor, int):
vendor_id = raw_vendor
else:
vendor_id = 0x045e
raw_product = controller_config.get("product_id")
if isinstance(raw_product, str):
product_id = int(raw_product, 16)
elif isinstance(raw_product, int):
product_id = raw_product
else:
product_id = 0x028e
raw_version = controller_config.get("version")
if isinstance(raw_version, str):
version_id = int(raw_version, 16)
elif isinstance(raw_version, int):
version_id = raw_version
else:
version_id = 0x0114
buttons_evdev_codes = controller_config.get("buttons", [])
axes_evdev_codes = controller_config.get("axes", [])
# Counts clamped to the array capacity: a count above the truncated
# map length would drive an out-of-bounds read in the C interposer.
num_actual_btns = min(len(buttons_evdev_codes), INTERPOSER_MAX_BTNS)
num_actual_axes = min(len(axes_evdev_codes), INTERPOSER_MAX_AXES)
padded_btn_map_for_pack = list(buttons_evdev_codes)
if len(padded_btn_map_for_pack) > INTERPOSER_MAX_BTNS:
logging.warning(f"Controller '{name_str}' has {len(padded_btn_map_for_pack)} buttons, truncating to {INTERPOSER_MAX_BTNS} for config.")
padded_btn_map_for_pack = padded_btn_map_for_pack[:INTERPOSER_MAX_BTNS]
else:
padded_btn_map_for_pack.extend([0] * (INTERPOSER_MAX_BTNS - len(padded_btn_map_for_pack)))
padded_axes_map_for_pack = list(axes_evdev_codes)
if len(padded_axes_map_for_pack) > INTERPOSER_MAX_AXES:
logging.warning(f"Controller '{name_str}' has {len(padded_axes_map_for_pack)} axes, truncating to {INTERPOSER_MAX_AXES} for config.")
padded_axes_map_for_pack = padded_axes_map_for_pack[:INTERPOSER_MAX_AXES]
else:
padded_axes_map_for_pack.extend([0] * (INTERPOSER_MAX_AXES - len(padded_axes_map_for_pack)))
base_struct_fmt = f"={CONTROLLER_NAME_MAX_LEN}sxHHHHH{INTERPOSER_MAX_BTNS}H{INTERPOSER_MAX_AXES}B"
size_without_explicit_end_padding = struct.calcsize(base_struct_fmt)
padding_needed = C_INTERPOSER_STRUCT_SIZE - size_without_explicit_end_padding
if padding_needed < 0:
logging.error(
f"CRITICAL STRUCT SIZE ERROR: Python base packed size ({size_without_explicit_end_padding}) "
f"is larger than C interposer expected size ({C_INTERPOSER_STRUCT_SIZE}). "
f"This means constants (MAX_BTNS, MAX_AXES, NAME_LEN) or field types/order "
f"differ between Python 'base_struct_fmt' and C 'js_config_t'."
)
return b'\0' * C_INTERPOSER_STRUCT_SIZE
struct_fmt = f"{base_struct_fmt}{padding_needed}x"
python_final_packed_size = struct.calcsize(struct_fmt)
if python_final_packed_size != C_INTERPOSER_STRUCT_SIZE:
logging.error(
f"CRITICAL FINAL PYTHON PACKED SIZE MISMATCH for js_config_t! "
f"C interposer expects: {C_INTERPOSER_STRUCT_SIZE}, "
f"Python struct.pack calculated final size: {python_final_packed_size} using format '{struct_fmt}'. "
f"This indicates an issue with padding calculation logic or the base_struct_fmt."
)
return b'\0' * C_INTERPOSER_STRUCT_SIZE
logging.debug(f"Using final struct_fmt: '{struct_fmt}' for js_config, packing to size {python_final_packed_size}")
payload_args = [
name_bytes_for_pack,
vendor_id,
product_id,
version_id,
num_actual_btns,
num_actual_axes,
]
payload_args.extend(padded_btn_map_for_pack)
payload_args.extend(padded_axes_map_for_pack)
payload = struct.pack(struct_fmt, *payload_args)
log_display_name = name_bytes_for_pack.split(b'\0',1)[0].decode('utf-8', errors='replace')
logging.info(f"Packed js_config payload for '{name_str}' (js{js_index}): "
f"len={len(payload)} bytes. "
f"Name='{log_display_name}', "
f"Vendor=0x{vendor_id:04x}, Product=0x{product_id:04x}, Version=0x{version_id:04x}, "
f"Reported Buttons={num_actual_btns} (Array capacity: {INTERPOSER_MAX_BTNS}), "
f"Reported Axes={num_actual_axes} (Array capacity: {INTERPOSER_MAX_AXES})")
if len(payload) != C_INTERPOSER_STRUCT_SIZE:
logging.error(f"FINAL PAYLOAD SIZE MISMATCH AFTER PACKING! Expected {C_INTERPOSER_STRUCT_SIZE}, got {len(payload)}. This is very bad.")
return b'\0' * C_INTERPOSER_STRUCT_SIZE
return payload
except struct.error as e:
current_struct_fmt = struct_fmt if struct_fmt != "undefined" else base_struct_fmt
logging.error(f"Error packing joystick config for js{js_index} with format '{current_struct_fmt}': {e}")
config_to_log = controller_config if 'controller_config' in locals() else {}
logging.error(f"Controller config was: {config_to_log}")
return b'\0' * C_INTERPOSER_STRUCT_SIZE
except Exception as e:
config_to_log = controller_config if 'controller_config' in locals() else {}
logging.exception(f"Unexpected error creating interposer config payload for js{js_index} with config {config_to_log}: {e}")
return b'\0' * C_INTERPOSER_STRUCT_SIZEparamselfparamjs_indexintparamcontroller_configdictReturns
bytesfunc_handle_interposer_client(self, reader, writer, is_evdev_socket) -> NonePer-client handshake and lifetime: send config, read the client's architecture byte, register the writer for event fan-out, then hold the connection open until shutdown or disconnect.
A JS client first gets its current state replayed as INIT events (joydev semantics); the snapshot, its write and the registration share one loop step, so no broadcast can interleave and the client's first live event strictly follows its snapshot. evdev has no in-band INIT: those clients poll state through the interposer's ioctl emulation.
Source Code
async def _handle_interposer_client(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
is_evdev_socket: bool) -> None:
"""Per-client handshake and lifetime: send config, read the client's
architecture byte, register the writer for event fan-out, then hold the
connection open until shutdown or disconnect.
A JS client first gets its current state replayed as INIT events
(joydev semantics); the snapshot, its write and the registration share
one loop step, so no broadcast can interleave and the client's first
live event strictly follows its snapshot. evdev has no in-band INIT:
those clients poll state through the interposer's ioctl emulation.
"""
peername = writer.get_extra_info('peername')
socket_type_str = "EVDEV" if is_evdev_socket else "JS"
clients_dict = self.evdev_clients if is_evdev_socket else self.js_clients
sock_path = self.evdev_sock_path if is_evdev_socket else self.js_sock_path
log_prefix = f"Gamepad {sock_path} Client {peername} ({socket_type_str}):"
logger_selkies_gamepad.info(f"{log_prefix} Handler started.")
try:
if not self.config_payload_cache:
logger_selkies_gamepad.error(f"{log_prefix} Config payload not ready. Aborting handler.")
return
logger_selkies_gamepad.info(f"{log_prefix} Preparing to send config payload. Length: {len(self.config_payload_cache)}, Expected C size: {EXPECTED_C_STRUCT_SIZE}, First 16 bytes: {self.config_payload_cache[:16].hex()}")
writer.write(self.config_payload_cache)
await writer.drain()
logger_selkies_gamepad.debug(f"{log_prefix} Sent config payload.")
arch_byte = await reader.readexactly(1)
client_sizeof_long = struct.unpack("=B", arch_byte)[0]
client_arch_bits = client_sizeof_long * 8
logger_selkies_gamepad.info(f"{log_prefix} Received arch specifier: {client_sizeof_long} bytes ({client_arch_bits}-bit).")
if not is_evdev_socket:
writer.write(self.init_state_burst())
clients_dict[writer] = {'arch_bits': client_arch_bits}
await writer.drain()
logger_selkies_gamepad.info(f"{log_prefix} Added to active list. Total {socket_type_str} clients: {len(clients_dict)}.")
while self.running and not writer.is_closing():
await asyncio.sleep(0.1)
if not self.running:
logger_selkies_gamepad.info(f"{log_prefix} Exiting handler normally because self.running is False.")
if writer.is_closing():
logger_selkies_gamepad.info(f"{log_prefix} Exiting handler normally because writer.is_closing() is True (client likely closed connection).")
except (asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError) as e:
logger_selkies_gamepad.info(f"{log_prefix} Disconnected (expected error): {type(e).__name__} - {e}")
except Exception as e:
logger_selkies_gamepad.error(f"{log_prefix} Unhandled error in handler: {e}", exc_info=True)
finally:
logger_selkies_gamepad.info(f"{log_prefix} Entering finally block.")
if writer in clients_dict:
del clients_dict[writer]
logger_selkies_gamepad.info(f"{log_prefix} Removed from active list. Total {socket_type_str} clients now: {len(clients_dict)}.")
else:
logger_selkies_gamepad.warning(f"{log_prefix} Writer not found in active list during finally block.")
if not writer.is_closing():
logger_selkies_gamepad.info(f"{log_prefix} Explicitly closing writer in finally block.")
writer.close()
await writer.wait_closed()
logger_selkies_gamepad.info(f"{log_prefix} Handler finished.")paramselfparamreaderasyncio.StreamReaderparamwriterasyncio.StreamWriterparamis_evdev_socketboolReturns
Nonefunc_run_single_server(self, interposer_socket_path, is_evdev_socket) -> Optional[asyncio.AbstractServer]Bind one interposer Unix server (unlinking a stale socket file first); None on failure.
Source Code
async def _run_single_server(self, interposer_socket_path: str,
is_evdev_socket: bool) -> Optional[asyncio.AbstractServer]:
"""Bind one interposer Unix server (unlinking a stale socket file first);
None on failure."""
sock_dir = os.path.dirname(interposer_socket_path)
if sock_dir and not os.path.exists(sock_dir):
try: os.makedirs(sock_dir, exist_ok=True)
except OSError as e:
logger_selkies_gamepad.error(f"Failed to create directory {sock_dir} for socket: {e}")
return None
if os.path.exists(interposer_socket_path):
try:
os.unlink(interposer_socket_path)
logger_selkies_gamepad.debug(f"Removed existing socket file: {interposer_socket_path}")
except OSError as e:
logger_selkies_gamepad.warning(f"Could not remove existing file at {interposer_socket_path}: {e}. Bind might fail.")
try:
server = await asyncio.start_unix_server(
lambda r, w: self._handle_interposer_client(r, w, is_evdev_socket),
path=interposer_socket_path
)
addr = server.sockets[0].getsockname() if server.sockets else interposer_socket_path
logger_selkies_gamepad.info(f"{'EVDEV' if is_evdev_socket else 'JS'} interposer server listening on {addr}")
return server
except Exception as e:
logger_selkies_gamepad.error(f"Failed to start {'EVDEV' if is_evdev_socket else 'JS'} server on {interposer_socket_path}: {e}", exc_info=True)
return Noneparamselfparaminterposer_socket_pathstrparamis_evdev_socketboolReturns
typing.Optional[asyncio.asyncio.AbstractServer]funcrun_servers(self) -> NoneStart both interposer servers and the event processor; runs until close().
Source Code
async def run_servers(self) -> None:
"""Start both interposer servers and the event processor; runs until close()."""
if not self.mapper:
logger_selkies_gamepad.error("Mapper not set. Call set_config() before run_servers().")
return
self.running = True
if self._event_processor_task is None or self._event_processor_task.done():
self._event_processor_task = asyncio.create_task(self._process_event_queue())
self.js_server = await self._run_single_server(self.js_sock_path, is_evdev_socket=False)
self.evdev_server = await self._run_single_server(self.evdev_sock_path, is_evdev_socket=True)
if not self.js_server and not self.evdev_server:
logger_selkies_gamepad.error("Neither JS nor EVDEV interposer server could be started. Stopping.")
self.running = False
if self._event_processor_task and not self._event_processor_task.done():
self._event_processor_task.cancel()
return
while self.running:
await asyncio.sleep(1)
logger_selkies_gamepad.info("run_servers loop exited.")paramselfReturns
Nonefuncsend_event(self, client_event_idx, client_value, is_button_event) -> NoneMap one client control change and queue it for fan-out to every client.
On overflow the oldest queued event is dropped to make room — for a gamepad the freshest state matters and a stale sample is worthless — so a slowly draining client cannot back-pressure into unbounded growth; the shutdown sentinel (None) is re-enqueued if evicted.
Source Code
def send_event(self, client_event_idx: int, client_value: float,
is_button_event: bool) -> None:
"""Map one client control change and queue it for fan-out to every client.
On overflow the oldest queued event is dropped to make room — for a
gamepad the freshest state matters and a stale sample is worthless —
so a slowly draining client cannot back-pressure into unbounded
growth; the shutdown sentinel (None) is re-enqueued if evicted.
"""
if not self.mapper or not self.running:
return
event_package = self.mapper.get_mapped_events(client_event_idx, client_value, is_button_event)
if event_package:
control = (is_button_event, client_event_idx)
if client_value:
self._held_controls.add(control)
else:
self._held_controls.discard(control)
js_data = event_package.get('js_event_data')
if js_data:
_, value, ev_type, number = struct.unpack("=IhBB", js_data)
self._js_state[(ev_type, number)] = value
logger_selkies_gamepad.debug(f"Gamepad {self.js_sock_path}: Queuing event: {event_package}")
try:
self.events_queue.put_nowait(event_package)
except asyncio.QueueFull:
try:
dropped = self.events_queue.get_nowait()
self.events_queue.task_done()
if dropped is None:
self.events_queue.put_nowait(None)
return
except asyncio.QueueEmpty:
pass
try:
self.events_queue.put_nowait(event_package)
except asyncio.QueueFull:
logger_selkies_gamepad.warning(
f"Gamepad {self.js_sock_path}: event queue full; dropping event."
)paramselfparamclient_event_idxintparamclient_valuefloatparamis_button_eventboolReturns
Nonefuncreset_state(self) -> NoneEmit a neutral value for every control still held non-neutral, so a dropped association leaves no stuck button or off-center axis behind on the app driving this pad.
Source Code
def reset_state(self) -> None:
"""Emit a neutral value for every control still held non-neutral, so a
dropped association leaves no stuck button or off-center axis behind on
the app driving this pad."""
for is_button_event, client_event_idx in list(self._held_controls):
self.send_event(client_event_idx, 0, is_button_event)paramselfReturns
Nonefuncinit_state_burst(self) -> bytesjoydev-parity state replay for a newly connected JS client: every control's current value as JS_EVENT_INIT-flagged events, so an app opening the pad mid-hold starts from the true state (and input during the connect handshake is covered as state, not lost edges). Buttons rest at 0, stick/hat axes at center, triggers at the mapper's released value.
Source Code
def init_state_burst(self) -> bytes:
"""joydev-parity state replay for a newly connected JS client: every
control's current value as JS_EVENT_INIT-flagged events, so an app
opening the pad mid-hold starts from the true state (and input during
the connect handshake is covered as state, not lost edges). Buttons
rest at 0, stick/hat axes at center, triggers at the mapper's released
value."""
mapping = STANDARD_XPAD_CONFIG["mapping"]
parts = []
for idx in range(len(STANDARD_XPAD_CONFIG["btn_map"])):
value = self._js_state.get((JS_EVENT_BUTTON, idx), 0)
parts.append(get_js_event_packed(JS_EVENT_BUTTON | JS_EVENT_INIT, idx, value))
for idx in range(len(STANDARD_XPAD_CONFIG["axes_map"])):
rest = normalize_axis_value(
0,
idx in mapping["trigger_internal_abstract_axis_indices"],
idx in mapping["hat_internal_abstract_axis_indices"],
for_js_event=True,
)
value = self._js_state.get((JS_EVENT_AXIS, idx), rest)
parts.append(get_js_event_packed(JS_EVENT_AXIS | JS_EVENT_INIT, idx, value))
return b"".join(parts)paramselfReturns
bytesfunc_process_event_queue(self) -> NoneDrain the event queue until the None sentinel, fanning each event out to JS, EVDEV and uinput consumers.
Each client drain is bounded and a stalled client is closed, so a game that stops reading its socket cannot freeze delivery for the others.
Source Code
async def _process_event_queue(self) -> None:
"""Drain the event queue until the None sentinel, fanning each event out
to JS, EVDEV and uinput consumers.
Each client drain is bounded and a stalled client is closed, so a game
that stops reading its socket cannot freeze delivery for the others.
"""
logger_selkies_gamepad.info(f"Gamepad {self.js_sock_path}: Event processor started.")
while self.running:
try:
event_package = await self.events_queue.get()
if event_package is None:
self.events_queue.task_done()
break
logger_selkies_gamepad.debug(f"Gamepad {self.js_sock_path}: Dequeued event: {event_package}")
js_data = event_package.get('js_event_data')
evdev_template = event_package.get('evdev_event_template')
if js_data:
for i, (writer, _client_info) in enumerate(list(self.js_clients.items())):
if not writer.is_closing():
try:
writer.write(js_data)
await asyncio.wait_for(writer.drain(), timeout=1.0)
logger_selkies_gamepad.debug(f"Gamepad {self.js_sock_path}: JS event drained to client #{i}.")
except asyncio.TimeoutError:
logger_selkies_gamepad.warning(f"Gamepad {self.js_sock_path}: JS client #{i} stalled; closing it.")
writer.close()
except (ConnectionResetError, BrokenPipeError): pass
except Exception as e:
logger_selkies_gamepad.error(f"Error sending to JS client #{i}: {e}", exc_info=True)
if evdev_template:
ev_type, ev_code, ev_value = evdev_template
self._emit_uinput(ev_type, ev_code, ev_value)
for i, (writer, client_info) in enumerate(list(self.evdev_clients.items())):
if not writer.is_closing():
try:
client_arch_bits = client_info.get('arch_bits', 64)
evdev_data = get_evdev_events_packed(ev_type, ev_code, ev_value, client_arch_bits)
writer.write(evdev_data)
await asyncio.wait_for(writer.drain(), timeout=1.0)
logger_selkies_gamepad.debug(f"Gamepad {self.js_sock_path}: EVDEV event drained to client #{i}.")
except asyncio.TimeoutError:
logger_selkies_gamepad.warning(f"Gamepad {self.js_sock_path}: EVDEV client #{i} stalled; closing it.")
writer.close()
except (ConnectionResetError, BrokenPipeError): pass
except Exception as e:
logger_selkies_gamepad.error(f"Error sending to EVDEV client #{i}: {e}", exc_info=True)
self.events_queue.task_done()
except asyncio.CancelledError:
logger_selkies_gamepad.info(f"Gamepad {self.js_sock_path}: Event processor task cancelled.")
break
except Exception as e:
logger_selkies_gamepad.error(f"Gamepad {self.js_sock_path}: Unhandled error in event processor: {e}", exc_info=True)
logger_selkies_gamepad.info(f"Gamepad {self.js_sock_path}: Event processor stopped.")paramselfReturns
Nonefuncclose(self) -> NoneStop servers, drop clients, unlink socket files, destroy the kernel device.
Source Code
async def close(self) -> None:
"""Stop servers, drop clients, unlink socket files, destroy the kernel device."""
logger_selkies_gamepad.info(f"Closing gamepad services for JS:{self.js_sock_path}, EVDEV:{self.evdev_sock_path}")
self.running = False
if self.js_server:
self.js_server.close()
await self.js_server.wait_closed()
self.js_server = None
logger_selkies_gamepad.info(f"JS interposer server {self.js_sock_path} closed.")
if self.evdev_server:
self.evdev_server.close()
await self.evdev_server.wait_closed()
self.evdev_server = None
logger_selkies_gamepad.info(f"EVDEV interposer server {self.evdev_sock_path} closed.")
for writer in list(self.js_clients.keys()):
if not writer.is_closing(): writer.close()
self.js_clients.clear()
for writer in list(self.evdev_clients.keys()):
if not writer.is_closing(): writer.close()
self.evdev_clients.clear()
if self._event_processor_task and not self._event_processor_task.done():
try:
self.events_queue.put_nowait(None)
await asyncio.wait_for(self._event_processor_task, timeout=2.0)
except asyncio.TimeoutError:
logger_selkies_gamepad.warning("Event processor task timed out on close, cancelling.")
self._event_processor_task.cancel()
except asyncio.CancelledError:
pass
except Exception as e:
logger_selkies_gamepad.error(f"Exception stopping event processor: {e}")
self._event_processor_task = None
for sock_path in [self.js_sock_path, self.evdev_sock_path]:
if sock_path and os.path.exists(sock_path):
try:
os.unlink(sock_path)
logger_selkies_gamepad.info(f"Removed socket file: {sock_path}")
except OSError as e:
logger_selkies_gamepad.warning(f"Could not remove socket file {sock_path} on close: {e}")
if self.uinput is not None:
self.uinput.destroy()
self.uinput = None
logger_selkies_gamepad.info("Gamepad services fully closed.")paramselfReturns
None