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

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

19 

20 

21class AppearanceValuesRegistry(BaseGenericRegistry[AppearanceInfo]): 

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

23 

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. 

27 

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. 

31 

32 Thread Safety: 

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

34 get_appearance_info() concurrently. 

35 

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

44 

45 def __init__(self) -> None: 

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

47 super().__init__() 

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

49 

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 

57 

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 

65 

66 self._load_yaml(yaml_path) 

67 self._loaded = True 

68 

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

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

71 

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

77 

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

79 return 

80 

81 appearance_values = data.get("appearance_values") 

82 if not isinstance(appearance_values, list): 

83 return 

84 

85 for item in appearance_values: 

86 if not isinstance(item, dict): 

87 continue 

88 

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

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

91 

92 if category_val is None or not category_name: 

93 continue 

94 

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 ) 

104 

105 # Store subcategories if present 

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

107 if not isinstance(subcategories, list): 

108 continue 

109 

110 for subcat in subcategories: 

111 if not isinstance(subcat, dict): 

112 continue 

113 

114 subcat_val = subcat.get("value") 

115 subcat_name = subcat.get("name") 

116 

117 if subcat_val is None or not subcat_name: 

118 continue 

119 

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 ) 

127 

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

129 """Get appearance info by appearance code. 

130 

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

132 

133 Args: 

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

135 

136 Returns: 

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

138 

139 Raises: 

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

141 

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

151 

152 self._ensure_loaded() 

153 return self._appearances.get(appearance_code) 

154 

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

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

157 

158 This method searches the registry for an appearance that matches 

159 the given category and subcategory names. 

160 

161 Args: 

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

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

164 

165 Returns: 

166 AppearanceInfo if found, None otherwise 

167 

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

175 

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 

186 

187 return None 

188 

189 

190# Singleton instance for global use 

191appearance_values_registry = cast("AppearanceValuesRegistry", AppearanceValuesRegistry.get_instance())