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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Types for Bluetooth SIG GSS Characteristic registry."""
3from __future__ import annotations
5import re
7import msgspec
10class SpecialValue(msgspec.Struct, frozen=True):
11 """A special sentinel value with its meaning.
13 Used for values like 0x8000 meaning "value is not known".
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 """
20 raw_value: int
21 meaning: str
24class FieldSpec(msgspec.Struct, frozen=True, kw_only=True):
25 """Specification for a field in a characteristic structure.
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 """
35 field: str
36 type: str
37 size: str
38 description: str
40 @property
41 def python_name(self) -> str:
42 """Convert field name to Python snake_case identifier.
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("_")
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"
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
79 @property
80 def unit_id(self) -> str | None:
81 """Extract org.bluetooth.unit.* identifier from description.
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)
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)
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(".")
100 return None
102 @property
103 def resolution(self) -> float | None:
104 """Extract resolution from 'M = X, d = Y, b = Z' notation.
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
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)
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
131 @property
132 def value_range(self) -> tuple[float, float] | None:
133 """Extract value range from description.
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))
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
164 return None
166 @property
167 def special_values(self) -> tuple[SpecialValue, ...]:
168 """Extract special value meanings from description.
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)
176 Returns:
177 Tuple of SpecialValue structs (immutable, hashable).
178 """
179 result: list[SpecialValue] = []
180 seen: set[int] = set() # Avoid duplicates
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", "'") # ' '
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)))
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))
204 return tuple(result)
206 @property
207 def presence_condition(self) -> str | None:
208 """Extract presence condition from description.
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
226 @property
227 def presence_flag_bit(self) -> int | None:
228 """Extract the flag bit number for optional field presence.
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
243class GssCharacteristicSpec(msgspec.Struct, frozen=True, kw_only=True):
244 """Specification for a Bluetooth SIG characteristic from GSS.
246 Contains the full structure definition from GSS YAML files,
247 enabling automatic metadata extraction for all fields.
248 """
250 identifier: str
251 name: str
252 description: str
253 structure: list[FieldSpec] = msgspec.field(default_factory=list)
255 def get_field(self, name: str) -> FieldSpec | None:
256 """Get a field specification by name (case-insensitive).
258 Args:
259 name: Field name or python_name to look up
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
270 @property
271 def primary_field(self) -> FieldSpec | None:
272 """Get the primary data field (first non-Flags field).
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
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
291 @property
292 def field_count(self) -> int:
293 """Return the number of fields in the structure."""
294 return len(self.structure)