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

1"""Read safe runtime hardware facts exposed by the Linux kernel.""" 

2 

3from dataclasses import dataclass 

4from pathlib import Path 

5 

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

14 

15 

16@dataclass(frozen=True, slots=True) 

17class DeviceTreeKey: 

18 """One Linux input key declared by a device-tree node.""" 

19 

20 label: str 

21 code: int 

22 node: str 

23 

24 

25@dataclass(frozen=True, slots=True) 

26class DeviceTreeInput: 

27 """A device-tree input-related node useful for diagnostics.""" 

28 

29 node: str 

30 compatible: tuple[str, ...] 

31 

32 

33@dataclass(frozen=True, slots=True) 

34class HardwareProfile: 

35 """Hardware facts that are useful to the handheld UI and its log.""" 

36 

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 

46 

47 @property 

48 def display_resolution(self) -> str: 

49 """Return a compact pixel resolution for display in the TUI.""" 

50 

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}" 

54 

55 

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

63 

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) 

76 

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 

93 

94 

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) 

113 

114 

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) 

144 

145 

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) 

156 

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) 

165 

166 

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) 

179 

180 

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"" 

187 

188 

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 ) 

195 

196 

197def _read_text(path: Path) -> str: 

198 return _read_bytes(path).decode("utf-8", errors="replace").strip("\0\r\n ") 

199 

200 

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

206 

207 

208def _relative_node(path: Path, root: Path) -> str: 

209 relative = path.relative_to(root).as_posix() 

210 return "/" if relative == "." else f"/{relative}"