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

1"""Role classification for GATT characteristics. 

2 

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. 

6 

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""" 

12 

13from __future__ import annotations 

14 

15import enum 

16 

17from ...types.gatt_enums import CharacteristicRole 

18from ...types.registry import CharacteristicSpec 

19 

20# Struct name words that indicate compound measurement data 

21_MEASUREMENT_STRUCT_WORDS = frozenset({"Range", "Statistics", "Specification", "Record", "Relative", "Coordinates"}) 

22 

23# Struct name words that indicate temporal or state-snapshot data 

24_STATUS_STRUCT_WORDS = frozenset({"Time", "Information", "Created", "Modified", "Changed", "Alert", "Setting"}) 

25 

26 

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. 

35 

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. 

47 

48 Uses a tiered priority system — strongest YAML signals first, 

49 then name conventions, type inference, and struct name patterns. 

50 

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 

55 

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 

60 

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 

65 

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 

74 

75 Tier 5 — Struct name patterns (for structs with no YAML signal): 

76 16. Measurement struct keyword → MEASUREMENT 

77 17. Status struct keyword → STATUS 

78 

79 Tier 6 — Fallback: 

80 18. Otherwise → UNKNOWN 

81 

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). 

88 

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 ) 

105 

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 ) 

115 

116 

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 

127 

128 # 2. Physical (non-unitless) unit_id from the GSS YAML 

129 if has_unit_id and not is_unitless: 

130 return CharacteristicRole.MEASUREMENT 

131 

132 # 3. Structure fields carry physical units 

133 if has_field_units: 

134 return CharacteristicRole.MEASUREMENT 

135 

136 return None 

137 

138 

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 

149 

150 # 5. Bitfield characteristics describe device capabilities 

151 if is_bitfield: 

152 return CharacteristicRole.FEATURE 

153 

154 # 6. IntFlag types are capability/category flag sets 

155 if is_intflag: 

156 return CharacteristicRole.FEATURE 

157 

158 return None 

159 

160 

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 

166 

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 

170 

171 return None 

172 

173 

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 

184 

185 # 10. Pure string types are info/metadata 

186 if python_type is str: 

187 return CharacteristicRole.INFO 

188 

189 # 11. String subtypes (e.g. "ReportData", "HidInformationData") 

190 if isinstance(python_type, str): 

191 return CharacteristicRole.INFO 

192 

193 # 12. Enum types are categorical state values 

194 if is_enum: 

195 return CharacteristicRole.STATUS 

196 

197 # 13. Unitless unit_id with numeric type → dimensionless measurement 

198 if is_unitless and python_type in (int, float): 

199 return CharacteristicRole.MEASUREMENT 

200 

201 # 14. Float type without any other signal → physical quantity 

202 if python_type is float: 

203 return CharacteristicRole.MEASUREMENT 

204 

205 # 15. Boolean type → state flag 

206 if python_type is bool: 

207 return CharacteristicRole.STATUS 

208 

209 return None 

210 

211 

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 

219 

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 

225 

226 # 17. Status struct patterns 

227 if any(w in char_name for w in _STATUS_STRUCT_WORDS): 

228 return CharacteristicRole.STATUS 

229 

230 return None 

231 

232 

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)