-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --[==[ evdev — an ergonomic wrapper over `evdev.core` (the C binding to the Linux input API, /dev/input/event*). It enumerates stick-like devices (`list`) and keyboards (`list_keyboards`), caches each device's identity and axis calibration, and provides pure normalization helpers. It stays game-agnostic: it never decides what an axis or key *means*. Two ways to consume a device's events: * poll `dev:read()` each frame and apply the events to your own state (what a frame loop does — no coroutine needed); or * hand `dev:fileno()` to `lev.wait_readable` for an event-driven reader (the `sound.lua run()` loop is the template). ]==] local core = require("evdev.core") local fs = require("std.fs") local INPUT_DIR = "/dev/input" -- Map a raw ABS reading to [-1, 1] using cached absinfo, with a deadzone that -- is the max of the hardware `flat` and a caller-supplied fraction. Values -- inside the deadzone collapse to 0; the remainder is rescaled so motion -- resumes smoothly from 0 just outside it. local function normalize(ai, raw, deadzone) local half = (ai.max - ai.min) * 0.5 if half <= 0 then return 0 end local mid = (ai.min + ai.max) * 0.5 local v = (raw - mid) / half if v > 1 then v = 1 elseif v < -1 then v = -1 end local dz = deadzone or 0 local hwdz = ai.flat and (ai.flat / half) or 0 if hwdz > dz then dz = hwdz end local mag = v < 0 and -v or v if mag <= dz then return 0 end if dz >= 1 then return 0 end local s = (mag - dz) / (1 - dz) if s > 1 then s = 1 end return v < 0 and -s or s end -- Optional response shaping. "expo" softens the centre and keeps the extremes -- (amount 0 = linear, 1 = full cubic); the cube preserves sign, so direction -- is untouched. local function curve(v, kind, amount) if kind ~= "expo" then return v end local e = amount or 0.5 return (1 - e) * v + e * v * v * v end local Device = {} Device.__index = Device function Device:read() return self.handle:read() end function Device:fileno() return self.handle:fileno() end function Device:close() if self.handle then self.handle:close() self.handle = nil end end -- Does this look like a flight stick / gamepad? It must report the X/Y axes and -- at least one key in the joystick/gamepad button range. This filters out -- keyboards and mice that also live under /dev/input/event*. local function looks_like_stick(dev) if not (dev.axes[core.ABS_X] and dev.axes[core.ABS_Y]) then return false end for code in pairs(dev.buttons) do if code >= core.BTN_JOYSTICK and code <= 0x2ff then return true end end return false end -- Does this look like a keyboard? It must report the letter block plus SPACE -- and ENTER, and no joystick/gamepad buttons. The letter test filters out -- power buttons, lid switches, and multimedia-key-only nodes that also claim -- EV_KEY; the stick test keeps a pad with a keyboard profile out of the -- keyboard list. Codes are ABI-stable kernel constants. local function looks_like_keyboard(dev) if looks_like_stick(dev) then return false end -- KEY_A = 30, KEY_ENTER = 28, KEY_SPACE = 57 return dev.buttons[30] and dev.buttons[28] and dev.buttons[57] and true or false end -- Open one device by path and cache its identity + capabilities. Returns a -- Device (axes keyed by ABS code -> absinfo; buttons as a set of EV_KEY codes) -- or nil, err, errno. local function open(path, nonblock) if nonblock == nil then nonblock = true end local h, err, errno = core.open(path, nonblock) if not h then return nil, err, errno end local info = h:info() or {} local axes = {} for _, a in ipairs(h:axes() or {}) do axes[a.code] = a end local buttons = {} for _, code in ipairs(h:buttons() or {}) do buttons[code] = true end return setmetatable({ path = path, handle = h, info = info, axes = axes, buttons = buttons, }, Device) end -- Enumerate devices under /dev/input matching a predicate. Returns an array -- of opened Devices (non-blocking). Unreadable or non-matching nodes are -- skipped silently, so this is safe to call with nothing attached (returns {}). local function list_matching(pred) local devices = {} local entries = fs.list_dir(INPUT_DIR) if not entries then return devices end table.sort(entries) for _, name in ipairs(entries) do if name:match("^event%d+$") then local dev = open(INPUT_DIR .. "/" .. name) if dev then if pred(dev) then devices[#devices + 1] = dev else dev:close() end end end end return devices end -- Enumerate stick-like devices (flight sticks / gamepads). local function list() return list_matching(looks_like_stick) end -- Enumerate keyboards. local function list_keyboards() return list_matching(looks_like_keyboard) end return { -- C-core re-exports open_raw = core.open, -- wrapper API open = open, list = list, list_keyboards = list_keyboards, looks_like_keyboard = looks_like_keyboard, normalize = normalize, curve = curve, -- constants (so callers can build binding tables by name) EV_SYN = core.EV_SYN, EV_KEY = core.EV_KEY, EV_ABS = core.EV_ABS, EV_MSC = core.EV_MSC, SYN_REPORT = core.SYN_REPORT, ABS_X = core.ABS_X, ABS_Y = core.ABS_Y, ABS_Z = core.ABS_Z, ABS_RX = core.ABS_RX, ABS_RY = core.ABS_RY, ABS_RZ = core.ABS_RZ, ABS_THROTTLE = core.ABS_THROTTLE, ABS_RUDDER = core.ABS_RUDDER, ABS_HAT0X = core.ABS_HAT0X, ABS_HAT0Y = core.ABS_HAT0Y, BTN_JOYSTICK = core.BTN_JOYSTICK, BTN_TRIGGER = core.BTN_TRIGGER, BTN_THUMB = core.BTN_THUMB, BTN_THUMB2 = core.BTN_THUMB2, BTN_TOP = core.BTN_TOP, BTN_TOP2 = core.BTN_TOP2, BTN_PINKIE = core.BTN_PINKIE, BTN_BASE = core.BTN_BASE, BTN_BASE2 = core.BTN_BASE2, BTN_BASE3 = core.BTN_BASE3, BTN_BASE4 = core.BTN_BASE4, BTN_BASE5 = core.BTN_BASE5, BTN_BASE6 = core.BTN_BASE6, }