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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Registry for Bluetooth SIG unit UUID metadata.
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
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"""
13from __future__ import annotations
15from bluetooth_sig.registry.base import BaseUUIDRegistry
16from bluetooth_sig.types.registry.units import UnitInfo
17from bluetooth_sig.types.uuid import BluetoothUUID
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}
81_SPECIAL_UNIT_NAMES: dict[str, str] = {
82 "percentage": "%",
83 "per mille": "‰",
84 "unitless": "",
85}
88def _derive_symbol_from_name(name: str) -> str:
89 """Derive SI symbol from unit name.
91 Handles formats like:
92 - "thermodynamic temperature (degree celsius)" -> "°C"
93 - "percentage" -> "%"
94 - "length (metre)" -> "m"
96 Args:
97 name: Unit name from YAML
99 Returns:
100 SI symbol, or empty string if unknown
101 """
102 name_lower = name.lower()
104 if name_lower in _SPECIAL_UNIT_NAMES:
105 return _SPECIAL_UNIT_NAMES[name_lower]
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
116 return ""
119class UnitsRegistry(BaseUUIDRegistry[UnitInfo]):
120 """Registry for Bluetooth SIG unit UUIDs."""
122 def _load_yaml_path(self) -> str:
123 """Return the YAML file path relative to bluetooth_sig/ root."""
124 return "units.yaml"
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 )
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 )
146 def get_unit_info(self, uuid: str | BluetoothUUID) -> UnitInfo | None:
147 """Get unit information by UUID.
149 Args:
150 uuid: 16-bit UUID as string (with or without 0x) or BluetoothUUID
152 Returns:
153 UnitInfo object, or None if not found
154 """
155 return self.get_info(uuid)
157 def get_unit_info_by_name(self, name: str) -> UnitInfo | None:
158 """Get unit information by name.
160 Args:
161 name: Unit name (case-insensitive)
163 Returns:
164 UnitInfo object, or None if not found
165 """
166 return self.get_info(name)
168 def get_unit_info_by_id(self, unit_id: str) -> UnitInfo | None:
169 """Get unit information by ID.
171 Args:
172 unit_id: Unit ID (e.g., "org.bluetooth.unit.celsius")
174 Returns:
175 UnitInfo object, or None if not found
176 """
177 return self.get_info(unit_id)
179 def is_unit_uuid(self, uuid: str | BluetoothUUID) -> bool:
180 """Check if a UUID is a registered unit UUID.
182 Args:
183 uuid: UUID to check
185 Returns:
186 True if the UUID is a unit UUID, False otherwise
187 """
188 return self.get_unit_info(uuid) is not None
190 def get_all_units(self) -> list[UnitInfo]:
191 """Get all registered units.
193 Returns:
194 List of all UnitInfo objects
195 """
196 self._ensure_loaded()
197 with self._lock:
198 return list(self._canonical_store.values())
201# Global instance
202units_registry = UnitsRegistry()
204_UNIT_ID_PREFIX = "org.bluetooth.unit."
207def resolve_unit_symbol(unit_id: str) -> str:
208 """Resolve a SIG unit identifier to its canonical symbol.
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`.
215 Args:
216 unit_id: Unit identifier (short or full form).
218 Returns:
219 SI symbol string (e.g. ``'°C'``, ``'bpm'``), or empty string
220 if the identifier cannot be resolved.
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 ""