Coverage for src / bluetooth_sig / gatt / characteristics / role_classifier.py: 98%
66 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"""Role classification for GATT characteristics.
3Classifies a characteristic's purpose (MEASUREMENT, FEATURE, CONTROL, etc.)
4from SIG spec metadata using a tiered heuristic based on GSS YAML signals,
5name conventions, and type inference.
7Validated against a hand-verified ground truth map of all 294 registered
8characteristics — see ``scripts/test_classifier.py``. Characteristics
9that cannot be classified from the available YAML metadata alone should
10use ``_manual_role`` on their class.
11"""
13from __future__ import annotations
15import enum
17from ...types.gatt_enums import CharacteristicRole
18from ...types.registry import CharacteristicSpec
20# Struct name words that indicate compound measurement data
21_MEASUREMENT_STRUCT_WORDS = frozenset({"Range", "Statistics", "Specification", "Record", "Relative", "Coordinates"})
23# Struct name words that indicate temporal or state-snapshot data
24_STATUS_STRUCT_WORDS = frozenset({"Time", "Information", "Created", "Modified", "Changed", "Alert", "Setting"})
27def classify_role(
28 char_name: str,
29 python_type: type | str | None,
30 is_bitfield: bool,
31 unit: str,
32 spec: CharacteristicSpec | None,
33) -> CharacteristicRole:
34 """Classify a characteristic's purpose from SIG spec metadata.
36 Role definitions:
37 - ``MEASUREMENT``: value represents something measured or observed from
38 the device or environment (physical quantity, sampled reading, derived
39 sensor metric).
40 - ``STATUS``: discrete operational/device state (mode, flag, trend,
41 categorical state snapshot).
42 - ``FEATURE``: capability declaration (supported options/bitmasks), not a
43 live measured value.
44 - ``CONTROL``: command/control endpoint (typically control point writes).
45 - ``INFO``: contextual metadata, identifiers, or descriptive/static data
46 that does not represent a measurement.
48 Uses a tiered priority system — strongest YAML signals first,
49 then name conventions, type inference, and struct name patterns.
51 Tier 1 — YAML-data-driven (highest confidence):
52 1. Name contains *Control Point* → CONTROL
53 2. Physical (non-unitless) ``unit_id`` → MEASUREMENT
54 3. Field-level physical units in structure → MEASUREMENT
56 Tier 2 — Name + type signals:
57 4. Name contains *Status* → STATUS
58 5. ``is_bitfield`` is True → FEATURE
59 6. ``python_type`` is IntFlag subclass → FEATURE
61 Tier 3 — SIG naming conventions:
62 7. Name contains *Measurement* or ends
63 with *Data* → MEASUREMENT
64 8. Name ends with *Feature(s)* → FEATURE
66 Tier 4 — Type-driven inference:
67 9. Non-empty unit string → MEASUREMENT
68 10. ``python_type is str`` → INFO
69 11. ``python_type`` is a string subtype name → INFO
70 12. ``python_type`` is an Enum subclass → STATUS
71 13. Unitless ``unit_id`` + numeric type → MEASUREMENT
72 14. ``python_type is float`` → MEASUREMENT
73 15. ``python_type is bool`` → STATUS
75 Tier 5 — Struct name patterns (for structs with no YAML signal):
76 16. Measurement struct keyword → MEASUREMENT
77 17. Status struct keyword → STATUS
79 Tier 6 — Fallback:
80 18. Otherwise → UNKNOWN
82 Args:
83 char_name: Display name of the characteristic.
84 python_type: Resolved Python type (int, float, str, etc.) or None.
85 is_bitfield: Whether the characteristic is a bitfield.
86 unit: Unit string (empty string if not applicable).
87 spec: Resolved YAML spec (may be None).
89 Returns:
90 The classified ``CharacteristicRole``.
91 """
92 # Derive YAML signals from spec
93 has_unit_id = bool(spec and spec.unit_id)
94 is_unitless = "unitless" in (spec.unit_id or "") if spec else False
95 has_field_units = _spec_has_physical_field_units(spec)
96 is_intflag = isinstance(python_type, type) and issubclass(python_type, enum.IntFlag)
97 is_enum = isinstance(python_type, type) and issubclass(python_type, enum.Enum)
98 is_struct = isinstance(python_type, type) and python_type not in (
99 int,
100 float,
101 str,
102 bool,
103 bytes,
104 )
106 # Walk tiers in priority order; first match wins
107 return (
108 _classify_yaml_signals(char_name, has_unit_id, is_unitless, has_field_units)
109 or _classify_name_and_type(char_name, is_bitfield, is_intflag)
110 or _classify_naming_conventions(char_name)
111 or _classify_type_inference(python_type, unit, is_enum, is_unitless)
112 or _classify_struct_patterns(char_name, is_struct)
113 or CharacteristicRole.UNKNOWN
114 )
117def _classify_yaml_signals(
118 char_name: str,
119 has_unit_id: bool,
120 is_unitless: bool,
121 has_field_units: bool,
122) -> CharacteristicRole | None:
123 """Tier 1: YAML-data-driven signals (highest confidence)."""
124 # 1. Control points — write-only command interfaces
125 if "Control Point" in char_name:
126 return CharacteristicRole.CONTROL
128 # 2. Physical (non-unitless) unit_id from the GSS YAML
129 if has_unit_id and not is_unitless:
130 return CharacteristicRole.MEASUREMENT
132 # 3. Structure fields carry physical units
133 if has_field_units:
134 return CharacteristicRole.MEASUREMENT
136 return None
139def _classify_name_and_type(
140 char_name: str,
141 is_bitfield: bool,
142 is_intflag: bool,
143) -> CharacteristicRole | None:
144 """Tier 2: Name + type signals."""
145 # 4. "Status" in name — checked BEFORE bitfield to catch status
146 # bitfields (e.g. Alert Status, Battery Critical Status)
147 if "Status" in char_name:
148 return CharacteristicRole.STATUS
150 # 5. Bitfield characteristics describe device capabilities
151 if is_bitfield:
152 return CharacteristicRole.FEATURE
154 # 6. IntFlag types are capability/category flag sets
155 if is_intflag:
156 return CharacteristicRole.FEATURE
158 return None
161def _classify_naming_conventions(char_name: str) -> CharacteristicRole | None:
162 """Tier 3: SIG naming conventions."""
163 # 7. Explicit measurement/data by SIG naming convention
164 if "Measurement" in char_name or char_name.endswith(" Data"):
165 return CharacteristicRole.MEASUREMENT
167 # 8. Feature by name (catches non-bitfield feature characteristics)
168 if char_name.endswith("Feature") or char_name.endswith("Features"):
169 return CharacteristicRole.FEATURE
171 return None
174def _classify_type_inference(
175 python_type: type | str | None,
176 unit: str,
177 is_enum: bool,
178 is_unitless: bool,
179) -> CharacteristicRole | None:
180 """Tier 4: Type-driven inference."""
181 # 9. Has unit string from CharacteristicInfo
182 if unit:
183 return CharacteristicRole.MEASUREMENT
185 # 10. Pure string types are info/metadata
186 if python_type is str:
187 return CharacteristicRole.INFO
189 # 11. String subtypes (e.g. "ReportData", "HidInformationData")
190 if isinstance(python_type, str):
191 return CharacteristicRole.INFO
193 # 12. Enum types are categorical state values
194 if is_enum:
195 return CharacteristicRole.STATUS
197 # 13. Unitless unit_id with numeric type → dimensionless measurement
198 if is_unitless and python_type in (int, float):
199 return CharacteristicRole.MEASUREMENT
201 # 14. Float type without any other signal → physical quantity
202 if python_type is float:
203 return CharacteristicRole.MEASUREMENT
205 # 15. Boolean type → state flag
206 if python_type is bool:
207 return CharacteristicRole.STATUS
209 return None
212def _classify_struct_patterns(
213 char_name: str,
214 is_struct: bool,
215) -> CharacteristicRole | None:
216 """Tier 5: Struct name patterns (for structs with no YAML signal)."""
217 if not is_struct:
218 return None
220 # 16. Measurement struct patterns
221 if any(w in char_name for w in _MEASUREMENT_STRUCT_WORDS):
222 return CharacteristicRole.MEASUREMENT
223 if " in a " in char_name:
224 return CharacteristicRole.MEASUREMENT
226 # 17. Status struct patterns
227 if any(w in char_name for w in _STATUS_STRUCT_WORDS):
228 return CharacteristicRole.STATUS
230 return None
233def _spec_has_physical_field_units(spec: CharacteristicSpec | None) -> bool:
234 """Check whether any field in the spec carries a physical (non-unitless) unit_id."""
235 if not spec or not spec.structure:
236 return False
237 return any(f.unit_id and "unitless" not in f.unit_id for f in spec.structure)