Files
firestar5683 fda22037d7 Wheel Controls
Route controller input and cruise actions through shared transport and mode-action owners.

Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
2026-10-14 20:02:39 -05:00

263 lines
9.4 KiB
Python

import hashlib
import os
from pathlib import Path
import stat
import struct
from types import SimpleNamespace
import pytest
from openpilot.starpilot.controllers import input as source
NOW = 10_000_000_000
def bitmap(codes):
width = struct.calcsize("L") * 8
words = [0] * (max(codes, default=0) // width + 1)
for code in codes:
words[code // width] |= 1 << (code % width)
return " ".join(f"{word:x}" for word in reversed(words))
def event(typ, code, value, stamp=NOW):
return source.EVENT.pack(stamp // 1_000_000_000, stamp % 1_000_000_000 // 1_000, typ, code, value)
class Kernel:
def __init__(self, monkeypatch, tmp_path):
self.dev = tmp_path / "dev"
self.sys = tmp_path / "sys"
self.dev.mkdir()
self.sys.mkdir()
self.next_fd = 100
self.queue = {}
self.path_by_fd = {}
self.closed = []
self.fail_read = set()
self.clocks = []
self.held_keys = {}
self.hats = {}
self.unsupported_clock = set()
self.real_open = os.open
self.real_read = os.read
self.real_close = os.close
self.real_fstat = os.fstat
monkeypatch.setattr(source, "DEV_ROOT", self.dev)
monkeypatch.setattr(source, "SYS_ROOT", self.sys)
monkeypatch.setattr(source.os, "open", self.open)
monkeypatch.setattr(source.os, "read", self.read)
monkeypatch.setattr(source.os, "close", self.close)
monkeypatch.setattr(source.os, "fstat", self.fstat)
monkeypatch.setattr(source.fcntl, "ioctl", self.ioctl)
def device(self, number, *, bus=3, vendor=0x1234, product=0x5678, name="Pad", uniq="01", keys=(304,), axes=(), rel=(), props=()):
path = self.dev / f"event{number}"
path.touch()
root = self.sys / path.name / "device"
(root / "capabilities").mkdir(parents=True)
(root / "modalias").write_text(f"input:b{bus:04X}v{vendor:04X}p{product:04X}e0001")
(root / "name").write_text(name)
(root / "uniq").write_text(uniq)
for kind, codes in (("key", keys), ("abs", axes), ("rel", rel)):
(root / "capabilities" / kind).write_text(bitmap(codes))
(root / "properties").write_text(bitmap(props))
self.queue[path] = bytearray()
self.held_keys[path] = set()
self.hats[path] = {}
return path
def push(self, path, *events):
self.queue[path].extend(b"".join(events))
def open(self, path, flags, mode=0o777, *, dir_fd=None):
if Path(path) not in self.queue:
return self.real_open(path, flags, mode, dir_fd=dir_fd)
assert flags & os.O_NONBLOCK and flags & os.O_NOFOLLOW and flags & os.O_CLOEXEC
assert Path(path) in self.queue
self.next_fd += 1
self.path_by_fd[self.next_fd] = Path(path)
return self.next_fd
def read(self, fd, count):
if fd not in self.path_by_fd:
return self.real_read(fd, count)
if fd in self.fail_read:
raise OSError("disconnected")
data = self.queue[self.path_by_fd[fd]]
if not data:
raise BlockingIOError()
chunk = bytes(data[:count])
del data[:count]
return chunk
def close(self, fd):
if fd not in self.path_by_fd:
return self.real_close(fd)
self.closed.append(fd)
self.path_by_fd.pop(fd, None)
def fstat(self, fd):
return SimpleNamespace(st_mode=stat.S_IFCHR) if fd in self.path_by_fd else self.real_fstat(fd)
def ioctl(self, fd, code, value):
if code == source.EVIOCSCLOCKID:
if self.path_by_fd[fd] in self.unsupported_clock:
raise OSError("clock unavailable")
assert struct.unpack("i", value)[0] == source.time.CLOCK_MONOTONIC
self.clocks.append(fd)
elif code == source.EVIOCGKEY:
state = bytearray(source.KEY_STATE_BYTES)
for key in self.held_keys[self.path_by_fd[fd]]:
state[key // 8] |= 1 << (key % 8)
return bytes(state)
elif 0x80184540 + 16 <= code <= 0x80184540 + 23:
axis = code - 0x80184540
value = self.hats[self.path_by_fd[fd]].get(axis, 0)
return source.ABS_INFO.pack(value, 0, 0, 0, 0, 0)
else:
raise AssertionError(code)
@pytest.fixture
def kernel(monkeypatch, tmp_path):
return Kernel(monkeypatch, tmp_path)
def test_external_identity_capability_and_duplicates(kernel):
kernel.device(0, bus=5, name="Road Pad", uniq="ABC")
kernel.device(1, bus=0x18, name="Internal")
kernel.device(2, keys=(0x110,), name="Mouse")
kernel.device(3, keys=(0x14A,), name="Touch")
kernel.device(4, keys=(115,), name="Media keys")
kernel.device(6, keys=(115,), rel=(0, 1), name="Mouse with media")
kernel.device(7, keys=(115,), axes=(0x35,), name="Touch with media")
kernel.device(8, keys=(115,), props=(1,), name="Direct touch")
reader = source.InputReader()
reader.poll(NOW)
devices = reader.devices()
expected = hashlib.sha256(b"0005:1234:5678:road pad:abc").hexdigest()[:20]
assert devices == [{"id": expected, "name": "Road Pad", "bus": 5},
{"id": devices[1]["id"], "name": "Media keys", "bus": 3}]
assert len(kernel.clocks) == 2
duplicate = kernel.device(5, bus=5, name="Road Pad", uniq="ABC")
reader.poll(NOW + source.RESCAN_NS)
assert [item["name"] for item in reader.devices()] == ["Media keys"]
assert kernel.closed
duplicate.unlink()
reader.poll(NOW + source.RESCAN_NS * 2)
assert [item["name"] for item in reader.devices()] == ["Road Pad", "Media keys"]
reader.close()
assert len(kernel.closed) == len(kernel.clocks)
assert reader.poll(NOW + source.RESCAN_NS * 3) == []
def test_release_required_repeats_and_fresh_timestamps(kernel):
path = kernel.device(0)
kernel.held_keys[path].add(304)
reader = source.InputReader()
reader.poll(NOW)
kernel.push(path, event(1, 304, 1), event(1, 304, 2), event(1, 304, 0), event(1, 304, 1))
assert reader.poll(NOW) == [source.Press(reader.devices()[0]["id"], 304, NOW)]
kernel.push(path, event(1, 304, 1), event(1, 304, 2))
assert reader.poll(NOW) == []
kernel.push(path, event(1, 304, 0, NOW - 300_000_000), event(1, 304, 1))
assert reader.poll(NOW) == []
kernel.push(path, event(1, 304, 0, NOW + 1_000_000), event(1, 304, 0), event(1, 304, 1))
assert reader.poll(NOW) == [source.Press(reader.devices()[0]["id"], 304, NOW)]
reader.close()
def test_initial_neutral_key_accepts_first_press(kernel):
path = kernel.device(0)
reader = source.InputReader()
reader.poll(NOW)
kernel.push(path, event(1, 304, 1))
assert reader.poll(NOW) == [source.Press(reader.devices()[0]["id"], 304, NOW)]
reader.close()
def test_unsupported_monotonic_clock_never_opens_device(kernel):
path = kernel.device(0)
kernel.unsupported_clock.add(path)
reader = source.InputReader()
kernel.push(path, event(1, 304, 1))
assert reader.poll(NOW) == []
assert reader.devices() == []
assert kernel.closed == [101]
reader.close()
def test_hats_need_neutral_and_emit_edges(kernel):
path = kernel.device(0, keys=(), axes=(16,))
kernel.hats[path][16] = 1
reader = source.InputReader()
reader.poll(NOW)
kernel.push(path, event(3, 16, 1), event(3, 16, 0), event(3, 16, 1), event(3, 16, 1), event(3, 16, -1))
identity = reader.devices()[0]["id"]
assert reader.poll(NOW) == [source.Press(identity, 0x10001, NOW), source.Press(identity, 0x10000, NOW)]
reader.close()
def test_dropped_and_overflow_close_until_reopened_and_neutral(kernel):
path = kernel.device(0)
kernel.held_keys[path].add(304)
reader = source.InputReader()
reader.poll(NOW)
kernel.push(path, event(1, 304, 0), event(1, 304, 1), event(0, 3, 0), event(1, 304, 1))
assert reader.poll(NOW) == []
assert reader.devices() == []
reader.poll(NOW + source.RESCAN_NS)
assert reader.devices()
kernel.held_keys[path].add(304)
assert reader.poll(NOW + source.RESCAN_NS) == []
kernel.push(path, event(1, 304, 0, NOW + source.RESCAN_NS), event(1, 304, 1, NOW + source.RESCAN_NS))
assert len(reader.poll(NOW + source.RESCAN_NS)) == 1
kernel.push(path, *(event(1, 304, 0, NOW + source.RESCAN_NS) for _ in range(65)))
assert reader.poll(NOW + source.RESCAN_NS) == []
assert reader.devices() == []
reader.close()
def test_one_device_error_and_disconnect_do_not_block_other(kernel):
a = kernel.device(0, name="A")
b = kernel.device(1, name="B")
reader = source.InputReader()
reader.poll(NOW)
kernel.push(a, event(1, 304, 0), event(1, 304, 1))
kernel.push(b, event(1, 304, 0), event(1, 304, 1))
fd_a = next(fd for fd, path in kernel.path_by_fd.items() if path == a)
kernel.fail_read.add(fd_a)
assert [item["name"] for item in reader.devices()] == ["A", "B"]
assert [press.device_id for press in reader.poll(NOW)] == [reader.devices()[0]["id"]]
assert [item["name"] for item in reader.devices()] == ["B"]
b.unlink()
reader.poll(NOW + source.RESCAN_NS)
assert reader.devices() == [{"id": reader.devices()[0]["id"], "name": "A", "bus": 3}]
reader.close()
def test_per_device_and_global_event_limits(kernel):
paths = [kernel.device(index, name=f"Pad {index}", uniq=str(index)) for index in range(5)]
reader = source.InputReader()
reader.poll(NOW)
for path in paths:
kernel.push(path, *(event(1, 304, value) for _ in range(32) for value in (0, 1)))
assert len(reader.poll(NOW)) == 128
assert len(reader.devices()) == 4
reader.close()
def test_device_and_scan_candidate_caps(kernel):
for index in range(17):
kernel.device(index, name=f"Pad {index}", uniq=str(index))
reader = source.InputReader()
reader.poll(NOW)
assert len(reader.devices()) == source.MAX_DEVICES
for index in range(17, source.MAX_CANDIDATES + 1):
kernel.device(index, name=f"Pad {index}", uniq=str(index))
reader.poll(NOW + source.RESCAN_NS)
assert reader.devices() == []
reader.close()