Coverage for src / bluetooth_sig / registry / core / appearance_values.py: 82%
68 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +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.registry.base import BaseGenericRegistry
16from bluetooth_sig.registry.utils import find_bluetooth_sig_path
17from bluetooth_sig.types.registry.appearance_info import AppearanceInfo, AppearanceSubcategoryInfo
20class AppearanceValuesRegistry(BaseGenericRegistry[AppearanceInfo]):
21 """Registry for Bluetooth appearance values with lazy loading.
23 This registry loads appearance values from the Bluetooth SIG assigned_numbers
24 YAML file and provides lookup methods to decode appearance codes into
25 human-readable device type information.
27 The registry uses lazy loading - the YAML file is only parsed on the first
28 lookup call. This improves startup time and reduces memory usage when the
29 registry is not needed.
31 Thread Safety:
32 This registry is thread-safe. Multiple threads can safely call
33 get_appearance_info() concurrently.
35 Example:
36 >>> registry = AppearanceValuesRegistry()
37 >>> info = registry.get_appearance_info(833)
38 >>> if info:
39 ... print(info.full_name) # "Heart Rate Sensor: Heart Rate Belt"
40 ... print(info.category) # "Heart Rate Sensor"
41 ... print(info.subcategory) # "Heart Rate Belt"
42 """
44 def __init__(self) -> None:
45 """Initialize the registry with lazy loading."""
46 super().__init__()
47 self._appearances: dict[int, AppearanceInfo] = {}
49 def _load(self) -> None:
50 """Perform the actual loading of appearance values data."""
51 # Get path to uuids/ directory
52 uuids_path = find_bluetooth_sig_path()
53 if not uuids_path:
54 self._loaded = True
55 return
57 # Appearance values are in core/ directory (sibling of uuids/)
58 # Navigate from uuids/ to assigned_numbers/ then to core/
59 assigned_numbers_path = uuids_path.parent
60 yaml_path = assigned_numbers_path / "core" / "appearance_values.yaml"
61 if not yaml_path.exists():
62 self._loaded = True
63 return
65 self._load_yaml(yaml_path)
66 self._loaded = True
68 def _load_yaml(self, yaml_path: Path) -> None:
69 """Load and parse the appearance values YAML file.
71 Args:
72 yaml_path: Path to the appearance_values.yaml file
73 """
74 with yaml_path.open("r", encoding="utf-8") as f:
75 data = msgspec.yaml.decode(f.read())
77 if not data or not isinstance(data, dict):
78 return
80 appearance_values = data.get("appearance_values")
81 if not isinstance(appearance_values, list):
82 return
84 for item in appearance_values:
85 if not isinstance(item, dict):
86 continue
88 category_val: int | None = item.get("category")
89 category_name: str | None = item.get("name")
91 if category_val is None or not category_name:
92 continue
94 # Store category without subcategory
95 # Appearance code = (category << 6) | subcategory
96 # Category only: subcategory = 0
97 appearance_code = category_val << 6
98 self._appearances[appearance_code] = AppearanceInfo(
99 category=category_name,
100 category_value=category_val,
101 subcategory=None,
102 )
104 # Store subcategories if present
105 subcategories = item.get("subcategory", [])
106 if not isinstance(subcategories, list):
107 continue
109 for subcat in subcategories:
110 if not isinstance(subcat, dict):
111 continue
113 subcat_val = subcat.get("value")
114 subcat_name = subcat.get("name")
116 if subcat_val is None or not subcat_name:
117 continue
119 # Full appearance code = (category << 6) | subcategory
120 full_code = (category_val << 6) | subcat_val
121 self._appearances[full_code] = AppearanceInfo(
122 category=category_name,
123 category_value=category_val,
124 subcategory=AppearanceSubcategoryInfo(name=subcat_name, value=subcat_val),
125 )
127 def get_appearance_info(self, appearance_code: int) -> AppearanceInfo | None:
128 """Get appearance info by appearance code.
130 This method lazily loads the YAML file on first call.
132 Args:
133 appearance_code: 16-bit appearance value from BLE (0-65535)
135 Returns:
136 AppearanceInfo with decoded information, or None if code not found
138 Raises:
139 ValueError: If appearance_code is outside valid range (0-65535)
141 Example:
142 >>> registry = AppearanceValuesRegistry()
143 >>> info = registry.get_appearance_info(833)
144 >>> if info:
145 ... print(info.full_name) # "Heart Rate Sensor: Heart Rate Belt"
146 """
147 # Validate input range for 16-bit appearance code
148 if not 0 <= appearance_code <= 65535:
149 raise ValueError(f"Appearance code must be in range 0-65535, got {appearance_code}")
151 self._ensure_loaded()
152 return self._appearances.get(appearance_code)
154 def find_by_category_subcategory(self, category: str, subcategory: str | None = None) -> AppearanceInfo | None:
155 """Find appearance info by category and subcategory names.
157 This method searches the registry for an appearance that matches
158 the given category and subcategory names.
160 Args:
161 category: Device category name (e.g., "Heart Rate Sensor")
162 subcategory: Optional subcategory name (e.g., "Heart Rate Belt")
164 Returns:
165 AppearanceInfo if found, None otherwise
167 Example:
168 >>> registry = AppearanceValuesRegistry()
169 >>> info = registry.find_by_category_subcategory("Heart Rate Sensor", "Heart Rate Belt")
170 >>> if info:
171 ... print(info.category_value) # Category value for lookup
172 """
173 self._ensure_loaded()
175 # Search for matching appearance
176 for info in self._appearances.values():
177 # Check category match
178 if info.category != category:
179 continue
180 # Check subcategory match
181 if subcategory is None and info.subcategory is None:
182 return info
183 if info.subcategory and info.subcategory.name == subcategory:
184 return info
186 return None
189# Singleton instance for global use
190appearance_values_registry = cast(AppearanceValuesRegistry, AppearanceValuesRegistry.get_instance())