Coverage for src / bluetooth_sig / types / registry / gss_characteristic.py: 74%

146 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Types for Bluetooth SIG GSS Characteristic registry.""" 

2 

3from __future__ import annotations 

4 

5import functools 

6import logging 

7import re 

8 

9import msgspec 

10 

11logger = logging.getLogger(__name__) 

12 

13 

14# Module-level caches for FieldSpec computed properties. 

15# msgspec frozen Structs cannot have per-instance cached_property, so we 

16# cache here keyed on the (field, description) pair which uniquely 

17# identifies a FieldSpec for these purposes. 

18 

19 

20@functools.lru_cache(maxsize=512) 

21def _compute_python_name(field: str) -> str: 

22 """Convert raw field name to Python snake_case (cached).""" 

23 name = field.lower() 

24 name = re.sub(r"[\s\-]+", "_", name) 

25 name = re.sub(r"\([^)]*\)", "", name) 

26 name = re.sub(r"[^\w]", "", name) 

27 name = re.sub(r"_+", "_", name) 

28 return name.strip("_") 

29 

30 

31@functools.lru_cache(maxsize=512) 

32def _compute_unit_id(description: str) -> str | None: 

33 """Extract org.bluetooth.unit.* identifier from description (cached).""" 

34 normalized = re.sub(r"org\.bluetooth\.unit\.\s*", "org.bluetooth.unit.", description) 

35 match = re.search(r"org\.bluetooth\.unit\.([a-z0-9_.]+)", normalized, re.IGNORECASE) 

36 if match: 

37 return match.group(1).rstrip(".") 

38 return None 

39 

40 

41@functools.lru_cache(maxsize=512) 

42def _resolve_field_unit_symbol(description: str) -> str: 

43 """Resolve unit symbol from a FieldSpec description string (cached).""" 

44 uid = _compute_unit_id(description) 

45 if not uid: 

46 return "" 

47 from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415 

48 

49 return resolve_unit_symbol(uid) 

50 

51 

52class SpecialValue(msgspec.Struct, frozen=True): 

53 """A special sentinel value with its meaning. 

54 

55 Used for values like 0x8000 meaning "value is not known". 

56 

57 Attributes: 

58 raw_value: The raw integer sentinel value (e.g., 0x8000). 

59 meaning: Human-readable meaning (e.g., "value is not known"). 

60 """ 

61 

62 raw_value: int 

63 meaning: str 

64 

65 

66class FieldSpec(msgspec.Struct, frozen=True, kw_only=True): 

67 """Specification for a field in a characteristic structure. 

68 

69 Parses rich metadata from the description field including: 

70 - Unit ID (org.bluetooth.unit.*) 

71 - Resolution from M, d, b notation 

72 - Value range from "Allowed range is: X to Y" 

73 - Special values like "0x8000 represents 'value is not known'" 

74 - Presence conditions from "Present if bit N..." 

75 """ 

76 

77 field: str 

78 type: str 

79 size: str 

80 description: str 

81 

82 @property 

83 def python_name(self) -> str: 

84 """Convert field name to Python snake_case identifier (cached). 

85 

86 Examples: 

87 "Instantaneous Speed" -> "instantaneous_speed" 

88 "Location - Latitude" -> "location_latitude" 

89 """ 

90 return _compute_python_name(self.field) 

91 

92 @property 

93 def is_optional(self) -> bool: 

94 """True if field size indicates optional presence (e.g., '0 or 2').""" 

95 return self.size.startswith("0 or") or self.size.lower() == "variable" 

96 

97 @property 

98 def fixed_size(self) -> int | None: 

99 """Extract fixed byte size if determinable, None for variable/optional.""" 

100 if self.is_optional: 

101 # For "0 or N", extract N as the size when present 

102 match = re.search(r"0 or (\d+)", self.size) 

103 if match: 

104 return int(match.group(1)) 

105 return None 

106 # Try to parse as simple integer 

107 try: 

108 return int(self.size.strip('"')) 

109 except ValueError: 

110 return None 

111 

112 @property 

113 def unit_id(self) -> str | None: 

114 """Extract org.bluetooth.unit.* identifier from description (cached). 

115 

116 Handles various formats: 

117 - "Base Unit:" or "Base unit:" (case-insensitive) 

118 - "Unit:" or "Unit;" (colon or semicolon) 

119 - Inline "or org.bluetooth.unit.*" patterns 

120 - Spaces after "org.bluetooth.unit." (YAML formatting issues) 

121 

122 Returns: 

123 Unit ID string (e.g., "thermodynamic_temperature.degree_celsius"), or None. 

124 """ 

125 return _compute_unit_id(self.description) 

126 

127 @property 

128 def unit_symbol(self) -> str: 

129 """Get the resolved SIG unit symbol for this field. 

130 

131 Resolves ``unit_id`` → ``UnitsRegistry`` → ``.symbol`` 

132 (e.g. ``'thermodynamic_temperature.degree_celsius'`` → ``'°C'``). 

133 Result is LRU-cached by description text. 

134 

135 Returns: 

136 SI symbol string, or empty string if no unit is available. 

137 

138 """ 

139 return _resolve_field_unit_symbol(self.description) 

140 

141 @property 

142 def resolution(self) -> float | None: 

143 """Extract resolution from 'M = X, d = Y, b = Z' notation. 

144 

145 The formula is: actual_value = raw_value * M * 10^d + b 

146 For most cases M=1 and b=0, so resolution = 10^d 

147 

148 Handles variations: 

149 - "M = 1, d = -2, b = 0" 

150 - "M = 1, d = 3, and b = 0" (with "and") 

151 - "M = 1, d = - 7, b = 0" (space between sign and number) 

152 

153 Returns: 

154 Resolution multiplier (e.g., 0.01 for d=-2), or None if not found. 

155 """ 

156 # Pattern handles: spaces in numbers (d = - 7), "and" separator, comma separator 

157 match = re.search( 

158 r"M\s*=\s*([+-]?\s*\d+)\s*,\s*d\s*=\s*([+-]?\s*\d+)\s*(?:,\s*and\s*|,\s*|and\s+)b\s*=\s*([+-]?\s*\d+)", 

159 self.description, 

160 re.IGNORECASE, 

161 ) 

162 if match: 

163 # Remove internal spaces from captured values 

164 m_val = int(match.group(1).replace(" ", "")) 

165 d_val = int(match.group(2).replace(" ", "")) # b_val (offset) is not used 

166 return float(m_val * (10**d_val)) 

167 return None 

168 

169 @property 

170 def value_range(self) -> tuple[float, float] | None: 

171 """Extract value range from description. 

172 

173 Looks for patterns like: 

174 - "Allowed range is: -273.15 to 327.67" 

175 - "Allowed range is 0 to 100" 

176 - "Range: X to Y" 

177 - "Minimum: X" and "Maximum: Y" 

178 - "Minimum value: X" and "Maximum value: Y" 

179 """ 

180 # Regex pattern: "Allowed range is/: X to Y" or "Range: X to Y" 

181 match = re.search( 

182 r"(?:Allowed\s+)?range(?:\s+is)?[:\s]+([+-]?\d+\.?\d*)\s*to\s*([+-]?\d+\.?\d*)", 

183 self.description, 

184 re.IGNORECASE, 

185 ) 

186 if match: 

187 return float(match.group(1)), float(match.group(2)) 

188 

189 # Pattern: Minimum/Maximum on separate lines (allow optional "value" wording) 

190 min_match = re.search(r"Minimum(?:\s+value)?[:\s]+([+-]?\d+\.?\d*)", self.description) 

191 max_match = re.search(r"Maximum(?:\s+value)?[:\s]+([+-]?\d+\.?\d*)", self.description) 

192 if min_match and max_match: 

193 min_val = min_match.group(1) 

194 max_val = max_match.group(1) 

195 # Only return if we captured actual numeric values 

196 if min_val and max_val: 

197 try: 

198 return float(min_val), float(max_val) 

199 except ValueError: 

200 logger.warning("Failed to parse min/max range values: min=%s, max=%s", min_val, max_val) 

201 

202 return None 

203 

204 @property 

205 def special_values(self) -> tuple[SpecialValue, ...]: 

206 """Extract special value meanings from description. 

207 

208 Looks for patterns like: 

209 - 'A value of 0x8000 represents "value is not known"' 

210 - '0xFF represents "unknown user"' 

211 - 'The special value of 0xFF for User ID represents "unknown user"' 

212 - '0xFFFFFF represents: Unknown' (colon format) 

213 

214 Returns: 

215 Tuple of SpecialValue structs (immutable, hashable). 

216 """ 

217 result: list[SpecialValue] = [] 

218 seen: set[int] = set() # Avoid duplicates 

219 

220 # Normalize Unicode curly quotes to ASCII for consistent matching 

221 desc = self.description 

222 desc = desc.replace("\u201c", '"').replace("\u201d", '"') # " " 

223 desc = desc.replace("\u2018", "'").replace("\u2019", "'") # ' ' 

224 

225 # Pattern 1: 0xXXXX ... represents "meaning" (flexible, captures hex before represents) 

226 pattern1 = r"(0x[0-9A-Fa-f]+)[^\n]*?represents\s*[\"']([^\"']+)[\"']" 

227 for match in re.finditer(pattern1, desc, re.MULTILINE | re.IGNORECASE): 

228 hex_val = int(match.group(1), 16) 

229 if hex_val not in seen: 

230 seen.add(hex_val) 

231 result.append(SpecialValue(raw_value=hex_val, meaning=match.group(2))) 

232 

233 # Pattern 2: "0xXXXX represents: Meaning" (colon format, meaning is next word/phrase) 

234 pattern2 = r"(0x[0-9A-Fa-f]+)\s*represents:\s*([A-Za-z][A-Za-z\s]*)" 

235 for match in re.finditer(pattern2, desc, re.MULTILINE | re.IGNORECASE): 

236 hex_val = int(match.group(1), 16) 

237 if hex_val not in seen: 

238 seen.add(hex_val) 

239 meaning = match.group(2).strip() 

240 result.append(SpecialValue(raw_value=hex_val, meaning=meaning)) 

241 

242 return tuple(result) 

243 

244 @property 

245 def presence_condition(self) -> str | None: 

246 """Extract presence condition from description. 

247 

248 Looks for patterns like: 

249 - "Present if bit 0 of Flags field is set to 1" 

250 - "Present if bit 1 of Flags field is set to 0" 

251 """ 

252 match = re.search( 

253 r"Present if bit (\d+) of (\w+) field is set to ([01])", 

254 self.description, 

255 re.IGNORECASE, 

256 ) 

257 if match: 

258 bit_num = match.group(1) 

259 field_name = match.group(2) 

260 bit_value = match.group(3) 

261 return f"bit {bit_num} of {field_name} == {bit_value}" 

262 return None 

263 

264 @property 

265 def presence_flag_bit(self) -> int | None: 

266 """Extract the flag bit number for optional field presence. 

267 

268 Returns: 

269 Bit number (0-indexed) if field presence depends on a flag bit, None otherwise 

270 """ 

271 match = re.search( 

272 r"Present if bit (\d+)", 

273 self.description, 

274 re.IGNORECASE, 

275 ) 

276 if match: 

277 return int(match.group(1)) 

278 return None 

279 

280 

281class GssCharacteristicSpec(msgspec.Struct, frozen=True, kw_only=True): 

282 """Specification for a Bluetooth SIG characteristic from GSS. 

283 

284 Contains the full structure definition from GSS YAML files, 

285 enabling automatic metadata extraction for all fields. 

286 """ 

287 

288 identifier: str 

289 name: str 

290 description: str 

291 structure: list[FieldSpec] = msgspec.field(default_factory=list) 

292 

293 def get_field(self, name: str) -> FieldSpec | None: 

294 """Get a field specification by name (case-insensitive). 

295 

296 Args: 

297 name: Field name or python_name to look up 

298 

299 Returns: 

300 FieldSpec if found, None otherwise 

301 """ 

302 name_lower = name.lower() 

303 for field in self.structure: 

304 if field.field.lower() == name_lower or field.python_name == name_lower: 

305 return field 

306 return None 

307 

308 @property 

309 def primary_field(self) -> FieldSpec | None: 

310 """Get the primary data field (first non-Flags field). 

311 

312 For simple characteristics, this is the main value field. 

313 For complex characteristics, this may be the first data field. 

314 """ 

315 for field in self.structure: 

316 if field.field.lower() != "flags" and not field.type.startswith("boolean["): 

317 return field 

318 return self.structure[0] if self.structure else None 

319 

320 @property 

321 def has_multiple_units(self) -> bool: 

322 """True if structure contains fields with different units.""" 

323 units: set[str] = set() 

324 for field in self.structure: 

325 if field.unit_id: 

326 units.add(field.unit_id) 

327 return len(units) > 1 

328 

329 @property 

330 def field_count(self) -> int: 

331 """Return the number of fields in the structure.""" 

332 return len(self.structure)