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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
1"""Base class for GATT service implementations."""
3from __future__ import annotations
5from enum import Enum
6from typing import Any, TypeVar, cast
8import msgspec
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
25# Type aliases
26GattCharacteristic = BaseCharacteristic
27# Strong-typed collection with enum keys
28ServiceCharacteristicCollection = CharacteristicCollection # Alias for compatibility
30# Generic type variable for service-specific characteristic definitions
31ServiceCharacteristics = TypeVar("ServiceCharacteristics")
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.
39class ServiceValidationConfig(msgspec.Struct, kw_only=True):
40 """Configuration for service validation constraints.
42 Groups validation parameters into a single, optional configuration object
43 to simplify BaseGattService constructor signatures.
44 """
46 strict_validation: bool = False
47 require_all_optional: bool = False
50class SIGServiceResolver:
51 """Resolves SIG service information from registry.
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 """
58 @staticmethod
59 def resolve_for_class(service_class: type[BaseGattService]) -> ServiceInfo:
60 """Resolve ServiceInfo for a SIG service class.
62 Args:
63 service_class: The service class to resolve info for
65 Returns:
66 ServiceInfo with resolved UUID, name, summary
68 Raises:
69 UUIDResolutionError: If no UUID can be resolved for the class
71 """
72 # Try registry resolution
73 registry_info = SIGServiceResolver.resolve_from_registry(service_class)
74 if registry_info:
75 return registry_info
77 # No resolution found
78 raise UUIDResolutionError(service_class.__name__, [service_class.__name__])
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)
89class ServiceHealthStatus(Enum):
90 """Health status of a GATT service."""
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
98class CharacteristicStatus(Enum):
99 """Status of characteristics within a service."""
101 PRESENT = "present" # Characteristic is present and functional
102 MISSING = "missing" # Expected characteristic not found
103 INVALID = "invalid" # Characteristic found but invalid/unusable
106class ServiceValidationResult(msgspec.Struct, kw_only=True):
107 """Result of service validation."""
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)
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 )
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
130class ServiceCharacteristicInfo(BaseCharacteristicInfo):
131 """Service-specific information about a characteristic with context about its presence.
133 Provides status, requirement, and class context for a characteristic within a service.
134 """
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
143class ServiceCompletenessReport(msgspec.Struct, kw_only=True): # pylint: disable=too-many-instance-attributes
144 """Comprehensive report about service completeness and health."""
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)
162# All type definitions moved to src/bluetooth_sig/types/gatt_services.py
163# Import them at the top of this file when needed
166class BaseGattService: # pylint: disable=too-many-public-methods
167 """Base class for all GATT services.
169 Automatically resolves UUID, name, and summary from Bluetooth SIG specifications.
170 Follows the same pattern as BaseCharacteristic for consistency.
171 """
173 # Class attributes for explicit name overrides
174 _service_name: str | None = None
175 _info: ServiceInfo | None = None # Populated in __post_init__
177 def __init__(
178 self,
179 info: ServiceInfo | None = None,
180 validation: ServiceValidationConfig | None = None,
181 ) -> None:
182 """Initialize service with structured configuration.
184 Args:
185 info: Complete service information (optional for SIG services)
186 validation: Validation constraints configuration (optional)
188 """
189 # Store provided info or None (will be resolved in __post_init__)
190 self._provided_info = info
192 self.characteristics: dict[BluetoothUUID, BaseCharacteristic] = {}
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
202 # Call post-init to resolve service info
203 self.__post_init__()
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))
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
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
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
232 @property
233 def info(self) -> ServiceInfo:
234 """Return the resolved service information for this instance.
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
241 @classmethod
242 def get_class_uuid(cls) -> BluetoothUUID:
243 """Get the UUID for this service class without instantiation.
245 Returns:
246 BluetoothUUID for this service class
248 Raises:
249 UUIDResolutionError: If UUID cannot be resolved
251 """
252 info = SIGServiceResolver.resolve_for_class(cls)
253 return info.uuid
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
268 @classmethod
269 def get_expected_characteristics(cls) -> ServiceCharacteristicCollection:
270 """Get the expected characteristics for this service from the service_characteristics dict.
272 Looks for a 'service_characteristics' class attribute containing a dictionary of
273 CharacteristicName -> required flag, and automatically builds CharacteristicSpec objects.
275 Returns:
276 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec
278 """
279 # Build expected mapping keyed by CharacteristicName enum for type safety.
280 expected: dict[CharacteristicName, CharacteristicSpec[BaseCharacteristic]] = {}
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)
290 # Return an enum-keyed dict for strong typing. Callers must perform
291 # explicit conversions from strings to `CharacteristicName` where needed.
292 return expected
294 @classmethod
295 def get_required_characteristics(cls) -> ServiceCharacteristicCollection:
296 """Get the required characteristics for this service from the characteristics dict.
298 Automatically filters the characteristics dictionary for required=True entries.
300 Returns:
301 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec
303 """
304 expected = cls.get_expected_characteristics()
305 return {name: spec for name, spec in expected.items() if spec.required}
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.
312 Override this method to provide strong typing for characteristics.
313 If not implemented, falls back to get_expected_characteristics().
315 Returns:
316 TypedDict class defining the service's characteristics, or None
318 """
319 return None
321 @classmethod
322 def get_required_characteristic_keys(cls) -> set[CharacteristicName]:
323 """Get the set of required characteristic keys from the schema.
325 Override this method when using strongly-typed characteristics.
326 If not implemented, falls back to get_required_characteristics().keys().
328 Returns:
329 Set of required characteristic field names
331 """
332 return set(cls.get_required_characteristics().keys())
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
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
361 def process_characteristics(self, characteristics: ServiceDiscoveryData) -> None:
362 """Process the characteristics for this service (default implementation).
364 Args:
365 characteristics: Dict mapping UUID to characteristic info
367 """
368 for uuid, _ in characteristics.items():
369 char = CharacteristicRegistry.create_characteristic(uuid=uuid)
370 if char:
371 self.characteristics[uuid] = char
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)
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())
385 # New enhanced methods for service validation and health
387 @classmethod
388 def get_optional_characteristics(cls) -> ServiceCharacteristicCollection:
389 """Get the optional characteristics for this service by name and class.
391 Returns:
392 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec
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}
399 @classmethod
400 def get_conditional_characteristics(cls) -> ServiceCharacteristicCollection:
401 """Get characteristics that are required only under certain conditions.
403 Returns:
404 ServiceCharacteristicCollection mapping characteristic name to CharacteristicSpec
406 Override in subclasses to specify conditional characteristics.
408 """
409 return {}
411 @classmethod
412 def validate_bluetooth_sig_compliance(cls) -> list[str]:
413 """Validate compliance with Bluetooth SIG service specification.
415 Returns:
416 List of compliance issues found
418 Override in subclasses to provide service-specific validation.
420 """
421 issues: list[str] = []
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")
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")
435 return issues
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)
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
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}")
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)
474 if not char_info:
475 result.warnings.append(f"Unknown conditional characteristic: {lookup_name}")
476 continue
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 )
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
496 def validate_service(self, strict: bool = False) -> ServiceValidationResult: # pylint: disable=too-many-branches
497 """Validate the completeness and health of this service.
499 Args:
500 strict: If True, missing optional characteristics are treated as warnings
502 Returns:
503 ServiceValidationResult with detailed status information
505 """
506 result = ServiceValidationResult(status=ServiceHealthStatus.COMPLETE)
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)
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}")
521 # Determine overall health status
522 self._determine_health_status(result, len(self.get_required_characteristics()), strict)
524 return result
526 def get_missing_characteristics(
527 self,
528 ) -> dict[CharacteristicName, ServiceCharacteristicInfo]:
529 """Get detailed information about missing characteristics.
531 Returns:
532 Dict mapping characteristic name to ServiceCharacteristicInfo
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()
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
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
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
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 )
574 return missing
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.
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
590 def _get_characteristic_metadata(self, char_enum: CharacteristicName) -> tuple[bool, bool, str]:
591 """Get characteristic metadata (is_required, is_conditional, condition_desc).
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()
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
606 return is_required, is_conditional, condition_desc
608 def _get_characteristic_status(self, char_info: UuidInfo) -> CharacteristicStatus:
609 """Get the status of a characteristic (present, missing, or invalid).
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
624 def get_characteristic_status(self, characteristic_name: CharacteristicName) -> ServiceCharacteristicInfo | None:
625 """Get detailed status of a specific characteristic.
627 Args:
628 characteristic_name: CharacteristicName enum
630 Returns:
631 CharacteristicInfo if characteristic is expected by this service, None otherwise
633 """
634 expected_chars = self.get_expected_characteristics()
636 char_enum = characteristic_name
638 # Only return status for characteristics that are expected by this service
639 if char_enum not in expected_chars:
640 return None
642 char_info = uuid_registry.get_characteristic_info(char_enum.value)
643 if not char_info:
644 return None
646 is_required, is_conditional, condition_desc = self._get_characteristic_metadata(char_enum)
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
656 status = self._get_characteristic_status(char_info)
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 )
668 def get_service_completeness_report(self) -> ServiceCompletenessReport:
669 """Get a comprehensive report about service completeness.
671 Returns:
672 ServiceCompletenessReport with detailed service status information
674 """
675 validation = self.validate_service(strict=True)
676 missing = self.get_missing_characteristics()
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)}")
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 }
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 )
715 def has_minimum_functionality(self) -> bool:
716 """Check if service has minimum required functionality.
718 Returns:
719 True if service has all required characteristics and is usable
721 """
722 validation = self.validate_service()
723 return (not validation.missing_required) and (validation.status != ServiceHealthStatus.INCOMPLETE)
726class CustomBaseGattService(BaseGattService):
727 """Helper base class for custom service implementations.
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 """
734 _is_custom = True
735 _configured_info: ServiceInfo | None = None # Stores class-level _info
736 _allows_sig_override = False # Default: no SIG override permission
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.
745 Args:
746 allow_sig_override: Set to True when intentionally overriding SIG UUIDs
747 **kwargs: Additional keyword arguments for subclassing.
749 Raises:
750 ValueError: If class uses SIG UUID without override permission
752 """
753 super().__init_subclass__(**kwargs)
755 cls._allows_sig_override = allow_sig_override
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
766 def __init__(
767 self,
768 info: ServiceInfo | None = None,
769 ) -> None:
770 """Initialize a custom service with automatic _info resolution.
772 Args:
773 info: Optional override for class-configured _info
775 Raises:
776 ValueError: If no valid info available from class or parameter
778 """
779 # Use provided info, or fall back to class-configured _info
780 final_info = info or self.__class__._configured_info
782 if not final_info:
783 raise ValueError(f"{self.__class__.__name__} requires either 'info' parameter or '_info' class attribute")
785 if not final_info.uuid or str(final_info.uuid) == "0000":
786 raise ValueError("Valid UUID is required for custom services")
788 # Call parent constructor with our info to maintain consistency
789 super().__init__(info=final_info)
791 def __post_init__(self) -> None:
792 """Initialise custom service info management for CustomBaseGattService.
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")
807 def process_characteristics(self, characteristics: ServiceDiscoveryData) -> None:
808 """Process discovered characteristics for this service.
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.
814 Args:
815 characteristics: Dictionary mapping characteristic UUIDs to CharacteristicInfo
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)
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 )
835 if char_instance:
836 self.characteristics[uuid_obj] = char_instance
839class UnknownService(CustomBaseGattService):
840 """Generic service for unknown/unregistered service UUIDs.
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 """
847 def __init__(self, uuid: BluetoothUUID, name: str | None = None) -> None:
848 """Initialize an unknown service with minimal info.
850 Args:
851 uuid: The service UUID
852 name: Optional custom name (defaults to "Unknown Service (UUID)")
854 """
855 info = ServiceInfo(
856 uuid=uuid,
857 name=name or f"Unknown Service ({uuid})",
858 description="",
859 )
860 super().__init__(info=info)