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

127 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

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

2 

3from __future__ import annotations 

4 

5import re 

6 

7import msgspec 

8 

9 

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

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

12 

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

14 

15 Attributes: 

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

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

18 """ 

19 

20 raw_value: int 

21 meaning: str 

22 

23 

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

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

26 

27 Parses rich metadata from the description field including: 

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

29 - Resolution from M, d, b notation 

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

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

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

33 """ 

34 

35 field: str 

36 type: str 

37 size: str 

38 description: str 

39 

40 @property 

41 def python_name(self) -> str: 

42 """Convert field name to Python snake_case identifier. 

43 

44 Examples: 

45 "Instantaneous Speed" -> "instantaneous_speed" 

46 "Location - Latitude" -> "location_latitude" 

47 """ 

48 name = self.field.lower() 

49 # Replace separators with underscores 

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

51 # Remove parentheses and their contents 

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

53 # Remove non-alphanumeric characters except underscores 

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

55 # Collapse multiple underscores 

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

57 return name.strip("_") 

58 

59 @property 

60 def is_optional(self) -> bool: 

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

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

63 

64 @property 

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

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

67 if self.is_optional: 

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

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

70 if match: 

71 return int(match.group(1)) 

72 return None 

73 # Try to parse as simple integer 

74 try: 

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

76 except ValueError: 

77 return None 

78 

79 @property 

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

81 """Extract org.bluetooth.unit.* identifier from description. 

82 

83 Handles various formats: 

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

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

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

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

88 

89 Returns: 

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

91 """ 

92 # Remove spaces around dots to handle "org.bluetooth.unit. foo" -> "org.bluetooth.unit.foo" 

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

94 

95 # Extract org.bluetooth.unit.* pattern 

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

97 if match: 

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

99 

100 return None 

101 

102 @property 

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

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

105 

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

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

108 

109 Handles variations: 

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

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

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

113 

114 Returns: 

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

116 """ 

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

118 match = re.search( 

119 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+)", 

120 self.description, 

121 re.IGNORECASE, 

122 ) 

123 if match: 

124 # Remove internal spaces from captured values 

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

126 d_val = int(match.group(2).replace(" ", "")) 

127 # b_val = int(match.group(3).replace(" ", "")) # Offset, not used 

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

129 return None 

130 

131 @property 

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

133 """Extract value range from description. 

134 

135 Looks for patterns like: 

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

137 - "Allowed range is 0 to 100" 

138 - "Range: X to Y" 

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

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

141 """ 

142 # Pattern: "Allowed range is/: X to Y" or "Range: X to Y" 

143 match = re.search( 

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

145 self.description, 

146 re.IGNORECASE, 

147 ) 

148 if match: 

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

150 

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

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

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

154 if min_match and max_match: 

155 min_val = min_match.group(1) 

156 max_val = max_match.group(1) 

157 # Only return if we captured actual numeric values 

158 if min_val and max_val: 

159 try: 

160 return float(min_val), float(max_val) 

161 except ValueError: 

162 pass 

163 

164 return None 

165 

166 @property 

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

168 """Extract special value meanings from description. 

169 

170 Looks for patterns like: 

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

172 - '0xFF represents "unknown user"' 

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

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

175 

176 Returns: 

177 Tuple of SpecialValue structs (immutable, hashable). 

178 """ 

179 result: list[SpecialValue] = [] 

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

181 

182 # Normalize Unicode curly quotes to ASCII for consistent matching 

183 desc = self.description 

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

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

186 

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

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

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

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

191 if hex_val not in seen: 

192 seen.add(hex_val) 

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

194 

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

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

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

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

199 if hex_val not in seen: 

200 seen.add(hex_val) 

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

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

203 

204 return tuple(result) 

205 

206 @property 

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

208 """Extract presence condition from description. 

209 

210 Looks for patterns like: 

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

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

213 """ 

214 match = re.search( 

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

216 self.description, 

217 re.IGNORECASE, 

218 ) 

219 if match: 

220 bit_num = match.group(1) 

221 field_name = match.group(2) 

222 bit_value = match.group(3) 

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

224 return None 

225 

226 @property 

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

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

229 

230 Returns: 

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

232 """ 

233 match = re.search( 

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

235 self.description, 

236 re.IGNORECASE, 

237 ) 

238 if match: 

239 return int(match.group(1)) 

240 return None 

241 

242 

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

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

245 

246 Contains the full structure definition from GSS YAML files, 

247 enabling automatic metadata extraction for all fields. 

248 """ 

249 

250 identifier: str 

251 name: str 

252 description: str 

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

254 

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

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

257 

258 Args: 

259 name: Field name or python_name to look up 

260 

261 Returns: 

262 FieldSpec if found, None otherwise 

263 """ 

264 name_lower = name.lower() 

265 for field in self.structure: 

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

267 return field 

268 return None 

269 

270 @property 

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

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

273 

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

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

276 """ 

277 for field in self.structure: 

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

279 return field 

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

281 

282 @property 

283 def has_multiple_units(self) -> bool: 

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

285 units: set[str] = set() 

286 for field in self.structure: 

287 if field.unit_id: 

288 units.add(field.unit_id) 

289 return len(units) > 1 

290 

291 @property 

292 def field_count(self) -> int: 

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

294 return len(self.structure)