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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Types for Bluetooth SIG GSS Characteristic registry."""
3from __future__ import annotations
5import functools
6import logging
7import re
9import msgspec
11logger = logging.getLogger(__name__)
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.
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("_")
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
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
49 return resolve_unit_symbol(uid)
52class SpecialValue(msgspec.Struct, frozen=True):
53 """A special sentinel value with its meaning.
55 Used for values like 0x8000 meaning "value is not known".
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 """
62 raw_value: int
63 meaning: str
66class FieldSpec(msgspec.Struct, frozen=True, kw_only=True):
67 """Specification for a field in a characteristic structure.
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 """
77 field: str
78 type: str
79 size: str
80 description: str
82 @property
83 def python_name(self) -> str:
84 """Convert field name to Python snake_case identifier (cached).
86 Examples:
87 "Instantaneous Speed" -> "instantaneous_speed"
88 "Location - Latitude" -> "location_latitude"
89 """
90 return _compute_python_name(self.field)
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"
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
112 @property
113 def unit_id(self) -> str | None:
114 """Extract org.bluetooth.unit.* identifier from description (cached).
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)
122 Returns:
123 Unit ID string (e.g., "thermodynamic_temperature.degree_celsius"), or None.
124 """
125 return _compute_unit_id(self.description)
127 @property
128 def unit_symbol(self) -> str:
129 """Get the resolved SIG unit symbol for this field.
131 Resolves ``unit_id`` → ``UnitsRegistry`` → ``.symbol``
132 (e.g. ``'thermodynamic_temperature.degree_celsius'`` → ``'°C'``).
133 Result is LRU-cached by description text.
135 Returns:
136 SI symbol string, or empty string if no unit is available.
138 """
139 return _resolve_field_unit_symbol(self.description)
141 @property
142 def resolution(self) -> float | None:
143 """Extract resolution from 'M = X, d = Y, b = Z' notation.
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
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)
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
169 @property
170 def value_range(self) -> tuple[float, float] | None:
171 """Extract value range from description.
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))
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)
202 return None
204 @property
205 def special_values(self) -> tuple[SpecialValue, ...]:
206 """Extract special value meanings from description.
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)
214 Returns:
215 Tuple of SpecialValue structs (immutable, hashable).
216 """
217 result: list[SpecialValue] = []
218 seen: set[int] = set() # Avoid duplicates
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", "'") # ' '
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)))
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))
242 return tuple(result)
244 @property
245 def presence_condition(self) -> str | None:
246 """Extract presence condition from description.
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
264 @property
265 def presence_flag_bit(self) -> int | None:
266 """Extract the flag bit number for optional field presence.
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
281class GssCharacteristicSpec(msgspec.Struct, frozen=True, kw_only=True):
282 """Specification for a Bluetooth SIG characteristic from GSS.
284 Contains the full structure definition from GSS YAML files,
285 enabling automatic metadata extraction for all fields.
286 """
288 identifier: str
289 name: str
290 description: str
291 structure: list[FieldSpec] = msgspec.field(default_factory=list)
293 def get_field(self, name: str) -> FieldSpec | None:
294 """Get a field specification by name (case-insensitive).
296 Args:
297 name: Field name or python_name to look up
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
308 @property
309 def primary_field(self) -> FieldSpec | None:
310 """Get the primary data field (first non-Flags field).
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
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
329 @property
330 def field_count(self) -> int:
331 """Return the number of fields in the structure."""
332 return len(self.structure)