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
7 changes: 3 additions & 4 deletions examples/connection_managers/bleak_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,16 +192,15 @@ async def get_services(self) -> list[DeviceService]:
char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(char_uuid)

if char_class:
# Create characteristic instance with runtime properties from device
char_instance = char_class(properties=properties)
char_instance = char_class()
characteristics[str(char_uuid)] = char_instance
else:
# Fallback: Create UnknownCharacteristic for unrecognized UUIDs
char_info = CharacteristicInfo(
uuid=char_uuid,
name=char.description or f"Unknown Characteristic ({char_uuid.short_form}...)",
name=char.description or char_uuid.short_form,
)
char_instance = UnknownCharacteristic(info=char_info, properties=properties)
char_instance = UnknownCharacteristic(info=char_info)
characteristics[str(char_uuid)] = char_instance

# Type ignore needed due to dict invariance with union types
Expand Down
7 changes: 3 additions & 4 deletions examples/connection_managers/bluepy.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,16 +279,15 @@ def _get_services() -> list[DeviceService]:
char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(char_uuid)

if char_class:
# Create characteristic instance with runtime properties from device
char_instance = char_class(properties=properties)
char_instance = char_class()
characteristics[str(char_uuid)] = char_instance
else:
# Fallback: Create UnknownCharacteristic for unrecognized UUIDs
char_info = CharacteristicInfo(
uuid=char_uuid,
name=f"Unknown Characteristic ({char_uuid.short_form}...)",
name=char_uuid.short_form,
)
char_instance = UnknownCharacteristic(info=char_info, properties=properties)
char_instance = UnknownCharacteristic(info=char_info)
characteristics[str(char_uuid)] = char_instance

# Type ignore needed due to dict invariance with union types
Expand Down
7 changes: 3 additions & 4 deletions examples/connection_managers/simpleble.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,16 +285,15 @@ def _get_services() -> list[DeviceService]:
char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(char_uuid)

if char_class:
# Create characteristic instance with runtime properties from device
char_instance = char_class(properties=properties)
char_instance = char_class()
characteristics[str(char_uuid)] = char_instance
else:
# Fallback: Create UnknownCharacteristic for unrecognized UUIDs
char_info = CharacteristicInfo(
uuid=char_uuid,
name=f"Unknown Characteristic ({char_uuid.short_form}...)",
name=char_uuid.short_form,
)
char_instance = UnknownCharacteristic(info=char_info, properties=properties)
char_instance = UnknownCharacteristic(info=char_info)
characteristics[str(char_uuid)] = char_instance

# Type ignore needed due to dict invariance with union types
Expand Down
4 changes: 1 addition & 3 deletions scripts/ble_device_debugger.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ async def debug_ble_device(target_address: str) -> None:
try:
if len(value) == 1:
int_val = value[0]
elif len(value) == 2:
int_val = int.from_bytes(value, byteorder="little")
elif len(value) == 4:
elif len(value) == 2 or len(value) == 4:
int_val = int.from_bytes(value, byteorder="little")
else:
int_val = None
Expand Down
25 changes: 12 additions & 13 deletions scripts/extract_validation_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,18 @@
import importlib
import inspect
import json
import tempfile
from pathlib import Path
from typing import Any

# Add src to path
import sys
import tempfile
from pathlib import Path
from typing import Any

src_path = Path(__file__).parent.parent / "src"
if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path))

from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic
from bluetooth_sig.types.gatt_enums import ValueType


def get_all_characteristic_classes() -> list[type[BaseCharacteristic]]:
Expand Down Expand Up @@ -349,7 +348,7 @@ def main() -> None:
# Generate report
try:
generate_validation_report(output_file)
print(f"\n📊 Report summary:")
print("\n📊 Report summary:")
print(f" Output file: {output_file}")

# Show stats
Expand All @@ -362,14 +361,14 @@ def main() -> None:
if with_errors:
print(f" With errors: {with_errors}")

print(f"\n📋 Output format:")
print(f" Each value includes a 'source' field indicating origin:")
print(f" - resolved_sig_info: From Bluetooth SIG registry")
print(f" - yaml_spec: From Bluetooth SIG YAML specifications")
print(f" - class_level_or_yaml: From characteristic class or YAML defaults")
print(f" - manual_override: Explicit manual override")
print(f" - runtime_descriptor_check: Discovered at runtime")
print(f" - (see script header for full source legend)")
print("\n📋 Output format:")
print(" Each value includes a 'source' field indicating origin:")
print(" - resolved_sig_info: From Bluetooth SIG registry")
print(" - yaml_spec: From Bluetooth SIG YAML specifications")
print(" - class_level_or_yaml: From characteristic class or YAML defaults")
print(" - manual_override: Explicit manual override")
print(" - runtime_descriptor_check: Discovered at runtime")
print(" - (see script header for full source legend)")

except Exception as e:
print(f"❌ Error: {e}", file=sys.stderr)
Expand Down
3 changes: 1 addition & 2 deletions scripts/test_real_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import logging
import sys
from pathlib import Path
from typing import Optional

# Configure path for imports
script_dir = Path(__file__).parent
Expand All @@ -32,7 +31,7 @@
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")


async def test_device_connection(mac_address: str) -> Optional[bool]:
async def test_device_connection(mac_address: str) -> bool | None:
"""Test connection to a real device using Bleak."""
print(f"\n🔍 Testing connection to device: {mac_address}")
print("=" * 60)
Expand Down
8 changes: 8 additions & 0 deletions src/bluetooth_sig/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
from .types.base_types import SIGInfo
from .types.data_types import CharacteristicInfo, ServiceInfo, ValidationResult

# Consumer utilities
from .utils.prewarm import prewarm_registries
from .utils.values import is_struct_value, to_primitive

try:
from ._version import __version__
except ImportError:
Expand All @@ -40,6 +44,10 @@
"SIGInfo",
"ServiceInfo",
"ValidationResult",
# Consumer utilities
"is_struct_value",
"prewarm_registries",
"to_primitive",
# Version
"__version__",
]
21 changes: 17 additions & 4 deletions src/bluetooth_sig/core/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from __future__ import annotations

import logging
from typing import Any

from ..gatt.characteristics.base import BaseCharacteristic
from ..gatt.characteristics.registry import CharacteristicRegistry
from ..gatt.services import ServiceName
from ..gatt.services.registry import GattServiceRegistry
Expand Down Expand Up @@ -234,14 +236,18 @@ def get_characteristics_info_by_uuids(self, uuids: list[str]) -> dict[str, Chara
results[uuid] = self.get_characteristic_info_by_uuid(uuid)
return results

def get_service_characteristics(self, service_uuid: str) -> list[str]:
"""Get the characteristic UUIDs associated with a service.
def get_service_characteristics(self, service_uuid: str) -> list[BaseCharacteristic[Any]]:
"""Get the characteristic instances associated with a service.

Instantiates each required characteristic class from the service
definition and returns the live objects.

Args:
service_uuid: The service UUID

Returns:
List of characteristic UUIDs for this service
List of BaseCharacteristic instances for this service's
required characteristics.

"""
service_class = GattServiceRegistry.get_service_class_by_uuid(BluetoothUUID(service_uuid))
Expand All @@ -251,9 +257,16 @@ def get_service_characteristics(self, service_uuid: str) -> list[str]:
try:
temp_service = service_class()
required_chars = temp_service.get_required_characteristics()
return [str(k) for k in required_chars]
result: list[BaseCharacteristic[Any]] = []
for spec in required_chars.values():
try:
result.append(spec.char_class())
except (TypeError, ValueError, AttributeError):
logger.debug("Could not instantiate %s", spec.char_class.__name__)
except Exception: # pylint: disable=broad-exception-caught
return []
else:
return result

def get_sig_info_by_name(self, name: str) -> SIGInfo | None:
"""Get Bluetooth SIG information for a characteristic or service by name.
Expand Down
10 changes: 7 additions & 3 deletions src/bluetooth_sig/core/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,14 +427,18 @@ def get_characteristics_info_by_uuids(self, uuids: list[str]) -> dict[str, Chara
"""
return self._query.get_characteristics_info_by_uuids(uuids)

def get_service_characteristics(self, service_uuid: str) -> list[str]:
"""Get the characteristic UUIDs associated with a service.
def get_service_characteristics(self, service_uuid: str) -> list[BaseCharacteristic[Any]]:
"""Get the characteristic instances associated with a service.

Instantiates each required characteristic class from the service
definition and returns the live objects.

Args:
service_uuid: The service UUID

Returns:
List of characteristic UUIDs for this service
List of BaseCharacteristic instances for this service's
required characteristics.

"""
return self._query.get_service_characteristics(service_uuid)
Comment on lines +430 to 444
Expand Down
2 changes: 1 addition & 1 deletion src/bluetooth_sig/device/dependency_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ async def _resolve_single(
if char_class_or_none:
char_instance = char_class_or_none()
else:
char_info = CharacteristicInfo(uuid=dep_uuid, name=f"Unknown-{dep_uuid_str}")
char_info = CharacteristicInfo(uuid=dep_uuid, name=dep_uuid_str)
char_instance = UnknownCharacteristic(info=char_info)

self._connected.cache_characteristic(dep_uuid, char_instance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class AccelerationDetectionStatus(IntEnum):
CHANGE_DETECTED = 1


class AccelerationDetectionStatusCharacteristic(BaseCharacteristic[int]):
class AccelerationDetectionStatusCharacteristic(BaseCharacteristic[AccelerationDetectionStatus]):
"""Acceleration Detection Status characteristic (0x2C0B).

org.bluetooth.characteristic.acceleration_detection_status
Expand Down
2 changes: 1 addition & 1 deletion src/bluetooth_sig/gatt/characteristics/alert_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class AlertLevel(IntEnum):
HIGH_ALERT = 0x02


class AlertLevelCharacteristic(BaseCharacteristic[int]):
class AlertLevelCharacteristic(BaseCharacteristic[AlertLevel]):
"""Alert Level characteristic (0x2A06).

org.bluetooth.characteristic.alert_level
Expand Down
2 changes: 2 additions & 0 deletions src/bluetooth_sig/gatt/characteristics/appearance.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from ...registry.core.appearance_values import appearance_values_registry
from ...types.appearance import AppearanceData
from ...types.gatt_enums import CharacteristicRole
from ..context import CharacteristicContext
from .base import BaseCharacteristic
from .utils import DataParser
Expand All @@ -17,6 +18,7 @@ class AppearanceCharacteristic(BaseCharacteristic[AppearanceData]):
Appearance characteristic with human-readable device type information.
"""

_manual_role = CharacteristicRole.INFO
expected_length = 2

def _decode_value(
Expand Down
55 changes: 49 additions & 6 deletions src/bluetooth_sig/gatt/characteristics/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
SpecialValueType,
classify_special_value,
)
from ...types.gatt_enums import CharacteristicRole, GattProperty
from ...types.gatt_enums import CharacteristicRole
from ...types.registry import CharacteristicSpec
from ...types.uuid import BluetoothUUID
from ..context import CharacteristicContext
Expand Down Expand Up @@ -106,14 +106,12 @@ def __init__(
self,
info: CharacteristicInfo | None = None,
validation: ValidationConfig | None = None,
properties: list[GattProperty] | None = None,
) -> None:
"""Initialize characteristic with structured configuration.

Args:
info: Complete characteristic information (optional for SIG characteristics)
validation: Validation constraints configuration (optional)
properties: Runtime BLE properties discovered from device (optional)

"""
# Store provided info or None (will be resolved in __post_init__)
Expand All @@ -123,9 +121,6 @@ def __init__(
self._info: CharacteristicInfo
self._spec: CharacteristicSpec | None = None

# Runtime properties (from actual device, not YAML)
self.properties: list[GattProperty] = properties if properties is not None else []

# Manual overrides with proper types (using explicit class attributes)
self._manual_unit: str | None = self.__class__._manual_unit

Expand Down Expand Up @@ -627,6 +622,54 @@ def unit(self) -> str:
"""
return self._info.unit or ""

@cached_property
def unit_symbol(self) -> str:
"""Get the canonical SIG unit symbol for this characteristic.

Resolves via the ``UnitsRegistry`` using the YAML ``unit_id``
(e.g. ``org.bluetooth.unit.thermodynamic_temperature.degree_celsius``
→ ``°C``). Falls back to :attr:`unit` when no symbol is available.

Returns:
SI symbol string (e.g. ``'°C'``, ``'%'``, ``'bpm'``),
or empty string if the characteristic has no unit.

"""
from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415

unit_id = self.get_yaml_unit_id()
if unit_id:
symbol = resolve_unit_symbol(unit_id)
if symbol:
return symbol

return self._info.unit or ""

def get_field_unit(self, field_name: str) -> str:
"""Get the resolved unit symbol for a specific struct field.

For struct-valued characteristics with per-field units (e.g.
Heart Rate Measurement: ``bpm`` for heart rate, ``J`` for
energy expended), this resolves the unit for a single field
via ``FieldSpec.unit_id`` → ``UnitsRegistry`` → ``.symbol``.

Args:
field_name: The Python-style field name (e.g. ``'heart_rate'``)
or raw GSS field name (e.g. ``'Heart Rate Measurement Value'``).

Returns:
Resolved unit symbol, or empty string if not found.

"""
if not self._spec or not self._spec.structure:
return ""

for field in self._spec.structure:
if field_name in (field.python_name, field.field):
return field.unit_symbol

return ""

@property
def size(self) -> int | None:
"""Get the size in bytes for this characteristic from YAML specifications.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ class BaseBloodPressureCharacteristic(BaseCharacteristic[Any]):

_is_base_class = True # Exclude from characteristic discovery

_python_type = str # Override since decode_value returns dataclass

# Declare optional dependency on Blood Pressure Feature for status interpretation
_optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [BloodPressureFeatureCharacteristic]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ class BloodPressureFeatureCharacteristic(BaseCharacteristic[BloodPressureFeature
available.
"""

_python_type: type | str | None = dict # Override since decode_value returns dataclass

# YAML has no range constraint; enforce full uint16 bitmap range.
min_value: int = 0
max_value: int = UINT16_MAX
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class BodySensorLocation(IntEnum):
FOOT = 6


class BodySensorLocationCharacteristic(BaseCharacteristic[int]):
class BodySensorLocationCharacteristic(BaseCharacteristic[BodySensorLocation]):
"""Body Sensor Location characteristic (0x2A38).

Represents the location of a sensor on the human body.
Expand Down
Loading
Loading