Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,5 @@ benchmark-history/
docs/source/_static/performance/*.json
docs/benchmarks/*.json.cache/gen_ref/
src/bluetooth_sig/_version.py

.tmp/
7 changes: 7 additions & 0 deletions docs/source/explanation/architecture/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ User's BLE Library → bytes → bluetooth-sig → structured data
- ✅ Consistent state across application
- ⚠️ Global state (acceptable for read-only registry data)

**Implementation note**:

- Access singleton registries via explicit getter/class methods (for example
``get_uuid_registry()`` and ``get_instance()``) instead of relying on
import-time module globals.
- Importing registry modules must be side-effect free with respect to YAML I/O.

## Summary

These architectural decisions prioritize:
Expand Down
6 changes: 5 additions & 1 deletion docs/source/explanation/architecture/registry-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,11 @@ The registry loading process follows these steps:
5. **Store canonically**: Index by normalized UUID in main dictionaries
6. **Generate aliases**: Create name-based lookup mappings

The loading is performed only once per application lifetime, with graceful degradation if YAML files are missing. See {py:class}`~bluetooth_sig.gatt.uuid_registry.UuidRegistry` for the complete implementation.
The loading is performed only once per application lifetime. Load failures are
surfaced deterministically (sticky failure state) rather than silently
continuing with partial empty data. See
{py:class}`~bluetooth_sig.gatt.uuid_registry.UuidRegistry` for the complete
implementation.

## Alias System

Expand Down
8 changes: 4 additions & 4 deletions docs/source/how-to/advertising-parsing.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ Interpreters are automatically registered when defined (via `__init_subclass__`)
from bluetooth_sig.advertising import (
AdvertisingPDUParser,
DeviceAdvertisingState,
payload_interpreter_registry,
get_payload_interpreter_registry,
)
from bluetooth_sig.advertising.base import AdvertisingData

Expand Down Expand Up @@ -367,7 +367,7 @@ advertising_data = AdvertisingData(
)

# Find interpreter for this advertisement
interpreter_class = payload_interpreter_registry.find_interpreter_class(advertising_data)
interpreter_class = get_payload_interpreter_registry().find_interpreter_class(advertising_data)

if interpreter_class:
# Create interpreter instance for this device
Expand Down Expand Up @@ -435,10 +435,10 @@ class StatefulInterpreter(PayloadInterpreter[MySensorData]):

```python
# SKIP: Depends on MySensorInterpreter class defined in previous examples
from bluetooth_sig.advertising import payload_interpreter_registry
from bluetooth_sig.advertising import get_payload_interpreter_registry

# Remove a specific interpreter
payload_interpreter_registry.unregister(MySensorInterpreter)
get_payload_interpreter_registry().unregister(MySensorInterpreter)
```

## See Also
Expand Down
11 changes: 11 additions & 0 deletions docs/source/performance/performance-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ The library is optimized for typical BLE use cases: periodic sensor reads, on-de

**Note**: UUID resolution dominates parsing overhead. Once registries are loaded, lookups are consistently fast (~190 μs).

### Registry Lifecycle Paths

| Operation | Mean | StdDev | Description |
|-----------|------|--------|-------------|
| UUID registry cold load | environment-dependent | n/a | First lookup from a fresh `UuidRegistry()` instance triggers YAML load |
| UUID registry warm lookup | ~190 μs | ±10 μs | Lookup after explicit `ensure_loaded()` |

Cold-load numbers vary based on filesystem and environment. Track regression
by comparing trends on the same CI runner class rather than hard-coding one
absolute number.

### Characteristic Parsing

| Characteristic Type | Mean | StdDev | Description |
Expand Down
4 changes: 2 additions & 2 deletions src/bluetooth_sig/advertising/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@
from bluetooth_sig.advertising.registry import (
PayloadContext,
PayloadInterpreterRegistry,
get_payload_interpreter_registry,
parse_advertising_payloads,
payload_interpreter_registry,
)
from bluetooth_sig.advertising.service_data_parser import ServiceDataParser
from bluetooth_sig.advertising.service_resolver import (
Expand Down Expand Up @@ -98,11 +98,11 @@
"SIGCharacteristicInterpreter",
"ServiceDataParser",
"UnsupportedVersionError",
"get_payload_interpreter_registry",
"build_ead_nonce",
"bytes_to_mac_address",
"decrypt_ead",
"decrypt_ead_from_raw",
"mac_address_to_bytes",
"parse_advertising_payloads",
"payload_interpreter_registry",
]
4 changes: 2 additions & 2 deletions src/bluetooth_sig/advertising/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ def __init_subclass__(cls, **kwargs: object) -> None:
return

# Lazy import to avoid circular dependency at module load time
from bluetooth_sig.advertising.registry import payload_interpreter_registry # noqa: PLC0415
from bluetooth_sig.advertising.registry import get_payload_interpreter_registry # noqa: PLC0415

payload_interpreter_registry.register(cls)
get_payload_interpreter_registry().register(cls)

@classmethod
@abstractmethod
Expand Down
12 changes: 6 additions & 6 deletions src/bluetooth_sig/advertising/pdu_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@

from bluetooth_sig.gatt.characteristics.utils import DataParser
from bluetooth_sig.gatt.constants import SIZE_UINT16, SIZE_UINT24, SIZE_UINT32, SIZE_UINT48, SIZE_UUID128
from bluetooth_sig.registry.core.ad_types import ad_types_registry
from bluetooth_sig.registry.core.appearance_values import appearance_values_registry
from bluetooth_sig.registry.core.class_of_device import class_of_device_registry
from bluetooth_sig.registry.core.ad_types import get_ad_types_registry
from bluetooth_sig.registry.core.appearance_values import get_appearance_values_registry
from bluetooth_sig.registry.core.class_of_device import get_class_of_device_registry
from bluetooth_sig.types import ManufacturerData
from bluetooth_sig.types.ad_types_constants import ADType
from bluetooth_sig.types.advertising.ad_structures import (
Expand Down Expand Up @@ -609,15 +609,15 @@ def _handle_property_ad_types(self, ad_type: int, ad_data: bytes, parsed: Advert
parsed.properties.tx_power = int.from_bytes(ad_data[:1], byteorder="little", signed=True)
elif ad_type == ADType.APPEARANCE and len(ad_data) >= SIZE_UINT16:
raw_value = DataParser.parse_int16(ad_data, 0, signed=False)
appearance_info = appearance_values_registry.get_appearance_info(raw_value)
appearance_info = get_appearance_values_registry().get_appearance_info(raw_value)
parsed.properties.appearance = AppearanceData(raw_value=raw_value, info=appearance_info)
elif ad_type == ADType.LE_SUPPORTED_FEATURES:
parsed.properties.le_supported_features = LEFeatures(raw_value=ad_data)
elif ad_type == ADType.LE_ROLE and len(ad_data) >= 1:
parsed.properties.le_role = ad_data[0]
elif ad_type == ADType.CLASS_OF_DEVICE and len(ad_data) >= SIZE_UINT24:
raw_cod = int.from_bytes(ad_data[:3], byteorder="little", signed=False)
parsed.properties.class_of_device = class_of_device_registry.decode_class_of_device(raw_cod)
parsed.properties.class_of_device = get_class_of_device_registry().decode_class_of_device(raw_cod)
elif ad_type == ADType.MANUFACTURER_SPECIFIC_DATA and len(ad_data) >= SIZE_UINT16:
self._parse_manufacturer_data(ad_data, parsed)
else:
Expand Down Expand Up @@ -768,7 +768,7 @@ def _parse_ad_structures(self, data: bytes) -> AdvertisingDataStructures:
ad_type = data[i + 1]
ad_data = data[i + 2 : i + length + 1]

if not ad_types_registry.is_known_ad_type(ad_type):
if not get_ad_types_registry().is_known_ad_type(ad_type):
logger.warning("Unknown AD type encountered: 0x%02X", ad_type)

# Dispatch to category handlers
Expand Down
24 changes: 19 additions & 5 deletions src/bluetooth_sig/advertising/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import threading
from typing import Any

import msgspec
Expand Down Expand Up @@ -41,6 +42,18 @@ class PayloadInterpreterRegistry:

"""

_instance: PayloadInterpreterRegistry | None = None
_instance_lock = threading.RLock()

@classmethod
def get_instance(cls) -> PayloadInterpreterRegistry:
"""Return the process-wide PayloadInterpreterRegistry singleton instance."""
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance

def __init__(self) -> None:
"""Initialise empty registry."""
self._by_service_uuid: dict[str, list[type[PayloadInterpreter[Any]]]] = {}
Expand Down Expand Up @@ -186,8 +199,9 @@ def clear(self) -> None:
self._fallback.clear()


# Global singleton for PayloadInterpreter registration
payload_interpreter_registry = PayloadInterpreterRegistry()
def get_payload_interpreter_registry() -> PayloadInterpreterRegistry:
"""Return the process-wide payload interpreter registry singleton instance."""
return PayloadInterpreterRegistry.get_instance()


def parse_advertising_payloads(
Expand All @@ -208,7 +222,7 @@ def parse_advertising_payloads(
service_data: Service UUID → payload bytes mapping.
context: Advertisement context (MAC address, RSSI, timestamp).
state: Current device advertising state (optional, created if None).
registry: Interpreter registry to use (defaults to global registry).
registry: Interpreter registry to use (defaults to process-wide registry).

Returns:
List of parsed data from all matching interpreters.
Expand All @@ -230,9 +244,9 @@ def parse_advertising_payloads(
"""
results: list[Any] = []

# Use global registry if none provided
# Use process-wide registry if none provided
if registry is None:
registry = payload_interpreter_registry
registry = get_payload_interpreter_registry()

# Create state if not provided
if state is None:
Expand Down
6 changes: 3 additions & 3 deletions src/bluetooth_sig/core/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from ..gatt.characteristics.registry import CharacteristicRegistry
from ..gatt.services import ServiceName
from ..gatt.services.registry import GattServiceRegistry
from ..gatt.uuid_registry import uuid_registry
from ..gatt.uuid_registry import get_uuid_registry
from ..types import (
CharacteristicInfo,
ServiceInfo,
Expand Down Expand Up @@ -154,7 +154,7 @@ def get_service_info_by_name(self, name: str | ServiceName) -> ServiceInfo | Non
name_str = name.value if isinstance(name, ServiceName) else name

try:
uuid_info = uuid_registry.get_service_info(name_str)
uuid_info = get_uuid_registry().get_service_info(name_str)
if uuid_info:
return ServiceInfo(uuid=uuid_info.uuid, name=uuid_info.name, characteristics=[])
except Exception: # pylint: disable=broad-exception-caught
Expand Down Expand Up @@ -279,7 +279,7 @@ def get_sig_info_by_name(self, name: str) -> SIGInfo | None:

"""
try:
char_info = uuid_registry.get_characteristic_info(name)
char_info = get_uuid_registry().get_characteristic_info(name)
if char_info:
return CharacteristicInfo(
uuid=char_info.uuid,
Expand Down
6 changes: 3 additions & 3 deletions src/bluetooth_sig/core/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ..gatt.characteristics.registry import CharacteristicRegistry
from ..gatt.services.base import BaseGattService
from ..gatt.services.registry import GattServiceRegistry
from ..gatt.uuid_registry import uuid_registry
from ..gatt.uuid_registry import get_uuid_registry
from ..types import (
CharacteristicInfo,
ServiceInfo,
Expand Down Expand Up @@ -49,7 +49,7 @@ def register_custom_characteristic_class(
CharacteristicRegistry.register_characteristic_class(uuid_or_name, cls, override)

if info:
uuid_registry.register_characteristic(
get_uuid_registry().register_characteristic(
uuid=info.uuid,
name=info.name or cls.__name__,
identifier=info.id,
Expand Down Expand Up @@ -81,7 +81,7 @@ def register_custom_service_class(
GattServiceRegistry.register_service_class(uuid_or_name, cls, override)

if info:
uuid_registry.register_service(
get_uuid_registry().register_service(
uuid=info.uuid,
name=info.name or cls.__name__,
identifier=info.id,
Expand Down
4 changes: 2 additions & 2 deletions src/bluetooth_sig/gatt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
ValueRangeError,
)
from .services.base import BaseGattService
from .uuid_registry import UuidRegistry, uuid_registry
from .uuid_registry import UuidRegistry, get_uuid_registry

__all__ = [
# Constants
Expand All @@ -48,5 +48,5 @@
"UnitMetadata",
"UuidRegistry",
"ValueRangeError",
"uuid_registry",
"get_uuid_registry",
]
4 changes: 2 additions & 2 deletions src/bluetooth_sig/gatt/characteristics/appearance.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from ...registry.core.appearance_values import appearance_values_registry
from ...registry.core.appearance_values import get_appearance_values_registry
from ...types.appearance import AppearanceData
from ...types.gatt_enums import CharacteristicRole
from ..context import CharacteristicContext
Expand Down Expand Up @@ -35,7 +35,7 @@ def _decode_value(
AppearanceData with raw value and optional human-readable info.
"""
raw_value = DataParser.parse_int16(data, 0, signed=False)
appearance_info = appearance_values_registry.get_appearance_info(raw_value)
appearance_info = get_appearance_values_registry().get_appearance_info(raw_value)

return AppearanceData(
raw_value=raw_value,
Expand Down
8 changes: 4 additions & 4 deletions src/bluetooth_sig/gatt/characteristics/characteristic_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@

import msgspec

from ...registry.uuids.units import units_registry
from ...registry.uuids.units import get_units_registry
from ...types import CharacteristicInfo
from ...types.gatt_enums import WIRE_TYPE_MAP
from ...types.registry import CharacteristicSpec
from ..exceptions import UUIDResolutionError
from ..resolver import CharacteristicRegistrySearch, NameNormalizer, NameVariantGenerator
from ..uuid_registry import uuid_registry
from ..uuid_registry import get_uuid_registry

# ---------------------------------------------------------------------------
# Validation configuration
Expand Down Expand Up @@ -87,7 +87,7 @@ def resolve_yaml_spec_for_class(char_class: type) -> CharacteristicSpec | None:
names_to_try = NameVariantGenerator.generate_characteristic_variants(char_class.__name__, characteristic_name)

for try_name in names_to_try:
spec = uuid_registry.resolve_characteristic_spec(try_name)
spec = get_uuid_registry().resolve_characteristic_spec(try_name)
if spec:
return spec

Expand Down Expand Up @@ -119,7 +119,7 @@ def _create_info_from_yaml(yaml_spec: CharacteristicSpec, char_class: type) -> C
unit_info = None
unit_name = getattr(yaml_spec, "unit_symbol", None) or getattr(yaml_spec, "unit", None)
if unit_name:
unit_info = units_registry.get_unit_info_by_name(unit_name)
unit_info = get_units_registry().get_unit_info_by_name(unit_name)
if unit_info:
unit_symbol = str(getattr(unit_info, "symbol", getattr(unit_info, "name", unit_name)))
else:
Expand Down
4 changes: 2 additions & 2 deletions src/bluetooth_sig/gatt/characteristics/context_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from ...types.gatt_enums import CharacteristicName
from ...types.uuid import BluetoothUUID
from ..context import CharacteristicContext
from ..uuid_registry import uuid_registry
from ..uuid_registry import get_uuid_registry


class ContextLookupMixin:
Expand All @@ -35,7 +35,7 @@ def _get_characteristic_uuid_by_name(
name_str = (
characteristic_name.value if isinstance(characteristic_name, CharacteristicName) else characteristic_name
)
char_info = uuid_registry.get_characteristic_info(name_str)
char_info = get_uuid_registry().get_characteristic_info(name_str)
return char_info.uuid if char_info else None

def get_context_characteristic(
Expand Down
6 changes: 3 additions & 3 deletions src/bluetooth_sig/gatt/characteristics/preferred_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import msgspec

from ...registry.uuids.units import units_registry
from ...registry.uuids.units import get_units_registry
from ...types.gatt_enums import CharacteristicRole
from ...types.registry.units import UnitInfo
from ...types.uuid import BluetoothUUID
Expand Down Expand Up @@ -88,7 +88,7 @@ def get_units(self, data: PreferredUnitsData) -> list[UnitInfo]:
"""
units: list[UnitInfo] = []
for unit_uuid in data.units:
unit_info = units_registry.get_unit_info(unit_uuid)
unit_info = get_units_registry().get_unit_info(unit_uuid)
if unit_info:
units.append(unit_info)
else:
Expand All @@ -113,6 +113,6 @@ def validate_units(self, data: PreferredUnitsData) -> list[str]:
"""
errors: list[str] = []
for i, unit_uuid in enumerate(data.units):
if not units_registry.is_unit_uuid(unit_uuid):
if not get_units_registry().is_unit_uuid(unit_uuid):
errors.append(f"Unit at index {i} ({unit_uuid.short_form}) is not a recognized Bluetooth SIG unit")
return errors
Loading
Loading