Coverage for src/ph/hardware.py: 92.6%
117 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
1"""Read safe runtime hardware facts exposed by the Linux kernel."""
3from dataclasses import dataclass
4from pathlib import Path
6DEFAULT_DEVICE_TREE_ROOTS = (
7 Path("/proc/device-tree"),
8 Path("/sys/firmware/devicetree/base"),
9)
10DEFAULT_GRAPHICS_ROOT = Path("/sys/class/graphics")
11DEFAULT_DRM_ROOT = Path("/sys/class/drm")
12MAX_PROPERTY_BYTES = 64 * 1024
13INPUT_COMPATIBLE_MARKERS = ("gamepad", "gpio-keys", "joystick")
16@dataclass(frozen=True, slots=True)
17class DeviceTreeKey:
18 """One Linux input key declared by a device-tree node."""
20 label: str
21 code: int
22 node: str
25@dataclass(frozen=True, slots=True)
26class DeviceTreeInput:
27 """A device-tree input-related node useful for diagnostics."""
29 node: str
30 compatible: tuple[str, ...]
33@dataclass(frozen=True, slots=True)
34class HardwareProfile:
35 """Hardware facts that are useful to the handheld UI and its log."""
37 model: str = "unknown"
38 compatible: tuple[str, ...] = ()
39 device_tree_root: str | None = None
40 input_nodes: tuple[DeviceTreeInput, ...] = ()
41 keys: tuple[DeviceTreeKey, ...] = ()
42 display_width: int | None = None
43 display_height: int | None = None
44 display_source: str | None = None
45 framebuffer_name: str | None = None
47 @property
48 def display_resolution(self) -> str:
49 """Return a compact pixel resolution for display in the TUI."""
51 if self.display_width is None or self.display_height is None:
52 return "not detected"
53 return f"{self.display_width}x{self.display_height}"
56def detect_hardware_profile(
57 *,
58 device_tree_roots: tuple[Path, ...] = DEFAULT_DEVICE_TREE_ROOTS,
59 graphics_root: Path = DEFAULT_GRAPHICS_ROOT,
60 drm_root: Path = DEFAULT_DRM_ROOT,
61) -> HardwareProfile:
62 """Inspect the kernel's live device tree and display class attributes."""
64 device_tree_root = next((root for root in device_tree_roots if root.is_dir()), None)
65 model = "unknown"
66 compatible: tuple[str, ...] = ()
67 input_nodes: tuple[DeviceTreeInput, ...] = ()
68 keys: tuple[DeviceTreeKey, ...] = ()
69 if device_tree_root is not None:
70 model_values = _read_strings(device_tree_root / "model")
71 if model_values:
72 model = model_values[0]
73 compatible = _read_strings(device_tree_root / "compatible")
74 input_nodes = _find_input_nodes(device_tree_root)
75 keys = _find_gpio_keys(device_tree_root, input_nodes)
77 display_width, display_height, display_source, framebuffer_name = _detect_display(
78 graphics_root,
79 drm_root,
80 )
81 profile = HardwareProfile(
82 model=model,
83 compatible=compatible,
84 device_tree_root=str(device_tree_root) if device_tree_root is not None else None,
85 input_nodes=input_nodes,
86 keys=keys,
87 display_width=display_width,
88 display_height=display_height,
89 display_source=display_source,
90 framebuffer_name=framebuffer_name,
91 )
92 return profile
95def _find_input_nodes(root: Path) -> tuple[DeviceTreeInput, ...]:
96 inputs: list[DeviceTreeInput] = []
97 try:
98 compatible_paths = sorted(root.rglob("compatible"))
99 except OSError:
100 return ()
101 for compatible_path in compatible_paths:
102 compatible = _read_strings(compatible_path)
103 searchable = " ".join((compatible_path.parent.name, *compatible)).casefold()
104 if not any(marker in searchable for marker in INPUT_COMPATIBLE_MARKERS):
105 continue
106 inputs.append(
107 DeviceTreeInput(
108 node=_relative_node(compatible_path.parent, root),
109 compatible=compatible,
110 )
111 )
112 return tuple(inputs)
115def _find_gpio_keys(
116 root: Path,
117 input_nodes: tuple[DeviceTreeInput, ...],
118) -> tuple[DeviceTreeKey, ...]:
119 keys: list[DeviceTreeKey] = []
120 gpio_node_names = {
121 item.node
122 for item in input_nodes
123 if any("gpio-keys" in value.casefold() for value in item.compatible)
124 }
125 for node_name in sorted(gpio_node_names):
126 node = root if node_name == "/" else root / node_name.removeprefix("/")
127 try:
128 code_paths = sorted(node.rglob("linux,code"))
129 except OSError:
130 continue
131 for code_path in code_paths:
132 code = _read_u32(code_path)
133 if code is None:
134 continue
135 labels = _read_strings(code_path.parent / "label")
136 keys.append(
137 DeviceTreeKey(
138 label=labels[0] if labels else code_path.parent.name,
139 code=code,
140 node=_relative_node(code_path.parent, root),
141 )
142 )
143 return tuple(keys)
146def _detect_display(
147 graphics_root: Path,
148 drm_root: Path,
149) -> tuple[int | None, int | None, str | None, str | None]:
150 for virtual_size in sorted(graphics_root.glob("fb*/virtual_size")):
151 resolution = _parse_resolution(_read_text(virtual_size))
152 if resolution is None:
153 continue
154 framebuffer_name = _read_text(virtual_size.parent / "name") or None
155 return (*resolution, str(virtual_size), framebuffer_name)
157 for modes_path in sorted(drm_root.glob("card*-*/modes")):
158 modes = _read_text(modes_path).splitlines()
159 if not modes: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 continue
161 resolution = _parse_resolution(modes[0])
162 if resolution is not None: 162 ↛ 157line 162 didn't jump to line 157 because the condition on line 162 was always true
163 return (*resolution, str(modes_path), None)
164 return (None, None, None, None)
167def _parse_resolution(value: str) -> tuple[int, int] | None:
168 normalized = value.strip().lower().replace(",", "x")
169 pieces = normalized.split("x", maxsplit=1)
170 if len(pieces) != 2:
171 return None
172 try:
173 width, height = (int(piece) for piece in pieces)
174 except ValueError:
175 return None
176 if width <= 0 or height <= 0: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 return None
178 return (width, height)
181def _read_bytes(path: Path) -> bytes:
182 try:
183 with path.open("rb") as stream:
184 return stream.read(MAX_PROPERTY_BYTES)
185 except OSError:
186 return b""
189def _read_strings(path: Path) -> tuple[str, ...]:
190 return tuple(
191 item.decode("utf-8", errors="replace").strip()
192 for item in _read_bytes(path).split(b"\0")
193 if item
194 )
197def _read_text(path: Path) -> str:
198 return _read_bytes(path).decode("utf-8", errors="replace").strip("\0\r\n ")
201def _read_u32(path: Path) -> int | None:
202 value = _read_bytes(path)
203 if len(value) != 4:
204 return None
205 return int.from_bytes(value, byteorder="big")
208def _relative_node(path: Path, root: Path) -> str:
209 relative = path.relative_to(root).as_posix()
210 return "/" if relative == "." else f"/{relative}"