Coverage for src / bluetooth_sig / gatt / characteristics / characteristic_meta.py: 94%
77 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Helper classes for GATT characteristic infrastructure.
3Contains the SIG resolver, validation configuration, and metaclass used by
4:class:`BaseCharacteristic`. Extracted to keep the base module focused on
5the characteristic API itself.
6"""
8from __future__ import annotations
10from abc import ABCMeta
11from typing import Any
13import msgspec
15from ...registry.uuids.units import units_registry
16from ...types import CharacteristicInfo
17from ...types.gatt_enums import WIRE_TYPE_MAP
18from ...types.registry import CharacteristicSpec
19from ..exceptions import UUIDResolutionError
20from ..resolver import CharacteristicRegistrySearch, NameNormalizer, NameVariantGenerator
21from ..uuid_registry import uuid_registry
23# ---------------------------------------------------------------------------
24# Validation configuration
25# ---------------------------------------------------------------------------
28class ValidationConfig(msgspec.Struct, kw_only=True):
29 """Configuration for characteristic validation constraints.
31 Groups validation parameters into a single, optional configuration object
32 to simplify BaseCharacteristic constructor signatures.
33 """
35 min_value: int | float | None = None
36 max_value: int | float | None = None
37 expected_length: int | None = None
38 min_length: int | None = None
39 max_length: int | None = None
40 allow_variable_length: bool = False
41 expected_type: type | None = None
44# ---------------------------------------------------------------------------
45# SIG characteristic resolver
46# ---------------------------------------------------------------------------
49class SIGCharacteristicResolver:
50 """Resolves SIG characteristic information from YAML and registry.
52 This class handles all SIG characteristic resolution logic, separating
53 concerns from the BaseCharacteristic constructor. Uses shared utilities
54 from the resolver module to avoid code duplication.
55 """
57 camel_case_to_display_name = staticmethod(NameNormalizer.camel_case_to_display_name)
59 @staticmethod
60 def resolve_for_class(char_class: type) -> CharacteristicInfo:
61 """Resolve CharacteristicInfo for a SIG characteristic class.
63 Args:
64 char_class: The characteristic class to resolve info for.
66 Returns:
67 CharacteristicInfo with resolved UUID, name, value_type, unit.
69 Raises:
70 UUIDResolutionError: If no UUID can be resolved for the class.
72 """
73 yaml_spec = SIGCharacteristicResolver.resolve_yaml_spec_for_class(char_class)
74 if yaml_spec:
75 return SIGCharacteristicResolver._create_info_from_yaml(yaml_spec, char_class)
77 registry_info = SIGCharacteristicResolver.resolve_from_registry(char_class)
78 if registry_info:
79 return registry_info
81 raise UUIDResolutionError(char_class.__name__, [char_class.__name__])
83 @staticmethod
84 def resolve_yaml_spec_for_class(char_class: type) -> CharacteristicSpec | None:
85 """Resolve YAML spec for a characteristic class using shared name variant logic."""
86 characteristic_name = getattr(char_class, "_characteristic_name", None)
87 names_to_try = NameVariantGenerator.generate_characteristic_variants(char_class.__name__, characteristic_name)
89 for try_name in names_to_try:
90 spec = uuid_registry.resolve_characteristic_spec(try_name)
91 if spec:
92 return spec
94 return None
96 @staticmethod
97 def _create_info_from_yaml(yaml_spec: CharacteristicSpec, char_class: type) -> CharacteristicInfo:
98 """Create CharacteristicInfo from YAML spec, resolving metadata via registry classes."""
99 python_type: type | None = None
100 is_bitfield = False
102 if yaml_spec.data_type:
103 raw_dt = yaml_spec.data_type.lower().strip()
105 # boolean[N] (N > 1) → bitfield stored as int
106 if raw_dt.startswith("boolean["):
107 python_type = int
108 is_bitfield = True
109 elif raw_dt == "struct":
110 python_type = dict
111 else:
112 # Strip range/array qualifiers: "uint16 [1-256]" → "uint16"
113 base_dt = raw_dt.split("[")[0].split(" ")[0]
114 python_type = WIRE_TYPE_MAP.get(base_dt)
115 elif yaml_spec.structure and len(yaml_spec.structure) > 1:
116 # Multi-field characteristics decode to dict
117 python_type = dict
119 unit_info = None
120 unit_name = getattr(yaml_spec, "unit_symbol", None) or getattr(yaml_spec, "unit", None)
121 if unit_name:
122 unit_info = units_registry.get_unit_info_by_name(unit_name)
123 if unit_info:
124 unit_symbol = str(getattr(unit_info, "symbol", getattr(unit_info, "name", unit_name)))
125 else:
126 unit_symbol = str(unit_name or "")
128 return CharacteristicInfo(
129 uuid=yaml_spec.uuid,
130 name=yaml_spec.name or char_class.__name__,
131 unit=unit_symbol,
132 python_type=python_type,
133 is_bitfield=is_bitfield,
134 )
136 @staticmethod
137 def resolve_from_registry(char_class: type) -> CharacteristicInfo | None:
138 """Fallback to registry resolution using shared search strategy."""
139 search_strategy = CharacteristicRegistrySearch()
140 characteristic_name = getattr(char_class, "_characteristic_name", None)
141 return search_strategy.search(char_class, characteristic_name)
144# ---------------------------------------------------------------------------
145# Metaclass
146# ---------------------------------------------------------------------------
149class CharacteristicMeta(ABCMeta):
150 """Metaclass to automatically handle template flags for characteristics."""
152 def __new__(
153 mcs,
154 name: str,
155 bases: tuple[type, ...],
156 namespace: dict[str, Any],
157 **kwargs: Any, # noqa: ANN401 # Metaclass receives arbitrary keyword arguments
158 ) -> type:
159 """Create the characteristic class and handle template markers.
161 This metaclass hook ensures template classes and concrete
162 implementations are correctly annotated with the ``_is_template``
163 attribute before the class object is created.
164 """
165 if bases:
166 module_name = namespace.get("__module__", "")
167 is_in_templates = "templates" in module_name
169 if not is_in_templates and not namespace.get("_is_template_override"):
170 has_template_parent = any(getattr(base, "_is_template", False) for base in bases)
171 if has_template_parent and "_is_template" not in namespace:
172 namespace["_is_template"] = False
174 return super().__new__(mcs, name, bases, namespace, **kwargs)