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

1"""Registry for Bluetooth appearance values. 

2 

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

7 

8from __future__ import annotations 

9 

10from pathlib import Path 

11from typing import cast 

12 

13import msgspec 

14 

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 

18 

19 

20class AppearanceValuesRegistry(BaseGenericRegistry[AppearanceInfo]): 

21 """Registry for Bluetooth appearance values with lazy loading. 

22 

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. 

26 

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. 

30 

31 Thread Safety: 

32 This registry is thread-safe. Multiple threads can safely call 

33 get_appearance_info() concurrently. 

34 

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

43 

44 def __init__(self) -> None: 

45 """Initialize the registry with lazy loading.""" 

46 super().__init__() 

47 self._appearances: dict[int, AppearanceInfo] = {} 

48 

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 

56 

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 

64 

65 self._load_yaml(yaml_path) 

66 self._loaded = True 

67 

68 def _load_yaml(self, yaml_path: Path) -> None: 

69 """Load and parse the appearance values YAML file. 

70 

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

76 

77 if not data or not isinstance(data, dict): 

78 return 

79 

80 appearance_values = data.get("appearance_values") 

81 if not isinstance(appearance_values, list): 

82 return 

83 

84 for item in appearance_values: 

85 if not isinstance(item, dict): 

86 continue 

87 

88 category_val: int | None = item.get("category") 

89 category_name: str | None = item.get("name") 

90 

91 if category_val is None or not category_name: 

92 continue 

93 

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 ) 

103 

104 # Store subcategories if present 

105 subcategories = item.get("subcategory", []) 

106 if not isinstance(subcategories, list): 

107 continue 

108 

109 for subcat in subcategories: 

110 if not isinstance(subcat, dict): 

111 continue 

112 

113 subcat_val = subcat.get("value") 

114 subcat_name = subcat.get("name") 

115 

116 if subcat_val is None or not subcat_name: 

117 continue 

118 

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 ) 

126 

127 def get_appearance_info(self, appearance_code: int) -> AppearanceInfo | None: 

128 """Get appearance info by appearance code. 

129 

130 This method lazily loads the YAML file on first call. 

131 

132 Args: 

133 appearance_code: 16-bit appearance value from BLE (0-65535) 

134 

135 Returns: 

136 AppearanceInfo with decoded information, or None if code not found 

137 

138 Raises: 

139 ValueError: If appearance_code is outside valid range (0-65535) 

140 

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

150 

151 self._ensure_loaded() 

152 return self._appearances.get(appearance_code) 

153 

154 def find_by_category_subcategory(self, category: str, subcategory: str | None = None) -> AppearanceInfo | None: 

155 """Find appearance info by category and subcategory names. 

156 

157 This method searches the registry for an appearance that matches 

158 the given category and subcategory names. 

159 

160 Args: 

161 category: Device category name (e.g., "Heart Rate Sensor") 

162 subcategory: Optional subcategory name (e.g., "Heart Rate Belt") 

163 

164 Returns: 

165 AppearanceInfo if found, None otherwise 

166 

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

174 

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 

185 

186 return None 

187 

188 

189# Singleton instance for global use 

190appearance_values_registry = cast(AppearanceValuesRegistry, AppearanceValuesRegistry.get_instance())