Coverage for src / bluetooth_sig / registry / uuids / units.py: 89%

46 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Registry for Bluetooth SIG unit UUID metadata. 

2 

3Loads unit definitions from the Bluetooth SIG units.yaml specification, 

4providing UUID-to-name-to-symbol resolution. Used primarily for: 

5- Resolving org.bluetooth.unit.* identifiers from GSS YAML files 

6- Converting unit IDs to human-readable SI symbols for display 

7 

8Note: This is distinct from the domain enums in types/units.py which 

9provide type-safe unit representations for decoded characteristic data. 

10The symbols derived here match those enum values for consistency. 

11""" 

12 

13from __future__ import annotations 

14 

15from bluetooth_sig.registry.base import BaseUUIDRegistry 

16from bluetooth_sig.types.registry.units import UnitInfo 

17from bluetooth_sig.types.uuid import BluetoothUUID 

18 

19# SI symbol mappings for unit names in parentheses 

20_UNIT_NAME_TO_SYMBOL: dict[str, str] = { 

21 "degree celsius": "°C", 

22 "degree fahrenheit": "°F", 

23 "kelvin": "K", 

24 "pascal": "Pa", 

25 "bar": "bar", 

26 "millimetre of mercury": "mmHg", 

27 "ampere": "A", 

28 "volt": "V", 

29 "coulomb": "C", 

30 "farad": "F", 

31 "ohm": "Ω", 

32 "siemens": "S", 

33 "weber": "Wb", 

34 "tesla": "T", 

35 "henry": "H", 

36 "joule": "J", 

37 "watt": "W", 

38 "hertz": "Hz", 

39 "metre": "m", 

40 "kilogram": "kg", 

41 "second": "s", 

42 "metres per second": "m/s", 

43 "metres per second squared": "m/s²", 

44 "square metres": "m²", 

45 "cubic metres": "m³", 

46 "radian per second": "rad/s", 

47 "newton": "N", 

48 "candela": "cd", 

49 "lux": "lx", 

50 "lumen": "lm", 

51 "becquerel": "Bq", 

52 "gray": "Gy", 

53 "sievert": "Sv", 

54 "katal": "kat", 

55 "degree": "°", 

56 "radian": "rad", 

57 "steradian": "sr", 

58 "mole": "mol", 

59 "beats per minute": "bpm", 

60 "kilometre per hour": "km/h", 

61 "kilowatt hour": "kWh", 

62 "watt per square metre": "W/m²", 

63 "newton metre": "N·m", 

64 "lumen per watt": "lm/W", 

65 "lux hour": "lx·h", 

66 "gram per second": "g/s", 

67 "litre per second": "L/s", 

68 "kilogram calorie": "kcal", 

69 "minute": "min", 

70 "hour": "h", 

71 "day": "d", 

72 "month": "mo", 

73 "year": "yr", 

74 "decibel": "dB", 

75 "count per second": "cps", 

76 "revolution per minute": "rpm", 

77 "step per minute": "steps/min", 

78 "stroke per minute": "strokes/min", 

79} 

80 

81_SPECIAL_UNIT_NAMES: dict[str, str] = { 

82 "percentage": "%", 

83 "per mille": "‰", 

84 "unitless": "", 

85} 

86 

87 

88def _derive_symbol_from_name(name: str) -> str: 

89 """Derive SI symbol from unit name. 

90 

91 Handles formats like: 

92 - "thermodynamic temperature (degree celsius)" -> "°C" 

93 - "percentage" -> "%" 

94 - "length (metre)" -> "m" 

95 

96 Args: 

97 name: Unit name from YAML 

98 

99 Returns: 

100 SI symbol, or empty string if unknown 

101 """ 

102 name_lower = name.lower() 

103 

104 if name_lower in _SPECIAL_UNIT_NAMES: 

105 return _SPECIAL_UNIT_NAMES[name_lower] 

106 

107 if "(" in name and ")" in name: 

108 start = name.find("(") + 1 

109 end = name.find(")", start) 

110 if 0 < start < end: 

111 parenthetical = name[start:end].strip().lower() 

112 if parenthetical in _UNIT_NAME_TO_SYMBOL: 

113 return _UNIT_NAME_TO_SYMBOL[parenthetical] 

114 return parenthetical 

115 

116 return "" 

117 

118 

119class UnitsRegistry(BaseUUIDRegistry[UnitInfo]): 

120 """Registry for Bluetooth SIG unit UUIDs.""" 

121 

122 def _load_yaml_path(self) -> str: 

123 """Return the YAML file path relative to bluetooth_sig/ root.""" 

124 return "units.yaml" 

125 

126 def _create_info_from_yaml(self, uuid_data: dict[str, str], uuid: BluetoothUUID) -> UnitInfo: 

127 """Create UnitInfo from YAML data with derived symbol.""" 

128 unit_name = uuid_data["name"] 

129 return UnitInfo( 

130 uuid=uuid, 

131 name=unit_name, 

132 id=uuid_data["id"], 

133 symbol=_derive_symbol_from_name(unit_name), 

134 ) 

135 

136 def _create_runtime_info(self, entry: object, uuid: BluetoothUUID) -> UnitInfo: 

137 """Create runtime UnitInfo from entry.""" 

138 unit_name = getattr(entry, "name", "") 

139 return UnitInfo( 

140 uuid=uuid, 

141 name=unit_name, 

142 id=getattr(entry, "id", ""), 

143 symbol=getattr(entry, "symbol", "") or _derive_symbol_from_name(unit_name), 

144 ) 

145 

146 def get_unit_info(self, uuid: str | BluetoothUUID) -> UnitInfo | None: 

147 """Get unit information by UUID. 

148 

149 Args: 

150 uuid: 16-bit UUID as string (with or without 0x) or BluetoothUUID 

151 

152 Returns: 

153 UnitInfo object, or None if not found 

154 """ 

155 return self.get_info(uuid) 

156 

157 def get_unit_info_by_name(self, name: str) -> UnitInfo | None: 

158 """Get unit information by name. 

159 

160 Args: 

161 name: Unit name (case-insensitive) 

162 

163 Returns: 

164 UnitInfo object, or None if not found 

165 """ 

166 return self.get_info(name) 

167 

168 def get_unit_info_by_id(self, unit_id: str) -> UnitInfo | None: 

169 """Get unit information by ID. 

170 

171 Args: 

172 unit_id: Unit ID (e.g., "org.bluetooth.unit.celsius") 

173 

174 Returns: 

175 UnitInfo object, or None if not found 

176 """ 

177 return self.get_info(unit_id) 

178 

179 def is_unit_uuid(self, uuid: str | BluetoothUUID) -> bool: 

180 """Check if a UUID is a registered unit UUID. 

181 

182 Args: 

183 uuid: UUID to check 

184 

185 Returns: 

186 True if the UUID is a unit UUID, False otherwise 

187 """ 

188 return self.get_unit_info(uuid) is not None 

189 

190 def get_all_units(self) -> list[UnitInfo]: 

191 """Get all registered units. 

192 

193 Returns: 

194 List of all UnitInfo objects 

195 """ 

196 self._ensure_loaded() 

197 with self._lock: 

198 return list(self._canonical_store.values()) 

199 

200 

201# Global instance 

202units_registry = UnitsRegistry() 

203 

204_UNIT_ID_PREFIX = "org.bluetooth.unit." 

205 

206 

207def resolve_unit_symbol(unit_id: str) -> str: 

208 """Resolve a SIG unit identifier to its canonical symbol. 

209 

210 Accepts both short-form (``thermodynamic_temperature.degree_celsius``) 

211 and full-form (``org.bluetooth.unit.thermodynamic_temperature.degree_celsius``) 

212 identifiers, normalises to full-form, and looks up the symbol via 

213 :data:`units_registry`. 

214 

215 Args: 

216 unit_id: Unit identifier (short or full form). 

217 

218 Returns: 

219 SI symbol string (e.g. ``'°C'``, ``'bpm'``), or empty string 

220 if the identifier cannot be resolved. 

221 

222 """ 

223 full_id = unit_id if unit_id.startswith(_UNIT_ID_PREFIX) else f"{_UNIT_ID_PREFIX}{unit_id}" 

224 info = units_registry.get_unit_info_by_id(full_id) 

225 return info.symbol if info and info.symbol else ""