Coverage for src / bluetooth_sig / registry / core / appearance_values.py: 83%
69 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 appearance values.
3This module provides a registry for looking up human-readable device types
4and categories from appearance codes found in advertising data and GATT
5characteristics.
6"""
8from __future__ import annotations
10from pathlib import Path
11from typing import cast
13import msgspec
15from bluetooth_sig.gatt.constants import UINT16_MAX
16from bluetooth_sig.registry.base import BaseGenericRegistry
17from bluetooth_sig.registry.utils import find_bluetooth_sig_path
18from bluetooth_sig.types.registry.appearance_info import AppearanceInfo, AppearanceSubcategoryInfo
21class AppearanceValuesRegistry(BaseGenericRegistry[AppearanceInfo]):
22 """Registry for Bluetooth appearance values with lazy loading.
24 This registry loads appearance values from the Bluetooth SIG assigned_numbers
25 YAML file and provides lookup methods to decode appearance codes into
26 human-readable device type information.
28 The registry uses lazy loading - the YAML file is only parsed on the first
29 lookup call. This improves startup time and reduces memory usage when the
30 registry is not needed.
32 Thread Safety:
33 This registry is thread-safe. Multiple threads can safely call
34 get_appearance_info() concurrently.
36 Example::
37 >>> registry = AppearanceValuesRegistry()
38 >>> info = registry.get_appearance_info(833)
39 >>> if info:
40 ... print(info.full_name) # "Heart Rate Sensor: Heart Rate Belt"
41 ... print(info.category) # "Heart Rate Sensor"
42 ... print(info.subcategory) # "Heart Rate Belt"
43 """
45 def __init__(self) -> None:
46 """Initialize the registry with lazy loading."""
47 super().__init__()
48 self._appearances: dict[int, AppearanceInfo] = {}
50 def _load(self) -> None:
51 """Perform the actual loading of appearance values data."""
52 # Get path to uuids/ directory
53 uuids_path = find_bluetooth_sig_path()
54 if not uuids_path:
55 self._loaded = True
56 return
58 # Appearance values are in core/ directory (sibling of uuids/)
59 # Navigate from uuids/ to assigned_numbers/ then to core/
60 assigned_numbers_path = uuids_path.parent
61 yaml_path = assigned_numbers_path / "core" / "appearance_values.yaml"
62 if not yaml_path.exists():
63 self._loaded = True
64 return
66 self._load_yaml(yaml_path)
67 self._loaded = True
69 def _load_yaml(self, yaml_path: Path) -> None:
70 """Load and parse the appearance values YAML file.
72 Args:
73 yaml_path: Path to the appearance_values.yaml file
74 """
75 with yaml_path.open("r", encoding="utf-8") as f:
76 data = msgspec.yaml.decode(f.read())
78 if not data or not isinstance(data, dict):
79 return
81 appearance_values = data.get("appearance_values")
82 if not isinstance(appearance_values, list):
83 return
85 for item in appearance_values:
86 if not isinstance(item, dict):
87 continue
89 category_val: int | None = item.get("category")
90 category_name: str | None = item.get("name")
92 if category_val is None or not category_name:
93 continue
95 # Store category without subcategory
96 # Appearance code = (category << 6) | subcategory
97 # Category only: subcategory = 0
98 appearance_code = category_val << 6
99 self._appearances[appearance_code] = AppearanceInfo(
100 category=category_name,
101 category_value=category_val,
102 subcategory=None,
103 )
105 # Store subcategories if present
106 subcategories = item.get("subcategory", [])
107 if not isinstance(subcategories, list):
108 continue
110 for subcat in subcategories:
111 if not isinstance(subcat, dict):
112 continue
114 subcat_val = subcat.get("value")
115 subcat_name = subcat.get("name")
117 if subcat_val is None or not subcat_name:
118 continue
120 # Full appearance code = (category << 6) | subcategory
121 full_code = (category_val << 6) | subcat_val
122 self._appearances[full_code] = AppearanceInfo(
123 category=category_name,
124 category_value=category_val,
125 subcategory=AppearanceSubcategoryInfo(name=subcat_name, value=subcat_val),
126 )
128 def get_appearance_info(self, appearance_code: int) -> AppearanceInfo | None:
129 """Get appearance info by appearance code.
131 This method lazily loads the YAML file on first call.
133 Args:
134 appearance_code: 16-bit appearance value from BLE (0-65535)
136 Returns:
137 AppearanceInfo with decoded information, or None if code not found
139 Raises:
140 ValueError: If appearance_code is outside valid range (0-65535)
142 Example::
143 >>> registry = AppearanceValuesRegistry()
144 >>> info = registry.get_appearance_info(833)
145 >>> if info:
146 ... print(info.full_name) # "Heart Rate Sensor: Heart Rate Belt"
147 """
148 # Validate input range for 16-bit appearance code
149 if not 0 <= appearance_code <= UINT16_MAX:
150 raise ValueError(f"Appearance code must be in range 0-{UINT16_MAX}, got {appearance_code}")
152 self._ensure_loaded()
153 return self._appearances.get(appearance_code)
155 def find_by_category_subcategory(self, category: str, subcategory: str | None = None) -> AppearanceInfo | None:
156 """Find appearance info by category and subcategory names.
158 This method searches the registry for an appearance that matches
159 the given category and subcategory names.
161 Args:
162 category: Device category name (e.g., "Heart Rate Sensor")
163 subcategory: Optional subcategory name (e.g., "Heart Rate Belt")
165 Returns:
166 AppearanceInfo if found, None otherwise
168 Example::
169 >>> registry = AppearanceValuesRegistry()
170 >>> info = registry.find_by_category_subcategory("Heart Rate Sensor", "Heart Rate Belt")
171 >>> if info:
172 ... print(info.category_value) # Category value for lookup
173 """
174 self._ensure_loaded()
176 # Search for matching appearance
177 for info in self._appearances.values():
178 # Check category match
179 if info.category != category:
180 continue
181 # Check subcategory match
182 if subcategory is None and info.subcategory is None:
183 return info
184 if info.subcategory and info.subcategory.name == subcategory:
185 return info
187 return None
190# Singleton instance for global use
191appearance_values_registry = cast("AppearanceValuesRegistry", AppearanceValuesRegistry.get_instance())