Coverage for src/bluetooth_sig/gatt/services/base.py: 81%

360 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +0000

1"""Base class for GATT service implementations.""" 

2 

3from __future__ import annotations 

4 

5from enum import Enum 

6from typing import Any, TypeVar, cast 

7 

8import msgspec 

9 

10from ...types import CharacteristicInfo as BaseCharacteristicInfo 

11from ...types import ServiceInfo 

12from ...types.gatt_services import ( 

13 CharacteristicCollection, 

14 CharacteristicSpec, 

15 ServiceDiscoveryData, 

16) 

17from ...types.uuid import BluetoothUUID 

18from ..characteristics import BaseCharacteristic, CharacteristicRegistry 

19from ..characteristics.base import UnknownCharacteristic 

20from ..characteristics.registry import CharacteristicName 

21from ..exceptions import UUIDResolutionError 

22from ..resolver import ServiceRegistrySearch 

23from ..uuid_registry import UuidInfo, uuid_registry 

24 

25# Type aliases 

26GattCharacteristic = BaseCharacteristic 

27# Strong-typed collection with enum keys 

28ServiceCharacteristicCollection = CharacteristicCollection # Alias for compatibility 

29 

30# Generic type variable for service-specific characteristic definitions 

31ServiceCharacteristics = TypeVar("ServiceCharacteristics") 

32 

33 

34# Internal collections are plain dicts keyed by `CharacteristicName` enums. 

35# Do not perform implicit string-based lookups here; callers must convert 

36# strings to `CharacteristicName` explicitly at public boundaries. 

37 

38 

39class ServiceValidationConfig(msgspec.Struct, kw_only=True): 

40 """Configuration for service validation constraints. 

41 

42 Groups validation parameters into a single, optional configuration object 

43 to simplify BaseGattService constructor signatures. 

44 """ 

45 

46 strict_validation: bool = False 

47 require_all_optional: bool = False 

48 

49 

50class SIGServiceResolver: 

51 """Resolves SIG service information from registry. 

52 

53 This class handles all SIG service resolution logic, separating 

54 concerns from the BaseGattService constructor. Uses shared utilities 

55 from the resolver module to avoid code duplication with characteristic resolution. 

56 """ 

57 

58 @staticmethod 

59 def resolve_for_class(service_class: type[BaseGattService]) -> ServiceInfo: 

60 """Resolve ServiceInfo for a SIG service class. 

61 

62 Args: 

63 service_class: The service class to resolve info for 

64 

65 Returns: 

66 ServiceInfo with resolved UUID, name, summary 

67 

68 Raises: 

69 UUIDResolutionError: If no UUID can be resolved for the class 

70 

71 """ 

72 # Try registry resolution 

73 registry_info = SIGServiceResolver.resolve_from_registry(service_class) 

74 if registry_info: 

75 return registry_info 

76 

77 # No resolution found 

78 raise UUIDResolutionError(service_class.__name__, [service_class.__name__]) 

79 

80 @staticmethod 

81 def resolve_from_registry(service_class: type[BaseGattService]) -> ServiceInfo | None: 

82 """Resolve service info from registry using shared search strategy.""" 

83 # Use shared registry search strategy 

84 search_strategy = ServiceRegistrySearch() 

85 service_name = getattr(service_class, "_service_name", None) 

86 return search_strategy.search(service_class, service_name) 

87 

88 

89class ServiceHealthStatus(Enum): 

90 """Health status of a GATT service.""" 

91 

92 COMPLETE = "complete" # All required characteristics present 

93 FUNCTIONAL = "functional" # Required characteristics present, some optional missing 

94 PARTIAL = "partial" # Some required characteristics missing but service still usable 

95 INCOMPLETE = "incomplete" # Critical required characteristics missing 

96 

97 

98class CharacteristicStatus(Enum): 

99 """Status of characteristics within a service.""" 

100 

101 PRESENT = "present" # Characteristic is present and functional 

102 MISSING = "missing" # Expected characteristic not found 

103 INVALID = "invalid" # Characteristic found but invalid/unusable 

104 

105 

106class ServiceValidationResult(msgspec.Struct, kw_only=True): 

107 """Result of service validation.""" 

108 

109 status: ServiceHealthStatus 

110 missing_required: list[BaseCharacteristic] = msgspec.field(default_factory=list) 

111 missing_optional: list[BaseCharacteristic] = msgspec.field(default_factory=list) 

112 invalid_characteristics: list[BaseCharacteristic] = msgspec.field(default_factory=list) 

113 warnings: list[str] = msgspec.field(default_factory=list) 

114 errors: list[str] = msgspec.field(default_factory=list) 

115 

116 @property 

117 def is_healthy(self) -> bool: 

118 """Check if service is in a healthy state.""" 

119 return self.status in ( 

120 ServiceHealthStatus.COMPLETE, 

121 ServiceHealthStatus.FUNCTIONAL, 

122 ) 

123 

124 @property 

125 def has_errors(self) -> bool: 

126 """Check if service has any errors.""" 

127 return len(self.errors) > 0 or len(self.missing_required) > 0 

128 

129 

130class ServiceCharacteristicInfo(BaseCharacteristicInfo): 

131 """Service-specific information about a characteristic with context about its presence. 

132 

133 Provides status, requirement, and class context for a characteristic within a service. 

134 """ 

135 

136 status: CharacteristicStatus = CharacteristicStatus.INVALID 

137 is_required: bool = False 

138 is_conditional: bool = False 

139 condition_description: str = "" 

140 char_class: type[BaseCharacteristic] | None = None 

141 

142 

143class ServiceCompletenessReport(msgspec.Struct, kw_only=True): # pylint: disable=too-many-instance-attributes 

144 """Comprehensive report about service completeness and health.""" 

145 

146 service_name: str 

147 service_uuid: BluetoothUUID 

148 health_status: ServiceHealthStatus 

149 is_healthy: bool 

150 characteristics_present: int 

151 characteristics_expected: int 

152 characteristics_required: int 

153 present_characteristics: list[BaseCharacteristic] = msgspec.field(default_factory=list) 

154 missing_required: list[BaseCharacteristic] = msgspec.field(default_factory=list) 

155 missing_optional: list[BaseCharacteristic] = msgspec.field(default_factory=list) 

156 invalid_characteristics: list[BaseCharacteristic] = msgspec.field(default_factory=list) 

157 warnings: list[str] = msgspec.field(default_factory=list) 

158 errors: list[str] = msgspec.field(default_factory=list) 

159 missing_details: dict[str, ServiceCharacteristicInfo] = msgspec.field(default_factory=dict) 

160 

161 

162# All type definitions moved to src/bluetooth_sig/types/gatt_services.py 

163# Import them at the top of this file when needed 

164 

165 

166class BaseGattService: # pylint: disable=too-many-public-methods 

167 """Base class for all GATT services. 

168 

169 Automatically resolves UUID, name, and summary from Bluetooth SIG specifications. 

170 Follows the same pattern as BaseCharacteristic for consistency. 

171 """ 

172 

173 # Class attributes for explicit name overrides 

174 _service_name: str | None = None 

175 _info: ServiceInfo | None = None # Populated in __post_init__ 

176 

177 def __init__( 

178 self, 

179 info: ServiceInfo | None = None, 

180 validation: ServiceValidationConfig | None = None, 

181 ) -> None: 

182 """Initialize service with structured configuration. 

183 

184 Args: 

185 info: Complete service information (optional for SIG services) 

186 validation: Validation constraints configuration (optional) 

187 

188 """ 

189 # Store provided info or None (will be resolved in __post_init__) 

190 self._provided_info = info 

191 

192 self.characteristics: dict[BluetoothUUID, BaseCharacteristic] = {} 

193 

194 # Set validation attributes from ServiceValidationConfig 

195 if validation: 

196 self.strict_validation = validation.strict_validation 

197 self.require_all_optional = validation.require_all_optional 

198 else: 

199 self.strict_validation = False 

200 self.require_all_optional = False 

201 

202 # Call post-init to resolve service info 

203 self.__post_init__() 

204 

205 def __post_init__(self) -> None: 

206 """Initialize service with resolved information.""" 

207 # Use provided info if available, otherwise resolve from SIG specs 

208 if self._provided_info: 

209 self._info = self._provided_info 

210 else: 

211 # Resolve service information using proper resolver 

212 self._info = SIGServiceResolver.resolve_for_class(type(self)) 

213 

214 @property 

215 def uuid(self) -> BluetoothUUID: 

216 """Get the service UUID from _info.""" 

217 assert self._info is not None, "Service info should be initialized in __post_init__" 

218 return self._info.uuid 

219 

220 @property 

221 def name(self) -> str: 

222 """Get the service name from _info.""" 

223 assert self._info is not None, "Service info should be initialized in __post_init__" 

224 return self._info.name 

225 

226 @property 

227 def summary(self) -> str: 

228 """Get the service summary from _info.""" 

229 assert self._info is not None, "Service info should be initialized in __post_init__" 

230 return self._info.description 

231 

232 @property 

233 def info(self) -> ServiceInfo: 

234 """Return the resolved service information for this instance. 

235 

236 The info property provides all metadata about the service, including UUID, name, and description. 

237 """ 

238 assert self._info is not None, "Service info should be initialized in __post_init__" 

239 return self._info 

240 

241 @classmethod 

242 def get_class_uuid(cls) -> BluetoothUUID: 

243 """Get the UUID for this service class without instantiation. 

244 

245 Returns: 

246 BluetoothUUID for this service class 

247 

248 Raises: 

249 UUIDResolutionError: If UUID cannot be resolved 

250 

251 """ 

252 info = SIGServiceResolver.resolve_for_class(cls) 

253 return info.uuid 

254 

255 @classmethod 

256 def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool: 

257 """Check if this service matches the given UUID.""" 

258 try: 

259 service_uuid = cls.get_class_uuid() 

260 if isinstance(uuid, BluetoothUUID): 

261 input_uuid = uuid 

262 else: 

263 input_uuid = BluetoothUUID(uuid) 

264 return service_uuid == input_uuid 

265 except (ValueError, UUIDResolutionError): 

266 return False 

267 

268 @classmethod 

269 def get_expected_characteristics(cls) -> ServiceCharacteristicCollection: 

270 """Get the expected characteristics for this service from the service_characteristics dict. 

271 

272 Looks for a 'service_characteristics' class attribute containing a dictionary of 

273 CharacteristicName -> required flag, and automatically builds CharacteristicSpec objects. 

274 

275 Returns: 

276 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec 

277 

278 """ 

279 # Build expected mapping keyed by CharacteristicName enum for type safety. 

280 expected: dict[CharacteristicName, CharacteristicSpec[BaseCharacteristic]] = {} 

281 

282 # Check if the service defines a service_characteristics dictionary 

283 svc_chars = getattr(cls, "service_characteristics", None) 

284 if svc_chars: 

285 for char_name, is_required in svc_chars.items(): 

286 char_class = CharacteristicRegistry.get_characteristic_class(char_name) 

287 if char_class: 

288 expected[char_name] = CharacteristicSpec(char_class=char_class, required=is_required) 

289 

290 # Return an enum-keyed dict for strong typing. Callers must perform 

291 # explicit conversions from strings to `CharacteristicName` where needed. 

292 return expected 

293 

294 @classmethod 

295 def get_required_characteristics(cls) -> ServiceCharacteristicCollection: 

296 """Get the required characteristics for this service from the characteristics dict. 

297 

298 Automatically filters the characteristics dictionary for required=True entries. 

299 

300 Returns: 

301 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec 

302 

303 """ 

304 expected = cls.get_expected_characteristics() 

305 return {name: spec for name, spec in expected.items() if spec.required} 

306 

307 # New strongly-typed methods (optional to implement) 

308 @classmethod 

309 def get_characteristics_schema(cls) -> type | None: 

310 """Get the TypedDict schema for this service's characteristics. 

311 

312 Override this method to provide strong typing for characteristics. 

313 If not implemented, falls back to get_expected_characteristics(). 

314 

315 Returns: 

316 TypedDict class defining the service's characteristics, or None 

317 

318 """ 

319 return None 

320 

321 @classmethod 

322 def get_required_characteristic_keys(cls) -> set[CharacteristicName]: 

323 """Get the set of required characteristic keys from the schema. 

324 

325 Override this method when using strongly-typed characteristics. 

326 If not implemented, falls back to get_required_characteristics().keys(). 

327 

328 Returns: 

329 Set of required characteristic field names 

330 

331 """ 

332 return set(cls.get_required_characteristics().keys()) 

333 

334 def get_expected_characteristic_uuids(self) -> set[BluetoothUUID]: 

335 """Get the set of expected characteristic UUIDs for this service.""" 

336 expected_uuids: set[BluetoothUUID] = set() 

337 for char_name, _char_spec in self.get_expected_characteristics().items(): 

338 # char_name is expected to be a CharacteristicName enum; handle accordingly 

339 try: 

340 lookup_name = char_name.value 

341 except AttributeError: 

342 lookup_name = str(char_name) 

343 char_info = uuid_registry.get_characteristic_info(lookup_name) 

344 if char_info: 

345 expected_uuids.add(char_info.uuid) 

346 return expected_uuids 

347 

348 def get_required_characteristic_uuids(self) -> set[BluetoothUUID]: 

349 """Get the set of required characteristic UUIDs for this service.""" 

350 required_uuids: set[BluetoothUUID] = set() 

351 for char_name, _char_spec in self.get_required_characteristics().items(): 

352 try: 

353 lookup_name = char_name.value 

354 except AttributeError: 

355 lookup_name = str(char_name) 

356 char_info = uuid_registry.get_characteristic_info(lookup_name) 

357 if char_info: 

358 required_uuids.add(char_info.uuid) 

359 return required_uuids 

360 

361 def process_characteristics(self, characteristics: ServiceDiscoveryData) -> None: 

362 """Process the characteristics for this service (default implementation). 

363 

364 Args: 

365 characteristics: Dict mapping UUID to characteristic info 

366 

367 """ 

368 for uuid, _ in characteristics.items(): 

369 char = CharacteristicRegistry.create_characteristic(uuid=uuid) 

370 if char: 

371 self.characteristics[uuid] = char 

372 

373 def get_characteristic(self, uuid: BluetoothUUID) -> GattCharacteristic | None: 

374 """Get a characteristic by UUID.""" 

375 if isinstance(uuid, str): 

376 uuid = BluetoothUUID(uuid) 

377 return self.characteristics.get(uuid) 

378 

379 @property 

380 def supported_characteristics(self) -> set[BaseCharacteristic]: 

381 """Get the set of characteristic UUIDs supported by this service.""" 

382 # Return set of characteristic instances, not UUID strings 

383 return set(self.characteristics.values()) 

384 

385 # New enhanced methods for service validation and health 

386 

387 @classmethod 

388 def get_optional_characteristics(cls) -> ServiceCharacteristicCollection: 

389 """Get the optional characteristics for this service by name and class. 

390 

391 Returns: 

392 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec 

393 

394 """ 

395 expected = cls.get_expected_characteristics() 

396 required = cls.get_required_characteristics() 

397 return {name: char_spec for name, char_spec in expected.items() if name not in required} 

398 

399 @classmethod 

400 def get_conditional_characteristics(cls) -> ServiceCharacteristicCollection: 

401 """Get characteristics that are required only under certain conditions. 

402 

403 Returns: 

404 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec 

405 

406 Override in subclasses to specify conditional characteristics. 

407 

408 """ 

409 return {} 

410 

411 @classmethod 

412 def validate_bluetooth_sig_compliance(cls) -> list[str]: 

413 """Validate compliance with Bluetooth SIG service specification. 

414 

415 Returns: 

416 List of compliance issues found 

417 

418 Override in subclasses to provide service-specific validation. 

419 

420 """ 

421 issues: list[str] = [] 

422 

423 # Check if service has at least one required characteristic 

424 required = cls.get_required_characteristics() 

425 if not required: 

426 issues.append("Service has no required characteristics defined") 

427 

428 # Check if all expected characteristics are valid 

429 expected = cls.get_expected_characteristics() 

430 for char_name, _char_spec in expected.items(): 

431 char_info = uuid_registry.get_characteristic_info(char_name.value) 

432 if not char_info: 

433 issues.append(f"Characteristic '{char_name.value}' not found in UUID registry") 

434 

435 return issues 

436 

437 def _validate_characteristic_group( 

438 self, 

439 char_dict: ServiceCharacteristicCollection, 

440 result: ServiceValidationResult, 

441 is_required: bool, 

442 strict: bool, 

443 ) -> None: 

444 """Validate a group of characteristics (required/optional/conditional).""" 

445 for char_name, char_spec in char_dict.items(): 

446 lookup_name = char_name.value if hasattr(char_name, "value") else str(char_name) 

447 char_info = uuid_registry.get_characteristic_info(lookup_name) 

448 

449 if not char_info: 

450 if is_required: 

451 result.errors.append(f"Unknown required characteristic: {lookup_name}") 

452 elif strict: 

453 result.warnings.append(f"Unknown optional characteristic: {lookup_name}") 

454 continue 

455 

456 if char_info.uuid not in self.characteristics: 

457 missing_char = char_spec.char_class() 

458 if is_required: 

459 result.missing_required.append(missing_char) 

460 result.errors.append(f"Missing required characteristic: {lookup_name}") 

461 else: 

462 result.missing_optional.append(missing_char) 

463 if strict: 

464 result.warnings.append(f"Missing optional characteristic: {lookup_name}") 

465 

466 def _validate_conditional_characteristics( 

467 self, conditional_chars: ServiceCharacteristicCollection, result: ServiceValidationResult 

468 ) -> None: 

469 """Validate conditional characteristics.""" 

470 for char_name, conditional_spec in conditional_chars.items(): 

471 lookup_name = char_name.value if hasattr(char_name, "value") else str(char_name) 

472 char_info = uuid_registry.get_characteristic_info(lookup_name) 

473 

474 if not char_info: 

475 result.warnings.append(f"Unknown conditional characteristic: {lookup_name}") 

476 continue 

477 

478 if char_info.uuid not in self.characteristics: 

479 result.warnings.append( 

480 f"Missing conditional characteristic: {lookup_name} (required when {conditional_spec.condition})" 

481 ) 

482 

483 def _determine_health_status(self, result: ServiceValidationResult, required_count: int, strict: bool) -> None: 

484 """Determine overall service health status.""" 

485 if result.missing_required: 

486 result.status = ( 

487 ServiceHealthStatus.INCOMPLETE 

488 if len(result.missing_required) >= required_count 

489 else ServiceHealthStatus.PARTIAL 

490 ) 

491 elif result.missing_optional and strict: 

492 result.status = ServiceHealthStatus.FUNCTIONAL 

493 elif result.warnings or result.invalid_characteristics: 

494 result.status = ServiceHealthStatus.FUNCTIONAL 

495 

496 def validate_service(self, strict: bool = False) -> ServiceValidationResult: # pylint: disable=too-many-branches 

497 """Validate the completeness and health of this service. 

498 

499 Args: 

500 strict: If True, missing optional characteristics are treated as warnings 

501 

502 Returns: 

503 ServiceValidationResult with detailed status information 

504 

505 """ 

506 result = ServiceValidationResult(status=ServiceHealthStatus.COMPLETE) 

507 

508 # Validate required, optional, and conditional characteristics 

509 self._validate_characteristic_group(self.get_required_characteristics(), result, True, strict) 

510 self._validate_characteristic_group(self.get_optional_characteristics(), result, False, strict) 

511 self._validate_conditional_characteristics(self.get_conditional_characteristics(), result) 

512 

513 # Validate existing characteristics 

514 for uuid, characteristic in self.characteristics.items(): 

515 try: 

516 _ = characteristic.uuid 

517 except (AttributeError, ValueError, TypeError) as e: 

518 result.invalid_characteristics.append(characteristic) 

519 result.errors.append(f"Invalid characteristic {uuid}: {e}") 

520 

521 # Determine overall health status 

522 self._determine_health_status(result, len(self.get_required_characteristics()), strict) 

523 

524 return result 

525 

526 def get_missing_characteristics( 

527 self, 

528 ) -> dict[CharacteristicName, ServiceCharacteristicInfo]: 

529 """Get detailed information about missing characteristics. 

530 

531 Returns: 

532 Dict mapping characteristic name to ServiceCharacteristicInfo 

533 

534 """ 

535 missing: dict[CharacteristicName, ServiceCharacteristicInfo] = {} 

536 expected_chars = self.get_expected_characteristics() 

537 required_chars = self.get_required_characteristics() 

538 conditional_chars = self.get_conditional_characteristics() 

539 

540 for char_name, _char_spec in expected_chars.items(): 

541 char_info = uuid_registry.get_characteristic_info(char_name.value) 

542 if not char_info: 

543 continue 

544 

545 uuid_obj = char_info.uuid 

546 if uuid_obj not in self.characteristics: 

547 is_required = char_name in required_chars 

548 is_conditional = char_name in conditional_chars 

549 condition_desc = "" 

550 if is_conditional: 

551 conditional_spec = conditional_chars.get(char_name) 

552 if conditional_spec: 

553 condition_desc = conditional_spec.condition 

554 

555 # Handle both new CharacteristicSpec format and legacy direct class format 

556 char_class = None 

557 if hasattr(_char_spec, "char_class"): 

558 char_class = _char_spec.char_class # New format 

559 else: 

560 char_class = cast( 

561 type[BaseCharacteristic], _char_spec 

562 ) # Legacy format: value is the class directly 

563 

564 missing[char_name] = ServiceCharacteristicInfo( 

565 name=char_name.value, 

566 uuid=char_info.uuid, 

567 status=CharacteristicStatus.MISSING, 

568 is_required=is_required, 

569 is_conditional=is_conditional, 

570 condition_description=condition_desc, 

571 char_class=char_class, 

572 ) 

573 

574 return missing 

575 

576 def _find_characteristic_enum( 

577 self, characteristic_name: str, expected_chars: dict[CharacteristicName, Any] 

578 ) -> CharacteristicName | None: 

579 """Find the enum that matches the characteristic name string. 

580 

581 NOTE: This is an internal helper. Public APIs should accept 

582 `CharacteristicName` enums directly; this helper will only be used 

583 temporarily by migrating call sites. 

584 """ 

585 for enum_char in expected_chars.keys(): 

586 if enum_char.value == characteristic_name: 

587 return enum_char 

588 return None 

589 

590 def _get_characteristic_metadata(self, char_enum: CharacteristicName) -> tuple[bool, bool, str]: 

591 """Get characteristic metadata (is_required, is_conditional, condition_desc). 

592 

593 Returns metadata about the characteristic's requirement and condition. 

594 """ 

595 required_chars = self.get_required_characteristics() 

596 conditional_chars = self.get_conditional_characteristics() 

597 

598 is_required = char_enum in required_chars 

599 is_conditional = char_enum in conditional_chars 

600 condition_desc = "" 

601 if is_conditional: 

602 conditional_spec = conditional_chars.get(char_enum) 

603 if conditional_spec: 

604 condition_desc = conditional_spec.condition 

605 

606 return is_required, is_conditional, condition_desc 

607 

608 def _get_characteristic_status(self, char_info: UuidInfo) -> CharacteristicStatus: 

609 """Get the status of a characteristic (present, missing, or invalid). 

610 

611 Returns the status of the characteristic for reporting and validation. 

612 """ 

613 uuid_obj = char_info.uuid 

614 if uuid_obj in self.characteristics: 

615 try: 

616 # Try to validate the characteristic 

617 char = self.characteristics[uuid_obj] 

618 _ = char.uuid # Basic validation 

619 return CharacteristicStatus.PRESENT 

620 except (KeyError, AttributeError, ValueError, TypeError): 

621 return CharacteristicStatus.INVALID 

622 return CharacteristicStatus.MISSING 

623 

624 def get_characteristic_status(self, characteristic_name: CharacteristicName) -> ServiceCharacteristicInfo | None: 

625 """Get detailed status of a specific characteristic. 

626 

627 Args: 

628 characteristic_name: CharacteristicName enum 

629 

630 Returns: 

631 CharacteristicInfo if characteristic is expected by this service, None otherwise 

632 

633 """ 

634 expected_chars = self.get_expected_characteristics() 

635 

636 char_enum = characteristic_name 

637 

638 # Only return status for characteristics that are expected by this service 

639 if char_enum not in expected_chars: 

640 return None 

641 

642 char_info = uuid_registry.get_characteristic_info(char_enum.value) 

643 if not char_info: 

644 return None 

645 

646 is_required, is_conditional, condition_desc = self._get_characteristic_metadata(char_enum) 

647 

648 char_class = None 

649 if char_enum in expected_chars: 

650 char_spec = expected_chars[char_enum] 

651 if hasattr(char_spec, "char_class"): 

652 char_class = char_spec.char_class # New format 

653 else: 

654 char_class = cast(type[BaseCharacteristic], char_spec) # Legacy format: value is the class directly 

655 

656 status = self._get_characteristic_status(char_info) 

657 

658 return ServiceCharacteristicInfo( 

659 name=characteristic_name.value, 

660 uuid=char_info.uuid, 

661 status=status, 

662 is_required=is_required, 

663 is_conditional=is_conditional, 

664 condition_description=condition_desc, 

665 char_class=char_class, 

666 ) 

667 

668 def get_service_completeness_report(self) -> ServiceCompletenessReport: 

669 """Get a comprehensive report about service completeness. 

670 

671 Returns: 

672 ServiceCompletenessReport with detailed service status information 

673 

674 """ 

675 validation = self.validate_service(strict=True) 

676 missing = self.get_missing_characteristics() 

677 

678 present_chars: list[str] = [] 

679 for uuid, char in self.characteristics.items(): 

680 try: 

681 char_name = char.name if hasattr(char, "name") else f"UUID:{str(uuid)}" 

682 present_chars.append(char_name) 

683 except (AttributeError, ValueError, TypeError): 

684 present_chars.append(f"Invalid:{str(uuid)}") 

685 

686 missing_details = { 

687 name.value: ServiceCharacteristicInfo( 

688 name=info.name, 

689 uuid=info.uuid, 

690 status=info.status, 

691 is_required=info.is_required, 

692 is_conditional=info.is_conditional, 

693 condition_description=info.condition_description, 

694 ) 

695 for name, info in missing.items() 

696 } 

697 

698 return ServiceCompletenessReport( 

699 service_name=self.name, 

700 service_uuid=self.uuid, 

701 health_status=validation.status, 

702 is_healthy=validation.is_healthy, 

703 characteristics_present=len(self.characteristics), 

704 characteristics_expected=len(self.get_expected_characteristics()), 

705 characteristics_required=len(self.get_required_characteristics()), 

706 present_characteristics=list(self.characteristics.values()), 

707 missing_required=validation.missing_required, 

708 missing_optional=validation.missing_optional, 

709 invalid_characteristics=validation.invalid_characteristics, 

710 warnings=validation.warnings, 

711 errors=validation.errors, 

712 missing_details=missing_details, 

713 ) 

714 

715 def has_minimum_functionality(self) -> bool: 

716 """Check if service has minimum required functionality. 

717 

718 Returns: 

719 True if service has all required characteristics and is usable 

720 

721 """ 

722 validation = self.validate_service() 

723 return (not validation.missing_required) and (validation.status != ServiceHealthStatus.INCOMPLETE) 

724 

725 

726class CustomBaseGattService(BaseGattService): 

727 """Helper base class for custom service implementations. 

728 

729 This class provides a wrapper around custom services that are not 

730 defined in the Bluetooth SIG specification. It supports both manual info passing 

731 and automatic class-level _info binding via __init_subclass__. 

732 """ 

733 

734 _is_custom = True 

735 _configured_info: ServiceInfo | None = None # Stores class-level _info 

736 _allows_sig_override = False # Default: no SIG override permission 

737 

738 # pylint: disable=duplicate-code 

739 # NOTE: __init_subclass__ and __init__ patterns are intentionally similar to CustomBaseCharacteristic. 

740 # This is by design - both custom characteristic and service classes need identical validation 

741 # and info management patterns. Consolidation not possible due to different base types and info types. 

742 def __init_subclass__(cls, allow_sig_override: bool = False, **kwargs: Any) -> None: # noqa: ANN401 # Receives subclass kwargs 

743 """Automatically set up _info if provided as class attribute. 

744 

745 Args: 

746 allow_sig_override: Set to True when intentionally overriding SIG UUIDs 

747 **kwargs: Additional keyword arguments for subclassing. 

748 

749 Raises: 

750 ValueError: If class uses SIG UUID without override permission 

751 

752 """ 

753 super().__init_subclass__(**kwargs) 

754 

755 cls._allows_sig_override = allow_sig_override 

756 

757 info = cls._info 

758 if info is not None: 

759 if not allow_sig_override and info.uuid.is_sig_service(): 

760 raise ValueError( 

761 f"{cls.__name__} uses SIG UUID {info.uuid} without override flag. " 

762 "Use custom UUID or add allow_sig_override=True parameter." 

763 ) 

764 cls._configured_info = info 

765 

766 def __init__( 

767 self, 

768 info: ServiceInfo | None = None, 

769 ) -> None: 

770 """Initialize a custom service with automatic _info resolution. 

771 

772 Args: 

773 info: Optional override for class-configured _info 

774 

775 Raises: 

776 ValueError: If no valid info available from class or parameter 

777 

778 """ 

779 # Use provided info, or fall back to class-configured _info 

780 final_info = info or self.__class__._configured_info 

781 

782 if not final_info: 

783 raise ValueError(f"{self.__class__.__name__} requires either 'info' parameter or '_info' class attribute") 

784 

785 if not final_info.uuid or str(final_info.uuid) == "0000": 

786 raise ValueError("Valid UUID is required for custom services") 

787 

788 # Call parent constructor with our info to maintain consistency 

789 super().__init__(info=final_info) 

790 

791 def __post_init__(self) -> None: 

792 """Initialise custom service info management for CustomBaseGattService. 

793 

794 Manages _info manually from provided or configured info, 

795 bypassing SIG resolution that would fail for custom services. 

796 """ 

797 # Use provided info if available (from manual override), otherwise use configured info 

798 if hasattr(self, "_provided_info") and self._provided_info: 

799 self._info = self._provided_info 

800 elif self.__class__._configured_info: # pylint: disable=protected-access 

801 # Access to _configured_info is intentional for class-level info management 

802 self._info = self.__class__._configured_info # pylint: disable=protected-access 

803 else: 

804 # This shouldn't happen if class setup is correct 

805 raise ValueError(f"CustomBaseGattService {self.__class__.__name__} has no valid info source") 

806 

807 def process_characteristics(self, characteristics: ServiceDiscoveryData) -> None: 

808 """Process discovered characteristics for this service. 

809 

810 Handles both Bluetooth SIG-defined characteristics and custom non-SIG characteristics. 

811 SIG characteristics are parsed using registered parsers, while non-SIG characteristics 

812 are stored as generic UnknownCharacteristic instances. 

813 

814 Args: 

815 characteristics: Dictionary mapping characteristic UUIDs to CharacteristicInfo 

816 

817 """ 

818 # Store characteristics for later access 

819 for uuid_obj, char_info in characteristics.items(): 

820 # Try to create SIG-defined characteristic first 

821 char_instance = CharacteristicRegistry.create_characteristic(uuid=uuid_obj.normalized) 

822 

823 # If no SIG characteristic found, create generic unknown characteristic 

824 if char_instance is None: 

825 char_instance = UnknownCharacteristic( 

826 info=BaseCharacteristicInfo( 

827 uuid=uuid_obj, 

828 name=char_info.name or f"Unknown Characteristic ({uuid_obj})", 

829 unit=char_info.unit or "", 

830 value_type=char_info.value_type, 

831 properties=char_info.properties or [], 

832 ) 

833 ) 

834 

835 if char_instance: 

836 self.characteristics[uuid_obj] = char_instance 

837 

838 

839class UnknownService(CustomBaseGattService): 

840 """Generic service for unknown/unregistered service UUIDs. 

841 

842 This class is used for services discovered at runtime that are not 

843 in the Bluetooth SIG specification or custom registry. It provides 

844 basic functionality while allowing characteristic processing. 

845 """ 

846 

847 def __init__(self, uuid: BluetoothUUID, name: str | None = None) -> None: 

848 """Initialize an unknown service with minimal info. 

849 

850 Args: 

851 uuid: The service UUID 

852 name: Optional custom name (defaults to "Unknown Service (UUID)") 

853 

854 """ 

855 info = ServiceInfo( 

856 uuid=uuid, 

857 name=name or f"Unknown Service ({uuid})", 

858 description="", 

859 ) 

860 super().__init__(info=info)