Coverage for src/ph/gamepad.py: 84.8%

166 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 08:17 +0000

1"""Direct Linux joystick input for handheld controls.""" 

2 

3import fcntl 

4import os 

5import struct 

6from collections import deque 

7from dataclasses import dataclass, field 

8from enum import Enum 

9from pathlib import Path 

10from time import monotonic 

11 

12 

13class InputAction(Enum): 

14 """Logical actions understood by the terminal UI.""" 

15 

16 UP = "up" 

17 DOWN = "down" 

18 LEFT = "left" 

19 RIGHT = "right" 

20 SELECT = "select" 

21 BACK = "back" 

22 BACKSPACE = "backspace" 

23 SUBMIT_SEARCH = "submit_search" 

24 PAGE_UP = "page_up" 

25 PAGE_DOWN = "page_down" 

26 START = "start" 

27 

28 

29JS_EVENT_BUTTON = 0x01 

30JS_EVENT_AXIS = 0x02 

31JS_EVENT_INIT = 0x80 

32AXIS_THRESHOLD = 16_000 

33EVENT_STRUCT = struct.Struct("IhBB") 

34REPEAT_DELAY_SECONDS = 0.35 

35REPEAT_INTERVAL_SECONDS = 0.08 

36REPEAT_ACTIONS = frozenset((InputAction.UP, InputAction.DOWN)) 

37 

38# Semantic Linux input-event codes returned for joydev button indexes. 

39KEY_UP = 103 

40KEY_LEFT = 105 

41KEY_RIGHT = 106 

42KEY_DOWN = 108 

43BTN_SOUTH = 0x130 

44BTN_EAST = 0x131 

45BTN_NORTH = 0x133 

46BTN_WEST = 0x134 

47BTN_TL = 0x136 

48BTN_TR = 0x137 

49BTN_SELECT = 0x13A 

50BTN_START = 0x13B 

51BTN_DPAD_UP = 0x220 

52BTN_DPAD_DOWN = 0x221 

53BTN_DPAD_LEFT = 0x222 

54BTN_DPAD_RIGHT = 0x223 

55 

56# Values generated by Linux's _IOR macro for linux/joystick.h. 

57JSIOCGBUTTONS = 0x80016A12 

58JSIOCGBTNMAP = 0x84006A34 

59BUTTON_MAP_BYTES = 1024 

60BUTTON_CODE_ACTIONS: dict[int, InputAction] = { 

61 KEY_UP: InputAction.UP, 

62 KEY_DOWN: InputAction.DOWN, 

63 KEY_LEFT: InputAction.LEFT, 

64 KEY_RIGHT: InputAction.RIGHT, 

65 BTN_SOUTH: InputAction.BACK, 

66 BTN_EAST: InputAction.SELECT, 

67 BTN_NORTH: InputAction.SUBMIT_SEARCH, 

68 BTN_WEST: InputAction.BACKSPACE, 

69 BTN_TL: InputAction.PAGE_UP, 

70 BTN_TR: InputAction.PAGE_DOWN, 

71 BTN_SELECT: InputAction.BACK, 

72 BTN_START: InputAction.START, 

73 BTN_DPAD_UP: InputAction.UP, 

74 BTN_DPAD_DOWN: InputAction.DOWN, 

75 BTN_DPAD_LEFT: InputAction.LEFT, 

76 BTN_DPAD_RIGHT: InputAction.RIGHT, 

77} 

78 

79BUTTON_ACTIONS: dict[int, InputAction] = { 

80 0: InputAction.BACK, 

81 1: InputAction.SELECT, 

82 2: InputAction.SUBMIT_SEARCH, 

83 3: InputAction.BACKSPACE, 

84 4: InputAction.PAGE_UP, 

85 5: InputAction.PAGE_DOWN, 

86 # Common images expose either Select/Start pair; accept both joydev layouts. 

87 6: InputAction.BACK, 

88 7: InputAction.START, 

89 8: InputAction.BACK, 

90 9: InputAction.START, 

91} 

92STANDARD_DPAD_BUTTONS: dict[int, InputAction] = { 

93 11: InputAction.UP, 

94 12: InputAction.DOWN, 

95 13: InputAction.LEFT, 

96 14: InputAction.RIGHT, 

97} 

98ALTERNATE_DPAD_BUTTONS: dict[int, InputAction] = { 

99 14: InputAction.UP, 

100 15: InputAction.DOWN, 

101 16: InputAction.LEFT, 

102 17: InputAction.RIGHT, 

103} 

104# Linux joydev commonly exposes X/Y on 0/1, Z or RX on 2/3, RY on 4, and the 

105# D-pad hat on 6/7. This also covers device-tree variants where the right 

106# stick is reported as RX/RY rather than the older Z/RX pairing. 

107HORIZONTAL_AXES = {0, 2, 6} 

108VERTICAL_AXES = {1, 3, 4, 7} 

109 

110 

111@dataclass(slots=True) 

112class LinuxJoystick: 

113 """Non-blocking reader for Linux's stable ``/dev/input/js*`` interface.""" 

114 

115 path: Path 

116 _file_descriptor: int 

117 _axis_state: dict[int, int] = field(default_factory=dict) 

118 _pending: deque[InputAction] = field(default_factory=deque) 

119 _repeat_action: InputAction | None = None 

120 _repeat_due_at: float = 0.0 

121 _highest_initialized_button: int = -1 

122 _button_codes: tuple[int, ...] = () 

123 

124 @classmethod 

125 def open_first(cls, input_directory: Path = Path("/dev/input")) -> LinuxJoystick | None: 

126 """Open the first readable built-in or connected Linux joystick.""" 

127 

128 for path in sorted(input_directory.glob("js*")): 

129 try: 

130 descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK) 

131 except OSError: 

132 continue 

133 button_codes = cls._read_button_codes(descriptor) 

134 return cls( 

135 path=path, 

136 _file_descriptor=descriptor, 

137 _button_codes=button_codes, 

138 ) 

139 return None 

140 

141 @staticmethod 

142 def _read_button_codes(descriptor: int) -> tuple[int, ...]: 

143 """Read semantic Linux key codes for every joydev button index.""" 

144 

145 try: 

146 count_buffer = bytearray(1) 

147 fcntl.ioctl(descriptor, JSIOCGBUTTONS, count_buffer, True) 

148 count = count_buffer[0] 

149 mapping_buffer = bytearray(BUTTON_MAP_BYTES) 

150 fcntl.ioctl(descriptor, JSIOCGBTNMAP, mapping_buffer, True) 

151 except OSError: 

152 return () 

153 return struct.unpack_from(f"={count}H", mapping_buffer) 

154 

155 def poll(self) -> InputAction | None: 

156 """Return the next available action without blocking the TUI.""" 

157 

158 if self._pending: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 return self._pending.popleft() 

160 try: 

161 payload = os.read(self._file_descriptor, EVENT_STRUCT.size * 16) 

162 except BlockingIOError: 

163 return self._poll_repeat() 

164 except OSError: 

165 self.close() 

166 return None 

167 

168 for offset in range(0, len(payload) - EVENT_STRUCT.size + 1, EVENT_STRUCT.size): 168 ↛ 169line 168 didn't jump to line 169 because the loop on line 168 never started

169 _timestamp, value, event_type, number = EVENT_STRUCT.unpack_from(payload, offset) 

170 action = self.decode_event(value, event_type, number) 

171 if action is not None: 

172 self._pending.append(action) 

173 return self._pending.popleft() if self._pending else self._poll_repeat() 

174 

175 def decode_event(self, value: int, event_type: int, number: int) -> InputAction | None: 

176 """Translate one js event; public to allow device-independent tests.""" 

177 

178 kind = event_type & ~JS_EVENT_INIT 

179 if event_type & JS_EVENT_INIT: 

180 if kind == JS_EVENT_BUTTON: 180 ↛ 182line 180 didn't jump to line 182 because the condition on line 180 was always true

181 self._highest_initialized_button = max(self._highest_initialized_button, number) 

182 return None 

183 if kind == JS_EVENT_BUTTON: 

184 action = self._button_action(number) 

185 if action not in REPEAT_ACTIONS: 

186 return action if value else None 

187 if value: 

188 self._start_repeat(action) 

189 return action 

190 self._stop_repeat(action) 

191 return None 

192 if kind != JS_EVENT_AXIS: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true

193 return None 

194 

195 direction = -1 if value < -AXIS_THRESHOLD else 1 if value > AXIS_THRESHOLD else 0 

196 previous = self._axis_state.get(number, 0) 

197 self._axis_state[number] = direction 

198 previous_action = self._axis_action(number, previous) 

199 if direction == 0: 

200 self._stop_repeat(previous_action) 

201 return None 

202 if direction == previous: 

203 return None 

204 action = self._axis_action(number, direction) 

205 self._stop_repeat(previous_action) 

206 if action in REPEAT_ACTIONS: 

207 self._start_repeat(action) 

208 return action 

209 

210 def _button_action(self, number: int) -> InputAction | None: 

211 if number < len(self._button_codes): 

212 code_action = BUTTON_CODE_ACTIONS.get(self._button_codes[number]) 

213 if code_action is not None: 213 ↛ 215line 213 didn't jump to line 215 because the condition on line 213 was always true

214 return code_action 

215 action = BUTTON_ACTIONS.get(number) 

216 if action is not None: 

217 return action 

218 dpad = ( 

219 ALTERNATE_DPAD_BUTTONS 

220 if self._highest_initialized_button < 11 or self._highest_initialized_button > 14 

221 else STANDARD_DPAD_BUTTONS 

222 ) 

223 return dpad.get(number) 

224 

225 @staticmethod 

226 def _axis_action(number: int, direction: int) -> InputAction | None: 

227 if not direction: 

228 return None 

229 if number in HORIZONTAL_AXES: 

230 return InputAction.LEFT if direction < 0 else InputAction.RIGHT 

231 if number in VERTICAL_AXES: 

232 return InputAction.UP if direction < 0 else InputAction.DOWN 

233 return None 

234 

235 def _start_repeat(self, action: InputAction) -> None: 

236 self._repeat_action = action 

237 self._repeat_due_at = monotonic() + REPEAT_DELAY_SECONDS 

238 

239 def _stop_repeat(self, action: InputAction | None) -> None: 

240 if action is not None and action == self._repeat_action: 

241 self._repeat_action = None 

242 self._repeat_due_at = 0.0 

243 

244 def _poll_repeat(self) -> InputAction | None: 

245 if self._repeat_action is None or monotonic() < self._repeat_due_at: 

246 return None 

247 self._repeat_due_at = monotonic() + REPEAT_INTERVAL_SECONDS 

248 return self._repeat_action 

249 

250 def close(self) -> None: 

251 """Release the device descriptor, tolerating repeated calls.""" 

252 

253 if self._file_descriptor < 0: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 return 

255 try: 

256 os.close(self._file_descriptor) 

257 finally: 

258 self._file_descriptor = -1