Selkies
Developer Referenceinput_handler

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_path
attributeevdev_sock_path
= evdev_interposer_socket_path
attributeloop
= loop or asyncio.get_running_loop()
attributeuinput_enabled
= uinput_enabled

Kernel gamepads are on for this slot.

attributeuinput
= None

The kernel device, created on first use so an unused slot is not a phantom controller in every application.

attributemapper
= None

Button/axis mapper for this pad, built by set_config.

attributeconfig_payload_cache
= None

js_config_t payload handed to interposer clients, built by set_config.

attributejs_server
= None
attributeevdev_server
= None
attributejs_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
= False
attribute_event_processor_task
= None
attribute_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) -> None
Source 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 = {}
paramself
paramjs_interposer_socket_pathstr
paramevdev_interposer_socket_pathstr
paramloopOptional[asyncio.AbstractEventLoop]
= None
paramuinput_enabledbool
= False

Returns

None
funcset_config(self, client_input_name, client_num_btns, client_num_axes) -> None

Build 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']}"
    )
paramself
paramclient_input_namestr
paramclient_num_btnsint
paramclient_num_axesint

Returns

None
funcensure_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.

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."
        )
paramself

Returns

None
func_emit_uinput(self, ev_type, ev_code, ev_value) -> None

Mirror 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 = False
paramself
paramev_typeint
paramev_codeint
paramev_valuefloat

Returns

None
func_make_interposer_config_payload(self, js_index, controller_config) -> 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.

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_SIZE
paramself
paramjs_indexint
paramcontroller_configdict

Returns

bytes
func_handle_interposer_client(self, reader, writer, is_evdev_socket) -> 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.

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.")
paramself
paramreaderasyncio.StreamReader
paramwriterasyncio.StreamWriter
paramis_evdev_socketbool

Returns

None
func_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 None
paramself
paraminterposer_socket_pathstr
paramis_evdev_socketbool

Returns

typing.Optional[asyncio.asyncio.AbstractServer]
funcrun_servers(self) -> None

Start 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.")
paramself

Returns

None
funcsend_event(self, client_event_idx, client_value, is_button_event) -> 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.

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."
                )
paramself
paramclient_event_idxint
paramclient_valuefloat
paramis_button_eventbool

Returns

None
funcreset_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.

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)
paramself

Returns

None
funcinit_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.

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)
paramself

Returns

bytes
func_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.

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.")
paramself

Returns

None
funcclose(self) -> None

Stop 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.")
paramself

Returns

None

On this page

Edit on GitHub