Coverage for src / bluetooth_sig / registry / core / namespace_description.py: 80%

64 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

1"""Namespace Description registry for CPF descriptor description field resolution. 

2 

3The CPF (Characteristic Presentation Format) descriptor has a description field 

4that, when namespace=0x01 (Bluetooth SIG), can be resolved to human-readable 

5names like "first", "left", "front", etc. 

6 

7Used during CPF descriptor parsing to provide description_name resolution. 

8""" 

9 

10from __future__ import annotations 

11 

12import logging 

13 

14import msgspec 

15 

16from bluetooth_sig.registry.base import BaseGenericRegistry 

17from bluetooth_sig.registry.utils import find_bluetooth_sig_path 

18from bluetooth_sig.types.registry.namespace import NamespaceDescriptionInfo 

19 

20logger = logging.getLogger(__name__) 

21 

22 

23class NamespaceDescriptionRegistry(BaseGenericRegistry[NamespaceDescriptionInfo]): 

24 """Registry for Bluetooth SIG namespace description values with lazy loading. 

25 

26 This registry loads namespace description definitions from the official 

27 Bluetooth SIG assigned_numbers YAML file (namespace.yaml), enabling 

28 resolution of CPF description field values to human-readable names. 

29 

30 The description field in CPF is a 16-bit value that, when namespace=0x01 

31 (Bluetooth SIG Assigned Numbers), can be resolved to names like: 

32 - 0x0001 → "first" 

33 - 0x010D → "left" 

34 - 0x010E → "right" 

35 - 0x0102 → "top" 

36 

37 Examples: 

38 >>> from bluetooth_sig.registry.core.namespace_description import namespace_description_registry 

39 >>> info = namespace_description_registry.get_description_info(0x010D) 

40 >>> info.name 

41 'left' 

42 """ 

43 

44 def __init__(self) -> None: 

45 """Initialize the namespace description registry.""" 

46 super().__init__() 

47 self._descriptions: dict[int, NamespaceDescriptionInfo] = {} 

48 self._descriptions_by_name: dict[str, NamespaceDescriptionInfo] = {} 

49 

50 def _load(self) -> None: 

51 """Perform the actual loading of namespace description data.""" 

52 base_path = find_bluetooth_sig_path() 

53 if not base_path: 

54 logger.warning("Bluetooth SIG path not found. Namespace description registry will be empty.") 

55 self._loaded = True 

56 return 

57 

58 yaml_path = base_path.parent / "core" / "namespace.yaml" 

59 if not yaml_path.exists(): 

60 logger.warning( 

61 "Namespace YAML file not found at %s. Registry will be empty.", 

62 yaml_path, 

63 ) 

64 self._loaded = True 

65 return 

66 

67 try: 

68 with yaml_path.open("r", encoding="utf-8") as f: 

69 data = msgspec.yaml.decode(f.read()) 

70 

71 if not data or "namespace" not in data: 

72 logger.warning("Invalid namespace YAML format. Registry will be empty.") 

73 self._loaded = True 

74 return 

75 

76 for item in data["namespace"]: 

77 value = item.get("value") 

78 name = item.get("name") 

79 

80 if value is None or not name: 

81 continue 

82 

83 # Handle hex values in YAML (e.g., 0x010D) 

84 if isinstance(value, str): 

85 value = int(value, 16) 

86 

87 description_info = NamespaceDescriptionInfo( 

88 value=value, 

89 name=name, 

90 ) 

91 

92 self._descriptions[value] = description_info 

93 self._descriptions_by_name[name.lower()] = description_info 

94 

95 logger.info("Loaded %d namespace descriptions from specification", len(self._descriptions)) 

96 except (FileNotFoundError, OSError, msgspec.DecodeError, KeyError) as e: 

97 logger.warning( 

98 "Failed to load namespace descriptions from YAML: %s. Registry will be empty.", 

99 e, 

100 ) 

101 

102 self._loaded = True 

103 

104 def get_description_info(self, value: int) -> NamespaceDescriptionInfo | None: 

105 """Get description info by value (lazy loads on first call). 

106 

107 Args: 

108 value: The description value (e.g., 0x010D for "left") 

109 

110 Returns: 

111 NamespaceDescriptionInfo object, or None if not found 

112 """ 

113 self._ensure_loaded() 

114 with self._lock: 

115 return self._descriptions.get(value) 

116 

117 def get_description_by_name(self, name: str) -> NamespaceDescriptionInfo | None: 

118 """Get description info by name (lazy loads on first call). 

119 

120 Args: 

121 name: Description name (case-insensitive, e.g., "left", "first") 

122 

123 Returns: 

124 NamespaceDescriptionInfo object, or None if not found 

125 """ 

126 self._ensure_loaded() 

127 with self._lock: 

128 return self._descriptions_by_name.get(name.lower()) 

129 

130 def is_known_description(self, value: int) -> bool: 

131 """Check if description value is known (lazy loads on first call). 

132 

133 Args: 

134 value: The description value to check 

135 

136 Returns: 

137 True if the description is registered, False otherwise 

138 """ 

139 self._ensure_loaded() 

140 with self._lock: 

141 return value in self._descriptions 

142 

143 def get_all_descriptions(self) -> dict[int, NamespaceDescriptionInfo]: 

144 """Get all registered namespace descriptions (lazy loads on first call). 

145 

146 Returns: 

147 Dictionary mapping description values to NamespaceDescriptionInfo objects 

148 """ 

149 self._ensure_loaded() 

150 with self._lock: 

151 return self._descriptions.copy() 

152 

153 def resolve_description_name(self, value: int) -> str | None: 

154 """Resolve a description value to its string name. 

155 

156 Convenience method for CPF parsing that returns the description 

157 name directly, or None if unknown. 

158 

159 Args: 

160 value: The description value to resolve 

161 

162 Returns: 

163 Description name string (e.g., "left", "first"), or None if unknown 

164 """ 

165 info = self.get_description_info(value) 

166 return info.name if info else None 

167 

168 

169# Global singleton instance 

170namespace_description_registry = NamespaceDescriptionRegistry()