Coverage for src / bluetooth_sig / registry / profiles / profile_lookup.py: 92%

120 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Profile Lookup Registry for simple name/value profile parameters.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import Any, cast 

7 

8import msgspec 

9 

10from bluetooth_sig.registry.base import BaseGenericRegistry 

11from bluetooth_sig.registry.utils import find_bluetooth_sig_path 

12from bluetooth_sig.types.registry.profile_types import ProfileLookupEntry 

13 

14# Field names tried (in order) when extracting the integer value from a YAML entry. 

15_VALUE_FIELDS: tuple[str, ...] = ("value", "id", "identifier", "attribute", "MDEP_data_type") 

16 

17# Field names tried (in order) when extracting the human-readable name. 

18_NAME_FIELDS: tuple[str, ...] = ( 

19 "name", 

20 "label", 

21 "codec", 

22 "description", 

23 "audio_location", 

24 "mnemonic", 

25 "client_name", 

26 "data_type", 

27 "document_name", 

28) 

29 

30# Directories containing LTV / codec-capability structures — deferred. 

31_DEFERRED_DIRS: frozenset[str] = frozenset( 

32 { 

33 "ltv_structures", 

34 "metadata_ltv", 

35 "codec_capabilities", 

36 "codec_configuration_ltv", 

37 }, 

38) 

39 

40 

41class ProfileLookupRegistry(BaseGenericRegistry["ProfileLookupRegistry"]): 

42 """Registry for simple profile parameter lookup tables. 

43 

44 Loads non-LTV, non-permitted-characteristics YAML files from 

45 ``profiles_and_services/`` and normalises each entry into a 

46 :class:`ProfileLookupEntry` keyed by the YAML top-level key. 

47 

48 Thread-safe: Multiple threads can safely access the registry concurrently. 

49 """ 

50 

51 def __init__(self) -> None: 

52 """Initialise the profile lookup registry.""" 

53 super().__init__() 

54 self._tables: dict[str, list[ProfileLookupEntry]] = {} 

55 

56 # ------------------------------------------------------------------ 

57 # Helpers 

58 # ------------------------------------------------------------------ 

59 

60 @staticmethod 

61 def _extract_int_value(entry: dict[str, Any]) -> int | None: 

62 """Return the first integer-coercible value from *entry*.""" 

63 for field in _VALUE_FIELDS: 

64 raw = entry.get(field) 

65 if raw is None: 

66 continue 

67 if isinstance(raw, int): 

68 return raw 

69 if isinstance(raw, str): 

70 try: 

71 return int(raw, 16) if raw.startswith("0x") else int(raw) 

72 except ValueError: 

73 continue 

74 return None 

75 

76 @staticmethod 

77 def _extract_name(entry: dict[str, Any]) -> str | None: 

78 """Return the first usable name string from *entry*.""" 

79 for field in _NAME_FIELDS: 

80 raw = entry.get(field) 

81 if isinstance(raw, str) and raw: 

82 return raw 

83 return None 

84 

85 @staticmethod 

86 def _build_metadata(entry: dict[str, Any], used_keys: set[str]) -> dict[str, str]: 

87 """Collect remaining string-coercible fields as metadata.""" 

88 meta: dict[str, str] = {} 

89 for key, val in entry.items(): 

90 if key in used_keys: 

91 continue 

92 if isinstance(val, (str, int, float, bool)): 

93 meta[key] = str(val) 

94 return meta 

95 

96 # ------------------------------------------------------------------ 

97 # Loading 

98 # ------------------------------------------------------------------ 

99 

100 def _load_yaml_file(self, yaml_path: Path) -> None: 

101 """Load a single YAML file and store entries keyed by top-level key.""" 

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

103 data = msgspec.yaml.decode(fh.read()) 

104 

105 if not isinstance(data, dict): 

106 return 

107 

108 data_dict = cast("dict[str, Any]", data) 

109 for top_key, entries_raw in data_dict.items(): 

110 if not isinstance(entries_raw, list): 

111 continue 

112 

113 entries: list[ProfileLookupEntry] = [] 

114 for entry in entries_raw: 

115 if not isinstance(entry, dict): 

116 continue 

117 

118 value = self._extract_int_value(entry) 

119 name = self._extract_name(entry) 

120 if value is None or name is None: 

121 continue 

122 

123 # Determine which keys were consumed for name and value 

124 used: set[str] = set() 

125 for field in _VALUE_FIELDS: 

126 raw = entry.get(field) 

127 if raw is not None: 

128 if isinstance(raw, int): 

129 used.add(field) 

130 break 

131 if isinstance(raw, str): 

132 try: 

133 int(raw, 16) if raw.startswith("0x") else int(raw) 

134 used.add(field) 

135 break 

136 except ValueError: 

137 continue 

138 for field in _NAME_FIELDS: 

139 raw = entry.get(field) 

140 if isinstance(raw, str) and raw: 

141 used.add(field) 

142 break 

143 

144 metadata = self._build_metadata(entry, used) 

145 entries.append(ProfileLookupEntry(name=name, value=value, metadata=metadata)) 

146 

147 if entries: 

148 self._tables[top_key] = entries 

149 

150 @staticmethod 

151 def _is_deferred(path: Path) -> bool: 

152 """Return True if *path* is inside a deferred subdirectory.""" 

153 return any(part in _DEFERRED_DIRS for part in path.parts) 

154 

155 @staticmethod 

156 def _is_permitted_characteristics(path: Path) -> bool: 

157 """Return True if *path* is a permitted-characteristics file.""" 

158 return "permitted_characteristics" in path.name 

159 

160 def _load(self) -> None: 

161 """Load all non-LTV, non-permitted-characteristics profile YAMLs.""" 

162 uuids_path = find_bluetooth_sig_path() 

163 if not uuids_path: 

164 self._loaded = True 

165 return 

166 

167 profiles_path = uuids_path.parent / "profiles_and_services" 

168 if not profiles_path.exists(): 

169 self._loaded = True 

170 return 

171 

172 for yaml_file in sorted(profiles_path.rglob("*.yaml")): 

173 if self._is_deferred(yaml_file) or self._is_permitted_characteristics(yaml_file): 

174 continue 

175 self._load_yaml_file(yaml_file) 

176 

177 self._loaded = True 

178 

179 # ------------------------------------------------------------------ 

180 # Query API 

181 # ------------------------------------------------------------------ 

182 

183 def get_entries(self, table_key: str) -> list[ProfileLookupEntry]: 

184 """Get all entries for a named lookup table. 

185 

186 Args: 

187 table_key: The YAML top-level key, e.g. ``"audio_codec_id"``, 

188 ``"bearer_technology"``, ``"display_types"``. 

189 

190 Returns: 

191 List of :class:`ProfileLookupEntry` or an empty list if not found. 

192 """ 

193 self._ensure_loaded() 

194 with self._lock: 

195 return list(self._tables.get(table_key, [])) 

196 

197 def get_all_table_keys(self) -> list[str]: 

198 """Return all loaded table key names (sorted).""" 

199 self._ensure_loaded() 

200 with self._lock: 

201 return sorted(self._tables) 

202 

203 def resolve_name(self, table_key: str, value: int) -> str | None: 

204 """Look up the name for a given numeric value within a table. 

205 

206 Args: 

207 table_key: Table key (e.g. ``"bearer_technology"``). 

208 value: The numeric identifier. 

209 

210 Returns: 

211 The entry name or ``None`` if not found. 

212 """ 

213 self._ensure_loaded() 

214 with self._lock: 

215 for entry in self._tables.get(table_key, []): 

216 if entry.value == value: 

217 return entry.name 

218 return None 

219 

220 

221# Singleton instance for global use 

222profile_lookup_registry = ProfileLookupRegistry()