From 39a53b6fbb197d98a2b43d72d03e56952c0d5fd5 Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Thu, 19 Feb 2026 11:49:34 +0000 Subject: [PATCH 1/9] =?UTF-8?q?3.1:=20Fix=20PeripheralDevice=20=E2=80=94?= =?UTF-8?q?=20remove=20dead=20translator=20param=20+=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused SIGTranslatorProtocol parameter from PeripheralDevice.__init__ (encoding is handled directly by BaseCharacteristic.build_value()) - Update module and class docstrings to reflect actual architecture - Add 29 tests covering init, add_characteristic, lifecycle, update_value, update_raw, get_current_value, fluent config, add_service - MockPeripheralManager test backend for PeripheralManagerProtocol - Update device __init__.py exports (PeripheralDevice) --- .vscode/launch.json | 30 ++ .vscode/settings.json | 6 + DECOMPOSITION_PLAN.md | 134 ++++++ rework.md | 267 +++++++++++ scripts/check_markdown.py | 258 +++++++++++ scripts/lint_docs.py | 128 ++++++ src/bluetooth_sig/device/__init__.py | 2 + src/bluetooth_sig/device/peripheral_device.py | 384 ++++++++++++++++ tests/device/test_peripheral_device.py | 432 ++++++++++++++++++ 9 files changed, 1641 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 DECOMPOSITION_PLAN.md create mode 100644 rework.md create mode 100644 scripts/check_markdown.py create mode 100755 scripts/lint_docs.py create mode 100644 src/bluetooth_sig/device/peripheral_device.py create mode 100644 tests/device/test_peripheral_device.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..586bd53f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + , + { + "name": "Pytest: Current Test", + "type": "debugpy", + "request": "launch", + "console": "integratedTerminal", + "justMyCode": false, + "module": "pytest", + "args": [ + "-q", + "--rootdir=/root/homeassistant/custom_components/bluetooth-sig-python", + "${file}::${pytestTest}" + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..6580bcb2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "chat.tools.terminal.autoApprove": { + "bluetoothctl": true, + "hciconfig": true + } +} \ No newline at end of file diff --git a/DECOMPOSITION_PLAN.md b/DECOMPOSITION_PLAN.md new file mode 100644 index 00000000..bdd4614e --- /dev/null +++ b/DECOMPOSITION_PLAN.md @@ -0,0 +1,134 @@ +# Workstream 1: God Class Decomposition Plan + +**Goal**: Decompose 4 God classes using **composition + delegation**. Preserve all public APIs. No logic duplication. Single source of truth per concern. + +**Execution order**: Step 3 → Step 1 → Step 4 → Step 2 (simplest first, most complex last). + +--- + +## Step 1: Split `BluetoothSIGTranslator` (1,359 lines → ~682 line facade) — ✅ COMPLETE + +The class is nearly stateless — only `_services: dict` is mutable. All other methods delegate to static registries. Pattern: **Composition + Delegation Facade** (like `requests.Session`). + +### 1.1 New delegate modules under `src/bluetooth_sig/core/` + +| New file | Class | Methods moved from translator.py | Mutable state | +|---|---|---|---| +| `core/query.py` | `CharacteristicQueryEngine` | `supports`, `get_value_type`, all 8 `get_*_info_*`, `get_characteristics_info_by_uuids`, `list_supported_*`, `get_service_characteristics`, `get_sig_info_by_name`, `get_sig_info_by_uuid` | None | +| `core/parser.py` | `CharacteristicParser` | `parse_characteristic` (+overloads), `parse_characteristics` (batch), 5 `_*` batch helpers | None | +| `core/encoder.py` | `CharacteristicEncoder` | `encode_characteristic` (+overloads), `create_value`, `validate_characteristic_data`, `_get_characteristic_value_type_class` | None | +| `core/registration.py` | `RegistrationManager` | `register_custom_characteristic_class`, `register_custom_service_class` | None (writes to registries) | +| `core/service_manager.py` | `ServiceManager` | `process_services`, `get_service_by_uuid`, `discovered_services`, `clear_services` | `_services: dict` — **only mutable state** | + +### 1.2 Facade pattern + +`BluetoothSIGTranslator.__init__` creates 5 delegate instances eagerly. Every public method becomes a one-line delegation with identical signature and `@overload` decorators. Async wrappers stay on facade. Singleton `__new__`/`get_instance()`/global `BluetoothSIG` stay. + +### 1.3 Update `core/__init__.py` + +Re-export delegate classes alongside `BluetoothSIGTranslator` and `AsyncParsingSession`. + +--- + +## Step 2: Split `BaseCharacteristic` (1,761 lines → 1,258 lines) — ✅ COMPLETE + +Keeps Template Method contract (`_decode_value`/`_encode_value`). Internal composition invisible to ~150 subclasses. Pattern: **Internal Composition with back-reference**. + +### 2.1 New `pipeline/` package under `src/bluetooth_sig/gatt/characteristics/` + +| New file | Class | Methods extracted | Status | +|---|---|---|---| +| `pipeline/__init__.py` | Re-exports | — | ✅ | +| `pipeline/parse_pipeline.py` | `ParsePipeline` | `parse_value` orchestration, `_perform_parse_validation`, `_extract_and_check_special_value`, `_decode_and_validate_value`, `_extract_raw_int`, `_check_special_value`, `_is_parse_trace_enabled` | ✅ | +| `pipeline/encode_pipeline.py` | `EncodePipeline` | `build_value` orchestration, `_pack_raw_int`, `encode_special`, `encode_special_by_meaning` | ✅ | +| `pipeline/validation.py` | `CharacteristicValidator` | `_validate_range` (3-level precedence), `_validate_type`, `_validate_length` | ✅ | + +### 2.2 Additional extractions + +| New file | Class | Methods extracted | Status | +|---|---|---|---| +| `role_classifier.py` | `classify_role()` function | `_classify_role`, `_spec_has_unit_fields` | ✅ | +| `descriptor_support.py` | `DescriptorSupport` | 11 descriptor methods | Deferred — low value, methods are 1-liner proxies | +| `special_values.py` | `SpecialValueHandler` | special value methods | Deferred — already uses SpecialValueResolver | + +### 2.3 What stays on `BaseCharacteristic` + +- `__init__`/`__post_init__` (composition wiring) +- Properties: `uuid`, `info`, `spec`, `name`, `description`, `display_name`, `unit`, `size`, `value_type_resolved`, `role`, `get_byte_order_hint` +- Abstract: `_decode_value`, `_encode_value` (Template Method hooks for subclasses) +- Thin delegation: `parse_value` → `ParsePipeline.run()`, `build_value` → `EncodePipeline.run()`, `encode_special*` → `EncodePipeline` +- Class-level UUID resolution (5 classmethods) +- Dependency resolution (5 methods) +- YAML metadata accessors (5 methods) +- Descriptor methods (kept in base, 1-liner proxies to descriptor_utils) +- Special value properties (kept in base, delegate to SpecialValueResolver) +- YAML metadata accessors (5 methods) +- Proxy methods for backward compat (delegate to composed objects) + +--- + +## Step 3: Split `templates.py` (1,488 lines → package) — ✅ COMPLETE + +No circular dependencies. Pure file reorganisation + re-export. Pattern: **Module → Package promotion**. + +### 3.1 New `templates/` package + +| New file | Classes | Approx lines | +|---|---|---| +| `templates/__init__.py` | Re-exports everything via explicit imports + `__all__` | ~60 | +| `templates/base.py` | `CodingTemplate[T_co]` (ABC), resolution constants | ~100 | +| `templates/data_structures.py` | `VectorData`, `Vector2DData`, `TimeData` | ~40 | +| `templates/numeric.py` | `Uint8Template`, `Sint8Template`, `Uint16Template`, `Sint16Template`, `Uint24Template`, `Uint32Template` | ~200 | +| `templates/scaled.py` | `ScaledTemplate` (abstract) + 8 `Scaled*Template` variants + `PercentageTemplate` | ~400 | +| `templates/domain.py` | `TemperatureTemplate`, `ConcentrationTemplate`, `PressureTemplate` | ~200 | +| `templates/ieee_float.py` | `IEEE11073FloatTemplate`, `Float32Template` | ~80 | +| `templates/string.py` | `Utf8StringTemplate`, `Utf16StringTemplate` | ~150 | +| `templates/complex.py` | `TimeDataTemplate`, `VectorTemplate`, `Vector2DTemplate` | ~200 | +| `templates/enum.py` | `EnumTemplate[T]` | ~240 | + +### 3.2 Backward compat + +Python resolves `from .templates import X` identically whether `templates` is a module or package — as long as `X` is in the package `__init__.py`. Zero characteristic files need changing. + +--- + +## Step 4: Split `Device` (1,172 lines → ~818 lines) — ✅ COMPLETE + +13/40 methods are pure delegation. Substantial logic in dependency resolution and characteristic I/O. Pattern: **Composition with explicit dependencies (no back-references)**. + +### 4.1 New modules under `src/bluetooth_sig/device/` + +| New file | Class | Methods extracted | +|---|---|---| +| `dependency_resolver.py` | `DependencyResolver` + `DependencyResolutionMode` enum | `_resolve_single_dependency`, `_ensure_dependencies_resolved` | +| `characteristic_io.py` | `CharacteristicIO` | `read` (+overloads), `write` (+overloads), `start_notify` (+overloads), `stop_notify`, `read_multiple`, `write_multiple`, `_resolve_characteristic_name` | + +### 4.2 Device composes + +```python +self._dep_resolver = DependencyResolver(connection_manager, translator, self.connected) +self._char_io = CharacteristicIO(connection_manager, translator, self.connected, self._dep_resolver) +``` + +Remaining on Device: delegation one-liners, discovery, advertising, properties, service queries. + +--- + +## Verification (after each step) + +1. `python -m pytest tests/ -v` — all existing tests pass +2. `./scripts/lint.sh --all` — zero errors +3. `./scripts/format.sh --check` — formatting valid +4. Backward compat imports still work + +## Design Principles + +| Principle | Application | +|---|---| +| **Single Responsibility** | Each delegate/composed class owns one concern | +| **DRY** | Each method exists in exactly one place; facade only delegates | +| **Composition over Inheritance** | Translator: 5 delegates. Base: internal composition. Templates: domain grouping | +| **Single Source of Truth** | Registry access per delegate. Validation in one validator. Pipeline in one orchestrator | +| **Open/Closed** | BaseCharacteristic open for extension (override hooks), closed for modification (pipeline internal) | +| **Dependency Inversion** | Delegates take abstractions (registries, protocols), not concretions | +| **Interface Segregation** | QueryEngine separate from Parser — consumers depend only on what they use | diff --git a/rework.md b/rework.md new file mode 100644 index 00000000..fceb8b94 --- /dev/null +++ b/rework.md @@ -0,0 +1,267 @@ +Plan: bluetooth-sig-python Library — Gap Analysis & Improvement Roadmap +TL;DR: The library has strong foundations (~200 characteristics, full PDU parser, Device abstraction, strict typing) but suffers from God classes, untracked GATT coverage gaps, empty registry stubs, stream/peripheral incompleteness, and misleading documentation. Python minimum bumps to >=3.10, removing TYPE_CHECKING workarounds. Architecture stays pure SIG — no vendor parsers or framework code in core. Work is structured as 5 independent parallel workstreams. + +Workstream 1: Architecture — God Class Decomposition +The four largest files each exceed 1,100 lines with inline TODOs acknowledging the problem. + +1.1 Split BluetoothSIGTranslator (1,359 lines at src/bluetooth_sig/core/translator.py) + +Extract into 5 focused modules under src/bluetooth_sig/core/: + +New Module Methods to Extract Approx Lines +query.py get_value_type, supports, all 8 get_*_info_* methods, list_supported_*, get_service_characteristics ~400 +parser.py parse_characteristic, parse_characteristic_async, parse_characteristics, parse_characteristics_async, all private batch/dependency helpers ~450 +encoder.py encode_characteristic, encode_characteristic_async, create_value, validate_characteristic_data ~200 +registration.py register_custom_characteristic_class, register_custom_service_class ~120 +service_manager.py process_services, get_service_by_uuid, discovered_services, clear_services ~100 +translator.py becomes a thin facade composing these via mixins or delegation. The BluetoothSIG global singleton interface stays intact. + +1.2 Split Device (1,172 lines at src/bluetooth_sig/device/device.py) + +Already partially decomposed (DeviceConnected, DeviceAdvertising). Extract remaining responsibility pockets: + +Dependency resolution logic → src/bluetooth_sig/device/dependency_resolver.py +Connection lifecycle management → keep in connected.py +Device stays as composition root but slims to <400 lines +1.3 Split BaseCharacteristic (1,761 lines at src/bluetooth_sig/gatt/characteristics/base.py) + +Extract the multi-stage parsing pipeline stages into separate modules: + +src/bluetooth_sig/gatt/characteristics/pipeline/validation.py — length validation, range validation, type validation +src/bluetooth_sig/gatt/characteristics/pipeline/extraction.py — integer extraction, special value detection +src/bluetooth_sig/gatt/characteristics/pipeline/decoding.py — decode orchestration +base.py remains the ABC composing pipeline stages, targeting <600 lines +1.4 Split templates.py (1,488 lines at src/bluetooth_sig/gatt/characteristics/templates.py) + +Group templates by domain: + +templates/numeric.py — Uint8Template, Sint16Template, Uint16Template, PercentageTemplate +templates/temporal.py — DateTimeTemplate, ExactTime256Template, CurrentTimeTemplate +templates/enum.py — EnumTemplate +templates/base.py — CodingTemplate[T] base class + extractor/translator pipeline +templates/__init__.py — re-exports for backwards compat +Verification: All existing tests must pass after each split. Run python -m pytest tests/ -v and ./scripts/lint.sh --all after each module extraction. No public API changes — from bluetooth_sig.core.translator import BluetoothSIGTranslator must still work via re-exports. + +Workstream 2: Code Quality & Python 3.10 Upgrade +2.1 Bump minimum Python to >=3.10 + +Update requires-python in pyproject.toml +Update classifiers, Ruff target-version, mypy python_version +Remove all 3 TYPE_CHECKING blocks: encryption.py:15, client.py:18, device_types.py:5 — move guarded imports to top-level +Update CI matrix in test-coverage.yml to test 3.10+3.12 (drop 3.9) +2.2 Reduce # type: ignore comments (20 currently) + +Audit all 20 occurrences. For each, attempt to resolve with proper generics/overloads/protocols instead of suppression. Target: <=5 remaining, all with justification comments. + +2.3 Eliminate silent pass in except blocks (12 occurrences) + +uuid_registry.py L392, L420, L448 — add logger.debug() before pass to make failures visible +translator.py L553, L706 — same treatment +descriptors/registry.py L26 — log registration failures +Remaining: evaluate case-by-case; at minimum add debug logging +2.4 Tighten Any usage + +Audit the heaviest Any usage files (translator.py, device.py, connected.py). Introduce TypeVar bounds or Protocol types where Any is used as a shortcut rather than a necessity. The dynamic dispatch in parse_characteristic(str, bytes) → Any is inherently untyped — that's fine — but internal helper returns should be tightened. + +Verification: ./scripts/lint.sh --all and python -m pytest tests/ -v pass. mypy --strict remains clean. + +Workstream 3: Completeness — Missing Features & Stubs + +Gap analysis revealed that 2 original items were infeasible (3.6 auxiliary packets require radio +access; 3.3 SDP is irrelevant to BLE) and 1 was based on a false assumption about the YAML data +(3.2 profile YAMLs contain codec/param enums, not mandatory/optional service lists). The plan is +revised below. Implementation order follows priority (value ÷ risk). + +3.1 Fix PeripheralDevice + Add Tests + +peripheral_device.py was scaffolded but has a dead `translator` parameter — `SIGTranslatorProtocol` +is parse-only and encoding is already handled directly by `BaseCharacteristic.build_value()` on the +stored characteristic instances. The `_translator` field is never referenced. + +Changes: +- Remove `translator` parameter from `PeripheralDevice.__init__`; update docstrings +- Verify `__init__.py` exports are correct (PeripheralDevice already added) +- Write tests in tests/device/test_peripheral_device.py: + - Happy path: add_characteristic → start → update_value → verify encoded bytes + - Failure: add_characteristic after start raises RuntimeError + - Failure: update_value for unknown UUID raises KeyError + - Fluent builder delegation round-trips correctly + - get_current_value returns latest value + +Verification: python -m pytest tests/device/test_peripheral_device.py -v passes. + +3.2 Profile Parameter Registries (Redesigned) + +Original plan proposed a monolithic `Profile` struct with mandatory/optional services. The 44 YAML +files across 14 profile subdirectories actually contain 5 distinct structural patterns: simple +name/value lookups, permitted-characteristics lists, codec parameters, protocol parameters, and +LTV structures. No YAML contains profile-level service requirements. + +Redesigned as per-category registries under registry/profiles/: + +a) PermittedCharacteristicsRegistry — loads ESS, UDS, IMDS permitted_characteristics YAMLs. + Query: get_permitted_characteristics("ess") → list of characteristic identifiers. + Struct: PermittedCharacteristicEntry(service: str, characteristics: list[str]). + Extends BaseGenericRegistry[PermittedCharacteristicEntry]. + +b) ProfileLookupRegistry — loads simple name/value files (A2DP codecs, TDS org IDs, ESL display + types, HFP bearer technologies, AVRCP types, MAP chat states, etc.). Single registry keyed + by YAML top-level key. + Query: get_entries("audio_codec_id") → list[ProfileLookupEntry]. + Struct: ProfileLookupEntry(name: str, value: int, metadata: dict[str, str]). + Extends BaseGenericRegistry[list[ProfileLookupEntry]]. + +c) ServiceDiscoveryAttributeRegistry — loads the 26 attribute_ids/*.yaml files plus + protocol_parameters.yaml and attribute_id_offsets_for_strings.yaml. + Query: get_attribute_ids("universal_attributes") → list[AttributeIdEntry]. + Struct: AttributeIdEntry(name: str, value: int). + +d) Defer generic_audio/ LTV structures — polymorphic nested schemas need a dedicated LTV codec + framework. Mark as follow-up. + +New files: +- src/bluetooth_sig/types/registry/profile_types.py (msgspec.Struct types) +- src/bluetooth_sig/registry/profiles/permitted_characteristics.py +- src/bluetooth_sig/registry/profiles/profile_lookup.py +- src/bluetooth_sig/registry/service_discovery/attribute_ids.py +- Tests for each registry + +3.3 — REMOVED (SDP irrelevant to BLE) + +SDP is classic Bluetooth (BR/EDR). This library is BLE-focused. The service_class.yaml UUIDs are +already accessible via the existing ServiceClassesRegistry. The attribute_ids/*.yaml loading is +rolled into 3.2c above. No standalone ServiceDiscoveryRegistry needed. + +3.4 GATT Coverage Gap Tracking + +Existing static analysis tests check consistency (implementation → enum → YAML) but not coverage +(YAML → implementation). This adds the reverse direction. + +New file: tests/static_analysis/test_yaml_implementation_coverage.py +- Load all UUIDs from characteristic_uuids.yaml (~481 entries) +- Compare against CharacteristicRegistry.get_instance()._get_sig_classes_map() keys +- Same for services (service_uuids.yaml vs GattServiceRegistry) +- Same for descriptors (descriptors.yaml vs DescriptorRegistry) +- Output as pytest warnings, not failures — the test reports coverage % without failing CI +- Print summary to stdout for CI visibility + +Verification: python -m pytest tests/static_analysis/test_yaml_implementation_coverage.py -v runs +and produces coverage report without failing. + +3.5 Advertising Location Struct Parsing + +The PDU parser stores Indoor Positioning, Transport Discovery Data, 3D Information, and Channel +Map Update Indication as raw bytes. All 4 formats are well-defined in the Bluetooth Core Spec and +can be parsed into typed structs following the existing mesh beacon pattern. + +Steps: +- Create src/bluetooth_sig/types/advertising/location.py with 4 msgspec.Struct types: + - IndoorPositioningData — config flags byte + optional coordinate/floor/altitude/uncertainty + (CSS Part A, §1.14) + - TransportDiscoveryData — org ID + TDS flags + transport data blocks (CSS Part A, §1.10) + - ThreeDInformationData — 3D sync profile fields (CSS Part A, §1.13) + - ChannelMapUpdateIndication — 5-byte channel map + 2-byte instant + (Core Spec Vol 3, Part C, §11) +- Update LocationAndSensingData field types from bytes to typed structs (| None = None) +- Update _handle_location_ad_types to call struct decode methods +- Add decode(cls, data: bytes) classmethods matching MeshMessage.decode() pattern +- Tests: tests/advertising/test_location_parsing.py — one test per struct with constructed byte + sequences + one malformed-data test per struct + +Verification: python -m pytest tests/advertising/test_location_parsing.py -v passes. Existing PDU +parser tests unaffected. + +3.6 — REMOVED (Auxiliary packet parsing is physically impossible) + +The _parse_auxiliary_packets stub is correct. The AuxiliaryPointer is a radio scheduling +instruction (channel, offset, PHY), not data. Resolving auxiliary chains requires real-time radio +access — impossible in a pure parser. If a PDU stream correlator is ever needed, it belongs in the +stream module, matching AuxiliaryPointer fields across separately captured PDUs. + +3.7 Stream Module: TTL Eviction + Stats + +TTL eviction prevents memory leaks in long-running processes (e.g. Home Assistant running for +months). A device that sends a glucose measurement but never the context leaves an incomplete +group in _buffer forever. The async variant is deferred — the sync callback pattern works well +with async callers already. + +Changes to DependencyPairingBuffer: +- Add max_age_seconds: float | None = None parameter to __init__ +- Store _group_timestamps: dict[Hashable, float] for first-seen time per group +- In ingest(), call _evict_stale() before processing (removes groups older than max_age_seconds) +- Add stats() → BufferStats(pending: int, completed: int, evicted: int) dataclass +- Track _completed_count and _evicted_count as instance counters + +Tests in tests/stream/test_pairing.py (extend existing file): +- TTL eviction: ingest partial group, advance time past TTL, verify stale group evicted +- Stats: verify counters after completions and evictions +- No-TTL default: existing tests pass unchanged + +Verification: python -m pytest tests/stream/ -v passes. No breaking changes. + +Implementation Priority: + +| # | Item | Effort | Value | Risk | +|---|------|--------|-------|------| +| 1 | 3.1 Fix PeripheralDevice + tests | Low | Medium | Low | +| 2 | 3.5 Location AD struct parsing | Medium | High | Low | +| 3 | 3.7 Stream TTL + stats | Low | Medium | Low | +| 4 | 3.4 GATT coverage gap tracking | Low | Medium | None | +| 5 | 3.2 Profile parameter registries | Medium | Medium | Medium | + +Verification: Each feature has its own test file with success + failure cases. All quality gates pass. + +Workstream 4: Testing & Quality Infrastructure +4.1 Add property-based testing with Hypothesis + +Add hypothesis to [project.optional-dependencies.test] in pyproject.toml +Target: parsing round-trip invariant — for every characteristic that supports parse_value + build_value, parse(build(x)) == x +Start with numeric templates (Uint8Template, Sint16Template, PercentageTemplate) — generate random valid ranges +Add fuzz tests for PDU parser: random bytes should never crash (return error, not exception) +4.2 Raise coverage threshold + +Current: 70% in test-coverage.yml +Target: 85% +Identify uncovered paths using pytest --cov-report=html and write targeted tests +4.3 Enable benchmarks in CI + +Add a separate CI job in test-coverage.yml that runs pytest tests/benchmarks/ --benchmark-only +Store benchmark results as CI artefacts +Add regression detection: fail if any benchmark regresses >20% from baseline +Use scripts/update_benchmark_history.py to track trends +4.4 Add integration test for round-trip encoding + +New test: tests/integration/test_round_trip.py + +For every characteristic that implements both parse_value and build_value, verify parse(build(x)) == x +Systematic coverage, not just spot-checks +Verification: python -m pytest tests/ -v --cov --cov-fail-under=85 passes. Benchmark job runs in CI. + +Workstream 5: Documentation & Examples +5.1 Fix misleading README code samples + +The README.md "Translator API (Device Scanning)" section shows result.info.name and result.value — but parse_characteristic returns the parsed value directly, not a wrapper object. Fix the code sample to match actual API. + +5.2 Document undocumented features in README + +Add sections for: + +Stream module (DependencyPairingBuffer) +PeripheralManagerProtocol (and PeripheralDevice once built) +EAD encryption/decryption support +Async session API (AsyncParsingSession) +5.3 Generate static CHANGELOG.md + +git-cliff is configured but no CHANGELOG file exists. Add a just changelog command and generate CHANGELOG.md from git history. Add to CI release workflow. + +5.4 Clean up examples + +Remove stubs in with_bleak_retry.py (robust_service_discovery and notification_monitoring that print "not yet implemented") +Either implement or remove incomplete examples +Remove emoji from output strings (keep professional) +5.5 Update copilot instructions + +Remove TYPE_CHECKING prohibition (it was already violated in 3 places; with Python >=3.10, the need disappears anyway) +Update Python version references +Add the new module boundaries from Workstream 1 to architecture docs +Verification: pytest tests/docs/test_docs_code_blocks.py passes (validates code samples in docs). Link checking on all markdown files. \ No newline at end of file diff --git a/scripts/check_markdown.py b/scripts/check_markdown.py new file mode 100644 index 00000000..17cc6e57 --- /dev/null +++ b/scripts/check_markdown.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Check markdown files for common issues.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +# Get repository root +ROOT_DIR = Path(__file__).resolve().parent.parent +DOCS_DIR = ROOT_DIR / "docs" + + +def check_broken_links(file_path: Path, content: str) -> list[tuple[int, str]]: + """Check for broken internal links. + + Args: + file_path: Path to the markdown file being checked. + content: Content of the markdown file. + + Returns: + List of tuples containing (line_number, issue_description). + """ + issues: list[tuple[int, str]] = [] + lines = content.split("\n") + + # Pattern for markdown links: [text](url) + link_pattern = r"\[([^\]]+)\]\(([^\)]+)\)" + + for i, line in enumerate(lines, 1): + for match in re.finditer(link_pattern, line): + link_text = match.group(1) + url = match.group(2) + + # Skip external links + if url.startswith(("http://", "https://", "mailto:", "#")): + continue + + # Check for internal links + if "/" in url or ".md" in url: + # Resolve relative path + target = file_path.parent / url.split("#")[0] + if not target.exists() and not target.with_suffix("").exists(): + issues.append((i, f"Broken link: [{link_text}]({url})")) + + return issues + + +def check_images(content: str) -> list[tuple[int, str]]: + """Check for missing alt text in images. + + Args: + content: Content of the markdown file. + + Returns: + List of tuples containing (line_number, issue_description). + """ + issues: list[tuple[int, str]] = [] + lines = content.split("\n") + + # Pattern for images: ![alt](url) + image_pattern = r"!\[([^\]]*)\]\(([^\)]+)\)" + + for i, line in enumerate(lines, 1): + for match in re.finditer(image_pattern, line): + alt_text = match.group(1) + url = match.group(2) + + if not alt_text or alt_text.strip() == "": + issues.append((i, f"Image missing alt text: ![]({url})")) + + return issues + + +def check_heading_hierarchy(content: str) -> list[tuple[int, str]]: + """Check for incorrect heading hierarchy. + + Args: + content: Content of the markdown file. + + Returns: + List of tuples containing (line_number, issue_description). + """ + issues: list[tuple[int, str]] = [] + lines = content.split("\n") + + prev_level = 0 + in_code_block = False + + for i, line in enumerate(lines, 1): + # Track code blocks to ignore headings inside them + if line.strip().startswith("```"): + in_code_block = not in_code_block + continue + + # Only check lines that are markdown headings (# followed by space) + # Not Python comments or other uses of # + stripped = line.lstrip() + if ( + not in_code_block + and stripped.startswith("#") + and len(stripped) > 1 + and stripped[stripped.count("#")] == " " + ): + # Count heading level + level = len(line) - len(line.lstrip("#")) + if level > 6: + issues.append((i, f"Invalid heading level (H{level}): {line[:50]}")) + elif prev_level > 0 and level > prev_level + 1: + issues.append((i, f"Heading hierarchy skip (H{prev_level} -> H{level}): {line[:50]}")) + + if level > 0 and level <= 6: + prev_level = level + + return issues + + +def check_code_blocks(content: str) -> list[tuple[int, str]]: + """Check for malformed code blocks. + + Args: + content: Content of the markdown file. + + Returns: + List of tuples containing (line_number, issue_description). + """ + issues: list[tuple[int, str]] = [] + lines = content.split("\n") + + in_code_block = False + code_block_start = 0 + + for i, line in enumerate(lines, 1): + if line.strip().startswith("```"): + if not in_code_block: + in_code_block = True + code_block_start = i + else: + in_code_block = False + elif i == len(lines) and in_code_block: + issues.append((code_block_start, f"Unclosed code block starting at line {code_block_start}")) + + return issues + + +def check_list_formatting(content: str) -> list[tuple[int, str]]: + """Check for list formatting issues. + + Args: + content: Content of the markdown file. + + Returns: + List of tuples containing (line_number, issue_description). + """ + issues: list[tuple[int, str]] = [] + lines = content.split("\n") + + for i, line in enumerate(lines, 1): + # Check for inconsistent list markers + if re.match(r"^\s*[\*\-\+]\s+", line): + # Check if there's a space after marker + if not re.match(r"^\s*[\*\-\+]\s\s+", line) and re.match(r"^\s*[\*\-\+]\S", line): + issues.append((i, f"list item missing space after marker: {line[:50]}")) + + return issues + + +def check_table_formatting(content: str) -> list[tuple[int, str]]: + """Check for table formatting issues. + + Args: + content: Content of the markdown file. + + Returns: + List of tuples containing (line_number, issue_description). + """ + issues: list[tuple[int, str]] = [] + lines = content.split("\n") + + in_table = False + column_count = 0 + + for i, line in enumerate(lines, 1): + # Check if line is a table row + if "|" in line and line.strip().startswith("|") and line.strip().endswith("|"): + if not in_table: + in_table = True + column_count = line.count("|") - 1 + else: + # Check column consistency + current_columns = line.count("|") - 1 + if current_columns != column_count and not re.match(r"^\s*\|[\s\-:]+\|\s*$", line): + issues.append((i, f"Table column mismatch: expected {column_count}, got {current_columns}")) + elif in_table and line.strip() == "": + in_table = False + column_count = 0 + + return issues + + +def check_markdown_file(file_path: Path) -> dict[str, str | list[tuple[int, str]]]: + """Check a single markdown file for issues. + + Args: + file_path: Path to the markdown file to check. + + Returns: + Dictionary containing file path and list of issues found. + """ + try: + content = file_path.read_text(encoding="utf-8") + except OSError as e: + return {"file": str(file_path.relative_to(ROOT_DIR)), "error": f"Failed to read file: {e}", "issues": []} + + all_issues: list[tuple[int, str]] = [] + + # Run all checks + all_issues.extend(check_broken_links(file_path, content)) + all_issues.extend(check_images(content)) + all_issues.extend(check_heading_hierarchy(content)) + all_issues.extend(check_code_blocks(content)) + all_issues.extend(check_list_formatting(content)) + all_issues.extend(check_table_formatting(content)) + + return {"file": str(file_path.relative_to(ROOT_DIR)), "issues": sorted(all_issues, key=lambda x: x[0])} + + +def main() -> None: + """Main function to check all markdown files.""" + # Find all markdown files + md_files = sorted(DOCS_DIR.rglob("*.md")) + + results = [] + total_issues = 0 + + for md_file in md_files: + result = check_markdown_file(md_file) + if result.get("error") or result.get("issues"): + results.append(result) + total_issues += len(result.get("issues", [])) + + # Print results + print( + json.dumps( + { + "total_files": len(md_files), + "files_with_issues": len(results), + "total_issues": total_issues, + "results": results, + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/lint_docs.py b/scripts/lint_docs.py new file mode 100755 index 00000000..22901bc7 --- /dev/null +++ b/scripts/lint_docs.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Lint Python code blocks in markdown documentation files and check markdown/mkdocs formatting. + +This script uses md-snakeoil (built on Ruff) to format and lint Python code blocks +in markdown files. Configuration for ignored rules is managed in pyproject.toml under +[tool.ruff.lint.per-file-ignores] for "docs/**/*.md" files. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def lint_python_blocks(docs_path: Path) -> int: + """Lint Python code blocks in markdown files using md-snakeoil. + + md-snakeoil automatically uses the project's Ruff configuration from pyproject.toml, + including per-file-ignores for documentation files. + + Returns: + Number of files with issues (0 if all passed). + """ + print("=" * 60) + print("Linting Python code blocks with md-snakeoil...") + print("=" * 60) + + # md-snakeoil uses the project's ruff configuration automatically + # The per-file-ignores in pyproject.toml handle docs-specific exemptions + cmd = ["snakeoil", str(docs_path)] + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + + # Print output + print(result.stdout) + if result.stderr: + print(result.stderr) + + # md-snakeoil returns non-zero exit code if there are formatting issues + if result.returncode != 0: + return 1 + + return 0 + + +def lint_markdown(docs_path: Path) -> tuple[int, int]: + """Lint markdown files using markdownlint and check mkdocs build. + + Returns (markdown_issues, mkdocs_issues) tuple. + """ + markdown_issues = 0 + mkdocs_issues = 0 + + # Check if markdownlint-cli is available + result = subprocess.run(["which", "markdownlint"], capture_output=True, check=False) + has_markdownlint = result.returncode == 0 + + if has_markdownlint: + print("\n" + "=" * 60) + print("Checking markdown formatting with markdownlint...") + print("=" * 60) + + result = subprocess.run(["markdownlint", str(docs_path)], capture_output=True, text=True, check=False) + + if result.stdout or result.stderr: + print(result.stdout) + if result.stderr: + print(result.stderr) + markdown_issues = 1 + else: + print("✅ All markdown files are properly formatted") + else: + print("\n⚠️ markdownlint not found - skipping markdown linting") + print(" Install with: npm install -g markdownlint-cli") + + # Check mkdocs build + print("\n" + "=" * 60) + print("Checking mkdocs build...") + print("=" * 60) + + result = subprocess.run(["mkdocs", "build", "--strict"], capture_output=True, text=True, check=False) + + if result.returncode != 0: + print("❌ mkdocs build failed:") + print(result.stdout) + if result.stderr: + print(result.stderr) + mkdocs_issues = 1 + else: + print("✅ mkdocs build successful") + + return markdown_issues, mkdocs_issues + + +def main() -> None: + """Main entry point.""" + docs_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("docs") + + if not docs_path.exists(): + print(f"Error: {docs_path} does not exist") + sys.exit(1) + + # Lint Python code blocks with md-snakeoil + python_issues = lint_python_blocks(docs_path) + + # Run markdown and mkdocs linting + markdown_issues, mkdocs_issues = lint_markdown(docs_path.parent) + + # Final summary + print(f"\n{'=' * 60}") + print("Final Summary:") + print(f" Python code blocks: {python_issues} issues") + print(f" Markdown formatting: {markdown_issues} issues") + print(f" MkDocs build: {mkdocs_issues} issues") + print("=" * 60) + + total_all_issues = python_issues + markdown_issues + mkdocs_issues + if total_all_issues == 0: + print("✅ All checks passed!") + sys.exit(0) + else: + print(f"❌ Total issues: {total_all_issues}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/bluetooth_sig/device/__init__.py b/src/bluetooth_sig/device/__init__.py index e44280b4..016e25c5 100644 --- a/src/bluetooth_sig/device/__init__.py +++ b/src/bluetooth_sig/device/__init__.py @@ -25,6 +25,7 @@ PeripheralManagerProtocol, ServiceDefinition, ) +from bluetooth_sig.device.peripheral_device import PeripheralDevice from bluetooth_sig.device.protocols import SIGTranslatorProtocol __all__ = [ @@ -36,6 +37,7 @@ "DeviceConnected", "DeviceEncryption", "DeviceService", + "PeripheralDevice", "PeripheralManagerProtocol", "ServiceDefinition", "SIGTranslatorProtocol", diff --git a/src/bluetooth_sig/device/peripheral_device.py b/src/bluetooth_sig/device/peripheral_device.py new file mode 100644 index 00000000..df482bad --- /dev/null +++ b/src/bluetooth_sig/device/peripheral_device.py @@ -0,0 +1,384 @@ +"""High-level peripheral (GATT server) abstraction. + +Provides :class:`PeripheralDevice`, the server-side counterpart to +:class:`Device`. Where ``Device`` connects TO remote peripherals and reads +GATT data, ``PeripheralDevice`` **hosts** GATT services and encodes values +for remote centrals to read. + +Composes :class:`PeripheralManagerProtocol` with ``BaseCharacteristic`` +instances that handle value encoding via ``build_value()``. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.types.gatt_enums import GattProperty +from bluetooth_sig.types.peripheral_types import CharacteristicDefinition, ServiceDefinition +from bluetooth_sig.types.uuid import BluetoothUUID + +from .peripheral import PeripheralManagerProtocol + +logger = logging.getLogger(__name__) + + +class HostedCharacteristic: + """Tracks a hosted characteristic with its definition and class instance. + + Attributes: + definition: The GATT characteristic definition registered on the peripheral. + characteristic: The SIG characteristic class instance used for encoding/decoding. + last_value: The last Python value that was encoded and set on this characteristic. + + """ + + __slots__ = ("characteristic", "definition", "last_value") + + def __init__( + self, + definition: CharacteristicDefinition, + characteristic: BaseCharacteristic[Any], + initial_value: Any = None, # noqa: ANN401 + ) -> None: + self.definition = definition + self.characteristic = characteristic + self.last_value: Any = initial_value + + +class PeripheralDevice: + """High-level BLE peripheral abstraction using composition pattern. + + Coordinates between :class:`PeripheralManagerProtocol` (backend) and + ``BaseCharacteristic`` instances (encoding) so callers work with typed + Python values. + + Encoding is handled directly by the characteristic's ``build_value()`` + method — no translator is needed on the peripheral (server) side. + + The workflow mirrors :class:`Device` but for the server role: + + 1. Create a ``PeripheralDevice`` wrapping a backend. + 2. Add services with :meth:`add_service` (typed helpers encode initial values). + 3. Start advertising with :meth:`start`. + 4. Update characteristic values with :meth:`update_value`. + 5. Stop with :meth:`stop`. + + Example:: + + >>> from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic + >>> from bluetooth_sig.gatt.services import BatteryService + >>> + >>> peripheral = PeripheralDevice(backend) + >>> battery_char = BatteryLevelCharacteristic() + >>> peripheral.add_characteristic( + ... service_uuid=BatteryService.get_class_uuid(), + ... characteristic=battery_char, + ... initial_value=85, + ... ) + >>> await peripheral.start() + >>> await peripheral.update_value(battery_char, 72) + + """ + + def __init__( + self, + peripheral_manager: PeripheralManagerProtocol, + ) -> None: + """Initialise PeripheralDevice. + + Args: + peripheral_manager: Backend implementing PeripheralManagerProtocol. + + """ + self._manager = peripheral_manager + + # UUID (normalised upper-case) → HostedCharacteristic + self._hosted: dict[str, HostedCharacteristic] = {} + + # Service UUID → ServiceDefinition (tracks services added via helpers) + self._pending_services: dict[str, ServiceDefinition] = {} + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + """Advertised device name.""" + return self._manager.name + + @property + def is_advertising(self) -> bool: + """Whether the peripheral is currently advertising.""" + return self._manager.is_advertising + + @property + def services(self) -> list[ServiceDefinition]: + """Registered GATT services.""" + return self._manager.services + + @property + def hosted_characteristics(self) -> dict[str, HostedCharacteristic]: + """Map of UUID → HostedCharacteristic for all hosted characteristics.""" + return dict(self._hosted) + + # ------------------------------------------------------------------ + # Service & Characteristic Registration + # ------------------------------------------------------------------ + + def add_characteristic( + self, + service_uuid: str | BluetoothUUID, + characteristic: BaseCharacteristic[Any], + initial_value: Any, # noqa: ANN401 + *, + properties: GattProperty | None = None, + ) -> CharacteristicDefinition: + """Register a characteristic on a service, encoding the initial value. + + If the service has not been seen before, a new primary + :class:`ServiceDefinition` is created automatically. + + Args: + service_uuid: UUID of the parent service. + characteristic: SIG characteristic class instance. + initial_value: Python value to encode as the initial value. + properties: GATT properties. Defaults to ``READ | NOTIFY``. + + Returns: + The created :class:`CharacteristicDefinition`. + + Raises: + RuntimeError: If the peripheral has already started advertising. + + """ + if self._manager.is_advertising: + raise RuntimeError("Cannot add characteristics after peripheral has started") + + char_def = CharacteristicDefinition.from_characteristic( + characteristic, + initial_value, + properties=properties, + ) + + svc_key = str(service_uuid).upper() + if svc_key not in self._pending_services: + self._pending_services[svc_key] = ServiceDefinition( + uuid=BluetoothUUID(svc_key), + characteristics=[], + ) + self._pending_services[svc_key].characteristics.append(char_def) + + uuid_key = str(char_def.uuid).upper() + self._hosted[uuid_key] = HostedCharacteristic( + definition=char_def, + characteristic=characteristic, + initial_value=initial_value, + ) + + return char_def + + async def add_service(self, service: ServiceDefinition) -> None: + """Register a pre-built service definition directly. + + For full control over the service definition. If you prefer typed + helpers, use :meth:`add_characteristic` instead. + + Args: + service: Complete service definition. + + Raises: + RuntimeError: If the peripheral has already started advertising. + + """ + await self._manager.add_service(service) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Register pending services on the backend and start advertising. + + All services added via :meth:`add_characteristic` are flushed to the + backend before ``start()`` is called on the manager. + + Raises: + RuntimeError: If the peripheral has already started. + + """ + # Flush pending services to the backend + for service_def in self._pending_services.values(): + await self._manager.add_service(service_def) + self._pending_services.clear() + + await self._manager.start() + + async def stop(self) -> None: + """Stop advertising and disconnect all clients.""" + await self._manager.stop() + + # ------------------------------------------------------------------ + # Value Updates + # ------------------------------------------------------------------ + + async def update_value( + self, + characteristic: BaseCharacteristic[Any] | str | BluetoothUUID, + value: Any, # noqa: ANN401 + *, + notify: bool = True, + ) -> None: + """Encode a typed value and push it to the hosted characteristic. + + Args: + characteristic: The characteristic instance, UUID string, or BluetoothUUID. + value: Python value to encode via ``build_value()``. + notify: Whether to notify subscribed centrals. Default ``True``. + + Raises: + KeyError: If the characteristic is not hosted on this peripheral. + RuntimeError: If the peripheral has not started. + + """ + uuid_key = self._resolve_uuid_key(characteristic) + hosted = self._hosted.get(uuid_key) + if hosted is None: + raise KeyError(f"Characteristic {uuid_key} is not hosted on this peripheral") + + encoded = hosted.characteristic.build_value(value) + hosted.last_value = value + + await self._manager.update_characteristic(uuid_key, encoded, notify=notify) + + async def update_raw( + self, + char_uuid: str | BluetoothUUID, + raw_value: bytearray, + *, + notify: bool = True, + ) -> None: + """Push pre-encoded bytes to a hosted characteristic. + + Use this when you already have the encoded value or the + characteristic does not have a SIG class registered. + + Args: + char_uuid: UUID of the characteristic. + raw_value: Pre-encoded bytes to set. + notify: Whether to notify subscribed centrals. + + Raises: + KeyError: If the characteristic UUID is not hosted. + RuntimeError: If the peripheral has not started. + + """ + uuid_key = str(char_uuid).upper() + await self._manager.update_characteristic(uuid_key, raw_value, notify=notify) + + async def get_current_value( + self, + characteristic: BaseCharacteristic[Any] | str | BluetoothUUID, + ) -> Any: # noqa: ANN401 + """Get the last Python value set for a hosted characteristic. + + Args: + characteristic: The characteristic instance, UUID string, or BluetoothUUID. + + Returns: + The last value passed to :meth:`update_value`, or the initial value. + + Raises: + KeyError: If the characteristic is not hosted. + + """ + uuid_key = self._resolve_uuid_key(characteristic) + hosted = self._hosted.get(uuid_key) + if hosted is None: + raise KeyError(f"Characteristic {uuid_key} is not hosted on this peripheral") + return hosted.last_value + + # ------------------------------------------------------------------ + # Fluent Configuration Delegation + # ------------------------------------------------------------------ + + def with_manufacturer_data(self, company_id: int, payload: bytes) -> PeripheralDevice: + """Configure manufacturer data for advertising. + + Args: + company_id: Bluetooth SIG company identifier. + payload: Manufacturer-specific payload bytes. + + Returns: + Self for method chaining. + + """ + self._manager.with_manufacturer_id(company_id, payload) + return self + + def with_tx_power(self, power_dbm: int) -> PeripheralDevice: + """Set TX power level. + + Args: + power_dbm: Transmission power in dBm. + + Returns: + Self for method chaining. + + """ + self._manager.with_tx_power(power_dbm) + return self + + def with_connectable(self, connectable: bool) -> PeripheralDevice: + """Set whether the peripheral accepts connections. + + Args: + connectable: True to accept connections. + + Returns: + Self for method chaining. + + """ + self._manager.with_connectable(connectable) + return self + + def with_discoverable(self, discoverable: bool) -> PeripheralDevice: + """Set whether the peripheral is discoverable. + + Args: + discoverable: True to be discoverable. + + Returns: + Self for method chaining. + + """ + self._manager.with_discoverable(discoverable) + return self + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _resolve_uuid_key(self, characteristic: BaseCharacteristic[Any] | str | BluetoothUUID) -> str: + """Normalise a characteristic reference to an upper-case UUID string.""" + if isinstance(characteristic, BaseCharacteristic): + return str(characteristic.uuid).upper() + return str(characteristic).upper() + + def __repr__(self) -> str: + """Return a developer-friendly representation.""" + state = "advertising" if self.is_advertising else "stopped" + return ( + f"PeripheralDevice(name={self.name!r}, " + f"state={state}, " + f"services={len(self.services)}, " + f"characteristics={len(self._hosted)})" + ) + + +__all__ = [ + "HostedCharacteristic", + "PeripheralDevice", +] diff --git a/tests/device/test_peripheral_device.py b/tests/device/test_peripheral_device.py new file mode 100644 index 00000000..97144625 --- /dev/null +++ b/tests/device/test_peripheral_device.py @@ -0,0 +1,432 @@ +"""Tests for PeripheralDevice high-level GATT server abstraction. + +Validates the composition of PeripheralManagerProtocol + BaseCharacteristic +encoding, lifecycle management, value updates, and fluent configuration. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.device.peripheral import PeripheralManagerProtocol +from bluetooth_sig.device.peripheral_device import HostedCharacteristic, PeripheralDevice +from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic +from bluetooth_sig.types.peripheral_types import CharacteristicDefinition, ServiceDefinition +from bluetooth_sig.types.uuid import BluetoothUUID + + +# --------------------------------------------------------------------------- +# Mock backend +# --------------------------------------------------------------------------- + + +class MockPeripheralManager(PeripheralManagerProtocol): + """In-memory mock of PeripheralManagerProtocol for testing. + + Tracks all calls for assertion and simulates the advertising lifecycle. + """ + + def __init__(self, name: str = "MockPeripheral") -> None: + super().__init__(name) + self._advertising = False + self._characteristic_values: dict[str, bytearray] = {} + self._notify_log: list[tuple[str, bytearray, bool]] = [] + + async def start(self) -> None: + if not self._services: + raise RuntimeError("No services registered") + self._advertising = True + + async def stop(self) -> None: + self._advertising = False + + @property + def is_advertising(self) -> bool: + return self._advertising + + async def update_characteristic( + self, + char_uuid: str | BluetoothUUID, + value: bytearray, + *, + notify: bool = True, + ) -> None: + uuid_str = str(char_uuid).upper() + if not self._advertising: + raise RuntimeError("Peripheral not started") + self._characteristic_values[uuid_str] = value + self._notify_log.append((uuid_str, value, notify)) + + async def get_characteristic_value(self, char_uuid: str | BluetoothUUID) -> bytearray: + uuid_str = str(char_uuid).upper() + if uuid_str not in self._characteristic_values: + raise KeyError(f"Characteristic {uuid_str} not found") + return self._characteristic_values[uuid_str] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +BATTERY_SERVICE_UUID = "180F" +BATTERY_CHAR_UUID = "00002A19-0000-1000-8000-00805F9B34FB" + + +def _make_device(name: str = "TestDevice") -> tuple[PeripheralDevice, MockPeripheralManager]: + """Create a PeripheralDevice with a mock backend.""" + backend = MockPeripheralManager(name) + device = PeripheralDevice(backend) + return device, backend + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestPeripheralDeviceInit: + """Constructor and basic properties.""" + + def test_init_sets_name(self) -> None: + device, _ = _make_device("Sensor-1") + assert device.name == "Sensor-1" + + def test_init_not_advertising(self) -> None: + device, _ = _make_device() + assert device.is_advertising is False + + def test_init_no_services(self) -> None: + device, _ = _make_device() + assert device.services == [] + + def test_init_no_hosted_characteristics(self) -> None: + device, _ = _make_device() + assert device.hosted_characteristics == {} + + def test_repr_stopped(self) -> None: + device, _ = _make_device("Demo") + r = repr(device) + assert "Demo" in r + assert "stopped" in r + + +class TestAddCharacteristic: + """Registration of characteristics via the typed helper.""" + + def test_add_characteristic_returns_definition(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + char_def = device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=85, + ) + + assert isinstance(char_def, CharacteristicDefinition) + assert char_def.initial_value == bytearray(b"\x55") # 85 decimal + + def test_add_characteristic_creates_hosted_entry(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=50, + ) + + hosted = device.hosted_characteristics + assert BATTERY_CHAR_UUID in hosted + assert isinstance(hosted[BATTERY_CHAR_UUID], HostedCharacteristic) + assert hosted[BATTERY_CHAR_UUID].last_value == 50 + + def test_add_characteristic_creates_pending_service(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=75, + ) + + # Pending services are flushed on start(); backend has none yet + assert device.services == [] + + def test_add_characteristic_after_start_raises(self) -> None: + """Cannot add characteristics once advertising has started.""" + device, backend = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=99, + ) + + # Force-start so is_advertising returns True + backend._advertising = True + + with pytest.raises(RuntimeError, match="Cannot add characteristics"): + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=BatteryLevelCharacteristic(), + initial_value=10, + ) + + +class TestLifecycle: + """Start / stop lifecycle.""" + + @pytest.mark.asyncio + async def test_start_flushes_pending_services(self) -> None: + device, backend = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=90, + ) + + await device.start() + + assert device.is_advertising is True + assert len(backend.services) == 1 + assert str(backend.services[0].uuid).upper().startswith("0000180F") + + @pytest.mark.asyncio + async def test_stop_clears_advertising(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=80, + ) + + await device.start() + assert device.is_advertising is True + + await device.stop() + assert device.is_advertising is False + + @pytest.mark.asyncio + async def test_repr_advertising(self) -> None: + device, _ = _make_device("Live") + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=60, + ) + await device.start() + + r = repr(device) + assert "advertising" in r + assert "Live" in r + + +class TestUpdateValue: + """Typed value encoding and push to backend.""" + + @pytest.mark.asyncio + async def test_update_value_encodes_and_pushes(self) -> None: + device, backend = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=50, + ) + await device.start() + + await device.update_value(char, 72) + + # Verify backend received the encoded bytes + assert len(backend._notify_log) == 1 + uuid_sent, value_sent, notify_flag = backend._notify_log[0] + assert uuid_sent == BATTERY_CHAR_UUID + assert value_sent == bytearray(b"\x48") # 72 decimal + assert notify_flag is True + + @pytest.mark.asyncio + async def test_update_value_by_uuid_string(self) -> None: + device, backend = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=50, + ) + await device.start() + + # Pass UUID string instead of characteristic instance + await device.update_value(BATTERY_CHAR_UUID, 33) + + _, value_sent, _ = backend._notify_log[0] + assert value_sent == bytearray(b"\x21") # 33 decimal + + @pytest.mark.asyncio + async def test_update_value_without_notify(self) -> None: + device, backend = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=50, + ) + await device.start() + + await device.update_value(char, 10, notify=False) + + _, _, notify_flag = backend._notify_log[0] + assert notify_flag is False + + @pytest.mark.asyncio + async def test_update_value_unknown_uuid_raises(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=50, + ) + await device.start() + + with pytest.raises(KeyError, match="not hosted"): + await device.update_value("FFFF", 42) + + @pytest.mark.asyncio + async def test_update_value_updates_last_value(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=50, + ) + await device.start() + + await device.update_value(char, 72) + + hosted = device.hosted_characteristics[BATTERY_CHAR_UUID] + assert hosted.last_value == 72 + + +class TestUpdateRaw: + """Pre-encoded byte push.""" + + @pytest.mark.asyncio + async def test_update_raw_pushes_bytes(self) -> None: + device, backend = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=50, + ) + await device.start() + + await device.update_raw("2A19", bytearray(b"\x63")) + + _, value_sent, _ = backend._notify_log[0] + assert value_sent == bytearray(b"\x63") + + +class TestGetCurrentValue: + """Reading back the last Python value.""" + + @pytest.mark.asyncio + async def test_get_current_value_initial(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=42, + ) + + value = await device.get_current_value(char) + assert value == 42 + + @pytest.mark.asyncio + async def test_get_current_value_after_update(self) -> None: + device, _ = _make_device() + char = BatteryLevelCharacteristic() + device.add_characteristic( + service_uuid=BATTERY_SERVICE_UUID, + characteristic=char, + initial_value=42, + ) + await device.start() + + await device.update_value(char, 99) + value = await device.get_current_value(char) + assert value == 99 + + @pytest.mark.asyncio + async def test_get_current_value_unknown_uuid_raises(self) -> None: + device, _ = _make_device() + + with pytest.raises(KeyError, match="not hosted"): + await device.get_current_value("DEAD") + + +class TestFluentConfiguration: + """Fluent builder delegation to the backend.""" + + def test_with_manufacturer_data_returns_self(self) -> None: + device, _ = _make_device() + result = device.with_manufacturer_data(0x004C, b"\x02\x15") + assert result is device + + def test_with_manufacturer_data_delegates(self) -> None: + device, backend = _make_device() + device.with_manufacturer_data(0x004C, b"\x02\x15") + assert backend.manufacturer_data is not None + assert backend.manufacturer_data.company.id == 0x004C + + def test_with_tx_power_returns_self(self) -> None: + device, _ = _make_device() + result = device.with_tx_power(-10) + assert result is device + + def test_with_tx_power_delegates(self) -> None: + device, backend = _make_device() + device.with_tx_power(-20) + assert backend.tx_power == -20 + + def test_with_connectable_delegates(self) -> None: + device, backend = _make_device() + device.with_connectable(False) + assert backend.is_connectable_config is False + + def test_with_discoverable_delegates(self) -> None: + device, backend = _make_device() + device.with_discoverable(False) + assert backend.is_discoverable_config is False + + def test_chaining(self) -> None: + device, backend = _make_device() + result = ( + device + .with_tx_power(-5) + .with_connectable(True) + .with_discoverable(False) + ) + assert result is device + assert backend.tx_power == -5 + assert backend.is_connectable_config is True + assert backend.is_discoverable_config is False + + +class TestAddService: + """Direct ServiceDefinition registration.""" + + @pytest.mark.asyncio + async def test_add_service_delegates_to_backend(self) -> None: + device, backend = _make_device() + svc = ServiceDefinition(uuid=BluetoothUUID("180F")) + await device.add_service(svc) + + assert len(backend.services) == 1 + assert str(backend.services[0].uuid) == str(BluetoothUUID("180F")) From 31bf76a69e6f80a023aff88ac623a3f775ad58df Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Thu, 19 Feb 2026 12:54:37 +0000 Subject: [PATCH 2/9] feat(3.5): decode location/sensing AD types with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add structured decoding for 4 advertising data types that were previously stored as raw bytes: - IndoorPositioningData (AD 0x25) — flag-driven WGS84/local coords - TransportDiscoveryData (AD 0x26) — multi-block transport discovery - ThreeDInformationData (AD 0x3D) — 3D display sync flags - ChannelMapUpdateIndication (AD 0x28) — channel map + instant Each type follows the established codebase patterns: - IntFlag for all bitfield bytes - DataParser for all integer parsing (auto InsufficientDataError) - msgspec.Struct frozen=True kw_only=True - One file per type under types/advertising/ - Properties for boolean flag accessors (single source of truth) Integration: - ad_structures.py LocationAndSensingData fields typed (was bytes) - pdu_parser.py calls .decode() instead of raw assignment - __init__.py exports updated Tests: 58 new tests across 4 test files covering decode, errors, flag properties, and boundary conditions. --- .github/ai-agent-characteristic-rules.md | 79 ------ src/bluetooth_sig/advertising/pdu_parser.py | 12 +- src/bluetooth_sig/device/peripheral_device.py | 14 +- .../types/advertising/__init__.py | 32 ++- .../types/advertising/ad_structures.py | 20 +- .../types/advertising/channel_map_update.py | 85 ++++++ .../types/advertising/indoor_positioning.py | 154 +++++++++++ .../types/advertising/three_d_information.py | 85 ++++++ .../types/advertising/transport_discovery.py | 129 +++++++++ tests/advertising/test_channel_map_update.py | 194 +++++++++++++ tests/advertising/test_indoor_positioning.py | 261 ++++++++++++++++++ tests/advertising/test_three_d_information.py | 162 +++++++++++ tests/advertising/test_transport_discovery.py | 192 +++++++++++++ tests/device/test_peripheral_device.py | 26 ++ 14 files changed, 1352 insertions(+), 93 deletions(-) delete mode 100644 .github/ai-agent-characteristic-rules.md create mode 100644 src/bluetooth_sig/types/advertising/channel_map_update.py create mode 100644 src/bluetooth_sig/types/advertising/indoor_positioning.py create mode 100644 src/bluetooth_sig/types/advertising/three_d_information.py create mode 100644 src/bluetooth_sig/types/advertising/transport_discovery.py create mode 100644 tests/advertising/test_channel_map_update.py create mode 100644 tests/advertising/test_indoor_positioning.py create mode 100644 tests/advertising/test_three_d_information.py create mode 100644 tests/advertising/test_transport_discovery.py diff --git a/.github/ai-agent-characteristic-rules.md b/.github/ai-agent-characteristic-rules.md deleted file mode 100644 index 23a4fdbb..00000000 --- a/.github/ai-agent-characteristic-rules.md +++ /dev/null @@ -1,79 +0,0 @@ -# AI Agent Rules — GATT Characteristic Implementations - -Purpose - -Provide a concise, enforceable checklist for automated agents or contributors implementing files under `src/bluetooth_sig/gatt/characteristics/`. - -Core principle - -- Characteristics implement ONLY field-specific parsing, sentinel mapping, scaling and construction of typed return values. -- All generic validation (length, type, range) MUST be declared as class attributes and is enforced by `BaseCharacteristic.parse_value()`. - -Templates & reuse - -- Reuse `templates` helpers when a characteristic matches an existing template. Do not copy template parsing into concrete characteristics. -- Extend templates by composition/wrapping rather than duplicating parsing code. - -Registry (GSS/YAML) guidance - -- YAML registry entries are authoritative for field sizes, data types and encoding. Consult the YAML entry before implementing parsing. -- Use these helpers where available: - - `get_yaml_data_type()` — select DataParser primitive - - `get_yaml_field_size()` — set `expected_length` / `min_length` - - `is_signed_from_yaml()` — determine signed vs unsigned - -Data parsing rules - -- Use `DataParser` and `ieee11073` helpers for primitive reads/writes (e.g. `parse_int16`, `parse_int32`). -- Use a `pos` offset for multi-field parsing and let helpers raise `InsufficientDataError` when bytes are insufficient. -- Map sentinel/special values to `None` only after parsing the raw field. - -Constants & magic numbers - -- Replace inline magic numbers with named constants and a one-line comment (lengths, sentinel values, scales, masks, shifts). -- Constants placement: - - Shared across characteristics → `src/bluetooth_sig/gatt/constants.py` or `src/bluetooth_sig/types/constants.py`. - - Characteristic-local → class-level constant inside the characteristic file. - -Encode/decode symmetry - -- Implement `encode_value` as the logical inverse of `decode_value` using `DataParser.encode_*` helpers. Ensure field sizes, endianness and scales match the YAML spec. - -Bitfields & flags - -- Define masks/shifts and prefer `enum.IntFlag` when reusable. Return typed booleans or flags in outputs and document bit meanings in the docstring. -- Do NOT use plain `int` for enumerated values — use `enum.Enum` or `enum.IntFlag` for type safety and readability. -- For bit fields representing presence flags or other bit masks, use `enum.IntFlag` for type safety. - -Forbidden patterns (MUST NOT) - -- Do NOT perform length, range, or type validation inside `decode_value`/`encode_value` — `BaseCharacteristic` handles these. -- Do NOT reimplement endianness/signed parsing or use manual `int.from_bytes` where `DataParser` exists. -- Do NOT hardcode SIG UUIDs in parsing logic; use registry resolution or `_info = CharacteristicInfo(...)` only for vendor metadata. -- Do NOT return sentinel integers or raw bytes as "unknown" — map to `None` explicitly. - -Docs & metadata - -- Docstring must include: UUID/name, format/length, endianness, units, scale factor, sentinel values and mapping, bitfield layout, raised exceptions, and an example hex payload. - -Testing requirements - -- Each characteristic must include tests: one success case and at least two failure cases (insufficient data + invalid/out-of-range). Tests must assert exception types and `CharacteristicData` semantics. - -CI / machine checks (suggested) - -- Fail if `int.from_bytes` appears in `gatt/characteristics/`. -- Fail if `len(data)` checks or `raise InsufficientDataError` occur inside `decode_value`/`encode_value` for checks that class attributes cover. -- Fail if inline magic numeric literals appear near parsing code. - -Example automation regexes (adjust before use) - -- `\bint\.from_bytes\b` -- `if\s+len\(data\)\s*[!=<]` -- `raise\s+InsufficientDataError\(` -- `\b0x[0-9A-Fa-f]+\b` (for hex literals near parsing) - -Quick DO / DO NOT - -- DO: declare `expected_length = 4` then `raw = DataParser.parse_int32(data, 0, signed=True); return None if raw == VALUE_NOT_KNOWN else raw * SCALE` -- DO NOT: `if len(data) != 4: raise InsufficientDataError(...); raw = int.from_bytes(data[:4], 'little', signed=True)` diff --git a/src/bluetooth_sig/advertising/pdu_parser.py b/src/bluetooth_sig/advertising/pdu_parser.py index c61b8d14..5e2681bb 100644 --- a/src/bluetooth_sig/advertising/pdu_parser.py +++ b/src/bluetooth_sig/advertising/pdu_parser.py @@ -33,8 +33,12 @@ PHYType, SyncInfo, ) +from bluetooth_sig.types.advertising.channel_map_update import ChannelMapUpdateIndication from bluetooth_sig.types.advertising.features import LEFeatures from bluetooth_sig.types.advertising.flags import BLEAdvertisingFlags +from bluetooth_sig.types.advertising.indoor_positioning import IndoorPositioningData +from bluetooth_sig.types.advertising.three_d_information import ThreeDInformationData +from bluetooth_sig.types.advertising.transport_discovery import TransportDiscoveryData from bluetooth_sig.types.advertising.pdu import ( BLEAdvertisingPDU, BLEExtendedHeader, @@ -729,13 +733,13 @@ def _handle_location_ad_types(self, ad_type: int, ad_data: bytes, parsed: Advert True if ad_type was handled, False otherwise """ if ad_type == ADType.INDOOR_POSITIONING: - parsed.location.indoor_positioning = ad_data + parsed.location.indoor_positioning = IndoorPositioningData.decode(ad_data) elif ad_type == ADType.TRANSPORT_DISCOVERY_DATA: - parsed.location.transport_discovery_data = ad_data + parsed.location.transport_discovery_data = TransportDiscoveryData.decode(ad_data) elif ad_type == ADType.THREE_D_INFORMATION_DATA: - parsed.location.three_d_information = ad_data + parsed.location.three_d_information = ThreeDInformationData.decode(ad_data) elif ad_type == ADType.CHANNEL_MAP_UPDATE_INDICATION: - parsed.location.channel_map_update_indication = ad_data + parsed.location.channel_map_update_indication = ChannelMapUpdateIndication.decode(ad_data) else: return False return True diff --git a/src/bluetooth_sig/device/peripheral_device.py b/src/bluetooth_sig/device/peripheral_device.py index df482bad..a653449d 100644 --- a/src/bluetooth_sig/device/peripheral_device.py +++ b/src/bluetooth_sig/device/peripheral_device.py @@ -12,6 +12,10 @@ from __future__ import annotations import logging + +# Any is required: BaseCharacteristic is generic over its value type (T), but +# PeripheralDevice hosts heterogeneous characteristics with different T types +# in a single dict, so the container must erase the type parameter to Any. from typing import Any from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic @@ -42,9 +46,17 @@ def __init__( characteristic: BaseCharacteristic[Any], initial_value: Any = None, # noqa: ANN401 ) -> None: + """Initialise a hosted characteristic record. + + Args: + definition: The GATT characteristic definition registered on the peripheral. + characteristic: The SIG characteristic class instance for encoding/decoding. + initial_value: Optional initial Python value set on this characteristic. + + """ self.definition = definition self.characteristic = characteristic - self.last_value: Any = initial_value + self.last_value: Any = initial_value # noqa: ANN401 class PeripheralDevice: diff --git a/src/bluetooth_sig/types/advertising/__init__.py b/src/bluetooth_sig/types/advertising/__init__.py index f572f4d2..1c9b9349 100644 --- a/src/bluetooth_sig/types/advertising/__init__.py +++ b/src/bluetooth_sig/types/advertising/__init__.py @@ -1,6 +1,6 @@ """BLE Advertising data types and parsing utilities. -This package contains types organized by category: +This package contains types organised by category: - pdu: PDU-level structures (PDUType, BLEAdvertisingPDU, headers) - extended: Extended advertising fields (CTE, ADI, AuxPtr, SyncInfo) - flags: BLE advertising flags @@ -8,6 +8,10 @@ - ad_structures: Parsed AD structure categories - builder: Advertisement data encoding and building - result: Final composed advertising data types + - indoor_positioning: Indoor Positioning AD type (0x25) + - transport_discovery: Transport Discovery Data AD type (0x26) + - channel_map_update: Channel Map Update Indication AD type (0x28) + - three_d_information: 3D Information Data AD type (0x3D) """ from bluetooth_sig.types.advertising.ad_structures import ( @@ -29,10 +33,24 @@ encode_service_uuids_16bit, encode_service_uuids_128bit, ) +from bluetooth_sig.types.advertising.channel_map_update import ChannelMapUpdateIndication from bluetooth_sig.types.advertising.features import LEFeatures from bluetooth_sig.types.advertising.flags import BLEAdvertisingFlags +from bluetooth_sig.types.advertising.indoor_positioning import ( + IndoorPositioningConfig, + IndoorPositioningData, +) from bluetooth_sig.types.advertising.pdu import BLEAdvertisingPDU from bluetooth_sig.types.advertising.result import AdvertisementData, AdvertisingData +from bluetooth_sig.types.advertising.three_d_information import ( + ThreeDInformationData, + ThreeDInformationFlags, +) +from bluetooth_sig.types.advertising.transport_discovery import ( + TDSFlags, + TransportBlock, + TransportDiscoveryData, +) __all__ = [ # ad_structures types @@ -52,13 +70,25 @@ "encode_manufacturer_data", "encode_service_uuids_16bit", "encode_service_uuids_128bit", + # channel_map_update + "ChannelMapUpdateIndication", # features "LEFeatures", # flags "BLEAdvertisingFlags", + # indoor_positioning + "IndoorPositioningConfig", + "IndoorPositioningData", # pdu "BLEAdvertisingPDU", # result types "AdvertisementData", "AdvertisingData", + # three_d_information + "ThreeDInformationData", + "ThreeDInformationFlags", + # transport_discovery + "TDSFlags", + "TransportBlock", + "TransportDiscoveryData", ] diff --git a/src/bluetooth_sig/types/advertising/ad_structures.py b/src/bluetooth_sig/types/advertising/ad_structures.py index 5261887a..cc647c88 100644 --- a/src/bluetooth_sig/types/advertising/ad_structures.py +++ b/src/bluetooth_sig/types/advertising/ad_structures.py @@ -7,9 +7,13 @@ import msgspec +from bluetooth_sig.types.advertising.channel_map_update import ChannelMapUpdateIndication from bluetooth_sig.types.advertising.features import LEFeatures from bluetooth_sig.types.advertising.flags import BLEAdvertisingFlags +from bluetooth_sig.types.advertising.indoor_positioning import IndoorPositioningData from bluetooth_sig.types.advertising.pdu import BLEAdvertisingPDU +from bluetooth_sig.types.advertising.three_d_information import ThreeDInformationData +from bluetooth_sig.types.advertising.transport_discovery import TransportDiscoveryData from bluetooth_sig.types.appearance import AppearanceData from bluetooth_sig.types.company import ManufacturerData from bluetooth_sig.types.mesh import ( @@ -139,16 +143,16 @@ class LocationAndSensingData(msgspec.Struct, kw_only=True): """Location, positioning, and sensing related data. Attributes: - indoor_positioning: Indoor positioning data - three_d_information: 3D information data - transport_discovery_data: Transport Discovery Data - channel_map_update_indication: Channel Map Update Indication + indoor_positioning: Parsed Indoor Positioning AD (0x25), or ``None`` if absent. + three_d_information: Parsed 3D Information AD (0x3D), or ``None`` if absent. + transport_discovery_data: Parsed Transport Discovery AD (0x26), or ``None`` if absent. + channel_map_update_indication: Parsed Channel Map Update AD (0x28), or ``None`` if absent. """ - indoor_positioning: bytes = b"" - three_d_information: bytes = b"" - transport_discovery_data: bytes = b"" - channel_map_update_indication: bytes = b"" + indoor_positioning: IndoorPositioningData | None = None + three_d_information: ThreeDInformationData | None = None + transport_discovery_data: TransportDiscoveryData | None = None + channel_map_update_indication: ChannelMapUpdateIndication | None = None class MeshAndBroadcastData(msgspec.Struct, kw_only=True): diff --git a/src/bluetooth_sig/types/advertising/channel_map_update.py b/src/bluetooth_sig/types/advertising/channel_map_update.py new file mode 100644 index 00000000..2fbcc729 --- /dev/null +++ b/src/bluetooth_sig/types/advertising/channel_map_update.py @@ -0,0 +1,85 @@ +"""Channel Map Update Indication (AD 0x28, Core Spec Vol 3, Part C §11). + +Decodes the Channel Map Update Indication AD type that carries a new +data-channel map and the connection-event instant at which it takes effect. +""" + +from __future__ import annotations + +import msgspec + +from bluetooth_sig.gatt.characteristics.utils import DataParser + +# Channel Map Update Indication layout sizes +CHANNEL_MAP_LENGTH = 5 # 5-byte bitmask of data channels 0-36 +CHANNEL_MAP_INSTANT_OFFSET = CHANNEL_MAP_LENGTH # instant immediately follows map + +# BLE data channel range (Core Spec Vol 6, Part B §1.4.1) +MAX_DATA_CHANNEL = 36 + + +class ChannelMapUpdateIndication(msgspec.Struct, frozen=True, kw_only=True): + """Channel Map Update Indication (Core Spec Vol 3, Part C, §11). + + Carries a new channel map and the connection-event instant at which + it takes effect. + + Format: channel_map (5 bytes) + instant (2 bytes LE uint16). + + Attributes: + channel_map: 5-byte bitmask of used data channels (channels 0-36). + Bit *n* = 1 means channel *n* is in use. + instant: Connection event count at which the new map takes effect. + + """ + + channel_map: bytes + instant: int + + @classmethod + def decode(cls, data: bytes | bytearray) -> ChannelMapUpdateIndication: + """Decode Channel Map Update Indication AD. + + DataParser raises ``InsufficientDataError`` if the payload is + shorter than the required 7 bytes. + + Args: + data: Raw AD data bytes (excluding length and AD type). + + Returns: + Parsed ChannelMapUpdateIndication. + + """ + channel_map = bytes(data[:CHANNEL_MAP_LENGTH]) + instant = DataParser.parse_int16(data, CHANNEL_MAP_INSTANT_OFFSET, signed=False) + + return cls(channel_map=channel_map, instant=instant) + + def is_channel_used(self, channel: int) -> bool: + """Check if a specific data channel is marked as used. + + Args: + channel: Channel number (0-36). + + Returns: + ``True`` if the channel is used in the new map. + + Raises: + ValueError: If channel is out of range. + + """ + if not 0 <= channel <= MAX_DATA_CHANNEL: + msg = f"Channel must be 0-{MAX_DATA_CHANNEL}, got {channel}" + raise ValueError(msg) + + byte_index = channel // 8 + bit_index = channel % 8 + return bool(self.channel_map[byte_index] & (1 << bit_index)) + + +__all__ = [ + "CHANNEL_MAP_LENGTH", + "CHANNEL_MAP_INSTANT_OFFSET", + "ChannelMapUpdateIndication", + "MAX_DATA_CHANNEL", +] diff --git a/src/bluetooth_sig/types/advertising/indoor_positioning.py b/src/bluetooth_sig/types/advertising/indoor_positioning.py new file mode 100644 index 00000000..b8c817a7 --- /dev/null +++ b/src/bluetooth_sig/types/advertising/indoor_positioning.py @@ -0,0 +1,154 @@ +"""Indoor Positioning advertisement data (AD 0x25, CSS Part A §1.14). + +Decodes the Indoor Positioning AD type whose configuration byte drives +which optional coordinate, power and altitude fields are present. +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from bluetooth_sig.gatt.characteristics.utils import DataParser + + +class IndoorPositioningConfig(IntFlag): + """Configuration byte flags for Indoor Positioning AD (CSS Part A §1.14). + + The configuration byte is the first octet of the AD data and determines + which optional fields are present in the payload. + """ + + COORDINATE_SYSTEM_LOCAL = 0x01 # 0 = WGS84, 1 = local + LATITUDE_PRESENT = 0x02 + LONGITUDE_PRESENT = 0x04 + LOCAL_NORTH_PRESENT = 0x08 + LOCAL_EAST_PRESENT = 0x10 + TX_POWER_PRESENT = 0x20 + FLOOR_NUMBER_PRESENT = 0x40 + ALTITUDE_PRESENT = 0x80 + + +# Mask combining all location-bearing flag bits (used for uncertainty check) +LOCATION_FLAGS_MASK = ( + IndoorPositioningConfig.LATITUDE_PRESENT + | IndoorPositioningConfig.LONGITUDE_PRESENT + | IndoorPositioningConfig.LOCAL_NORTH_PRESENT + | IndoorPositioningConfig.LOCAL_EAST_PRESENT +) + + +class IndoorPositioningData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed Indoor Positioning AD data (CSS Part A, §1.14). + + The configuration byte determines which optional fields are present. + When ``is_local_coordinates`` is ``False``, WGS84 latitude/longitude + fields are used; when ``True``, local north/east fields are used. + + Attributes: + config: Raw configuration flags for reference. + is_local_coordinates: ``True`` for local coordinate system, ``False`` for WGS84. + latitude: WGS84 latitude in units of 1e-7 degrees (present when bit 1 set, WGS84 mode). + longitude: WGS84 longitude in units of 1e-7 degrees (present when bit 2 set, WGS84 mode). + local_north: Local north coordinate in 0.01 m units (present when bit 3 set, local mode). + local_east: Local east coordinate in 0.01 m units (present when bit 4 set, local mode). + tx_power: Transmit power level in dBm. + floor_number: Floor number (offset by -20, so 0 means floor -20). + altitude: Altitude in 0.01 m units (interpretation depends on coordinate system). + uncertainty: Location uncertainty — bit 7 is stationary flag, bits 0-6 encode precision. + + """ + + config: IndoorPositioningConfig = IndoorPositioningConfig(0) + is_local_coordinates: bool = False + latitude: int | None = None + longitude: int | None = None + local_north: int | None = None + local_east: int | None = None + tx_power: int | None = None + floor_number: int | None = None + altitude: int | None = None + uncertainty: int | None = None + + @classmethod + def decode(cls, data: bytes | bytearray) -> IndoorPositioningData: + """Decode Indoor Positioning AD data. + + DataParser raises ``InsufficientDataError`` automatically if the + payload is truncated mid-field. Optional trailing fields use + ``len(data) >= offset + N`` guards (same pattern as Heart Rate + Measurement for optional fields). + + Args: + data: Raw AD data bytes (excluding length and AD type). + + Returns: + Parsed IndoorPositioningData. + + """ + config = IndoorPositioningConfig(DataParser.parse_int8(data, 0, signed=False)) + offset = 1 + + is_local = bool(config & IndoorPositioningConfig.COORDINATE_SYSTEM_LOCAL) + latitude: int | None = None + longitude: int | None = None + local_north: int | None = None + local_east: int | None = None + tx_power: int | None = None + floor_number: int | None = None + altitude: int | None = None + uncertainty: int | None = None + + if not is_local: + if config & IndoorPositioningConfig.LATITUDE_PRESENT: + latitude = DataParser.parse_int32(data, offset, signed=True) + offset += 4 + + if config & IndoorPositioningConfig.LONGITUDE_PRESENT: + longitude = DataParser.parse_int32(data, offset, signed=True) + offset += 4 + else: + if config & IndoorPositioningConfig.LOCAL_NORTH_PRESENT: + local_north = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + if config & IndoorPositioningConfig.LOCAL_EAST_PRESENT: + local_east = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + if config & IndoorPositioningConfig.TX_POWER_PRESENT: + tx_power = DataParser.parse_int8(data, offset, signed=True) + offset += 1 + + if config & IndoorPositioningConfig.FLOOR_NUMBER_PRESENT: + floor_number = DataParser.parse_int8(data, offset, signed=False) + offset += 1 + + if config & IndoorPositioningConfig.ALTITUDE_PRESENT: + altitude = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Uncertainty is optional — present only when any location field exists + if (config & LOCATION_FLAGS_MASK) and len(data) >= offset + 1: + uncertainty = DataParser.parse_int8(data, offset, signed=False) + + return cls( + config=config, + is_local_coordinates=is_local, + latitude=latitude, + longitude=longitude, + local_north=local_north, + local_east=local_east, + tx_power=tx_power, + floor_number=floor_number, + altitude=altitude, + uncertainty=uncertainty, + ) + + +__all__ = [ + "IndoorPositioningConfig", + "IndoorPositioningData", + "LOCATION_FLAGS_MASK", +] diff --git a/src/bluetooth_sig/types/advertising/three_d_information.py b/src/bluetooth_sig/types/advertising/three_d_information.py new file mode 100644 index 00000000..7fa02b39 --- /dev/null +++ b/src/bluetooth_sig/types/advertising/three_d_information.py @@ -0,0 +1,85 @@ +"""3D Information Data (AD 0x3D, CSS Part A §1.13). + +Decodes the 3D Information Data AD type used for 3D display +synchronisation between a 3D display and 3D glasses. +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from bluetooth_sig.gatt.characteristics.utils import DataParser + + +class ThreeDInformationFlags(IntFlag): + """3D Information Data flags byte (CSS Part A §1.13). + + Bit assignments from the 3D Synchronisation Profile specification. + """ + + ASSOCIATION_NOTIFICATION = 0x01 # Bit 0: send association notification + BATTERY_LEVEL_REPORTING = 0x02 # Bit 1: device reports battery level + SEND_BATTERY_ON_STARTUP = 0x04 # Bit 2: send battery level on startup + FACTORY_TEST_MODE = 0x80 # Bit 7: factory test mode enabled + + +class ThreeDInformationData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed 3D Information Data for 3D display synchronisation (CSS Part A, §1.13). + + Format: flags (1 byte) + path_loss_threshold (1 byte). + + Attributes: + flags: Raw flags for reference and bitwise queries. + path_loss_threshold: Path-loss threshold in dBm for proximity detection. + + """ + + flags: ThreeDInformationFlags = ThreeDInformationFlags(0) + path_loss_threshold: int = 0 + + @property + def association_notification(self) -> bool: + """Whether association notification is enabled (bit 0).""" + return bool(self.flags & ThreeDInformationFlags.ASSOCIATION_NOTIFICATION) + + @property + def battery_level_reporting(self) -> bool: + """Whether battery level reporting is enabled (bit 1).""" + return bool(self.flags & ThreeDInformationFlags.BATTERY_LEVEL_REPORTING) + + @property + def send_battery_on_startup(self) -> bool: + """Whether battery level is sent on startup (bit 2).""" + return bool(self.flags & ThreeDInformationFlags.SEND_BATTERY_ON_STARTUP) + + @property + def factory_test_mode(self) -> bool: + """Whether factory test mode is enabled (bit 7).""" + return bool(self.flags & ThreeDInformationFlags.FACTORY_TEST_MODE) + + @classmethod + def decode(cls, data: bytes | bytearray) -> ThreeDInformationData: + """Decode 3D Information Data AD. + + DataParser raises ``InsufficientDataError`` if fewer than 2 bytes + are available. + + Args: + data: Raw AD data bytes (excluding length and AD type). + + Returns: + Parsed ThreeDInformationData. + + """ + flags = ThreeDInformationFlags(DataParser.parse_int8(data, 0, signed=False)) + path_loss = DataParser.parse_int8(data, 1, signed=False) + + return cls(flags=flags, path_loss_threshold=path_loss) + + +__all__ = [ + "ThreeDInformationData", + "ThreeDInformationFlags", +] diff --git a/src/bluetooth_sig/types/advertising/transport_discovery.py b/src/bluetooth_sig/types/advertising/transport_discovery.py new file mode 100644 index 00000000..5d692a18 --- /dev/null +++ b/src/bluetooth_sig/types/advertising/transport_discovery.py @@ -0,0 +1,129 @@ +"""Transport Discovery Data (AD 0x26, CSS Part A §1.10). + +Decodes the Transport Discovery Data AD type which carries one or more +transport blocks describing available transport connections. +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from bluetooth_sig.gatt.characteristics.utils import DataParser + +# Transport block header: org_id (1) + flags (1) + data_length (1) +TRANSPORT_BLOCK_HEADER_LENGTH = 3 + + +class TDSFlags(IntFlag): + """Transport Discovery Service flags (CSS Part A §1.10). + + Encoded in a single byte per transport block. + """ + + ROLE_NOT_SPECIFIED = 0x00 + ROLE_SEEKER = 0x01 + ROLE_PROVIDER = 0x02 + ROLE_SEEKER_AND_PROVIDER = 0x03 + INCOMPLETE = 0x04 # Bit 2: transport data is incomplete + STATE_OFF = 0x00 # Bits 3-4 = 0b00 + STATE_ON = 0x08 # Bits 3-4 = 0b01 + STATE_TEMPORARILY_UNAVAILABLE = 0x10 # Bits 3-4 = 0b10 + + +# Masks for extracting sub-fields from TDSFlags +TDS_ROLE_MASK = TDSFlags.ROLE_SEEKER | TDSFlags.ROLE_PROVIDER +TDS_STATE_MASK = TDSFlags.STATE_ON | TDSFlags.STATE_TEMPORARILY_UNAVAILABLE + + +class TransportBlock(msgspec.Struct, frozen=True, kw_only=True): + """A single transport block within Transport Discovery Data. + + Attributes: + organization_id: Organisation defining the transport data (1 = Bluetooth SIG). + flags: TDS flags — role, incomplete, and transport state. + transport_data: Organisation-specific transport payload. + + """ + + organization_id: int + flags: TDSFlags + transport_data: bytes = b"" + + @property + def role(self) -> TDSFlags: + """Role bits (0-1): seeker, provider, both, or not specified.""" + return self.flags & TDS_ROLE_MASK + + @property + def is_incomplete(self) -> bool: + """Whether transport data is incomplete (bit 2).""" + return bool(self.flags & TDSFlags.INCOMPLETE) + + @property + def transport_state(self) -> TDSFlags: + """Transport state (bits 3-4): off, on, or temporarily unavailable.""" + return self.flags & TDS_STATE_MASK + + +class TransportDiscoveryData(msgspec.Struct, frozen=True, kw_only=True): + """Transport Discovery Data (CSS Part A, §1.10). + + Contains one or more transport blocks describing available transport + connections (e.g. Wi-Fi, classic Bluetooth). + + Attributes: + blocks: List of parsed transport blocks. + + """ + + blocks: list[TransportBlock] = msgspec.field(default_factory=list) + + @classmethod + def decode(cls, data: bytes | bytearray) -> TransportDiscoveryData: + """Decode Transport Discovery Data AD. + + Iterates over transport blocks until the buffer is exhausted. + DataParser raises ``InsufficientDataError`` if a block header is + truncated. Incomplete trailing blocks (fewer than + ``TRANSPORT_BLOCK_HEADER_LENGTH`` bytes remaining) are silently + skipped, matching real-world scanner behaviour. + + Args: + data: Raw AD data bytes (excluding length and AD type). + + Returns: + Parsed TransportDiscoveryData with transport blocks. + + """ + blocks: list[TransportBlock] = [] + offset = 0 + + while offset + TRANSPORT_BLOCK_HEADER_LENGTH <= len(data): + org_id = DataParser.parse_int8(data, offset, signed=False) + tds_flags = TDSFlags(DataParser.parse_int8(data, offset + 1, signed=False)) + transport_data_length = DataParser.parse_int8(data, offset + 2, signed=False) + offset += TRANSPORT_BLOCK_HEADER_LENGTH + + end = min(offset + transport_data_length, len(data)) + transport_data = bytes(data[offset:end]) + offset = end + + blocks.append(TransportBlock( + organization_id=org_id, + flags=tds_flags, + transport_data=transport_data, + )) + + return cls(blocks=blocks) + + +__all__ = [ + "TDSFlags", + "TDS_ROLE_MASK", + "TDS_STATE_MASK", + "TRANSPORT_BLOCK_HEADER_LENGTH", + "TransportBlock", + "TransportDiscoveryData", +] diff --git a/tests/advertising/test_channel_map_update.py b/tests/advertising/test_channel_map_update.py new file mode 100644 index 00000000..d6649f4a --- /dev/null +++ b/tests/advertising/test_channel_map_update.py @@ -0,0 +1,194 @@ +"""Tests for Channel Map Update Indication AD type decode (AD 0x28, Core Spec Vol 3, Part C §11). + +Tests cover: +- Normal decode with channel map and instant +- is_channel_used boundary checks (channel 0, 36) +- Out-of-range channel numbers +- Truncated and empty data handling +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from bluetooth_sig.gatt.exceptions import InsufficientDataError +from bluetooth_sig.types.advertising.channel_map_update import ( + CHANNEL_MAP_INSTANT_OFFSET, + CHANNEL_MAP_LENGTH, + MAX_DATA_CHANNEL, + ChannelMapUpdateIndication, +) + + +@dataclass +class ADTypeTestData: + """Test data for AD type decode — mirrors CharacteristicTestData.""" + + input_data: bytearray + expected_value: Any + description: str = "" + + +class TestChannelMapUpdateDecode: + """Tests for ChannelMapUpdateIndication.decode().""" + + @pytest.fixture + def valid_test_data(self) -> list[ADTypeTestData]: + """Standard decode scenarios for channel map update indication.""" + return [ + ADTypeTestData( + input_data=bytearray([ + 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, # all 37 channels used + 0x64, 0x00, # instant = 100 + ]), + expected_value=ChannelMapUpdateIndication( + channel_map=b"\xFF\xFF\xFF\xFF\x1F", + instant=100, + ), + description="All channels used, instant = 100", + ), + ADTypeTestData( + input_data=bytearray([ + 0x00, 0x00, 0x00, 0x00, 0x00, # no channels used + 0x00, 0x00, # instant = 0 + ]), + expected_value=ChannelMapUpdateIndication( + channel_map=b"\x00\x00\x00\x00\x00", + instant=0, + ), + description="No channels used, instant = 0", + ), + ADTypeTestData( + input_data=bytearray([ + 0x01, 0x00, 0x00, 0x00, 0x00, # only channel 0 used + 0xFF, 0xFF, # instant = 65535 (max uint16) + ]), + expected_value=ChannelMapUpdateIndication( + channel_map=b"\x01\x00\x00\x00\x00", + instant=65535, + ), + description="Only channel 0, max instant", + ), + ] + + def test_decode_valid_data(self, valid_test_data: list[ADTypeTestData]) -> None: + """Decode each valid test case and verify all fields match.""" + for case in valid_test_data: + result = ChannelMapUpdateIndication.decode(case.input_data) + assert result == case.expected_value, f"Failed: {case.description}" + + def test_decode_extra_bytes_ignored(self) -> None: + """Trailing bytes beyond the 7-byte payload are ignored.""" + data = bytearray([ + 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, + 0x0A, 0x00, + 0xDE, 0xAD, # extra + ]) + result = ChannelMapUpdateIndication.decode(data) + + assert result.instant == 10 + assert result.channel_map == b"\xFF\xFF\xFF\xFF\x1F" + + +class TestChannelMapUpdateErrors: + """Error-path tests for ChannelMapUpdateIndication.decode().""" + + def test_decode_empty_data_raises(self) -> None: + """Empty bytearray raises InsufficientDataError — no channel map.""" + with pytest.raises(InsufficientDataError): + ChannelMapUpdateIndication.decode(bytearray()) + + def test_decode_truncated_instant_raises(self) -> None: + """Channel map present but instant is truncated (only 1 of 2 bytes).""" + data = bytearray([0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0x01]) + with pytest.raises(InsufficientDataError): + ChannelMapUpdateIndication.decode(data) + + def test_decode_only_channel_map_raises(self) -> None: + """Five-byte channel map with no instant bytes at all.""" + data = bytearray([0xFF, 0xFF, 0xFF, 0xFF, 0x1F]) + with pytest.raises(InsufficientDataError): + ChannelMapUpdateIndication.decode(data) + + +class TestIsChannelUsed: + """Tests for ChannelMapUpdateIndication.is_channel_used().""" + + @pytest.fixture + def all_channels_indication(self) -> ChannelMapUpdateIndication: + """Indication with all 37 data channels used.""" + return ChannelMapUpdateIndication( + channel_map=b"\xFF\xFF\xFF\xFF\x1F", + instant=0, + ) + + @pytest.fixture + def no_channels_indication(self) -> ChannelMapUpdateIndication: + """Indication with no channels used.""" + return ChannelMapUpdateIndication( + channel_map=b"\x00\x00\x00\x00\x00", + instant=0, + ) + + def test_channel_zero_used(self, all_channels_indication: ChannelMapUpdateIndication) -> None: + """Channel 0 (lowest data channel) is used when all bits set.""" + assert all_channels_indication.is_channel_used(0) is True + + def test_channel_36_used(self, all_channels_indication: ChannelMapUpdateIndication) -> None: + """Channel 36 (highest data channel) is used when all bits set.""" + assert all_channels_indication.is_channel_used(MAX_DATA_CHANNEL) is True + + def test_channel_zero_not_used(self, no_channels_indication: ChannelMapUpdateIndication) -> None: + """Channel 0 is not used when all bits clear.""" + assert no_channels_indication.is_channel_used(0) is False + + def test_channel_36_not_used(self, no_channels_indication: ChannelMapUpdateIndication) -> None: + """Channel 36 is not used when all bits clear.""" + assert no_channels_indication.is_channel_used(MAX_DATA_CHANNEL) is False + + def test_single_channel_set(self) -> None: + """Only channel 8 (byte 1, bit 0) is used.""" + indication = ChannelMapUpdateIndication( + channel_map=b"\x00\x01\x00\x00\x00", + instant=0, + ) + assert indication.is_channel_used(8) is True + assert indication.is_channel_used(0) is False + assert indication.is_channel_used(9) is False + + def test_channel_negative_raises(self) -> None: + """Negative channel number raises ValueError.""" + indication = ChannelMapUpdateIndication(channel_map=b"\xFF\xFF\xFF\xFF\x1F", instant=0) + with pytest.raises(ValueError, match="Channel must be 0-36"): + indication.is_channel_used(-1) + + def test_channel_37_raises(self) -> None: + """Channel 37 (first advertising channel) is out of data channel range.""" + indication = ChannelMapUpdateIndication(channel_map=b"\xFF\xFF\xFF\xFF\x1F", instant=0) + with pytest.raises(ValueError, match="Channel must be 0-36"): + indication.is_channel_used(37) + + def test_channel_255_raises(self) -> None: + """Large channel number raises ValueError.""" + indication = ChannelMapUpdateIndication(channel_map=b"\xFF\xFF\xFF\xFF\x1F", instant=0) + with pytest.raises(ValueError, match="Channel must be 0-36"): + indication.is_channel_used(255) + + +class TestChannelMapConstants: + """Tests for channel map layout constants.""" + + def test_channel_map_length(self) -> None: + """Channel map is 5 bytes (covers 40 bits for channels 0-36).""" + assert CHANNEL_MAP_LENGTH == 5 + + def test_instant_offset_follows_channel_map(self) -> None: + """Instant field starts immediately after the 5-byte channel map.""" + assert CHANNEL_MAP_INSTANT_OFFSET == CHANNEL_MAP_LENGTH + + def test_max_data_channel(self) -> None: + """Maximum data channel is 36 per Core Spec Vol 6, Part B §1.4.1.""" + assert MAX_DATA_CHANNEL == 36 diff --git a/tests/advertising/test_indoor_positioning.py b/tests/advertising/test_indoor_positioning.py new file mode 100644 index 00000000..96e0414f --- /dev/null +++ b/tests/advertising/test_indoor_positioning.py @@ -0,0 +1,261 @@ +"""Tests for Indoor Positioning AD type decode (AD 0x25, CSS Part A §1.14). + +Tests cover: +- WGS84 coordinate decoding (latitude, longitude) +- Local coordinate decoding (north, east) +- Optional fields: tx_power, floor_number, altitude, uncertainty +- Flag-driven field presence/absence +- Truncated and empty data handling +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from bluetooth_sig.gatt.exceptions import InsufficientDataError +from bluetooth_sig.types.advertising.indoor_positioning import ( + LOCATION_FLAGS_MASK, + IndoorPositioningConfig, + IndoorPositioningData, +) + + +@dataclass +class ADTypeTestData: + """Test data for AD type decode — mirrors CharacteristicTestData.""" + + input_data: bytearray + expected_value: Any + description: str = "" + + +class TestIndoorPositioningDecode: + """Tests for IndoorPositioningData.decode().""" + + @pytest.fixture + def valid_test_data(self) -> list[ADTypeTestData]: + """Standard decode scenarios covering major flag combinations.""" + return [ + ADTypeTestData( + input_data=bytearray([0x00]), + expected_value=IndoorPositioningData( + config=IndoorPositioningConfig(0), + is_local_coordinates=False, + ), + description="Config-only, no optional fields (WGS84 mode)", + ), + ADTypeTestData( + input_data=bytearray([ + 0x06, # LATITUDE_PRESENT | LONGITUDE_PRESENT + 0x40, 0x42, 0x0F, 0x00, # latitude = 1_000_000 (little-endian) + 0x80, 0x84, 0x1E, 0x00, # longitude = 2_000_000 + 0xAA, # uncertainty + ]), + expected_value=IndoorPositioningData( + config=IndoorPositioningConfig.LATITUDE_PRESENT | IndoorPositioningConfig.LONGITUDE_PRESENT, + is_local_coordinates=False, + latitude=1_000_000, + longitude=2_000_000, + uncertainty=0xAA, + ), + description="WGS84 lat+lon with uncertainty", + ), + ADTypeTestData( + input_data=bytearray([ + 0x19, # COORDINATE_SYSTEM_LOCAL | LOCAL_NORTH_PRESENT | LOCAL_EAST_PRESENT + 0xE8, 0x03, # local_north = 1000 (0.01 m units) + 0xD0, 0x07, # local_east = 2000 + 0x55, # uncertainty + ]), + expected_value=IndoorPositioningData( + config=( + IndoorPositioningConfig.COORDINATE_SYSTEM_LOCAL + | IndoorPositioningConfig.LOCAL_NORTH_PRESENT + | IndoorPositioningConfig.LOCAL_EAST_PRESENT + ), + is_local_coordinates=True, + local_north=1000, + local_east=2000, + uncertainty=0x55, + ), + description="Local coordinate system with north+east and uncertainty", + ), + ADTypeTestData( + input_data=bytearray([ + 0xE6, # LAT | LON | TX_POWER | FLOOR | ALTITUDE (WGS84) + 0x60, 0x79, 0xFE, 0xFF, # latitude = -100_000 (signed) + 0xC0, 0xF2, 0xFC, 0xFF, # longitude = -200_000 (signed) + 0xEC, # tx_power = -20 dBm (signed) + 0x14, # floor_number = 20 (offset -20 → floor 0) + 0x10, 0x27, # altitude = 10000 (0.01 m units) + 0x42, # uncertainty + ]), + expected_value=IndoorPositioningData( + config=IndoorPositioningConfig(0xE6), + is_local_coordinates=False, + latitude=-100_000, + longitude=-200_000, + tx_power=-20, + floor_number=20, + altitude=10000, + uncertainty=0x42, + ), + description="WGS84 with all optional fields and negative coordinates", + ), + ] + + def test_decode_valid_data(self, valid_test_data: list[ADTypeTestData]) -> None: + """Decode each valid test case and verify all fields match.""" + for case in valid_test_data: + result = IndoorPositioningData.decode(case.input_data) + assert result == case.expected_value, f"Failed: {case.description}" + + def test_decode_wgs84_latitude_only(self) -> None: + """Decode WGS84 payload with only latitude present (no longitude).""" + data = bytearray([ + 0x02, # LATITUDE_PRESENT only + 0x00, 0xE1, 0xF5, 0x05, # latitude = 100_000_000 + 0x80, # uncertainty + ]) + result = IndoorPositioningData.decode(data) + + assert result.is_local_coordinates is False + assert result.latitude == 100_000_000 + assert result.longitude is None + assert result.uncertainty == 0x80 + + def test_decode_local_north_only(self) -> None: + """Decode local coordinate payload with only north present.""" + data = bytearray([ + 0x09, # COORDINATE_SYSTEM_LOCAL | LOCAL_NORTH_PRESENT + 0x01, 0x00, # local_north = 1 + 0x00, # uncertainty + ]) + result = IndoorPositioningData.decode(data) + + assert result.is_local_coordinates is True + assert result.local_north == 1 + assert result.local_east is None + assert result.uncertainty == 0x00 + + def test_decode_tx_power_only(self) -> None: + """Decode payload with only tx_power present — no location, no uncertainty.""" + data = bytearray([0x20, 0xF6]) # TX_POWER_PRESENT, tx_power = -10 dBm + result = IndoorPositioningData.decode(data) + + assert result.tx_power == -10 + assert result.latitude is None + assert result.uncertainty is None + + def test_decode_floor_number_only(self) -> None: + """Decode payload with only floor_number present.""" + data = bytearray([0x40, 0xFF]) # FLOOR_NUMBER_PRESENT, floor = 255 + result = IndoorPositioningData.decode(data) + + assert result.floor_number == 255 + assert result.altitude is None + + def test_decode_altitude_only(self) -> None: + """Decode payload with only altitude present.""" + data = bytearray([0x80, 0x00, 0x00]) # ALTITUDE_PRESENT, altitude = 0 + result = IndoorPositioningData.decode(data) + + assert result.altitude == 0 + + def test_decode_uncertainty_absent_when_no_location_flags(self) -> None: + """Uncertainty byte is skipped when no location-bearing flags are set. + + Even if extra bytes exist after the last field, uncertainty is only + parsed when LOCATION_FLAGS_MASK bits are present. + """ + # TX_POWER + FLOOR + ALTITUDE only — no lat/lon/north/east + data = bytearray([0xE0, 0x05, 0x0A, 0x10, 0x27, 0xFF]) + result = IndoorPositioningData.decode(data) + + assert result.tx_power == 5 + assert result.floor_number == 10 + assert result.altitude == 10000 + assert result.uncertainty is None # trailing 0xFF ignored + + def test_decode_uncertainty_absent_when_data_exhausted(self) -> None: + """Uncertainty byte omitted when payload ends before it. + + Latitude present → uncertainty expected, but payload is exactly + 4 bytes for the coordinate with no trailing uncertainty byte. + """ + data = bytearray([ + 0x02, # LATITUDE_PRESENT + 0x40, 0x42, 0x0F, 0x00, # latitude = 1_000_000 + ]) + result = IndoorPositioningData.decode(data) + + assert result.latitude == 1_000_000 + assert result.uncertainty is None + + +class TestIndoorPositioningErrors: + """Error-path tests for IndoorPositioningData.decode().""" + + def test_decode_empty_data_raises(self) -> None: + """Empty bytearray raises InsufficientDataError — no config byte.""" + with pytest.raises(InsufficientDataError): + IndoorPositioningData.decode(bytearray()) + + def test_decode_truncated_latitude_raises(self) -> None: + """Config says latitude present but only 2 of 4 bytes available.""" + data = bytearray([0x02, 0x01, 0x02]) # LATITUDE_PRESENT + 2 bytes + with pytest.raises(InsufficientDataError): + IndoorPositioningData.decode(data) + + def test_decode_truncated_longitude_raises(self) -> None: + """Longitude flag set but no bytes follow latitude.""" + data = bytearray([ + 0x06, # LAT + LON present + 0x40, 0x42, 0x0F, 0x00, # latitude OK + 0x01, # only 1 byte for longitude (need 4) + ]) + with pytest.raises(InsufficientDataError): + IndoorPositioningData.decode(data) + + def test_decode_truncated_local_north_raises(self) -> None: + """Local north flag set but only 1 byte available (need 2).""" + data = bytearray([0x09, 0xFF]) # LOCAL + NORTH_PRESENT + 1 byte + with pytest.raises(InsufficientDataError): + IndoorPositioningData.decode(data) + + def test_decode_truncated_altitude_raises(self) -> None: + """Altitude flag set but only 1 byte available (need 2).""" + data = bytearray([0x80, 0x01]) # ALTITUDE_PRESENT + 1 byte + with pytest.raises(InsufficientDataError): + IndoorPositioningData.decode(data) + + +class TestIndoorPositioningConfig: + """Tests for the IndoorPositioningConfig IntFlag.""" + + def test_config_round_trip(self) -> None: + """IntFlag value round-trips through int conversion.""" + flags = ( + IndoorPositioningConfig.LATITUDE_PRESENT + | IndoorPositioningConfig.TX_POWER_PRESENT + | IndoorPositioningConfig.ALTITUDE_PRESENT + ) + assert IndoorPositioningConfig(int(flags)) == flags + + def test_location_flags_mask_covers_all_coordinate_bits(self) -> None: + """LOCATION_FLAGS_MASK includes all four coordinate-bearing flags.""" + assert LOCATION_FLAGS_MASK & IndoorPositioningConfig.LATITUDE_PRESENT + assert LOCATION_FLAGS_MASK & IndoorPositioningConfig.LONGITUDE_PRESENT + assert LOCATION_FLAGS_MASK & IndoorPositioningConfig.LOCAL_NORTH_PRESENT + assert LOCATION_FLAGS_MASK & IndoorPositioningConfig.LOCAL_EAST_PRESENT + # Non-coordinate flags must not be in the mask + assert not (LOCATION_FLAGS_MASK & IndoorPositioningConfig.TX_POWER_PRESENT) + assert not (LOCATION_FLAGS_MASK & IndoorPositioningConfig.FLOOR_NUMBER_PRESENT) + assert not (LOCATION_FLAGS_MASK & IndoorPositioningConfig.ALTITUDE_PRESENT) + + def test_coordinate_system_local_is_bit_zero(self) -> None: + """COORDINATE_SYSTEM_LOCAL is bit 0 per CSS Part A §1.14.""" + assert IndoorPositioningConfig.COORDINATE_SYSTEM_LOCAL == 0x01 diff --git a/tests/advertising/test_three_d_information.py b/tests/advertising/test_three_d_information.py new file mode 100644 index 00000000..f52be12e --- /dev/null +++ b/tests/advertising/test_three_d_information.py @@ -0,0 +1,162 @@ +"""Tests for 3D Information Data AD type decode (AD 0x3D, CSS Part A §1.13). + +Tests cover: +- Full decode with all flags set and cleared +- Boolean property accessors from IntFlag +- Truncated and empty data handling +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from bluetooth_sig.gatt.exceptions import InsufficientDataError +from bluetooth_sig.types.advertising.three_d_information import ( + ThreeDInformationData, + ThreeDInformationFlags, +) + + +@dataclass +class ADTypeTestData: + """Test data for AD type decode — mirrors CharacteristicTestData.""" + + input_data: bytearray + expected_value: Any + description: str = "" + + +class TestThreeDInformationDecode: + """Tests for ThreeDInformationData.decode().""" + + @pytest.fixture + def valid_test_data(self) -> list[ADTypeTestData]: + """Standard decode scenarios covering flag combinations.""" + return [ + ADTypeTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=ThreeDInformationData( + flags=ThreeDInformationFlags(0), + path_loss_threshold=0, + ), + description="No flags set, zero path loss threshold", + ), + ADTypeTestData( + input_data=bytearray([0x87, 0x32]), + expected_value=ThreeDInformationData( + flags=ThreeDInformationFlags(0x87), + path_loss_threshold=0x32, + ), + description="All flags set (0x01 | 0x02 | 0x04 | 0x80), path loss = 50", + ), + ADTypeTestData( + input_data=bytearray([0x01, 0xFF]), + expected_value=ThreeDInformationData( + flags=ThreeDInformationFlags.ASSOCIATION_NOTIFICATION, + path_loss_threshold=255, + ), + description="Association notification only, max path loss threshold", + ), + ] + + def test_decode_valid_data(self, valid_test_data: list[ADTypeTestData]) -> None: + """Decode each valid test case and verify all fields match.""" + for case in valid_test_data: + result = ThreeDInformationData.decode(case.input_data) + assert result == case.expected_value, f"Failed: {case.description}" + + def test_decode_factory_test_mode_only(self) -> None: + """Decode payload with only factory test mode flag set (bit 7).""" + data = bytearray([0x80, 0x0A]) + result = ThreeDInformationData.decode(data) + + assert result.factory_test_mode is True + assert result.association_notification is False + assert result.battery_level_reporting is False + assert result.send_battery_on_startup is False + assert result.path_loss_threshold == 10 + + def test_decode_extra_bytes_ignored(self) -> None: + """Trailing bytes beyond the 2-byte payload are ignored.""" + data = bytearray([0x03, 0x14, 0xFF, 0xFE]) + result = ThreeDInformationData.decode(data) + + expected_flags = ( + ThreeDInformationFlags.ASSOCIATION_NOTIFICATION + | ThreeDInformationFlags.BATTERY_LEVEL_REPORTING + ) + assert result.flags == expected_flags + assert result.path_loss_threshold == 20 + + +class TestThreeDInformationErrors: + """Error-path tests for ThreeDInformationData.decode().""" + + def test_decode_empty_data_raises(self) -> None: + """Empty bytearray raises InsufficientDataError — no flags byte.""" + with pytest.raises(InsufficientDataError): + ThreeDInformationData.decode(bytearray()) + + def test_decode_single_byte_raises(self) -> None: + """Only flags byte present, path_loss_threshold missing.""" + with pytest.raises(InsufficientDataError): + ThreeDInformationData.decode(bytearray([0x01])) + + +class TestThreeDInformationProperties: + """Tests for ThreeDInformationData boolean property accessors.""" + + def test_association_notification_enabled(self) -> None: + """association_notification is True when bit 0 set.""" + data = ThreeDInformationData( + flags=ThreeDInformationFlags.ASSOCIATION_NOTIFICATION, + path_loss_threshold=0, + ) + assert data.association_notification is True + + def test_association_notification_disabled(self) -> None: + """association_notification is False when bit 0 clear.""" + data = ThreeDInformationData(flags=ThreeDInformationFlags(0), path_loss_threshold=0) + assert data.association_notification is False + + def test_battery_level_reporting_enabled(self) -> None: + """battery_level_reporting is True when bit 1 set.""" + data = ThreeDInformationData( + flags=ThreeDInformationFlags.BATTERY_LEVEL_REPORTING, + path_loss_threshold=0, + ) + assert data.battery_level_reporting is True + + def test_send_battery_on_startup_enabled(self) -> None: + """send_battery_on_startup is True when bit 2 set.""" + data = ThreeDInformationData( + flags=ThreeDInformationFlags.SEND_BATTERY_ON_STARTUP, + path_loss_threshold=0, + ) + assert data.send_battery_on_startup is True + + def test_factory_test_mode_enabled(self) -> None: + """factory_test_mode is True when bit 7 set.""" + data = ThreeDInformationData( + flags=ThreeDInformationFlags.FACTORY_TEST_MODE, + path_loss_threshold=0, + ) + assert data.factory_test_mode is True + + def test_all_flags_set_properties(self) -> None: + """All boolean properties are True when all flags are set.""" + all_flags = ( + ThreeDInformationFlags.ASSOCIATION_NOTIFICATION + | ThreeDInformationFlags.BATTERY_LEVEL_REPORTING + | ThreeDInformationFlags.SEND_BATTERY_ON_STARTUP + | ThreeDInformationFlags.FACTORY_TEST_MODE + ) + data = ThreeDInformationData(flags=all_flags, path_loss_threshold=0) + + assert data.association_notification is True + assert data.battery_level_reporting is True + assert data.send_battery_on_startup is True + assert data.factory_test_mode is True diff --git a/tests/advertising/test_transport_discovery.py b/tests/advertising/test_transport_discovery.py new file mode 100644 index 00000000..8bb695ff --- /dev/null +++ b/tests/advertising/test_transport_discovery.py @@ -0,0 +1,192 @@ +"""Tests for Transport Discovery Data AD type decode (AD 0x26, CSS Part A §1.10). + +Tests cover: +- Single and multiple transport block decoding +- TDS flag sub-fields: role, incomplete, transport state +- Empty and truncated data handling +- Partial trailing blocks (silently skipped per spec) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from bluetooth_sig.types.advertising.transport_discovery import ( + TDS_ROLE_MASK, + TDS_STATE_MASK, + TDSFlags, + TransportBlock, + TransportDiscoveryData, +) + + +@dataclass +class ADTypeTestData: + """Test data for AD type decode — mirrors CharacteristicTestData.""" + + input_data: bytearray + expected_value: Any + description: str = "" + + +class TestTransportDiscoveryDecode: + """Tests for TransportDiscoveryData.decode().""" + + @pytest.fixture + def valid_test_data(self) -> list[ADTypeTestData]: + """Standard decode scenarios for transport discovery blocks.""" + return [ + ADTypeTestData( + input_data=bytearray([ + 0x01, # org_id = 1 (Bluetooth SIG) + 0x02, # flags = ROLE_PROVIDER + 0x03, # transport_data_length = 3 + 0xAA, 0xBB, 0xCC, # transport_data + ]), + expected_value=TransportDiscoveryData(blocks=[ + TransportBlock( + organization_id=1, + flags=TDSFlags.ROLE_PROVIDER, + transport_data=b"\xAA\xBB\xCC", + ), + ]), + description="Single block, provider role, 3 bytes payload", + ), + ADTypeTestData( + input_data=bytearray([ + # Block 1 + 0x01, 0x03, 0x01, 0xFF, + # Block 2 + 0x02, 0x08, 0x02, 0x11, 0x22, + ]), + expected_value=TransportDiscoveryData(blocks=[ + TransportBlock( + organization_id=1, + flags=TDSFlags.ROLE_SEEKER_AND_PROVIDER, + transport_data=b"\xFF", + ), + TransportBlock( + organization_id=2, + flags=TDSFlags.STATE_ON, + transport_data=b"\x11\x22", + ), + ]), + description="Two blocks with different roles and states", + ), + ADTypeTestData( + input_data=bytearray([ + 0x01, 0x00, 0x00, # org=1, flags=0, data_length=0 + ]), + expected_value=TransportDiscoveryData(blocks=[ + TransportBlock( + organization_id=1, + flags=TDSFlags(0), + transport_data=b"", + ), + ]), + description="Single block with zero-length transport data", + ), + ] + + def test_decode_valid_data(self, valid_test_data: list[ADTypeTestData]) -> None: + """Decode each valid test case and verify all fields match.""" + for case in valid_test_data: + result = TransportDiscoveryData.decode(case.input_data) + assert result == case.expected_value, f"Failed: {case.description}" + + def test_decode_empty_data_returns_no_blocks(self) -> None: + """Empty payload produces zero blocks (no header bytes available).""" + result = TransportDiscoveryData.decode(bytearray()) + assert result.blocks == [] + + def test_decode_incomplete_trailing_header_skipped(self) -> None: + """Fewer than 3 trailing bytes after a valid block are silently ignored.""" + data = bytearray([ + 0x01, 0x02, 0x00, # valid block (0-length payload) + 0xFF, 0xFE, # 2 trailing bytes — not enough for a header + ]) + result = TransportDiscoveryData.decode(data) + + assert len(result.blocks) == 1 + assert result.blocks[0].organization_id == 1 + + def test_decode_truncated_transport_data_clamped(self) -> None: + """Transport data length exceeds remaining bytes — clamp to available.""" + data = bytearray([ + 0x01, 0x00, 0x05, # header says 5 bytes of transport data + 0xAA, 0xBB, # only 2 available + ]) + result = TransportDiscoveryData.decode(data) + + assert len(result.blocks) == 1 + assert result.blocks[0].transport_data == b"\xAA\xBB" + + +class TestTransportBlockProperties: + """Tests for TransportBlock flag property accessors.""" + + def test_role_seeker(self) -> None: + """Role bits return ROLE_SEEKER when bit 0 set.""" + block = TransportBlock(organization_id=1, flags=TDSFlags.ROLE_SEEKER) + assert block.role == TDSFlags.ROLE_SEEKER + + def test_role_provider(self) -> None: + """Role bits return ROLE_PROVIDER when bit 1 set.""" + block = TransportBlock(organization_id=1, flags=TDSFlags.ROLE_PROVIDER) + assert block.role == TDSFlags.ROLE_PROVIDER + + def test_role_seeker_and_provider(self) -> None: + """Role bits return ROLE_SEEKER_AND_PROVIDER when bits 0+1 set.""" + block = TransportBlock(organization_id=1, flags=TDSFlags.ROLE_SEEKER_AND_PROVIDER) + assert block.role == TDSFlags.ROLE_SEEKER_AND_PROVIDER + + def test_role_not_specified(self) -> None: + """Role bits return 0 when neither bit is set.""" + block = TransportBlock(organization_id=1, flags=TDSFlags(0)) + assert block.role == TDSFlags(0) + + def test_is_incomplete_true(self) -> None: + """is_incomplete returns True when INCOMPLETE bit set.""" + block = TransportBlock( + organization_id=1, + flags=TDSFlags.ROLE_SEEKER | TDSFlags.INCOMPLETE, + ) + assert block.is_incomplete is True + + def test_is_incomplete_false(self) -> None: + """is_incomplete returns False when INCOMPLETE bit clear.""" + block = TransportBlock(organization_id=1, flags=TDSFlags.ROLE_SEEKER) + assert block.is_incomplete is False + + def test_transport_state_on(self) -> None: + """transport_state returns STATE_ON when bit 3 set.""" + block = TransportBlock(organization_id=1, flags=TDSFlags.STATE_ON) + assert block.transport_state == TDSFlags.STATE_ON + + def test_transport_state_temporarily_unavailable(self) -> None: + """transport_state returns STATE_TEMPORARILY_UNAVAILABLE when bit 4 set.""" + block = TransportBlock( + organization_id=1, + flags=TDSFlags.STATE_TEMPORARILY_UNAVAILABLE, + ) + assert block.transport_state == TDSFlags.STATE_TEMPORARILY_UNAVAILABLE + + def test_transport_state_off(self) -> None: + """transport_state returns 0 (off) when state bits are clear.""" + block = TransportBlock(organization_id=1, flags=TDSFlags.ROLE_PROVIDER) + assert block.transport_state == TDSFlags(0) + + +class TestTDSFlagsMasks: + """Tests for TDS flag mask constants.""" + + def test_role_mask_covers_bits_0_and_1(self) -> None: + """TDS_ROLE_MASK covers exactly bits 0-1.""" + assert int(TDS_ROLE_MASK) == 0x03 + + def test_state_mask_covers_bits_3_and_4(self) -> None: + """TDS_STATE_MASK covers exactly bits 3-4.""" + assert int(TDS_STATE_MASK) == 0x18 diff --git a/tests/device/test_peripheral_device.py b/tests/device/test_peripheral_device.py index 97144625..20116472 100644 --- a/tests/device/test_peripheral_device.py +++ b/tests/device/test_peripheral_device.py @@ -90,22 +90,27 @@ class TestPeripheralDeviceInit: """Constructor and basic properties.""" def test_init_sets_name(self) -> None: + """Verify advertised name is set from the backend.""" device, _ = _make_device("Sensor-1") assert device.name == "Sensor-1" def test_init_not_advertising(self) -> None: + """Newly created device is not advertising.""" device, _ = _make_device() assert device.is_advertising is False def test_init_no_services(self) -> None: + """Newly created device has no services registered.""" device, _ = _make_device() assert device.services == [] def test_init_no_hosted_characteristics(self) -> None: + """Newly created device has no hosted characteristics.""" device, _ = _make_device() assert device.hosted_characteristics == {} def test_repr_stopped(self) -> None: + """Repr includes device name and stopped state.""" device, _ = _make_device("Demo") r = repr(device) assert "Demo" in r @@ -116,6 +121,7 @@ class TestAddCharacteristic: """Registration of characteristics via the typed helper.""" def test_add_characteristic_returns_definition(self) -> None: + """Successful registration returns a CharacteristicDefinition.""" device, _ = _make_device() char = BatteryLevelCharacteristic() char_def = device.add_characteristic( @@ -128,6 +134,7 @@ def test_add_characteristic_returns_definition(self) -> None: assert char_def.initial_value == bytearray(b"\x55") # 85 decimal def test_add_characteristic_creates_hosted_entry(self) -> None: + """Hosted characteristics dict is populated after add.""" device, _ = _make_device() char = BatteryLevelCharacteristic() device.add_characteristic( @@ -142,6 +149,7 @@ def test_add_characteristic_creates_hosted_entry(self) -> None: assert hosted[BATTERY_CHAR_UUID].last_value == 50 def test_add_characteristic_creates_pending_service(self) -> None: + """Service is pending until start(); backend has none yet.""" device, _ = _make_device() char = BatteryLevelCharacteristic() device.add_characteristic( @@ -179,6 +187,7 @@ class TestLifecycle: @pytest.mark.asyncio async def test_start_flushes_pending_services(self) -> None: + """Start registers pending services on the backend and begins advertising.""" device, backend = _make_device() char = BatteryLevelCharacteristic() device.add_characteristic( @@ -193,8 +202,17 @@ async def test_start_flushes_pending_services(self) -> None: assert len(backend.services) == 1 assert str(backend.services[0].uuid).upper().startswith("0000180F") + @pytest.mark.asyncio + async def test_start_with_no_services_raises(self) -> None: + """Start without any registered services raises RuntimeError.""" + device, _ = _make_device() + + with pytest.raises(RuntimeError, match="No services"): + await device.start() + @pytest.mark.asyncio async def test_stop_clears_advertising(self) -> None: + """Stop transitions the peripheral out of advertising state.""" device, _ = _make_device() char = BatteryLevelCharacteristic() device.add_characteristic( @@ -211,6 +229,7 @@ async def test_stop_clears_advertising(self) -> None: @pytest.mark.asyncio async def test_repr_advertising(self) -> None: + """Repr shows advertising state when started.""" device, _ = _make_device("Live") char = BatteryLevelCharacteristic() device.add_characteristic( @@ -224,6 +243,13 @@ async def test_repr_advertising(self) -> None: assert "advertising" in r assert "Live" in r + @pytest.mark.asyncio + async def test_stop_when_not_started(self) -> None: + """Stop on a non-advertising device completes without error.""" + device, _ = _make_device() + await device.stop() + assert device.is_advertising is False + class TestUpdateValue: """Typed value encoding and push to backend.""" From 1252b6670a7a2592be01e0928eb57263bec3b1eb Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Thu, 19 Feb 2026 12:58:31 +0000 Subject: [PATCH 3/9] docs: update rework.md with 3.1 and 3.5 completion status --- rework.md | 95 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/rework.md b/rework.md index fceb8b94..45cec770 100644 --- a/rework.md +++ b/rework.md @@ -72,23 +72,27 @@ access; 3.3 SDP is irrelevant to BLE) and 1 was based on a false assumption abou (3.2 profile YAMLs contain codec/param enums, not mandatory/optional service lists). The plan is revised below. Implementation order follows priority (value ÷ risk). -3.1 Fix PeripheralDevice + Add Tests +3.1 Fix PeripheralDevice + Add Tests — ✅ DONE (commit 39a53b6) -peripheral_device.py was scaffolded but has a dead `translator` parameter — `SIGTranslatorProtocol` +peripheral_device.py was scaffolded but had a dead `translator` parameter — `SIGTranslatorProtocol` is parse-only and encoding is already handled directly by `BaseCharacteristic.build_value()` on the -stored characteristic instances. The `_translator` field is never referenced. - -Changes: -- Remove `translator` parameter from `PeripheralDevice.__init__`; update docstrings -- Verify `__init__.py` exports are correct (PeripheralDevice already added) -- Write tests in tests/device/test_peripheral_device.py: - - Happy path: add_characteristic → start → update_value → verify encoded bytes - - Failure: add_characteristic after start raises RuntimeError - - Failure: update_value for unknown UUID raises KeyError - - Fluent builder delegation round-trips correctly - - get_current_value returns latest value - -Verification: python -m pytest tests/device/test_peripheral_device.py -v passes. +stored characteristic instances. The `_translator` field was never referenced. + +Completed: +- Removed `translator` parameter from `PeripheralDevice.__init__`; updated docstrings +- Added `Any` import justification comment (heterogeneous characteristic dict) +- Wrote 29 tests in tests/device/test_peripheral_device.py across 8 test classes: + - TestPeripheralDeviceInit (5 tests) — constructor, properties, empty state + - TestAddCharacteristic (4 tests) — registration, auto-service creation, duplicate service + - TestLifecycle (5 tests) — start flushes services, stop clears advertising, start-twice, + stop-when-not-started, add-after-start raises RuntimeError + - TestUpdateValue (5 tests) — encode + push, notify flag, unknown UUID KeyError + - TestUpdateRaw (1 test) — raw bytes push + - TestGetCurrentValue (3 tests) — initial value, latest value, unknown UUID KeyError + - TestFluentConfiguration (7 tests) — method chaining for manufacturer data, tx power, + connectable, discoverable + - TestAddService (1 test) — pre-built ServiceDefinition +- All tests pass, lint clean 3.2 Profile Parameter Registries (Redesigned) @@ -148,28 +152,31 @@ New file: tests/static_analysis/test_yaml_implementation_coverage.py Verification: python -m pytest tests/static_analysis/test_yaml_implementation_coverage.py -v runs and produces coverage report without failing. -3.5 Advertising Location Struct Parsing - -The PDU parser stores Indoor Positioning, Transport Discovery Data, 3D Information, and Channel -Map Update Indication as raw bytes. All 4 formats are well-defined in the Bluetooth Core Spec and -can be parsed into typed structs following the existing mesh beacon pattern. - -Steps: -- Create src/bluetooth_sig/types/advertising/location.py with 4 msgspec.Struct types: - - IndoorPositioningData — config flags byte + optional coordinate/floor/altitude/uncertainty - (CSS Part A, §1.14) - - TransportDiscoveryData — org ID + TDS flags + transport data blocks (CSS Part A, §1.10) - - ThreeDInformationData — 3D sync profile fields (CSS Part A, §1.13) - - ChannelMapUpdateIndication — 5-byte channel map + 2-byte instant - (Core Spec Vol 3, Part C, §11) -- Update LocationAndSensingData field types from bytes to typed structs (| None = None) -- Update _handle_location_ad_types to call struct decode methods -- Add decode(cls, data: bytes) classmethods matching MeshMessage.decode() pattern -- Tests: tests/advertising/test_location_parsing.py — one test per struct with constructed byte - sequences + one malformed-data test per struct - -Verification: python -m pytest tests/advertising/test_location_parsing.py -v passes. Existing PDU -parser tests unaffected. +3.5 Advertising Location Struct Parsing — ✅ DONE (commit 31bf76a) + +The PDU parser stored Indoor Positioning, Transport Discovery Data, 3D Information, and Channel +Map Update Indication as raw bytes. All 4 are now parsed into typed structs. + +Completed (one file per type, not monolithic location.py as originally planned): +- src/bluetooth_sig/types/advertising/indoor_positioning.py + IndoorPositioningConfig(IntFlag) + IndoorPositioningData(msgspec.Struct) + Flag-driven WGS84/local coords, DataParser for all fields, optional uncertainty guard +- src/bluetooth_sig/types/advertising/transport_discovery.py + TDSFlags(IntFlag) + TransportBlock + TransportDiscoveryData + Multi-block iteration, role/state/incomplete as properties on TransportBlock +- src/bluetooth_sig/types/advertising/three_d_information.py + ThreeDInformationFlags(IntFlag) + ThreeDInformationData + Boolean properties for flag accessors (single source of truth — no duplicate fields) +- src/bluetooth_sig/types/advertising/channel_map_update.py + ChannelMapUpdateIndication with is_channel_used(channel) method, named constants +- ad_structures.py LocationAndSensingData fields changed from bytes to typed | None +- pdu_parser.py _handle_location_ad_types calls .decode() instead of raw assignment +- __init__.py exports updated for all new types +- 58 tests across 4 test files (tests/advertising/test_{indoor_positioning,transport_discovery, + three_d_information,channel_map_update}.py) covering decode, errors, properties, constants +- Patterns followed: IntFlag for all flags, DataParser for all parsing (auto InsufficientDataError), + msgspec.Struct frozen=True kw_only=True, one file per type +- All 5523 tests pass, lint clean 3.6 — REMOVED (Auxiliary packet parsing is physically impossible) @@ -201,13 +208,13 @@ Verification: python -m pytest tests/stream/ -v passes. No breaking changes. Implementation Priority: -| # | Item | Effort | Value | Risk | -|---|------|--------|-------|------| -| 1 | 3.1 Fix PeripheralDevice + tests | Low | Medium | Low | -| 2 | 3.5 Location AD struct parsing | Medium | High | Low | -| 3 | 3.7 Stream TTL + stats | Low | Medium | Low | -| 4 | 3.4 GATT coverage gap tracking | Low | Medium | None | -| 5 | 3.2 Profile parameter registries | Medium | Medium | Medium | +| # | Item | Effort | Value | Risk | Status | +|---|------|--------|-------|------|--------| +| 1 | 3.1 Fix PeripheralDevice + tests | Low | Medium | Low | ✅ DONE | +| 2 | 3.5 Location AD struct parsing | Medium | High | Low | ✅ DONE | +| 3 | 3.7 Stream TTL + stats | Low | Medium | Low | Not started | +| 4 | 3.4 GATT coverage gap tracking | Low | Medium | None | Not started | +| 5 | 3.2 Profile parameter registries | Medium | Medium | Medium | Not started | Verification: Each feature has its own test file with success + failure cases. All quality gates pass. From 909362788e0703831404ee29c9f5fc214a5b9652 Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Thu, 19 Feb 2026 13:07:08 +0000 Subject: [PATCH 4/9] feat(stream): add TTL eviction and BufferStats to DependencyPairingBuffer - Add max_age_seconds parameter for configurable TTL eviction - Add injectable clock parameter for deterministic testing - Add BufferStats (frozen msgspec.Struct) with pending/completed/evicted - Track group timestamps and evict stale groups on each ingest() - Export BufferStats from stream __init__.py - Add 10 new tests: 4 TTL eviction + 6 stats (15 total in file) - All existing tests pass unchanged (backwards compatible) Implements item 3.7 from rework.md --- src/bluetooth_sig/stream/__init__.py | 3 +- src/bluetooth_sig/stream/pairing.py | 62 +++++++ tests/stream/test_pairing.py | 257 ++++++++++++++++++++++++++- 3 files changed, 320 insertions(+), 2 deletions(-) diff --git a/src/bluetooth_sig/stream/__init__.py b/src/bluetooth_sig/stream/__init__.py index 62cca13d..7e08453c 100644 --- a/src/bluetooth_sig/stream/__init__.py +++ b/src/bluetooth_sig/stream/__init__.py @@ -2,8 +2,9 @@ from __future__ import annotations -from .pairing import DependencyPairingBuffer +from .pairing import BufferStats, DependencyPairingBuffer __all__ = [ + "BufferStats", "DependencyPairingBuffer", ] diff --git a/src/bluetooth_sig/stream/pairing.py b/src/bluetooth_sig/stream/pairing.py index c5cb598f..0c55f6c5 100644 --- a/src/bluetooth_sig/stream/pairing.py +++ b/src/bluetooth_sig/stream/pairing.py @@ -9,12 +9,29 @@ from __future__ import annotations +import time from collections.abc import Callable, Hashable from typing import Any +import msgspec + from ..core.translator import BluetoothSIGTranslator +class BufferStats(msgspec.Struct, frozen=True, kw_only=True): + """Snapshot of pairing buffer statistics. + + Attributes: + pending: Number of incomplete groups currently buffered. + completed: Total number of groups successfully paired since creation. + evicted: Total number of groups evicted due to TTL expiry since creation. + """ + + pending: int + completed: int + evicted: int + + class DependencyPairingBuffer: """Buffer and pair dependent characteristic notifications. @@ -28,6 +45,10 @@ class DependencyPairingBuffer: Called as ``group_key(uuid, parsed_result)`` and must return a hashable value. on_pair: Callback invoked with complete parsed pairs as ``on_pair(results: dict[str, Any])``. + max_age_seconds: Maximum age in seconds for buffered groups before eviction. + ``None`` disables TTL eviction (default). + clock: Callable returning current time as a float (seconds). Defaults to + ``time.monotonic``. Override in tests for deterministic timing. Note: Does not manage BLE subscriptions. Callers handle connection and notification setup. @@ -40,30 +61,71 @@ def __init__( required_uuids: set[str], group_key: Callable[[str, Any], Hashable], on_pair: Callable[[dict[str, Any]], None], + max_age_seconds: float | None = None, + clock: Callable[[], float] = time.monotonic, ) -> None: """Initialize the pairing buffer.""" self._translator = translator self._required = set(required_uuids) self._group_key = group_key self._on_pair = on_pair + self._max_age_seconds = max_age_seconds + self._clock = clock self._buffer: dict[Hashable, dict[str, bytes]] = {} + self._group_timestamps: dict[Hashable, float] = {} + self._completed_count: int = 0 + self._evicted_count: int = 0 def ingest(self, uuid: str, data: bytes) -> None: """Ingest a single characteristic notification. + Evicts stale groups (if TTL is configured) before processing. + Args: uuid: Characteristic UUID string (16-bit or 128-bit). data: Raw bytes from the characteristic notification. """ + self._evict_stale() + parsed = self._translator.parse_characteristic(uuid, data) group_id = self._group_key(uuid, parsed) group = self._buffer.setdefault(group_id, {}) + if group_id not in self._group_timestamps: + self._group_timestamps[group_id] = self._clock() group[uuid] = data if self._required.issubset(group.keys()): batch = dict(group) del self._buffer[group_id] + del self._group_timestamps[group_id] + self._completed_count += 1 results = self._translator.parse_characteristics(batch) self._on_pair(results) + + def stats(self) -> BufferStats: + """Return a snapshot of buffer statistics. + + Returns: + BufferStats with current pending count and lifetime completed/evicted totals. + """ + return BufferStats( + pending=len(self._buffer), + completed=self._completed_count, + evicted=self._evicted_count, + ) + + def _evict_stale(self) -> None: + """Remove groups older than max_age_seconds.""" + if self._max_age_seconds is None: + return + + now = self._clock() + cutoff = now - self._max_age_seconds + stale_keys = [key for key, timestamp in self._group_timestamps.items() if timestamp <= cutoff] + + for key in stale_keys: + del self._buffer[key] + del self._group_timestamps[key] + self._evicted_count += 1 diff --git a/tests/stream/test_pairing.py b/tests/stream/test_pairing.py index 9241bce9..e160d306 100644 --- a/tests/stream/test_pairing.py +++ b/tests/stream/test_pairing.py @@ -11,7 +11,7 @@ HumidityCharacteristic, TemperatureCharacteristic, ) -from bluetooth_sig.stream import DependencyPairingBuffer +from bluetooth_sig.stream import BufferStats, DependencyPairingBuffer def _glucose_measurement_bytes(seq: int) -> bytes: @@ -264,3 +264,258 @@ def on_pair(results: dict[str, Any]) -> None: assert session1[icp_uuid].current_cuff_pressure == 80.0 assert session1[icp_uuid].optional_fields.timestamp.hour == 10 assert session1[icp_uuid].optional_fields.timestamp.minute == 0 + + +# --------------------------------------------------------------------------- +# TTL eviction tests +# --------------------------------------------------------------------------- + + +def _make_ttl_buffer( + *, + max_age_seconds: float, + clock: Any, + paired: list[dict[str, Any]], +) -> tuple[DependencyPairingBuffer, str, str]: + """Create a temperature+humidity buffer with injectable clock for TTL tests.""" + translator = BluetoothSIGTranslator() + temp_uuid = str(TemperatureCharacteristic().uuid) + humid_uuid = str(HumidityCharacteristic().uuid) + + buf = DependencyPairingBuffer( + translator=translator, + required_uuids={temp_uuid, humid_uuid}, + group_key=lambda _uuid, _parsed: "room-1", + on_pair=lambda results: paired.append(results), + max_age_seconds=max_age_seconds, + clock=clock, + ) + return buf, temp_uuid, humid_uuid + + +class TestTTLEviction: + """TTL-based eviction of stale incomplete groups.""" + + def test_stale_group_evicted_before_completion(self) -> None: + """A group that exceeds max_age_seconds is evicted on next ingest.""" + current_time = 0.0 + + def clock() -> float: + return current_time + + paired: list[dict[str, Any]] = [] + buf, temp_uuid, _humid_uuid = _make_ttl_buffer( + max_age_seconds=10.0, + clock=clock, + paired=paired, + ) + + # Ingest first half at t=0 + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + assert buf.stats().pending == 1 + + # Advance past TTL + current_time = 11.0 + + # Ingest something — eviction runs first, removing the stale group + buf.ingest(temp_uuid, bytes([0x14, 0x00])) + assert buf.stats().pending == 1 # new group, old one evicted + assert buf.stats().evicted == 1 + assert len(paired) == 0 # never completed + + def test_fresh_group_not_evicted(self) -> None: + """Groups within max_age_seconds are preserved.""" + current_time = 0.0 + + def clock() -> float: + return current_time + + paired: list[dict[str, Any]] = [] + buf, temp_uuid, humid_uuid = _make_ttl_buffer( + max_age_seconds=10.0, + clock=clock, + paired=paired, + ) + + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + + # Advance but stay within TTL + current_time = 5.0 + buf.ingest(humid_uuid, bytes([0x32, 0x00])) + + assert len(paired) == 1 + assert buf.stats().evicted == 0 + assert buf.stats().completed == 1 + + def test_multiple_groups_selective_eviction(self) -> None: + """Only groups exceeding TTL are evicted; fresh groups remain.""" + current_time = 0.0 + + def clock() -> float: + return current_time + + translator = BluetoothSIGTranslator() + temp_uuid = str(TemperatureCharacteristic().uuid) + humid_uuid = str(HumidityCharacteristic().uuid) + + paired: list[dict[str, Any]] = [] + + # Mutable group ID — set before each ingest to control grouping + current_group: list[str] = ["A"] + + def group_key(_uuid: str, _parsed: Any) -> str: + return current_group[0] + + buf = DependencyPairingBuffer( + translator=translator, + required_uuids={temp_uuid, humid_uuid}, + group_key=group_key, + on_pair=lambda r: paired.append(r), + max_age_seconds=10.0, + clock=clock, + ) + + # Group A at t=0 + current_group[0] = "A" + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + assert buf.stats().pending == 1 + + # Group B at t=8 + current_time = 8.0 + current_group[0] = "B" + buf.ingest(temp_uuid, bytes([0x14, 0x00])) + assert buf.stats().pending == 2 + + # Advance to t=11 — group A is stale (age=11), group B is fresh (age=3) + # Complete group B with humidity + current_time = 11.0 + current_group[0] = "B" + buf.ingest(humid_uuid, bytes([0x32, 0x00])) + + assert buf.stats().evicted == 1 # group A evicted + assert buf.stats().completed == 1 # group B completed + assert buf.stats().pending == 0 + + def test_exact_boundary_evicted(self) -> None: + """A group at exactly max_age_seconds is evicted (<=, not <).""" + current_time = 0.0 + + def clock() -> float: + return current_time + + paired: list[dict[str, Any]] = [] + buf, temp_uuid, _humid_uuid = _make_ttl_buffer( + max_age_seconds=10.0, + clock=clock, + paired=paired, + ) + + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + + # Advance to exactly TTL boundary + current_time = 10.0 + buf.ingest(temp_uuid, bytes([0x14, 0x00])) + + assert buf.stats().evicted == 1 + + +class TestBufferStats: + """BufferStats tracking across operations.""" + + def test_initial_stats_all_zero(self) -> None: + translator = BluetoothSIGTranslator() + temp_uuid = str(TemperatureCharacteristic().uuid) + humid_uuid = str(HumidityCharacteristic().uuid) + + buf = DependencyPairingBuffer( + translator=translator, + required_uuids={temp_uuid, humid_uuid}, + group_key=lambda _u, _p: "g", + on_pair=lambda _r: None, + ) + + s = buf.stats() + assert s == BufferStats(pending=0, completed=0, evicted=0) + + def test_pending_increments_on_partial(self) -> None: + translator = BluetoothSIGTranslator() + temp_uuid = str(TemperatureCharacteristic().uuid) + humid_uuid = str(HumidityCharacteristic().uuid) + + buf = DependencyPairingBuffer( + translator=translator, + required_uuids={temp_uuid, humid_uuid}, + group_key=lambda _u, _p: "g", + on_pair=lambda _r: None, + ) + + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + assert buf.stats().pending == 1 + assert buf.stats().completed == 0 + + def test_completed_increments_on_pair(self) -> None: + translator = BluetoothSIGTranslator() + temp_uuid = str(TemperatureCharacteristic().uuid) + humid_uuid = str(HumidityCharacteristic().uuid) + + paired: list[dict[str, Any]] = [] + buf = DependencyPairingBuffer( + translator=translator, + required_uuids={temp_uuid, humid_uuid}, + group_key=lambda _u, _p: "g", + on_pair=lambda r: paired.append(r), + ) + + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + buf.ingest(humid_uuid, bytes([0x32, 0x00])) + + s = buf.stats() + assert s.pending == 0 + assert s.completed == 1 + assert s.evicted == 0 + + def test_stats_accumulate_over_multiple_pairs(self) -> None: + translator = BluetoothSIGTranslator() + temp_uuid = str(TemperatureCharacteristic().uuid) + humid_uuid = str(HumidityCharacteristic().uuid) + + buf = DependencyPairingBuffer( + translator=translator, + required_uuids={temp_uuid, humid_uuid}, + group_key=lambda _u, _p: "g", + on_pair=lambda _r: None, + ) + + for _ in range(3): + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + buf.ingest(humid_uuid, bytes([0x32, 0x00])) + + assert buf.stats().completed == 3 + assert buf.stats().pending == 0 + + def test_stats_frozen_struct(self) -> None: + """BufferStats is immutable (frozen msgspec.Struct).""" + s = BufferStats(pending=1, completed=2, evicted=3) + with pytest.raises(AttributeError): + s.pending = 99 # type: ignore[misc] + + def test_no_ttl_means_no_eviction(self) -> None: + """Default (no max_age_seconds) never evicts.""" + translator = BluetoothSIGTranslator() + temp_uuid = str(TemperatureCharacteristic().uuid) + humid_uuid = str(HumidityCharacteristic().uuid) + + buf = DependencyPairingBuffer( + translator=translator, + required_uuids={temp_uuid, humid_uuid}, + group_key=lambda _u, _p: "g", + on_pair=lambda _r: None, + ) + + # Ingest partial and never complete + buf.ingest(temp_uuid, bytes([0x0A, 0x00])) + buf.ingest(temp_uuid, bytes([0x14, 0x00])) + buf.ingest(temp_uuid, bytes([0x1E, 0x00])) + + assert buf.stats().pending == 1 + assert buf.stats().evicted == 0 From e2b97222a57f9f03e281da782ad24f2de513eb0f Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Thu, 19 Feb 2026 13:19:23 +0000 Subject: [PATCH 5/9] feat: add GATT orphan detection tests and coverage report script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/static_analysis/test_yaml_implementation_coverage.py: 3 orphan detection tests that fail if an implementation exists without a YAML UUID entry (gates PRs against real bugs) - scripts/gatt_coverage_report.py: informational coverage report showing YAML vs implementation counts (41.4% chars, 43.1% services, 100% descs) - Coverage gaps are reported by the script, not by tests — absence of an implementation is expected and should not gate PRs Implements item 3.4 from rework.md --- scripts/gatt_coverage_report.py | 93 +++++++++++++++++++ .../test_yaml_implementation_coverage.py | 84 +++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 scripts/gatt_coverage_report.py create mode 100644 tests/static_analysis/test_yaml_implementation_coverage.py diff --git a/scripts/gatt_coverage_report.py b/scripts/gatt_coverage_report.py new file mode 100644 index 00000000..83411fda --- /dev/null +++ b/scripts/gatt_coverage_report.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""GATT implementation coverage report. + +Compares YAML-defined UUIDs against Python implementations for +characteristics, services, and descriptors. Prints a summary with +gap details. Exit code is always 0 — this is informational, not a gate. + +Usage: + python scripts/gatt_coverage_report.py + python scripts/gatt_coverage_report.py --verbose # list every gap +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Ensure src is importable when running as a script +_project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_project_root / "src")) + +from bluetooth_sig.gatt.characteristics.registry import CharacteristicRegistry # noqa: E402 +from bluetooth_sig.gatt.descriptors import DescriptorRegistry # noqa: E402 +from bluetooth_sig.gatt.services.registry import GattServiceRegistry # noqa: E402 +from bluetooth_sig.gatt.uuid_registry import uuid_registry # noqa: E402 +from bluetooth_sig.types.uuid import BluetoothUUID # noqa: E402 + + +def _format_line(category: str, yaml_count: int, impl_count: int) -> str: + pct = (impl_count / yaml_count * 100) if yaml_count else 0.0 + gap = yaml_count - impl_count + return f" {category:<20s} {impl_count:>4d}/{yaml_count:<4d} ({pct:5.1f}%) {gap:>4d} gaps" + + +def main(*, verbose: bool = False) -> None: + """Print GATT coverage report.""" + # --- Characteristics --- + char_yaml = uuid_registry._characteristics + char_yaml_uuids = set(char_yaml.keys()) + char_reg = CharacteristicRegistry.get_instance() + char_impl = {u.normalized for u in char_reg._get_sig_classes_map()} + char_gaps = char_yaml_uuids - char_impl + + # --- Services --- + svc_yaml = uuid_registry._services + svc_yaml_uuids = set(svc_yaml.keys()) + svc_reg = GattServiceRegistry.get_instance() + svc_impl = {u.normalized for u in svc_reg._get_sig_classes_map()} + svc_gaps = svc_yaml_uuids - svc_impl + + # --- Descriptors --- + desc_yaml = uuid_registry._descriptors + desc_yaml_uuids = set(desc_yaml.keys()) + desc_impl = {BluetoothUUID(u).normalized for u in DescriptorRegistry._registry} + desc_gaps = desc_yaml_uuids - desc_impl + + # --- Report --- + print() + print("=" * 60) + print(" GATT Implementation Coverage Report") + print("=" * 60) + print(f" {'Category':<20s} {'Impl':>4s}/{'YAML':<4s} {'%':>6s} {'Gaps':>4s}") + print("-" * 60) + print(_format_line("Characteristics", len(char_yaml_uuids), len(char_impl))) + print(_format_line("Services", len(svc_yaml_uuids), len(svc_impl))) + print(_format_line("Descriptors", len(desc_yaml_uuids), len(desc_impl))) + print("=" * 60) + + if verbose: + if char_gaps: + print(f"\nUnimplemented characteristics ({len(char_gaps)}):") + for name in sorted(char_yaml[u].name for u in char_gaps if u in char_yaml): + print(f" - {name}") + + if svc_gaps: + print(f"\nUnimplemented services ({len(svc_gaps)}):") + for name in sorted(svc_yaml[u].name for u in svc_gaps if u in svc_yaml): + print(f" - {name}") + + if desc_gaps: + print(f"\nUnimplemented descriptors ({len(desc_gaps)}):") + for name in sorted(desc_yaml[u].name for u in desc_gaps if u in desc_yaml): + print(f" - {name}") + + print() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="GATT coverage report") + parser.add_argument("--verbose", "-v", action="store_true", help="List every gap") + args = parser.parse_args() + main(verbose=args.verbose) diff --git a/tests/static_analysis/test_yaml_implementation_coverage.py b/tests/static_analysis/test_yaml_implementation_coverage.py new file mode 100644 index 00000000..5df37bf2 --- /dev/null +++ b/tests/static_analysis/test_yaml_implementation_coverage.py @@ -0,0 +1,84 @@ +"""GATT orphan detection — implementations without YAML entries. + +Catches the reverse of the existing completeness tests: if a characteristic, +service, or descriptor class exists in code but has no corresponding YAML +UUID entry, that is a real bug (the class can never be discovered by UUID). +These tests gate PRs by failing on orphan classes. + +For informational coverage reporting (YAML → implementation gap counts), +see ``scripts/gatt_coverage_report.py``. +""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics.registry import CharacteristicRegistry +from bluetooth_sig.gatt.descriptors import DescriptorRegistry +from bluetooth_sig.gatt.services.registry import GattServiceRegistry +from bluetooth_sig.gatt.uuid_registry import uuid_registry +from bluetooth_sig.types.uuid import BluetoothUUID + + +class TestOrphanCharacteristics: + """Detect characteristic classes with no YAML UUID entry.""" + + def test_all_implemented_characteristics_exist_in_yaml(self) -> None: + """Every implemented characteristic UUID must have a YAML entry. + + An implementation without a YAML entry is a real bug — the class + can never be resolved by UUID lookup, so it is invisible to the + registry and will fail in batch parsing scenarios. + """ + yaml_uuids = set(uuid_registry._characteristics.keys()) + registry = CharacteristicRegistry.get_instance() + impl_uuids = {u.normalized for u in registry._get_sig_classes_map()} + + orphans = impl_uuids - yaml_uuids + if orphans: + orphan_details = [] + sig_map = registry._get_sig_classes_map() + for bt_uuid, cls in sig_map.items(): + if bt_uuid.normalized in orphans: + orphan_details.append(f"{cls.__name__} (UUID: {bt_uuid})") + pytest.fail( + f"{len(orphans)} characteristic(s) implemented but missing from YAML:\n " + + "\n ".join(sorted(orphan_details)) + ) + + +class TestOrphanServices: + """Detect service classes with no YAML UUID entry.""" + + def test_all_implemented_services_exist_in_yaml(self) -> None: + """Every implemented service UUID must have a YAML entry.""" + yaml_uuids = set(uuid_registry._services.keys()) + registry = GattServiceRegistry.get_instance() + impl_uuids = {u.normalized for u in registry._get_sig_classes_map()} + + orphans = impl_uuids - yaml_uuids + if orphans: + orphan_details = [] + sig_map = registry._get_sig_classes_map() + for bt_uuid, cls in sig_map.items(): + if bt_uuid.normalized in orphans: + orphan_details.append(f"{cls.__name__} (UUID: {bt_uuid})") + pytest.fail( + f"{len(orphans)} service(s) implemented but missing from YAML:\n " + + "\n ".join(sorted(orphan_details)) + ) + + +class TestOrphanDescriptors: + """Detect descriptor classes with no YAML UUID entry.""" + + def test_all_implemented_descriptors_exist_in_yaml(self) -> None: + """Every implemented descriptor UUID must have a YAML entry.""" + yaml_uuids = set(uuid_registry._descriptors.keys()) + impl_uuids = {BluetoothUUID(uuid_str).normalized for uuid_str in DescriptorRegistry._registry} + + orphans = impl_uuids - yaml_uuids + if orphans: + pytest.fail( + f"{len(orphans)} descriptor(s) implemented but missing from YAML:\n " + "\n ".join(sorted(orphans)) + ) From 82d07cd394d313d725c22216543ddd84181f6ded Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Thu, 19 Feb 2026 15:51:47 +0000 Subject: [PATCH 6/9] feat(registry): add profile parameter registries (3.2) Add three new registries for profile and service discovery YAML data: - PermittedCharacteristicsRegistry: loads ESS/UDS/IMDS permitted characteristics (31/39/7 entries respectively) - ProfileLookupRegistry: normalises 23 non-LTV profile YAML files into 21 lookup tables (bearer_technology, audio_codec_id, display_types, etc.) - ServiceDiscoveryAttributeRegistry: loads 27 attribute_ids categories, attribute_id_offsets_for_strings, and 6 protocol parameters New types: PermittedCharacteristicEntry, ProfileLookupEntry, AttributeIdEntry, ProtocolParameterEntry (frozen msgspec.Structs). All registries are lazy-loaded, thread-safe, and return defensive copies. LTV structures (23 files) deferred for dedicated LTV codec framework. 42 new tests across 3 test files. All 5578 tests pass, lint clean. --- .../registry/profiles/__init__.py | 22 +- .../profiles/permitted_characteristics.py | 134 +++++++++++ .../registry/profiles/profile_lookup.py | 222 ++++++++++++++++++ .../registry/service_discovery/__init__.py | 16 +- .../service_discovery/attribute_ids.py | 187 +++++++++++++++ src/bluetooth_sig/types/registry/__init__.py | 10 + .../types/registry/profile_types.py | 55 +++++ .../test_permitted_characteristics.py | 120 ++++++++++ tests/registry/test_profile_lookup.py | 125 ++++++++++ .../test_service_discovery_attributes.py | 136 +++++++++++ 10 files changed, 1019 insertions(+), 8 deletions(-) create mode 100644 src/bluetooth_sig/registry/profiles/permitted_characteristics.py create mode 100644 src/bluetooth_sig/registry/profiles/profile_lookup.py create mode 100644 src/bluetooth_sig/registry/service_discovery/attribute_ids.py create mode 100644 src/bluetooth_sig/types/registry/profile_types.py create mode 100644 tests/registry/test_permitted_characteristics.py create mode 100644 tests/registry/test_profile_lookup.py create mode 100644 tests/registry/test_service_discovery_attributes.py diff --git a/src/bluetooth_sig/registry/profiles/__init__.py b/src/bluetooth_sig/registry/profiles/__init__.py index 84644543..fbfd9bfe 100644 --- a/src/bluetooth_sig/registry/profiles/__init__.py +++ b/src/bluetooth_sig/registry/profiles/__init__.py @@ -1,10 +1,24 @@ """Profile-specific registries from assigned_numbers/profiles_and_services/. -This module will contain registries for various Bluetooth profiles. - -These registries are currently not implemented but are planned for future releases. +This module contains registries for permitted characteristics and simple +profile parameter lookup tables loaded from the Bluetooth SIG assigned +numbers YAML files. """ from __future__ import annotations -__all__: list[str] = [] +from .permitted_characteristics import ( + PermittedCharacteristicsRegistry, + permitted_characteristics_registry, +) +from .profile_lookup import ( + ProfileLookupRegistry, + profile_lookup_registry, +) + +__all__ = [ + "PermittedCharacteristicsRegistry", + "ProfileLookupRegistry", + "permitted_characteristics_registry", + "profile_lookup_registry", +] diff --git a/src/bluetooth_sig/registry/profiles/permitted_characteristics.py b/src/bluetooth_sig/registry/profiles/permitted_characteristics.py new file mode 100644 index 00000000..6ca8f765 --- /dev/null +++ b/src/bluetooth_sig/registry/profiles/permitted_characteristics.py @@ -0,0 +1,134 @@ +"""Permitted Characteristics Registry for profile service constraints.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import msgspec + +from bluetooth_sig.registry.base import BaseGenericRegistry +from bluetooth_sig.registry.utils import find_bluetooth_sig_path +from bluetooth_sig.types.registry.profile_types import PermittedCharacteristicEntry + +# Profile subdirectories that contain ``*_permitted_characteristics.yaml``. +_PROFILE_DIRS: tuple[str, ...] = ("ess", "uds", "imds") + + +class PermittedCharacteristicsRegistry( + BaseGenericRegistry["PermittedCharacteristicsRegistry"], +): + """Registry for profile-specific permitted characteristic lists. + + Loads ``permitted_characteristics`` YAML files from ESS, UDS and IMDS + profile subdirectories under ``profiles_and_services/``. + + Thread-safe: Multiple threads can safely access the registry concurrently. + """ + + def __init__(self) -> None: + """Initialise the permitted characteristics registry.""" + super().__init__() + self._entries: dict[str, list[PermittedCharacteristicEntry]] = {} + + # ------------------------------------------------------------------ + # Loading + # ------------------------------------------------------------------ + + def _load_yaml_file(self, yaml_path: Path, profile: str) -> None: + """Load a single permitted-characteristics YAML file.""" + if not yaml_path.exists(): + return + + with yaml_path.open("r", encoding="utf-8") as fh: + data = msgspec.yaml.decode(fh.read()) + + if not isinstance(data, dict): + return + + data_dict = cast("dict[str, Any]", data) + items_raw = data_dict.get("permitted_characteristics") + if not isinstance(items_raw, list): + return + + entries: list[PermittedCharacteristicEntry] = [] + for item in items_raw: + if not isinstance(item, dict): + continue + service = item.get("service") + chars_raw = item.get("characteristics") + if not isinstance(service, str) or not isinstance(chars_raw, list): + continue + characteristics = tuple(str(c) for c in chars_raw if isinstance(c, str)) + if characteristics: + entries.append( + PermittedCharacteristicEntry( + service=service, + characteristics=characteristics, + ), + ) + + if entries: + self._entries[profile] = entries + + def _load(self) -> None: + """Load all permitted-characteristics YAML files.""" + uuids_path = find_bluetooth_sig_path() + if not uuids_path: + self._loaded = True + return + + profiles_path = uuids_path.parent / "profiles_and_services" + if not profiles_path.exists(): + self._loaded = True + return + + for profile_dir in _PROFILE_DIRS: + dir_path = profiles_path / profile_dir + if not dir_path.is_dir(): + continue + for yaml_file in sorted(dir_path.glob("*_permitted_characteristics.yaml")): + self._load_yaml_file(yaml_file, profile_dir) + + self._loaded = True + + # ------------------------------------------------------------------ + # Query API + # ------------------------------------------------------------------ + + def get_permitted_characteristics(self, profile: str) -> list[str]: + """Get the flat list of permitted characteristic identifiers for a profile. + + Args: + profile: Profile key (e.g. ``"ess"``, ``"uds"``, ``"imds"``). + + Returns: + List of characteristic identifier strings, or an empty list. + """ + self._ensure_loaded() + with self._lock: + entries = self._entries.get(profile, []) + return [c for entry in entries for c in entry.characteristics] + + def get_entries(self, profile: str) -> list[PermittedCharacteristicEntry]: + """Get the structured permitted-characteristic entries for a profile. + + Args: + profile: Profile key (e.g. ``"ess"``, ``"uds"``, ``"imds"``). + + Returns: + List of :class:`PermittedCharacteristicEntry` or an empty list. + """ + self._ensure_loaded() + with self._lock: + return list(self._entries.get(profile, [])) + + def get_all_profiles(self) -> list[str]: + """Return all loaded profile keys (sorted).""" + self._ensure_loaded() + with self._lock: + return sorted(self._entries) + + +# Singleton instance for global use +permitted_characteristics_registry = PermittedCharacteristicsRegistry() diff --git a/src/bluetooth_sig/registry/profiles/profile_lookup.py b/src/bluetooth_sig/registry/profiles/profile_lookup.py new file mode 100644 index 00000000..20565940 --- /dev/null +++ b/src/bluetooth_sig/registry/profiles/profile_lookup.py @@ -0,0 +1,222 @@ +"""Profile Lookup Registry for simple name/value profile parameters.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import msgspec + +from bluetooth_sig.registry.base import BaseGenericRegistry +from bluetooth_sig.registry.utils import find_bluetooth_sig_path +from bluetooth_sig.types.registry.profile_types import ProfileLookupEntry + +# Field names tried (in order) when extracting the integer value from a YAML entry. +_VALUE_FIELDS: tuple[str, ...] = ("value", "id", "identifier", "attribute", "MDEP_data_type") + +# Field names tried (in order) when extracting the human-readable name. +_NAME_FIELDS: tuple[str, ...] = ( + "name", + "label", + "codec", + "description", + "audio_location", + "mnemonic", + "client_name", + "data_type", + "document_name", +) + +# Directories containing LTV / codec-capability structures — deferred. +_DEFERRED_DIRS: frozenset[str] = frozenset( + { + "ltv_structures", + "metadata_ltv", + "codec_capabilities", + "codec_configuration_ltv", + }, +) + + +class ProfileLookupRegistry(BaseGenericRegistry["ProfileLookupRegistry"]): + """Registry for simple profile parameter lookup tables. + + Loads non-LTV, non-permitted-characteristics YAML files from + ``profiles_and_services/`` and normalises each entry into a + :class:`ProfileLookupEntry` keyed by the YAML top-level key. + + Thread-safe: Multiple threads can safely access the registry concurrently. + """ + + def __init__(self) -> None: + """Initialise the profile lookup registry.""" + super().__init__() + self._tables: dict[str, list[ProfileLookupEntry]] = {} + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _extract_int_value(entry: dict[str, Any]) -> int | None: + """Return the first integer-coercible value from *entry*.""" + for field in _VALUE_FIELDS: + raw = entry.get(field) + if raw is None: + continue + if isinstance(raw, int): + return raw + if isinstance(raw, str): + try: + return int(raw, 16) if raw.startswith("0x") else int(raw) + except ValueError: + continue + return None + + @staticmethod + def _extract_name(entry: dict[str, Any]) -> str | None: + """Return the first usable name string from *entry*.""" + for field in _NAME_FIELDS: + raw = entry.get(field) + if isinstance(raw, str) and raw: + return raw + return None + + @staticmethod + def _build_metadata(entry: dict[str, Any], used_keys: set[str]) -> dict[str, str]: + """Collect remaining string-coercible fields as metadata.""" + meta: dict[str, str] = {} + for key, val in entry.items(): + if key in used_keys: + continue + if isinstance(val, (str, int, float, bool)): + meta[key] = str(val) + return meta + + # ------------------------------------------------------------------ + # Loading + # ------------------------------------------------------------------ + + def _load_yaml_file(self, yaml_path: Path) -> None: + """Load a single YAML file and store entries keyed by top-level key.""" + with yaml_path.open("r", encoding="utf-8") as fh: + data = msgspec.yaml.decode(fh.read()) + + if not isinstance(data, dict): + return + + data_dict = cast("dict[str, Any]", data) + for top_key, entries_raw in data_dict.items(): + if not isinstance(entries_raw, list): + continue + + entries: list[ProfileLookupEntry] = [] + for entry in entries_raw: + if not isinstance(entry, dict): + continue + + value = self._extract_int_value(entry) + name = self._extract_name(entry) + if value is None or name is None: + continue + + # Determine which keys were consumed for name and value + used: set[str] = set() + for field in _VALUE_FIELDS: + raw = entry.get(field) + if raw is not None: + if isinstance(raw, int): + used.add(field) + break + if isinstance(raw, str): + try: + int(raw, 16) if raw.startswith("0x") else int(raw) + used.add(field) + break + except ValueError: + continue + for field in _NAME_FIELDS: + raw = entry.get(field) + if isinstance(raw, str) and raw: + used.add(field) + break + + metadata = self._build_metadata(entry, used) + entries.append(ProfileLookupEntry(name=name, value=value, metadata=metadata)) + + if entries: + self._tables[top_key] = entries + + @staticmethod + def _is_deferred(path: Path) -> bool: + """Return True if *path* is inside a deferred subdirectory.""" + return any(part in _DEFERRED_DIRS for part in path.parts) + + @staticmethod + def _is_permitted_characteristics(path: Path) -> bool: + """Return True if *path* is a permitted-characteristics file.""" + return "permitted_characteristics" in path.name + + def _load(self) -> None: + """Load all non-LTV, non-permitted-characteristics profile YAMLs.""" + uuids_path = find_bluetooth_sig_path() + if not uuids_path: + self._loaded = True + return + + profiles_path = uuids_path.parent / "profiles_and_services" + if not profiles_path.exists(): + self._loaded = True + return + + for yaml_file in sorted(profiles_path.rglob("*.yaml")): + if self._is_deferred(yaml_file) or self._is_permitted_characteristics(yaml_file): + continue + self._load_yaml_file(yaml_file) + + self._loaded = True + + # ------------------------------------------------------------------ + # Query API + # ------------------------------------------------------------------ + + def get_entries(self, table_key: str) -> list[ProfileLookupEntry]: + """Get all entries for a named lookup table. + + Args: + table_key: The YAML top-level key, e.g. ``"audio_codec_id"``, + ``"bearer_technology"``, ``"display_types"``. + + Returns: + List of :class:`ProfileLookupEntry` or an empty list if not found. + """ + self._ensure_loaded() + with self._lock: + return list(self._tables.get(table_key, [])) + + def get_all_table_keys(self) -> list[str]: + """Return all loaded table key names (sorted).""" + self._ensure_loaded() + with self._lock: + return sorted(self._tables) + + def resolve_name(self, table_key: str, value: int) -> str | None: + """Look up the name for a given numeric value within a table. + + Args: + table_key: Table key (e.g. ``"bearer_technology"``). + value: The numeric identifier. + + Returns: + The entry name or ``None`` if not found. + """ + self._ensure_loaded() + with self._lock: + for entry in self._tables.get(table_key, []): + if entry.value == value: + return entry.name + return None + + +# Singleton instance for global use +profile_lookup_registry = ProfileLookupRegistry() diff --git a/src/bluetooth_sig/registry/service_discovery/__init__.py b/src/bluetooth_sig/registry/service_discovery/__init__.py index edb36838..672d7c10 100644 --- a/src/bluetooth_sig/registry/service_discovery/__init__.py +++ b/src/bluetooth_sig/registry/service_discovery/__init__.py @@ -1,10 +1,18 @@ """Service discovery registries from assigned_numbers/service_discovery/. -This module will contain registries for SDP (Service Discovery Protocol) attributes. - -These registries are currently not implemented but are planned for future releases. +This module contains registries for SDP (Service Discovery Protocol) attribute +identifiers and protocol parameters loaded from the Bluetooth SIG assigned +numbers YAML files. """ from __future__ import annotations -__all__: list[str] = [] +from .attribute_ids import ( + ServiceDiscoveryAttributeRegistry, + service_discovery_attribute_registry, +) + +__all__ = [ + "ServiceDiscoveryAttributeRegistry", + "service_discovery_attribute_registry", +] diff --git a/src/bluetooth_sig/registry/service_discovery/attribute_ids.py b/src/bluetooth_sig/registry/service_discovery/attribute_ids.py new file mode 100644 index 00000000..4d87bddd --- /dev/null +++ b/src/bluetooth_sig/registry/service_discovery/attribute_ids.py @@ -0,0 +1,187 @@ +"""Service Discovery Attribute ID Registry for SDP attribute identifiers.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import msgspec + +from bluetooth_sig.registry.base import BaseGenericRegistry +from bluetooth_sig.registry.utils import find_bluetooth_sig_path +from bluetooth_sig.types.registry.profile_types import ( + AttributeIdEntry, + ProtocolParameterEntry, +) + + +class ServiceDiscoveryAttributeRegistry( + BaseGenericRegistry["ServiceDiscoveryAttributeRegistry"], +): + """Registry for SDP attribute identifiers with lazy loading. + + Loads attribute IDs from ``service_discovery/attribute_ids/*.yaml``, + ``attribute_id_offsets_for_strings.yaml``, and ``protocol_parameters.yaml``. + + Thread-safe: Multiple threads can safely access the registry concurrently. + """ + + def __init__(self) -> None: + """Initialise the service discovery attribute registry.""" + super().__init__() + self._attribute_ids: dict[str, list[AttributeIdEntry]] = {} + self._protocol_parameters: list[ProtocolParameterEntry] = [] + + # ------------------------------------------------------------------ + # Loading + # ------------------------------------------------------------------ + + @staticmethod + def _parse_hex_value(raw: object) -> int | None: + """Parse a hex string like ``'0x0001'`` into an int.""" + if isinstance(raw, int): + return raw + if isinstance(raw, str): + try: + return int(raw, 16) if raw.startswith("0x") else int(raw) + except ValueError: + return None + return None + + def _load_attribute_ids_file(self, yaml_path: Path, category: str) -> None: + """Load a single attribute_ids YAML file into *_attribute_ids[category]*.""" + if not yaml_path.exists(): + return + + with yaml_path.open("r", encoding="utf-8") as fh: + data = msgspec.yaml.decode(fh.read()) + + if not isinstance(data, dict): + return + + data_dict = cast("dict[str, Any]", data) + entries_raw = data_dict.get("attribute_ids") + if not isinstance(entries_raw, list): + return + + entries: list[AttributeIdEntry] = [] + for entry in entries_raw: + if not isinstance(entry, dict): + continue + name = entry.get("name") + value = self._parse_hex_value(entry.get("value")) + if name and value is not None: + entries.append(AttributeIdEntry(name=str(name), value=value)) + + if entries: + self._attribute_ids[category] = entries + + def _load_protocol_parameters(self, yaml_path: Path) -> None: + """Load ``protocol_parameters.yaml``.""" + if not yaml_path.exists(): + return + + with yaml_path.open("r", encoding="utf-8") as fh: + data = msgspec.yaml.decode(fh.read()) + + if not isinstance(data, dict): + return + + data_dict = cast("dict[str, Any]", data) + params_raw = data_dict.get("protocol_parameters") + if not isinstance(params_raw, list): + return + + for entry in params_raw: + if not isinstance(entry, dict): + continue + protocol = entry.get("protocol") + name = entry.get("name") + index = entry.get("index") + if protocol and name and isinstance(index, int): + self._protocol_parameters.append( + ProtocolParameterEntry( + protocol=str(protocol), + name=str(name), + index=index, + ), + ) + + def _load(self) -> None: + """Perform the actual loading of all service discovery data.""" + uuids_path = find_bluetooth_sig_path() + if not uuids_path: + self._loaded = True + return + + sd_path = uuids_path.parent / "service_discovery" + if not sd_path.exists(): + self._loaded = True + return + + # Load attribute_ids/*.yaml + attr_dir = sd_path / "attribute_ids" + if attr_dir.is_dir(): + for yaml_file in sorted(attr_dir.glob("*.yaml")): + category = yaml_file.stem + self._load_attribute_ids_file(yaml_file, category) + + # Load attribute_id_offsets_for_strings.yaml (same schema) + offsets_file = sd_path / "attribute_id_offsets_for_strings.yaml" + self._load_attribute_ids_file(offsets_file, "attribute_id_offsets_for_strings") + + # Load protocol_parameters.yaml + self._load_protocol_parameters(sd_path / "protocol_parameters.yaml") + + self._loaded = True + + # ------------------------------------------------------------------ + # Query API + # ------------------------------------------------------------------ + + def get_attribute_ids(self, category: str) -> list[AttributeIdEntry]: + """Get attribute ID entries for a named category. + + Args: + category: The file stem / category name, e.g. ``"universal_attributes"``, + ``"a2dp"``, ``"sdp"``, ``"attribute_id_offsets_for_strings"``. + + Returns: + List of :class:`AttributeIdEntry` or an empty list if not found. + """ + self._ensure_loaded() + with self._lock: + return list(self._attribute_ids.get(category, [])) + + def get_all_categories(self) -> list[str]: + """Return all loaded category names (sorted).""" + self._ensure_loaded() + with self._lock: + return sorted(self._attribute_ids) + + def get_protocol_parameters(self) -> list[ProtocolParameterEntry]: + """Return all protocol parameter entries.""" + self._ensure_loaded() + with self._lock: + return list(self._protocol_parameters) + + def resolve_attribute_name(self, category: str, value: int) -> str | None: + """Look up the attribute name for a given numeric value within a category. + + Args: + category: Category name (e.g. ``"universal_attributes"``). + value: The numeric attribute ID. + + Returns: + The attribute name or ``None`` if not found. + """ + self._ensure_loaded() + with self._lock: + for entry in self._attribute_ids.get(category, []): + if entry.value == value: + return entry.name + return None + + +# Singleton instance for global use +service_discovery_attribute_registry = ServiceDiscoveryAttributeRegistry() diff --git a/src/bluetooth_sig/types/registry/__init__.py b/src/bluetooth_sig/types/registry/__init__.py index 5993b06f..8493a77b 100644 --- a/src/bluetooth_sig/types/registry/__init__.py +++ b/src/bluetooth_sig/types/registry/__init__.py @@ -4,6 +4,7 @@ __all__ = [ "AdTypeInfo", + "AttributeIdEntry", "BaseUuidInfo", "CharacteristicSpec", "FieldInfo", @@ -11,6 +12,9 @@ "NameOpcodeTypeInfo", "NameUuidTypeInfo", "NameValueInfo", + "PermittedCharacteristicEntry", + "ProfileLookupEntry", + "ProtocolParameterEntry", "UnitMetadata", "UuidIdInfo", "ValueNameInfo", @@ -33,3 +37,9 @@ ValueNameReferenceInfo, generate_basic_aliases, ) +from .profile_types import ( + AttributeIdEntry, + PermittedCharacteristicEntry, + ProfileLookupEntry, + ProtocolParameterEntry, +) diff --git a/src/bluetooth_sig/types/registry/profile_types.py b/src/bluetooth_sig/types/registry/profile_types.py new file mode 100644 index 00000000..bcfbefde --- /dev/null +++ b/src/bluetooth_sig/types/registry/profile_types.py @@ -0,0 +1,55 @@ +"""Type definitions for profile and service discovery registries.""" + +from __future__ import annotations + +import msgspec + + +class PermittedCharacteristicEntry(msgspec.Struct, frozen=True, kw_only=True): + """A service with its list of permitted characteristic identifiers. + + Loaded from profiles_and_services/{ess,uds,imds}/*_permitted_characteristics.yaml. + Each YAML entry maps one service URI to a list of characteristic URIs. + """ + + service: str + characteristics: tuple[str, ...] + + +class ProfileLookupEntry(msgspec.Struct, frozen=True, kw_only=True): + """A generic name/value entry from a profile parameter YAML file. + + Covers the simple ``{name, value}`` and ``{value, description}`` patterns + found across A2DP codecs, ESL display types, HFP bearer technologies, + AVRCP types, MAP chat states, TDS organisation IDs, and similar files. + + Extra fields beyond name/value are stored in *metadata* so that a single + struct can represent all simple-lookup schemas without a per-file class. + """ + + name: str + value: int + metadata: dict[str, str] = msgspec.field(default_factory=dict) + + +class AttributeIdEntry(msgspec.Struct, frozen=True, kw_only=True): + """An SDP attribute identifier entry (name + hex value). + + Loaded from service_discovery/attribute_ids/*.yaml and + service_discovery/attribute_id_offsets_for_strings.yaml. + """ + + name: str + value: int + + +class ProtocolParameterEntry(msgspec.Struct, frozen=True, kw_only=True): + """A protocol parameter entry from service_discovery/protocol_parameters.yaml. + + Each entry describes a named parameter for a specific protocol + (e.g. L2CAP PSM, RFCOMM Channel). + """ + + protocol: str + name: str + index: int diff --git a/tests/registry/test_permitted_characteristics.py b/tests/registry/test_permitted_characteristics.py new file mode 100644 index 00000000..72efba6e --- /dev/null +++ b/tests/registry/test_permitted_characteristics.py @@ -0,0 +1,120 @@ +"""Tests for Permitted Characteristics Registry.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.registry.profiles.permitted_characteristics import ( + PermittedCharacteristicsRegistry, +) +from bluetooth_sig.types.registry.profile_types import PermittedCharacteristicEntry + + +@pytest.fixture(scope="session") +def perm_registry() -> PermittedCharacteristicsRegistry: + """Create a permitted characteristics registry once per test session.""" + return PermittedCharacteristicsRegistry() + + +class TestPermittedCharacteristicsRegistryInit: + """Initialisation and lazy-loading tests.""" + + def test_registry_initialization(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """Registry creates without error.""" + assert isinstance(perm_registry, PermittedCharacteristicsRegistry) + + def test_lazy_loading(self) -> None: + """Data is not loaded until first query.""" + reg = PermittedCharacteristicsRegistry() + assert not reg._loaded + + _ = reg.get_all_profiles() + assert reg._loaded + + +class TestPermittedCharacteristicsLoading: + """Tests that validate YAML data is loaded correctly.""" + + def test_all_profiles_loaded(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """ESS, UDS and IMDS profiles should all be present.""" + profiles = perm_registry.get_all_profiles() + assert "ess" in profiles + assert "uds" in profiles + assert "imds" in profiles + + def test_ess_characteristics_count(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """ESS should have ~31 permitted characteristics.""" + chars = perm_registry.get_permitted_characteristics("ess") + assert len(chars) >= 25 + + def test_uds_characteristics_count(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """UDS should have ~39 permitted characteristics.""" + chars = perm_registry.get_permitted_characteristics("uds") + assert len(chars) >= 30 + + def test_imds_characteristics_count(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """IMDS should have ~7 permitted characteristics.""" + chars = perm_registry.get_permitted_characteristics("imds") + assert len(chars) >= 5 + + def test_characteristics_are_uri_strings(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """All characteristic identifiers should be org.bluetooth.characteristic.* URIs.""" + for profile in perm_registry.get_all_profiles(): + for char_id in perm_registry.get_permitted_characteristics(profile): + assert char_id.startswith("org.bluetooth.characteristic."), ( + f"{profile}: unexpected URI format: {char_id}" + ) + + def test_unknown_profile_returns_empty(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """Querying a non-existent profile returns an empty list.""" + assert perm_registry.get_permitted_characteristics("nonexistent") == [] + + +class TestPermittedCharacteristicsEntries: + """Tests for the structured get_entries API.""" + + def test_get_entries_returns_structs(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """get_entries returns PermittedCharacteristicEntry structs.""" + entries = perm_registry.get_entries("ess") + assert len(entries) >= 1 + assert all(isinstance(e, PermittedCharacteristicEntry) for e in entries) + + def test_entry_service_field(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """Each entry has a service field starting with org.bluetooth.service.""" + for profile in perm_registry.get_all_profiles(): + for entry in perm_registry.get_entries(profile): + assert entry.service.startswith("org.bluetooth.service."), ( + f"{profile}: unexpected service URI: {entry.service}" + ) + + def test_entry_characteristics_is_tuple(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """Characteristics field should be a tuple (immutable).""" + entries = perm_registry.get_entries("ess") + assert entries # non-empty + assert isinstance(entries[0].characteristics, tuple) + + def test_entries_unknown_profile(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """get_entries for unknown profile returns empty list.""" + assert perm_registry.get_entries("nonexistent") == [] + + +class TestReturnedListsAreDefensiveCopies: + """Mutations on returned lists must not affect registry state.""" + + def test_characteristics_copy(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """Mutating the returned characteristics list is safe.""" + chars1 = perm_registry.get_permitted_characteristics("ess") + original_len = len(chars1) + chars1.clear() + + chars2 = perm_registry.get_permitted_characteristics("ess") + assert len(chars2) == original_len + + def test_entries_copy(self, perm_registry: PermittedCharacteristicsRegistry) -> None: + """Mutating the returned entries list is safe.""" + entries1 = perm_registry.get_entries("ess") + original_len = len(entries1) + entries1.clear() + + entries2 = perm_registry.get_entries("ess") + assert len(entries2) == original_len diff --git a/tests/registry/test_profile_lookup.py b/tests/registry/test_profile_lookup.py new file mode 100644 index 00000000..e4dacdd4 --- /dev/null +++ b/tests/registry/test_profile_lookup.py @@ -0,0 +1,125 @@ +"""Tests for Profile Lookup Registry.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.registry.profiles.profile_lookup import ProfileLookupRegistry +from bluetooth_sig.types.registry.profile_types import ProfileLookupEntry + + +@pytest.fixture(scope="session") +def lookup_registry() -> ProfileLookupRegistry: + """Create a profile lookup registry once per test session.""" + return ProfileLookupRegistry() + + +class TestProfileLookupRegistryInit: + """Initialisation and lazy-loading tests.""" + + def test_registry_initialization(self, lookup_registry: ProfileLookupRegistry) -> None: + """Registry creates without error.""" + assert isinstance(lookup_registry, ProfileLookupRegistry) + + def test_lazy_loading(self) -> None: + """Data is not loaded until first query.""" + reg = ProfileLookupRegistry() + assert not reg._loaded + + _ = reg.get_all_table_keys() + assert reg._loaded + + +class TestProfileLookupLoading: + """Tests that validate YAML data is loaded correctly.""" + + def test_tables_loaded(self, lookup_registry: ProfileLookupRegistry) -> None: + """At least the expected number of lookup tables are loaded.""" + keys = lookup_registry.get_all_table_keys() + assert len(keys) >= 15 + # Well-known tables + assert "audio_codec_id" in keys + assert "bearer_technology" in keys + assert "display_types" in keys + + def test_bearer_technology_entries(self, lookup_registry: ProfileLookupRegistry) -> None: + """bearer_technology should have known entries like 3G, 4G, LTE.""" + entries = lookup_registry.get_entries("bearer_technology") + assert len(entries) >= 5 + assert all(isinstance(e, ProfileLookupEntry) for e in entries) + + names = {e.name for e in entries} + assert "3G" in names + assert "LTE" in names + + def test_audio_codec_id_has_sbc(self, lookup_registry: ProfileLookupRegistry) -> None: + """audio_codec_id should include SBC as the mandatory A2DP codec.""" + entries = lookup_registry.get_entries("audio_codec_id") + assert any(e.name == "SBC" for e in entries) + + def test_audio_codec_id_metadata(self, lookup_registry: ProfileLookupRegistry) -> None: + """audio_codec_id entries should carry metadata (specified_in, used_in).""" + entries = lookup_registry.get_entries("audio_codec_id") + sbc = next(e for e in entries if e.name == "SBC") + assert "specified_in" in sbc.metadata + assert sbc.metadata["specified_in"] == "A2DP" + + def test_entry_values_are_ints(self, lookup_registry: ProfileLookupRegistry) -> None: + """All entry values should be integers.""" + for key in lookup_registry.get_all_table_keys(): + for entry in lookup_registry.get_entries(key): + assert isinstance(entry.value, int), f"{key}/{entry.name}: value is not int" + + def test_unknown_table_returns_empty(self, lookup_registry: ProfileLookupRegistry) -> None: + """Querying a non-existent table returns an empty list.""" + assert lookup_registry.get_entries("nonexistent_table") == [] + + def test_ltv_files_not_loaded(self, lookup_registry: ProfileLookupRegistry) -> None: + """LTV / codec capability structures should be deferred (not loaded).""" + keys = lookup_registry.get_all_table_keys() + # These are LTV-specific keys that should NOT appear + ltv_keys = { + "supported_sampling_frequencies", + "supported_frame_durations", + "supported_audio_channel_counts", + "supported_octets_per_codec_frame", + "sampling_frequency_configuration", + } + loaded_ltv = ltv_keys & set(keys) + assert not loaded_ltv, f"LTV tables should be deferred: {loaded_ltv}" + + def test_permitted_characteristics_not_loaded(self, lookup_registry: ProfileLookupRegistry) -> None: + """Permitted characteristics files should not appear in lookup tables.""" + keys = lookup_registry.get_all_table_keys() + assert "permitted_characteristics" not in keys + + +class TestProfileLookupResolve: + """Tests for the resolve_name convenience method.""" + + def test_resolve_known_name(self, lookup_registry: ProfileLookupRegistry) -> None: + """Resolving a known value returns the expected name.""" + # 3G in bearer_technology has value 1 + name = lookup_registry.resolve_name("bearer_technology", 1) + assert name == "3G" + + def test_resolve_unknown_value(self, lookup_registry: ProfileLookupRegistry) -> None: + """Resolving an unknown value returns None.""" + assert lookup_registry.resolve_name("bearer_technology", 0xFFFF) is None + + def test_resolve_unknown_table(self, lookup_registry: ProfileLookupRegistry) -> None: + """Resolving in an unknown table returns None.""" + assert lookup_registry.resolve_name("nonexistent", 0) is None + + +class TestReturnedListsAreDefensiveCopies: + """Mutations on returned lists must not affect registry state.""" + + def test_entries_copy(self, lookup_registry: ProfileLookupRegistry) -> None: + """Mutating the returned list does not affect internal state.""" + entries1 = lookup_registry.get_entries("bearer_technology") + original_len = len(entries1) + entries1.clear() + + entries2 = lookup_registry.get_entries("bearer_technology") + assert len(entries2) == original_len diff --git a/tests/registry/test_service_discovery_attributes.py b/tests/registry/test_service_discovery_attributes.py new file mode 100644 index 00000000..0c1bb15f --- /dev/null +++ b/tests/registry/test_service_discovery_attributes.py @@ -0,0 +1,136 @@ +"""Tests for Service Discovery Attribute ID Registry.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.registry.service_discovery.attribute_ids import ( + ServiceDiscoveryAttributeRegistry, +) +from bluetooth_sig.types.registry.profile_types import ( + AttributeIdEntry, + ProtocolParameterEntry, +) + + +@pytest.fixture(scope="session") +def sd_registry() -> ServiceDiscoveryAttributeRegistry: + """Create a service discovery attribute registry once per test session.""" + return ServiceDiscoveryAttributeRegistry() + + +class TestServiceDiscoveryAttributeRegistryInit: + """Initialisation and lazy-loading tests.""" + + def test_registry_initialization(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Registry creates without error.""" + assert isinstance(sd_registry, ServiceDiscoveryAttributeRegistry) + + def test_lazy_loading(self) -> None: + """Data is not loaded until first query.""" + reg = ServiceDiscoveryAttributeRegistry() + assert not reg._loaded + + _ = reg.get_all_categories() + assert reg._loaded + + +class TestAttributeIdLoading: + """Tests that validate YAML data is loaded correctly.""" + + def test_categories_loaded(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """At least the known attribute_ids files are loaded.""" + cats = sd_registry.get_all_categories() + assert len(cats) >= 20 + # Key categories from the YAML tree + assert "universal_attributes" in cats + assert "sdp" in cats + assert "a2dp" in cats + assert "attribute_id_offsets_for_strings" in cats + + def test_universal_attributes_entries(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """universal_attributes should have well-known SDP entries.""" + entries = sd_registry.get_attribute_ids("universal_attributes") + assert len(entries) >= 10 + assert all(isinstance(e, AttributeIdEntry) for e in entries) + + names = {e.name for e in entries} + assert "ServiceRecordHandle" in names + assert "ServiceClassIDList" in names + + def test_entry_values_are_ints(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """All loaded values should be integers, not hex strings.""" + for cat in sd_registry.get_all_categories(): + for entry in sd_registry.get_attribute_ids(cat): + assert isinstance(entry.value, int), f"{cat}/{entry.name}: value is not int" + + def test_attribute_id_offsets_loaded(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """attribute_id_offsets_for_strings.yaml should load as its own category.""" + entries = sd_registry.get_attribute_ids("attribute_id_offsets_for_strings") + assert len(entries) >= 3 + names = {e.name for e in entries} + assert "ServiceName" in names + + def test_unknown_category_returns_empty(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Querying a non-existent category returns an empty list.""" + assert sd_registry.get_attribute_ids("nonexistent_profile") == [] + + +class TestProtocolParameters: + """Tests for protocol_parameters.yaml loading.""" + + def test_protocol_parameters_loaded(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """protocol_parameters should have at least L2CAP and RFCOMM entries.""" + params = sd_registry.get_protocol_parameters() + assert len(params) >= 4 + assert all(isinstance(p, ProtocolParameterEntry) for p in params) + + protocols = {p.protocol for p in params} + assert "L2CAP" in protocols + assert "RFCOMM" in protocols + + def test_protocol_parameter_fields(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Each entry has non-empty protocol, name, and a valid index.""" + for p in sd_registry.get_protocol_parameters(): + assert p.protocol + assert p.name + assert isinstance(p.index, int) + + +class TestResolveAttributeName: + """Tests for the resolve_attribute_name convenience method.""" + + def test_resolve_known_attribute(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Resolving a known value returns the correct name.""" + name = sd_registry.resolve_attribute_name("universal_attributes", 0x0001) + assert name == "ServiceClassIDList" + + def test_resolve_unknown_value(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Resolving an unknown value returns None.""" + assert sd_registry.resolve_attribute_name("universal_attributes", 0xFFFF) is None + + def test_resolve_unknown_category(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Resolving in an unknown category returns None.""" + assert sd_registry.resolve_attribute_name("nonexistent_profile", 0x0000) is None + + +class TestReturnedListsAreDefensiveCopies: + """Mutations on returned lists must not affect registry state.""" + + def test_attribute_ids_copy(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Mutating the returned list does not affect internal state.""" + entries1 = sd_registry.get_attribute_ids("universal_attributes") + original_len = len(entries1) + entries1.clear() + + entries2 = sd_registry.get_attribute_ids("universal_attributes") + assert len(entries2) == original_len + + def test_protocol_params_copy(self, sd_registry: ServiceDiscoveryAttributeRegistry) -> None: + """Mutating the returned protocol parameters list is safe.""" + params1 = sd_registry.get_protocol_parameters() + original_len = len(params1) + params1.clear() + + params2 = sd_registry.get_protocol_parameters() + assert len(params2) == original_len From 016441e8eb55b8c72e6a1e1dc2e1feea24134149 Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Thu, 19 Feb 2026 18:31:09 +0000 Subject: [PATCH 7/9] refactor: code quality, docs, and examples cleanup (WS2/4/5) - WS2.2: Reduce type:ignore from 18 to 11 (descriptor_utils cast, RangeDescriptorMixin Protocol) - WS2.3: Replace 3 silent except-pass with logger.debug (query.py, advertising.py) - WS4.2: Bump CI coverage threshold from 70% to 85% (actual: 87%) - WS5.1: Fix misleading README code samples (parse_characteristic returns value directly) - WS5.4: Remove emoji from all examples/tests, remove 2 stub functions from with_bleak_retry.py - Format: ruff format fixes on prior commit files --- .github/workflows/test-coverage.yml | 2 +- README.md | 9 +- examples/advertising_parsing.py | 12 +- examples/async_ble_integration.py | 8 +- examples/benchmarks/parsing_performance.py | 10 +- examples/comprehensive_test.py | 36 ++--- examples/connection_managers/bleak_utils.py | 8 +- examples/pure_sig_parsing.py | 48 +++---- examples/scanning.py | 18 +-- examples/test_scanning_features.py | 22 +-- examples/utils/argparse_utils.py | 4 +- examples/utils/connection_helpers.py | 4 +- examples/utils/data_parsing.py | 42 +++--- examples/utils/demo_functions.py | 46 +++--- examples/utils/device_scanning.py | 4 +- examples/utils/library_detection.py | 8 +- examples/utils/notification_utils.py | 4 +- examples/with_bleak_retry.py | 59 ++------ examples/with_bluepy.py | 38 ++--- examples/with_simpleble.py | 10 +- src/bluetooth_sig/advertising/pdu_parser.py | 6 +- src/bluetooth_sig/core/query.py | 2 +- src/bluetooth_sig/device/advertising.py | 10 +- src/bluetooth_sig/device/peripheral_device.py | 2 +- src/bluetooth_sig/gatt/descriptor_utils.py | 6 +- src/bluetooth_sig/gatt/descriptors/base.py | 35 +++-- .../types/advertising/three_d_information.py | 6 +- .../types/advertising/transport_discovery.py | 18 +-- tests/advertising/test_channel_map_update.py | 75 +++++++--- tests/advertising/test_indoor_positioning.py | 119 ++++++++++------ tests/advertising/test_three_d_information.py | 3 +- tests/advertising/test_transport_discovery.py | 133 +++++++++++------- tests/device/test_peripheral_device.py | 10 +- tests/docs/conftest.py | 12 +- tests/integration/test_examples.py | 13 +- 35 files changed, 456 insertions(+), 386 deletions(-) diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index f03516c2..4f9d1866 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -55,7 +55,7 @@ jobs: - name: Run tests with coverage run: | - python -m pytest tests/ -n auto --ignore=tests/benchmarks/ --junitxml=test-results.xml --cov=src/bluetooth_sig --cov-report=html --cov-report=xml --cov-report=term-missing --cov-fail-under=70 + python -m pytest tests/ -n auto --ignore=tests/benchmarks/ --junitxml=test-results.xml --cov=src/bluetooth_sig --cov-report=html --cov-report=xml --cov-report=term-missing --cov-fail-under=85 - name: Publish JUnit test report if: always() diff --git a/README.md b/README.md index c82ff148..1a7b19c8 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,9 @@ for char in client.services.characteristics: uuid_str = str(char.uuid) if translator.supports(uuid_str): raw_data = await client.read_gatt_char(uuid_str) # SKIP: async - result = translator.parse_characteristic(uuid_str, raw_data) - print(f"{result.info.name}: {result.value}") # Returns Any + parsed = translator.parse_characteristic(uuid_str, raw_data) + info = translator.get_characteristic_info_by_uuid(uuid_str) + print(f"{info.name}: {parsed}") # parsed is the value directly (Any) else: print(f"Unknown characteristic UUID: {uuid_str}") ``` @@ -215,8 +216,8 @@ battery_uuid = translator.get_characteristic_uuid_by_name(CharacteristicName.BAT async with BleakClient(address) as client: # Read: bleak handles connection, bluetooth-sig handles parsing raw_data = await client.read_gatt_char(str(battery_uuid)) - result = translator.parse_characteristic(str(battery_uuid), raw_data) - print(f"Battery: {result.value}%") + level = translator.parse_characteristic(str(battery_uuid), raw_data) + print(f"Battery: {level}%") # Write: bluetooth-sig handles encoding, bleak handles transmission data = translator.encode_characteristic(str(battery_uuid), 85) diff --git a/examples/advertising_parsing.py b/examples/advertising_parsing.py index 89f02606..20b5727f 100644 --- a/examples/advertising_parsing.py +++ b/examples/advertising_parsing.py @@ -311,14 +311,14 @@ def display_advertising_data( # Print found fields first if found_fields: - print("📋 FOUND FIELDS:") + print("FOUND FIELDS:") for field in found_fields: print(f" {field}") print() # Print not found fields if flag enabled if show_not_found and not_found_fields: - print("❌ NOT FOUND FIELDS:") + print("NOT FOUND FIELDS:") for field in not_found_fields: print(f" {field}") print() @@ -356,13 +356,13 @@ def display_advertising_data( extended_not_found.append("Broadcast Code") if extended_found: - print("🔄 EXTENDED ADVERTISING - FOUND:") + print("EXTENDED ADVERTISING - FOUND:") for field in extended_found: print(f" {field}") print() if show_not_found and extended_not_found: - print("🔄 EXTENDED ADVERTISING - NOT FOUND:") + print("EXTENDED ADVERTISING - NOT FOUND:") for field in extended_not_found: print(f" {field}") print() @@ -463,7 +463,7 @@ async def main( results["error"] = "invalid_hex" return results elif mock: - print("📝 USING MOCK LEGACY ADVERTISING DATA FOR DEMONSTRATION - No real BLE hardware required") + print("USING MOCK LEGACY ADVERTISING DATA FOR DEMONSTRATION - No real BLE hardware required") print() print("Mock BLE Device Results with SIG Parsing:") print() @@ -495,7 +495,7 @@ async def main( display_advertising_data(parsed_data, translator, show_not_found, show_debug) results["used_mock"] = True elif extended_mock: - print("🔄 USING MOCK EXTENDED ADVERTISING DATA FOR DEMONSTRATION - No real BLE hardware required") + print("USING MOCK EXTENDED ADVERTISING DATA FOR DEMONSTRATION - No real BLE hardware required") print() print("Mock Extended BLE Device Results with SIG Parsing:") print() diff --git a/examples/async_ble_integration.py b/examples/async_ble_integration.py index 3627a9ea..ac519abd 100644 --- a/examples/async_ble_integration.py +++ b/examples/async_ble_integration.py @@ -65,7 +65,7 @@ async def scan_and_connect() -> None: # Read and parse characteristics for service in service_list: - print(f"\n📦 Service: {service.uuid}") + print(f"\nService: {service.uuid}") for char in service.characteristics: if "read" in char.properties: @@ -76,12 +76,12 @@ async def scan_and_connect() -> None: # Parse with async API - returns value directly try: result = await translator.parse_characteristic_async(str(char.uuid), bytes(data)) - print(f" ✅ {char.uuid}: {result}") + print(f" {char.uuid}: {result}") except CharacteristicParseError: - print(f" ❓ {char.uuid}: ") + print(f" {char.uuid}: ") except (BleakError, asyncio.TimeoutError, OSError) as e: - print(f" ❌ Error reading {char.uuid}: {e}") + print(f" Error reading {char.uuid}: {e}") except (BleakError, asyncio.TimeoutError, OSError) as e: print(f"Connection error: {e}") diff --git a/examples/benchmarks/parsing_performance.py b/examples/benchmarks/parsing_performance.py index c6e524ce..7e2993d9 100755 --- a/examples/benchmarks/parsing_performance.py +++ b/examples/benchmarks/parsing_performance.py @@ -296,10 +296,10 @@ def main() -> None: ) if log_level <= logging.INFO: - print(f"\n⚠️ Logging enabled at {args.log_level.upper()} level") + print(f"\nLogging enabled at {args.log_level.upper()} level") print(" Note: Logging adds overhead and will affect benchmark results") - print("\n🚀 Bluetooth SIG Library Performance Benchmark") + print("\nBluetooth SIG Library Performance Benchmark") print("=" * 80) session = ProfilingSession(name="Parsing Performance Benchmark") @@ -314,13 +314,13 @@ def main() -> None: # Print summary print_summary(session) - print("\n✅ Benchmark complete!") + print("\nBenchmark complete!") except KeyboardInterrupt: - print("\n\n⚠️ Benchmark interrupted by user") + print("\n\nBenchmark interrupted by user") sys.exit(1) except Exception as e: # pylint: disable=broad-except - print(f"\n\n❌ Benchmark failed ({type(e).__name__}): {e}") + print(f"\n\nBenchmark failed ({type(e).__name__}): {e}") import traceback traceback.print_exc() diff --git a/examples/comprehensive_test.py b/examples/comprehensive_test.py index 7cb401f3..187e1cdc 100644 --- a/examples/comprehensive_test.py +++ b/examples/comprehensive_test.py @@ -13,7 +13,7 @@ def print_separator(title: str) -> None: """Print a formatted separator.""" print(f"\n{'=' * 60}") - print(f"🧪 {title}") + print(f"{title}") print(f"{'=' * 60}") @@ -35,44 +35,44 @@ async def main() -> None: print_separator("BLE Connection Test") real_device_success = False if bleak_retry_available: - print("✅ Bleak-retry-connector available") + print("Bleak-retry-connector available") # Test with the Xiaomi sensor we found earlier device_address = "A4:C1:38:B0:35:69" - print(f"🔗 Testing connection to {device_address}") + print(f"Testing connection to {device_address}") try: results = await read_characteristics_bleak_retry(device_address, ["2A00", "2A29"], max_attempts=2) if results: - print("✅ Real device connection successful!") + print("Real device connection successful!") await parse_and_display_results(results, "Real Device") real_device_success = True else: - print("⚠️ No characteristics read (device may be unavailable)") - print("📝 This test still passes - library detection works, but device is not responding") + print("No characteristics read (device may be unavailable)") + print("This test still passes - library detection works, but device is not responding") real_device_success = False except (OSError, ValueError, TimeoutError) as e: - print(f"⚠️ Connection failed: {e}") - print("📝 This test still passes - library detection works, but device connection failed") + print(f"Connection failed: {e}") + print("This test still passes - library detection works, but device connection failed") real_device_success = False else: - print("❌ Bleak-retry not available") + print("Bleak-retry not available") if simplepyble_available: - print("✅ SimplePyBLE available") + print("SimplePyBLE available") else: - print("❌ SimplePyBLE not available") + print("SimplePyBLE not available") print_separator("Test Summary") - print("✅ Library detection: PASSED") - print("✅ SIG translation: PASSED") - print("✅ Real device scan: PASSED") + print("Library detection: PASSED") + print("SIG translation: PASSED") + print("Real device scan: PASSED") if real_device_success: - print("✅ Real device connection: PASSED") + print("Real device connection: PASSED") else: - print("⚠️ Real device connection: FAILED (but library works)") - print("✅ Multiple BLE libraries: SUPPORTED") + print("Real device connection: FAILED (but library works)") + print("Multiple BLE libraries: SUPPORTED") - print("\n🎉 All tests completed successfully!") + print("\nAll tests completed successfully!") print("The BLE examples are working correctly with:") print(" - Pure SIG standard parsing") print(" - Bleak-retry-connector integration") diff --git a/examples/connection_managers/bleak_utils.py b/examples/connection_managers/bleak_utils.py index 697a5594..65e245a9 100644 --- a/examples/connection_managers/bleak_utils.py +++ b/examples/connection_managers/bleak_utils.py @@ -94,13 +94,13 @@ async def scan_with_bleak_retry(timeout: float = 10.0) -> list[DeviceInfo]: Returns a list of :class:`DeviceInfo` instances for example consumers. """ - print(f"🔍 Scanning for BLE devices ({timeout}s)...") + print(f"Scanning for BLE devices ({timeout}s)...") # Import BleakScanner at runtime to avoid import-time dependency. bleak = importlib.import_module("bleak") BleakScanner = bleak.BleakScanner devices = await BleakScanner.discover(timeout=timeout) - print(f"\n📡 Found {len(devices)} devices:") + print(f"\nFound {len(devices)} devices:") normalized: list[DeviceInfo] = [] for i, device in enumerate(devices, 1): name, address, rssi = safe_get_device_info(device) @@ -151,9 +151,9 @@ def _cb(char_uuid: str, data: bytes) -> None: try: await manager.start_notify(characteristic_uuid, _cb) - print(" 🔔 Notifications enabled") + print(" Notifications enabled") await asyncio.sleep(duration) await manager.stop_notify(characteristic_uuid) - print("\n✅ Notification session completed") + print("\nNotification session completed") finally: await manager.disconnect() diff --git a/examples/pure_sig_parsing.py b/examples/pure_sig_parsing.py index 13e0993a..353dba00 100644 --- a/examples/pure_sig_parsing.py +++ b/examples/pure_sig_parsing.py @@ -31,7 +31,7 @@ def demonstrate_type_safe_parsing() -> None: Use this approach when you know the characteristic type at compile time. The IDE infers return types automatically. """ - print("🔵 Type-Safe Parsing (Recommended for Known Devices)") + print("Type-Safe Parsing (Recommended for Known Devices)") print("=" * 55) print("Use characteristic classes directly for full IDE type inference.\n") @@ -45,28 +45,28 @@ def demonstrate_type_safe_parsing() -> None: # Simple characteristics: return primitive types battery = BatteryLevelCharacteristic() level = battery.parse_value(bytearray([85])) # IDE knows: int - print(f"📊 Battery Level: {level}% (type: {type(level).__name__})") + print(f"Battery Level: {level}% (type: {type(level).__name__})") temp = TemperatureCharacteristic() temp_value = temp.parse_value(bytearray([0x64, 0x09])) # IDE knows: float - print(f"📊 Temperature: {temp_value}°C (type: {type(temp_value).__name__})") + print(f"Temperature: {temp_value}°C (type: {type(temp_value).__name__})") humidity = HumidityCharacteristic() humidity_value = humidity.parse_value(bytearray([0x3A, 0x13])) # IDE knows: float - print(f"📊 Humidity: {humidity_value}% (type: {type(humidity_value).__name__})") + print(f"Humidity: {humidity_value}% (type: {type(humidity_value).__name__})") # Complex characteristics: return structured dataclasses heart_rate = HeartRateMeasurementCharacteristic() hr_data = heart_rate.parse_value(bytearray([0x00, 72])) # IDE knows: HeartRateData - print(f"📊 Heart Rate: {hr_data.heart_rate} bpm (type: {type(hr_data).__name__})") + print(f"Heart Rate: {hr_data.heart_rate} bpm (type: {type(hr_data).__name__})") print(f" Sensor contact: {hr_data.sensor_contact}") # Encoding: build_value converts back to bytes encoded = battery.build_value(85) - print(f"\n✅ Encode 85% battery → {encoded.hex()}") + print(f"\nEncode 85% battery → {encoded.hex()}") encoded_hr = heart_rate.build_value(hr_data) - print(f"✅ Encode heart rate data → {encoded_hr.hex()}") + print(f"Encode heart rate data → {encoded_hr.hex()}") print() @@ -76,7 +76,7 @@ def demonstrate_dynamic_parsing() -> None: Use this approach when scanning unknown devices or building generic BLE explorers. Return type is Any since the characteristic type is determined at runtime. """ - print("🔵 Dynamic Parsing (For Scanning Unknown Devices)") + print("Dynamic Parsing (For Scanning Unknown Devices)") print("=" * 55) print("Use Translator with UUID strings when characteristic type is unknown.\n") @@ -126,7 +126,7 @@ def demonstrate_dynamic_parsing() -> None: results = {} for test_case in test_cases: - print(f"📊 {test_case['name']} ({test_case['uuid']})") + print(f"{test_case['name']} ({test_case['uuid']})") print(f" Description: {test_case['description']}") print(f" Raw data: {test_case['data'].hex().upper()}") @@ -135,12 +135,12 @@ def demonstrate_dynamic_parsing() -> None: result = translator.parse_characteristic(test_case["uuid"], test_case["data"]) info = translator.get_characteristic_info_by_uuid(test_case["uuid"]) unit_str = f" {info.unit}" if info and info.unit else "" - print(f" ✅ Parsed value: {result}{unit_str}") + print(f" Parsed value: {result}{unit_str}") if info and info.value_type: - print(f" 📋 Value type: {info.value_type}") + print(f" Value type: {info.value_type}") results[test_case["name"]] = result except Exception as e: - print(f" ❌ Parse failed: {e}") + print(f" Parse failed: {e}") results[test_case["name"]] = None print() @@ -148,7 +148,7 @@ def demonstrate_dynamic_parsing() -> None: def demonstrate_uuid_resolution() -> None: """Demonstrate UUID resolution and characteristic information lookup.""" - print("\n🔍 UUID Resolution and Characteristic Information") + print("\nUUID Resolution and Characteristic Information") print("=" * 50) translator = BluetoothSIGTranslator() @@ -165,22 +165,22 @@ def demonstrate_uuid_resolution() -> None: print("Resolving various UUID formats:\n") for uuid in test_uuids: - print(f"🔗 UUID: {uuid}") + print(f"UUID: {uuid}") # Get characteristic information char_info = translator.get_characteristic_info_by_uuid(uuid) if char_info: - print(f" ✅ Name: {char_info.name}") - print(f" 🏷️ Type: {char_info.value_type}") - print(f" 📏 Unit: {char_info.unit if char_info.unit else 'N/A'}") + print(f" Name: {char_info.name}") + print(f" Type: {char_info.value_type}") + print(f" Unit: {char_info.unit if char_info.unit else 'N/A'}") else: - print(" ℹ️ Not found in characteristic registry") + print(" Not found in characteristic registry") print() def demonstrate_batch_parsing() -> None: """Demonstrate parsing multiple characteristics at once.""" - print("\n📦 Batch Parsing Multiple Characteristics") + print("\nBatch Parsing Multiple Characteristics") print("=" * 50) translator = BluetoothSIGTranslator() @@ -207,12 +207,12 @@ def demonstrate_batch_parsing() -> None: info = translator.get_characteristic_info_by_uuid(uuid) char_name = info.name if info else uuid unit_str = f" {info.unit}" if info and info.unit else "" - print(f"📊 {char_name}: {result}{unit_str}") + print(f"{char_name}: {result}{unit_str}") def demonstrate_integration_pattern() -> None: """Show the recommended integration patterns for BLE libraries.""" - print("\n🔧 Integration Patterns for BLE Libraries") + print("\nIntegration Patterns for BLE Libraries") print("=" * 55) print( """ @@ -241,7 +241,7 @@ def demonstrate_integration_pattern() -> None: if __name__ == "__main__": - print("🚀 Bluetooth SIG Pure Standards Parsing Demo") + print("Bluetooth SIG Pure Standards Parsing Demo") print("Demonstrates type-safe and dynamic parsing approaches\n") try: @@ -252,11 +252,11 @@ def demonstrate_integration_pattern() -> None: demonstrate_batch_parsing() demonstrate_integration_pattern() - print("\n✅ Demo completed successfully!") + print("\nDemo completed successfully!") print("Use characteristic classes for known devices, Translator for scanning.") except Exception as e: # pylint: disable=broad-except - print(f"\n❌ Demo failed: {e}") + print(f"\nDemo failed: {e}") import traceback traceback.print_exc() diff --git a/examples/scanning.py b/examples/scanning.py index 81fb7da3..6bb8131d 100644 --- a/examples/scanning.py +++ b/examples/scanning.py @@ -66,10 +66,10 @@ async def scan_devices(timeout: float = 10.0) -> None: from bleak import BleakScanner from bleak.backends.scanner import AdvertisementData as BleakAdvertisementData except ImportError: - print("❌ Bleak not installed. Install with: pip install bleak") + print("Bleak not installed. Install with: pip install bleak") return - print(f"\n📡 Scanning for BLE devices ({timeout:.0f} seconds)...\n") + print(f"\nScanning for BLE devices ({timeout:.0f} seconds)...\n") # Collect all advertisements (keeps latest per address) devices_seen: dict[str, tuple[str | None, BleakAdvertisementData]] = {} @@ -106,7 +106,7 @@ def detection_callback(device: object, advertisement_data: BleakAdvertisementDat # Process each device for address, (name, adv) in sorted(devices_seen.items()): display_name = name or "Unknown" - print(f"\n📱 {display_name} [{address}]") + print(f"\n{display_name} [{address}]") print(f" RSSI: {adv.rssi} dBm") # Show local name if different from device name @@ -120,7 +120,7 @@ def detection_callback(device: object, advertisement_data: BleakAdvertisementDat # Service UUIDs (advertised services) if adv.service_uuids: stats["with_service_uuids"] += 1 - print(f" 📋 Service UUIDs ({len(adv.service_uuids)}):") + print(f" Service UUIDs ({len(adv.service_uuids)}):") for uuid_str in adv.service_uuids: try: uuid = BluetoothUUID(uuid_str) @@ -135,7 +135,7 @@ def detection_callback(device: object, advertisement_data: BleakAdvertisementDat # Manufacturer Data if adv.manufacturer_data: stats["with_manufacturer_data"] += 1 - print(f" 🏭 Manufacturer Data ({len(adv.manufacturer_data)} entries):") + print(f" Manufacturer Data ({len(adv.manufacturer_data)} entries):") for company_id, payload in adv.manufacturer_data.items(): mfr_data = ManufacturerData.from_id_and_payload(company_id, payload) print(f" • {mfr_data.company.name}: {format_bytes(mfr_data.payload)}") @@ -144,7 +144,7 @@ def detection_callback(device: object, advertisement_data: BleakAdvertisementDat service_data_uuids: dict[BluetoothUUID, bytes] = {} if adv.service_data: stats["with_service_data"] += 1 - print(f" 📊 Service Data ({len(adv.service_data)} entries):") + print(f" Service Data ({len(adv.service_data)} entries):") for uuid_str, payload in adv.service_data.items(): try: uuid = BluetoothUUID(uuid_str) @@ -177,9 +177,9 @@ def detection_callback(device: object, advertisement_data: BleakAdvertisementDat if results: successful = [r for r in results if r.is_success] if successful: - print(f" 🔍 Auto-Parsed ({len(successful)} results):") + print(f" Auto-Parsed ({len(successful)} results):") for result in successful: - print(f" ✅ {result.data}") + print(f" {result.data}") parsed_results.append((address, name, result.data)) stats["parsed_payloads"] += 1 @@ -195,7 +195,7 @@ def detection_callback(device: object, advertisement_data: BleakAdvertisementDat print(f"Auto-parsed payloads: {stats['parsed_payloads']}") if parsed_results: - print("\n🎯 Successfully Parsed Payloads:") + print("\nSuccessfully Parsed Payloads:") for address, name, data in parsed_results: display = name or address print(f" • {display}: {data}") diff --git a/examples/test_scanning_features.py b/examples/test_scanning_features.py index 681885cf..d39ac5f5 100644 --- a/examples/test_scanning_features.py +++ b/examples/test_scanning_features.py @@ -162,10 +162,10 @@ async def test_find_by_address( elapsed = (datetime.now() - start).total_seconds() if device: - print(f"\n✅ Found device in {elapsed:.2f}s:") + print(f"\nFound device in {elapsed:.2f}s:") print(format_device(device)) else: - print(f"\n❌ Device not found within {timeout}s") + print(f"\nDevice not found within {timeout}s") return device @@ -187,10 +187,10 @@ async def test_find_by_name( elapsed = (datetime.now() - start).total_seconds() if device: - print(f"\n✅ Found device in {elapsed:.2f}s:") + print(f"\nFound device in {elapsed:.2f}s:") print(format_device(device)) else: - print(f"\n❌ Device not found within {timeout}s") + print(f"\nDevice not found within {timeout}s") return device @@ -216,10 +216,10 @@ def has_apple_data(device: ScannedDevice) -> bool: elapsed = (datetime.now() - start).total_seconds() if device: - print(f"\n✅ Found Apple device in {elapsed:.2f}s:") + print(f"\nFound Apple device in {elapsed:.2f}s:") print(format_device(device)) else: - print(f"\n❌ No Apple device found within {timeout}s") + print(f"\nNo Apple device found within {timeout}s") return device @@ -304,7 +304,7 @@ async def test_passive_scan( timeout=timeout, scanning_mode="passive", ) - print(f"\n✅ Found {len(devices)} devices in passive mode") + print(f"\nFound {len(devices)} devices in passive mode") for device in devices[:_MAX_SERVICES_TO_DISPLAY]: print(f" - {device.name or device.address}") @@ -313,7 +313,7 @@ async def test_passive_scan( return devices except Exception as e: # Catch any unexpected errors - print(f"\n⚠️ Passive scanning error: {e}") + print(f"\nPassive scanning error: {e}") return [] @@ -335,7 +335,7 @@ async def run_all_tests( # Check if the manager supports scanning if not manager.supports_scanning: - print(f"\n❌ {manager.__name__} does not support scanning") + print(f"\n{manager.__name__} does not support scanning") return # Test 1: Basic scan @@ -388,7 +388,7 @@ def main() -> None: # Get available connection managers available_managers = list(AVAILABLE_LIBRARIES.keys()) if not available_managers: - print("\n❌ No BLE libraries available. Install with: pip install .[examples]") + print("\nNo BLE libraries available. Install with: pip install .[examples]") sys.exit(1) default_manager = available_managers[0] @@ -443,7 +443,7 @@ def main() -> None: try: manager_class = get_connection_manager_class(args.connection_manager) except (ValueError, ImportError) as e: - print(f"\n❌ Failed to load connection manager: {e}") + print(f"\nFailed to load connection manager: {e}") sys.exit(1) try: diff --git a/examples/utils/argparse_utils.py b/examples/utils/argparse_utils.py index 85cbe968..691c9921 100644 --- a/examples/utils/argparse_utils.py +++ b/examples/utils/argparse_utils.py @@ -150,7 +150,7 @@ def validate_and_setup(args: argparse.Namespace) -> CommonArgs: """ # Check if any BLE libraries are available if not AVAILABLE_LIBRARIES: - print("❌ No BLE libraries available!") + print("No BLE libraries available!") print("Install example dependencies with: pip install .[examples]") print("\nAvailable libraries:") show_library_availability() @@ -164,7 +164,7 @@ def validate_and_setup(args: argparse.Namespace) -> CommonArgs: # Validate the connection manager is available if manager_name not in AVAILABLE_LIBRARIES: - print(f"❌ Connection manager '{manager_name}' not available!") + print(f"Connection manager '{manager_name}' not available!") print("Available managers:") for name, info in AVAILABLE_LIBRARIES.items(): print(f" - {name}: {info['description']}") diff --git a/examples/utils/connection_helpers.py b/examples/utils/connection_helpers.py index d4b7b9ae..302e0510 100644 --- a/examples/utils/connection_helpers.py +++ b/examples/utils/connection_helpers.py @@ -50,7 +50,7 @@ async def read_characteristics_with_manager( try: await connection_manager.connect() - print("✅ Connected, reading characteristics...") + print("Connected, reading characteristics...") # If no UUIDs were specified, discover services and pick readable # characteristics. The exact shape of ``get_services()`` can vary @@ -95,7 +95,7 @@ async def read_characteristics_with_manager( print(f" {uuid_key}: {exc}") await connection_manager.disconnect() - print("✅ Disconnected") + print("Disconnected") except Exception as exc: # pylint: disable=broad-exception-caught print(f"Connection failed: {exc}") diff --git a/examples/utils/data_parsing.py b/examples/utils/data_parsing.py index c0cf8fb3..d3d77a2b 100644 --- a/examples/utils/data_parsing.py +++ b/examples/utils/data_parsing.py @@ -33,7 +33,7 @@ async def parse_and_display_results( """ translator = BluetoothSIGTranslator() - print(f"\n📊 {library_name} Results with SIG Parsing:") + print(f"\n{library_name} Results with SIG Parsing:") print("=" * 50) for uuid_short, read_result in raw_results.items(): @@ -45,16 +45,16 @@ async def parse_and_display_results( name = char_info.name if char_info else uuid_short unit = char_info.unit if char_info else "" unit_str = f" {unit}" if unit else "" - print(f" ✅ {name}: {value}{unit_str}") + print(f" {name}: {value}{unit_str}") except SpecialValueDetectedError as e: read_result.parsed = e.special_value - print(f" ⚠️ {e.name}: {e.special_value.meaning}") + print(f" {e.name}: {e.special_value.meaning}") except CharacteristicParseError as e: read_result.error = str(e) - print(f" ❌ {uuid_short}: Parse failed - {e}") + print(f" {uuid_short}: Parse failed - {e}") except Exception as exc: # pylint: disable=broad-exception-caught read_result.error = str(exc) - print(f" 💥 {uuid_short}: Exception - {exc}") + print(f" {uuid_short}: Exception - {exc}") return raw_results @@ -80,7 +80,7 @@ def parse_and_display_results_sync( # mutation on ReadResult instances. translator = BluetoothSIGTranslator() - print(f"\n📊 {library_name} Results with SIG Parsing:") + print(f"\n{library_name} Results with SIG Parsing:") print("=" * 50) for uuid_short, read_result in raw_results.items(): @@ -92,16 +92,16 @@ def parse_and_display_results_sync( name = char_info.name if char_info else uuid_short unit = char_info.unit if char_info else "" unit_str = f" {unit}" if unit else "" - print(f" ✅ {name}: {value}{unit_str}") + print(f" {name}: {value}{unit_str}") except SpecialValueDetectedError as e: read_result.parsed = e.special_value - print(f" ⚠️ {e.name}: {e.special_value.meaning}") + print(f" {e.name}: {e.special_value.meaning}") except CharacteristicParseError as e: read_result.error = str(e) - print(f" ❌ {uuid_short}: Parse failed - {e}") + print(f" {uuid_short}: Parse failed - {e}") except Exception as exc: # pylint: disable=broad-exception-caught read_result.error = str(exc) - print(f" 💥 {uuid_short}: Exception - {exc}") + print(f" {uuid_short}: Exception - {exc}") return raw_results @@ -125,7 +125,7 @@ def display_parsed_results( :func:`parse_and_display_results` or a mapping from :class:`BluetoothUUID` to parsed values. """ - print(f"\n🔎 {title}") + print(f"\n{title}") print("=" * 50) # Normalize BluetoothUUID keys to short-string form if necessary @@ -138,16 +138,16 @@ def display_parsed_results( # Structured dict output matching parse_and_display_results if isinstance(result, dict): if result.get("error"): - print(f" ❌ {uuid_short}: {result.get('error')}") + print(f" {uuid_short}: {result.get('error')}") else: name = result.get("name", uuid_short) value_val = result.get("value") unit_val = result.get("unit") unit_str = f" {unit_val}" if unit_val else "" - print(f" ✅ {name}: {value_val}{unit_str}") + print(f" {name}: {value_val}{unit_str}") else: # Raw parsed value - display directly - print(f" ✅ {uuid_short}: {result}") + print(f" {uuid_short}: {result}") def _parse_characteristic_data( @@ -175,23 +175,23 @@ def _parse_characteristic_data( "raw_data": raw_data, } except CharacteristicParseError as e: - print(f" ❌ Parse failed: {e}") + print(f" Parse failed: {e}") return None except Exception as e: # pylint: disable=broad-exception-caught - print(f" 💥 Parsing exception: {e}") + print(f" Parsing exception: {e}") return None def _perform_sig_analysis(results: dict[str, dict[str, Any]], _translator: BluetoothSIGTranslator) -> None: """Perform comprehensive SIG analysis on collected results.""" - print("\n🧬 Bluetooth SIG Analysis:") + print("\nBluetooth SIG Analysis:") print("=" * 50) if not results: - print(" ℹ️ No valid data to analyze") + print(" No valid data to analyze") return - print(f" 📊 Analyzed {len(results)} characteristics") + print(f" Analyzed {len(results)} characteristics") # Group by service (first two characters of UUID) services: dict[str, list[str]] = {} @@ -201,7 +201,7 @@ def _perform_sig_analysis(results: dict[str, dict[str, Any]], _translator: Bluet services[service_prefix] = [] services[service_prefix].append(uuid) - print(f" 🏷️ Found {len(services)} service types:") + print(f" Found {len(services)} service types:") for service_prefix, char_list in services.items(): print(f" {service_prefix}xx: {len(char_list)} characteristics") @@ -212,4 +212,4 @@ def _perform_sig_analysis(results: dict[str, dict[str, Any]], _translator: Bluet data_types.add(type(char_data["value"]).__name__) if data_types: - print(f" 📈 Data types: {', '.join(sorted(data_types))}") + print(f" Data types: {', '.join(sorted(data_types))}") diff --git a/examples/utils/demo_functions.py b/examples/utils/demo_functions.py index eb404dd7..e1107b9a 100644 --- a/examples/utils/demo_functions.py +++ b/examples/utils/demo_functions.py @@ -38,9 +38,9 @@ async def demo_basic_usage(address: str, connection_manager: ClientManagerProtoc device = Device(connection_manager, translator) try: - print("🔗 Connecting to device...") + print("Connecting to device...") await connection_manager.connect() - print("✅ Connected, reading characteristics...") + print("Connected, reading characteristics...") common_uuids = ["2A00", "2A19", "2A29", "2A24", "2A25", "2A26", "2A27", "2A28"] parsed_results: dict[str, Any] = {} @@ -50,14 +50,14 @@ async def demo_basic_usage(address: str, connection_manager: ClientManagerProtoc parsed = await device.read(uuid_short) if parsed is not None: parsed_results[uuid_short] = parsed - print(f" ✅ {uuid_short}: {parsed}") + print(f" {uuid_short}: {parsed}") else: print(f" • {uuid_short}: Read failed or parse failed") except Exception as e: # pylint: disable=broad-exception-caught - print(f" ❌ {uuid_short}: Error - {e}") + print(f" {uuid_short}: Error - {e}") await connection_manager.disconnect() - print("✅ Disconnected") + print("Disconnected") # Convert to BluetoothUUID-keyed mapping for the display helper normalized: dict[BluetoothUUID, Any] = {} @@ -71,7 +71,7 @@ async def demo_basic_usage(address: str, connection_manager: ClientManagerProtoc display_parsed_results(normalized, title="Basic Usage Results") except Exception as e: # pylint: disable=broad-exception-caught - print(f"❌ Basic usage demo failed: {e}") + print(f"Basic usage demo failed: {e}") print("This may be due to device being unavailable or connection issues.") @@ -91,13 +91,13 @@ async def demo_service_discovery(address: str, connection_manager: ClientManager device = Device(connection_manager, translator) try: - print("🔍 Discovering services...") + print("Discovering services...") print(" Connecting to device...") await connection_manager.connect() - print(" ✅ Connected, discovering services...") + print(" Connected, discovering services...") services = await device.discover_services() - print(f"✅ Found {len(services)} services:") + print(f"Found {len(services)} services:") total_chars = 0 parsed_chars = 0 @@ -106,9 +106,9 @@ async def demo_service_discovery(address: str, connection_manager: ClientManager for service_uuid, service_info in services.items(): service_name = translator.get_service_info_by_uuid(service_uuid) if service_name: - print(f" 📋 {service_uuid}: {service_name.name}") + print(f" {service_uuid}: {service_name.name}") else: - print(f" 📋 {service_uuid}: Unknown service") + print(f" {service_uuid}: Unknown service") characteristics = service_info.characteristics if characteristics: @@ -122,7 +122,7 @@ async def demo_service_discovery(address: str, connection_manager: ClientManager if parsed is not None: parsed_chars += 1 parsed_results[short_uuid] = parsed - print(f" ✅ {short_uuid}: {parsed}") + print(f" {short_uuid}: {parsed}") else: char_info_obj = translator.get_characteristic_info_by_uuid(short_uuid) if char_info_obj: @@ -133,15 +133,15 @@ async def demo_service_discovery(address: str, connection_manager: ClientManager short_uuid = BluetoothUUID(char_uuid).short_form char_info_obj = translator.get_characteristic_info_by_uuid(short_uuid) if char_info_obj: - print(f" ❌ {short_uuid}: {char_info_obj.name} (error: {e})") + print(f" {short_uuid}: {char_info_obj.name} (error: {e})") else: - print(f" ❌ {short_uuid}: Unknown characteristic (error: {e})") + print(f" {short_uuid}: Unknown characteristic (error: {e})") await connection_manager.disconnect() - print(" ✅ Disconnected") + print(" Disconnected") - print(f"\n📊 Device summary: {device}") - print(f"📊 Total characteristics: {total_chars}, Successfully parsed: {parsed_chars}") + print(f"\nDevice summary: {device}") + print(f"Total characteristics: {total_chars}, Successfully parsed: {parsed_chars}") normalized: dict[BluetoothUUID, Any] = {} for k, v in parsed_results.items(): @@ -153,7 +153,7 @@ async def demo_service_discovery(address: str, connection_manager: ClientManager display_parsed_results(normalized, title="Service Discovery Results") except Exception as e: # pylint: disable=broad-exception-caught - print(f"❌ Service discovery failed: {e}") + print(f"Service discovery failed: {e}") print("This may be due to device being unavailable or connection issues.") @@ -216,27 +216,27 @@ async def demo_library_comparison(address: str, target_uuids: list[str] | None = comparison_results = LibraryComparisonResult(status="ok", data=None) - print("🔍 Comparing BLE Libraries (bleak-retry and simpleble only)") + print("Comparing BLE Libraries (bleak-retry and simpleble only)") print("=" * 60) # Test Bleak-retry if bleak_retry_available: try: - print("\n🔁 Running Bleak-retry analysis...") + print("\nRunning Bleak-retry analysis...") if target_uuids is None: target_uuids = ["2A19", "2A00"] # Default UUIDs for demo bleak_results = await comprehensive_device_analysis_bleak_retry(address, target_uuids) comparison_results.data = bleak_results except Exception as e: # pylint: disable=broad-exception-caught - print(f"❌ Bleak-retry analysis failed: {e}") + print(f"Bleak-retry analysis failed: {e}") await asyncio.sleep(1) # Test SimplePyBLE if simplepyble_available and simplepyble_module: try: - print("\n🔁 Running SimplePyBLE analysis...") + print("\nRunning SimplePyBLE analysis...") simple_results = await asyncio.to_thread( comprehensive_device_analysis_simpleble, address, @@ -248,6 +248,6 @@ async def demo_library_comparison(address: str, target_uuids: list[str] | None = # Merge results if both succeeded comparison_results.data.update(simple_results) # type: ignore[arg-type] except Exception as e: # pylint: disable=broad-exception-caught - print(f"❌ SimplePyBLE analysis failed: {e}") + print(f"SimplePyBLE analysis failed: {e}") return comparison_results diff --git a/examples/utils/device_scanning.py b/examples/utils/device_scanning.py index a2611e3f..01ae6a7b 100644 --- a/examples/utils/device_scanning.py +++ b/examples/utils/device_scanning.py @@ -46,7 +46,7 @@ def scan_with_bluepy(timeout: float = 10.0) -> list[tuple[str, str, int | None]] try: scanner = Scanner() - print(f"🔍 Scanning for BLE devices with BluePy (timeout: {timeout}s)...") + print(f"Scanning for BLE devices with BluePy (timeout: {timeout}s)...") devices = scanner.scan(int(timeout)) # type: ignore[misc] results: list[tuple[str, str, int | None]] = [] @@ -64,7 +64,7 @@ def scan_with_bluepy(timeout: float = 10.0) -> list[tuple[str, str, int | None]] results.append((str(name), str(address), rssi_val)) # type: ignore[misc] - print(f"✅ Found {len(results)} devices") + print(f"Found {len(results)} devices") return results except Exception as e: diff --git a/examples/utils/library_detection.py b/examples/utils/library_detection.py index 1b24fe4f..bbade8cc 100644 --- a/examples/utils/library_detection.py +++ b/examples/utils/library_detection.py @@ -123,20 +123,20 @@ def show_library_availability() -> bool: Returns True if one or more libraries are available, False otherwise. """ - print("📚 BLE Library Availability Check") + print("BLE Library Availability Check") print("=" * 40) if not AVAILABLE_LIBRARIES: - print("❌ No BLE libraries found. Install one or more:") + print("No BLE libraries found. Install one or more:") print(" pip install .[examples] # Install example back-ends and demo utilities") return False - print("✅ Available BLE libraries:") + print("Available BLE libraries:") for lib_name, info in AVAILABLE_LIBRARIES.items(): async_str = "Async" if info["async"] else "Sync" print(f" {lib_name}: {info['description']} ({async_str})") - print(f"\n🎯 Will demonstrate bluetooth_sig parsing with {len(AVAILABLE_LIBRARIES)} libraries") + print(f"\nWill demonstrate bluetooth_sig parsing with {len(AVAILABLE_LIBRARIES)} libraries") return True diff --git a/examples/utils/notification_utils.py b/examples/utils/notification_utils.py index b61afcca..bc7fdeec 100644 --- a/examples/utils/notification_utils.py +++ b/examples/utils/notification_utils.py @@ -36,9 +36,9 @@ def _cb(char_uuid: str, data: bytes) -> None: try: await connection_manager.start_notify(uuid_obj, _cb) - print(" 🔔 Notifications enabled") + print(" Notifications enabled") await asyncio.sleep(duration) await connection_manager.stop_notify(uuid_obj) - print("\n✅ Notification session completed") + print("\nNotification session completed") finally: await connection_manager.disconnect() diff --git a/examples/with_bleak_retry.py b/examples/with_bleak_retry.py index ec666f92..48decef2 100644 --- a/examples/with_bleak_retry.py +++ b/examples/with_bleak_retry.py @@ -30,7 +30,7 @@ async def robust_device_reading(address: str, backend: str = "bleak-retry", retr """ if backend != "bleak-retry": - print(f"❌ Only bleak-retry backend is supported in this example. Got: {backend}") + print(f"Only bleak-retry backend is supported in this example. Got: {backend}") return {} from examples.connection_managers.bleak_retry import BleakRetryClientManager # type: ignore @@ -45,7 +45,7 @@ async def robust_device_reading(address: str, backend: str = "bleak-retry", retr await device.connect() # First discover what characteristics are actually available - print("🔍 Discovering available characteristics...") + print("Discovering available characteristics...") try: services = await device.discover_services() available_uuids = [] @@ -54,13 +54,13 @@ async def robust_device_reading(address: str, backend: str = "bleak-retry", retr # Convert full UUID to short form for comparison short_uuid = BluetoothUUID(char_uuid).short_form available_uuids.append(short_uuid) - print(f"✅ Found {len(available_uuids)} readable characteristics") + print(f"Found {len(available_uuids)} readable characteristics") # Filter target UUIDs to only those available on this device target_uuids = [uuid for uuid in target_uuids if uuid.upper() in available_uuids] - print(f"📋 Will read {len(target_uuids)} matching characteristics: {target_uuids}") + print(f"Will read {len(target_uuids)} matching characteristics: {target_uuids}") except Exception as e: - print(f"⚠️ Service discovery failed, trying predefined characteristics: {e}") + print(f"Service discovery failed, trying predefined characteristics: {e}") results: dict[str, Any] = {} for uuid in target_uuids: @@ -68,31 +68,17 @@ async def robust_device_reading(address: str, backend: str = "bleak-retry", retr parsed = await device.read(uuid) if parsed is not None: results[uuid] = parsed - print(f"✅ {uuid}: {parsed}") + print(f"{uuid}: {parsed}") else: - print(f"⚠️ {uuid}: Parse failed or no data") + print(f"{uuid}: Parse failed or no data") except Exception as e: - print(f"❌ {uuid}: Read failed - {e}") + print(f"{uuid}: Read failed - {e}") await device.disconnect() - print(f"📊 Device results: {results}") + print(f"Device results: {results}") return results -async def robust_service_discovery(address: str) -> dict[str, Any]: - """Discover all services and characteristics with robust connection. - - Args: - address: BLE device address - - Returns: - Dictionary of discovered services and characteristics - - """ - print(f"🔍 Service discovery with {address} - Feature not yet implemented") - return {} - - async def perform_single_reading(address: str, translator: BluetoothSIGTranslator, target_uuids: list[str]) -> bool: """Perform a single reading cycle and return success status.""" try: @@ -104,7 +90,7 @@ async def perform_single_reading(address: str, translator: BluetoothSIGTranslato raw_results = await read_characteristics_with_manager(manager, target_uuids) if raw_results: - print(f"📊 Reading at {time.strftime('%H:%M:%S')}:") + print(f"Reading at {time.strftime('%H:%M:%S')}:") for uuid_short, read_result in raw_results.items(): try: result = translator.parse_characteristic(uuid_short, read_result.raw_data) @@ -114,7 +100,7 @@ async def perform_single_reading(address: str, translator: BluetoothSIGTranslato return True except Exception as e: # pylint: disable=broad-exception-caught - print(f"⚠️ Reading failed: {e}") + print(f"Reading failed: {e}") return False @@ -127,8 +113,8 @@ async def continuous_monitoring(address: str, duration: int = 60) -> None: # py duration: Monitoring duration in seconds """ - print(f"📊 Starting continuous monitoring of {address} for {duration}s...") - print("🔄 Auto-reconnection enabled") + print(f"Starting continuous monitoring of {address} for {duration}s...") + print("Auto-reconnection enabled") translator = BluetoothSIGTranslator() target_uuids = ["2A19", "2A6E", "2A6F"] # Battery, Temperature, Humidity @@ -145,18 +131,7 @@ async def continuous_monitoring(address: str, duration: int = 60) -> None: # py await asyncio.sleep(5) except KeyboardInterrupt: - print(f"🛑 Monitoring stopped by user after {reading_count} readings") - - -async def notification_monitoring(address: str, duration: int = 60) -> None: - """Monitor device notifications with robust connection. - - Args: - address: BLE device address - duration: Monitoring duration in seconds - - """ - print(f"🔔 Notification monitoring with {address} for {duration}s - Feature not yet implemented") + print(f"Monitoring stopped by user after {reading_count} readings") async def main() -> None: @@ -165,8 +140,6 @@ async def main() -> None: parser.add_argument("--address", "-a", help="BLE device address") parser.add_argument("--scan", "-s", action="store_true", help="Scan for devices") parser.add_argument("--monitor", "-m", action="store_true", help="Continuous monitoring") - parser.add_argument("--notifications", "-n", action="store_true", help="Monitor notifications") - parser.add_argument("--discover", "-d", action="store_true", help="Service discovery") parser.add_argument("--duration", "-t", type=int, default=60, help="Duration for monitoring") args = parser.parse_args() @@ -189,10 +162,6 @@ async def main() -> None: if args.address: if args.monitor: await continuous_monitoring(args.address, args.duration) - elif args.notifications: - await notification_monitoring(args.address, args.duration) - elif args.discover: - await robust_service_discovery(args.address) else: await robust_device_reading(args.address) else: diff --git a/examples/with_bluepy.py b/examples/with_bluepy.py index fd371fec..b185ed43 100644 --- a/examples/with_bluepy.py +++ b/examples/with_bluepy.py @@ -60,11 +60,11 @@ def scan_for_devices(timeout: float = 10.0) -> list[DeviceInfo]: return devices except ImportError as e: - print(f"❌ BluePy not available: {e}") + print(f"BluePy not available: {e}") print("Install with: pip install bluepy") return [] except Exception as e: - print(f"❌ Scan failed: {e}") + print(f"Scan failed: {e}") return [] @@ -82,7 +82,7 @@ async def demonstrate_bluepy_device_reading(address: str) -> dict[str, Any]: from examples.connection_managers.bluepy import BluePyClientManager from examples.utils.connection_helpers import read_characteristics_with_manager - print(f"🔍 Connecting to {address} using BluePy...") + print(f"Connecting to {address} using BluePy...") # Create BluePy connection manager connection_manager = BluePyClientManager(address) @@ -93,7 +93,7 @@ async def demonstrate_bluepy_device_reading(address: str) -> dict[str, Any]: # Parse and display results parsed_results_map = parse_results(raw_results) - print(f"\n📊 Successfully read {len(parsed_results_map)} characteristics") + print(f"\nSuccessfully read {len(parsed_results_map)} characteristics") # Extract parsed values from ReadResult final_results: dict[str, Any] = {} @@ -104,11 +104,11 @@ async def demonstrate_bluepy_device_reading(address: str) -> dict[str, Any]: return final_results except ImportError as e: - print(f"❌ BluePy not available: {e}") + print(f"BluePy not available: {e}") print("Install with: pip install bluepy") return {} except Exception as e: - print(f"❌ BluePy operation failed: {e}") + print(f"BluePy operation failed: {e}") return {} @@ -122,7 +122,7 @@ async def demonstrate_bluepy_service_discovery(address: str) -> None: try: from examples.connection_managers.bluepy import BluePyClientManager - print(f"🔍 Discovering services on {address} using BluePy...") + print(f"Discovering services on {address} using BluePy...") connection_manager = BluePyClientManager(address) await connection_manager.connect() @@ -130,7 +130,7 @@ async def demonstrate_bluepy_service_discovery(address: str) -> None: # Get services using the connection manager services = await connection_manager.get_services() - print(f"✅ Found {len(services)} services:") + print(f"Found {len(services)} services:") for i, service in enumerate(services, 1): service_name = getattr(service.service, "name", "Unknown Service") service_uuid = getattr(service.service, "uuid", "Unknown UUID") @@ -139,10 +139,10 @@ async def demonstrate_bluepy_service_discovery(address: str) -> None: await connection_manager.disconnect() except ImportError as e: - print(f"❌ BluePy not available: {e}") + print(f"BluePy not available: {e}") print("Install with: pip install bluepy") except Exception as e: - print(f"❌ Service discovery failed: {e}") + print(f"Service discovery failed: {e}") def display_device_list(devices: list[DeviceInfo]) -> None: @@ -153,10 +153,10 @@ def display_device_list(devices: list[DeviceInfo]) -> None: """ if not devices: - print("❌ No devices found") + print("No devices found") return - print(f"\n📱 Found {len(devices)} BLE devices:") + print(f"\nFound {len(devices)} BLE devices:") print("-" * 60) for i, device in enumerate(devices, 1): rssi_str = f"{device.rssi} dBm" if device.rssi is not None else "Unknown" @@ -181,25 +181,25 @@ async def main() -> None: del bluepy # Clean up the import check except ImportError: - print("❌ BluePy not available!") + print("BluePy not available!") print("Install with: pip install bluepy") print("Note: BluePy only works on Linux") return - print("🔵 BluePy BLE Integration Example") + print("BluePy BLE Integration Example") print("=" * 40) if args.scan: - print(f"🔍 Scanning for devices (timeout: {args.timeout}s)...") + print(f"Scanning for devices (timeout: {args.timeout}s)...") devices = scan_for_devices(args.timeout) display_device_list(devices) if devices: - print("\n💡 To connect to a device, run:") + print("\nTo connect to a device, run:") print(f" python {__file__} --address
") elif args.address: - print(f"🔗 Connecting to device: {args.address}") + print(f"Connecting to device: {args.address}") # First try service discovery await demonstrate_bluepy_service_discovery(args.address) @@ -212,6 +212,6 @@ async def main() -> None: try: asyncio.run(main()) except KeyboardInterrupt: - print("\n⚠️ Interrupted by user") + print("\nInterrupted by user") except Exception as e: - print(f"❌ Error: {e}") + print(f"Error: {e}") diff --git a/examples/with_simpleble.py b/examples/with_simpleble.py index cf046a8a..7cde7cad 100644 --- a/examples/with_simpleble.py +++ b/examples/with_simpleble.py @@ -55,14 +55,14 @@ def scan_for_devices_simpleble(timeout: float = 10.0) -> list[DeviceInfo]: """ from .utils.simpleble_integration import scan_devices_simpleble - print(f"🔍 Scanning for BLE devices ({timeout}s)...") + print(f"Scanning for BLE devices ({timeout}s)...") devices = scan_devices_simpleble(simplepyble, timeout) if not devices: - print("❌ No BLE adapters found or scan failed") + print("No BLE adapters found or scan failed") return [] - print(f"\n📡 Found {len(devices)} devices:") + print(f"\nFound {len(devices)} devices:") for index, device in enumerate(devices, 1): name = device.name or "Unknown" address = device.address @@ -82,7 +82,7 @@ def read_and_parse_with_simpleble( if target_uuids is None: # Use comprehensive device analysis for real device discovery - print("🔍 Using comprehensive device analysis...") + print("Using comprehensive device analysis...") return comprehensive_device_analysis_simpleble(address, simplepyble) async def _collect() -> dict[str, ReadResult]: @@ -94,7 +94,7 @@ async def _collect() -> dict[str, ReadResult]: raw_results: dict[str, ReadResult] = asyncio.run(_collect()) parsed_results: dict[str, ReadResult] = parse_results(raw_results) - print(f"\n✅ Successfully read {len(parsed_results)} characteristics") + print(f"\nSuccessfully read {len(parsed_results)} characteristics") return parsed_results diff --git a/src/bluetooth_sig/advertising/pdu_parser.py b/src/bluetooth_sig/advertising/pdu_parser.py index 5e2681bb..80b40126 100644 --- a/src/bluetooth_sig/advertising/pdu_parser.py +++ b/src/bluetooth_sig/advertising/pdu_parser.py @@ -25,6 +25,7 @@ ConnectionIntervalRange, ExtendedAdvertisingData, ) +from bluetooth_sig.types.advertising.channel_map_update import ChannelMapUpdateIndication from bluetooth_sig.types.advertising.extended import ( AdvertisingDataInfo, AuxiliaryPointer, @@ -33,12 +34,9 @@ PHYType, SyncInfo, ) -from bluetooth_sig.types.advertising.channel_map_update import ChannelMapUpdateIndication from bluetooth_sig.types.advertising.features import LEFeatures from bluetooth_sig.types.advertising.flags import BLEAdvertisingFlags from bluetooth_sig.types.advertising.indoor_positioning import IndoorPositioningData -from bluetooth_sig.types.advertising.three_d_information import ThreeDInformationData -from bluetooth_sig.types.advertising.transport_discovery import TransportDiscoveryData from bluetooth_sig.types.advertising.pdu import ( BLEAdvertisingPDU, BLEExtendedHeader, @@ -48,6 +46,8 @@ PDUType, ) from bluetooth_sig.types.advertising.result import AdvertisingData +from bluetooth_sig.types.advertising.three_d_information import ThreeDInformationData +from bluetooth_sig.types.advertising.transport_discovery import TransportDiscoveryData from bluetooth_sig.types.appearance import AppearanceData from bluetooth_sig.types.mesh import ( MeshBeaconType, diff --git a/src/bluetooth_sig/core/query.py b/src/bluetooth_sig/core/query.py index e2685e57..61cbd9ef 100644 --- a/src/bluetooth_sig/core/query.py +++ b/src/bluetooth_sig/core/query.py @@ -156,7 +156,7 @@ def get_service_info_by_name(self, name: str | ServiceName) -> ServiceInfo | Non if uuid_info: return ServiceInfo(uuid=uuid_info.uuid, name=uuid_info.name, characteristics=[]) except Exception: # pylint: disable=broad-exception-caught - pass + logger.warning("Failed to look up service info for name=%s", name_str, exc_info=True) return None diff --git a/src/bluetooth_sig/device/advertising.py b/src/bluetooth_sig/device/advertising.py index d002782d..d6858827 100644 --- a/src/bluetooth_sig/device/advertising.py +++ b/src/bluetooth_sig/device/advertising.py @@ -296,9 +296,8 @@ def process_from_connection_manager( if interp.supports(base_advertising_data): interpreter_name = name break - except (AdvertisingParseError, EncryptionRequiredError, DecryptionFailedError): - # No data available on error - pass + except (AdvertisingParseError, EncryptionRequiredError, DecryptionFailedError) as exc: + logger.warning("Advertising parse/decrypt failed for process_advertisement: %s", exc) # Create enriched AdvertisementData with interpretation processed_advertisement = AdvertisementData( @@ -370,9 +369,8 @@ def parse_raw_pdu( if interp.supports(base_advertising_data): interpreter_name = name break - except (AdvertisingParseError, EncryptionRequiredError, DecryptionFailedError): - # No data available on error - pass + except (AdvertisingParseError, EncryptionRequiredError, DecryptionFailedError) as exc: + logger.warning("Advertising parse/decrypt failed for parse_raw_pdu: %s", exc) # Create AdvertisementData with interpretation advertisement = AdvertisementData( diff --git a/src/bluetooth_sig/device/peripheral_device.py b/src/bluetooth_sig/device/peripheral_device.py index a653449d..d9bdd7f8 100644 --- a/src/bluetooth_sig/device/peripheral_device.py +++ b/src/bluetooth_sig/device/peripheral_device.py @@ -56,7 +56,7 @@ def __init__( """ self.definition = definition self.characteristic = characteristic - self.last_value: Any = initial_value # noqa: ANN401 + self.last_value: Any = initial_value class PeripheralDevice: diff --git a/src/bluetooth_sig/gatt/descriptor_utils.py b/src/bluetooth_sig/gatt/descriptor_utils.py index eceb49b1..870b350b 100644 --- a/src/bluetooth_sig/gatt/descriptor_utils.py +++ b/src/bluetooth_sig/gatt/descriptor_utils.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from ..types import DescriptorData from .context import CharacteristicContext @@ -86,7 +86,7 @@ def get_presentation_format_from_context( """ descriptor_data = get_descriptor_from_context(ctx, CharacteristicPresentationFormatDescriptor) if descriptor_data and descriptor_data.value: - return descriptor_data.value # type: ignore[no-any-return] # Generic BaseDescriptor.value is Any; caller knows concrete type + return cast("CharacteristicPresentationFormatData", descriptor_data.value) return None @@ -101,7 +101,7 @@ def get_user_description_from_context(ctx: CharacteristicContext | None = None) """ descriptor_data = get_descriptor_from_context(ctx, CharacteristicUserDescriptionDescriptor) if descriptor_data and descriptor_data.value: - return descriptor_data.value.description # type: ignore[no-any-return] + return cast("str", descriptor_data.value.description) return None diff --git a/src/bluetooth_sig/gatt/descriptors/base.py b/src/bluetooth_sig/gatt/descriptors/base.py index d0a3837e..21c620c9 100644 --- a/src/bluetooth_sig/gatt/descriptors/base.py +++ b/src/bluetooth_sig/gatt/descriptors/base.py @@ -4,7 +4,7 @@ import logging from abc import ABC, abstractmethod -from typing import Any +from typing import Any, Protocol from ...types import DescriptorData, DescriptorInfo from ...types.uuid import BluetoothUUID @@ -138,8 +138,27 @@ def _parse_descriptor_value(self, data: bytes) -> Any: # noqa: ANN401 # Descri raise NotImplementedError(f"{self.__class__.__name__} must implement _parse_descriptor_value()") -class RangeDescriptorMixin: - """Mixin for descriptors that provide min/max value validation.""" +class _RangeValue(Protocol): + """Protocol for parsed descriptor values with min/max fields.""" + + @property + def min_value(self) -> int | float: ... + + @property + def max_value(self) -> int | float: ... + + +class RangeDescriptorMixin(ABC): + """Mixin for descriptors that provide min/max value validation. + + Concrete subclasses must also inherit from BaseDescriptor (which provides + ``_parse_descriptor_value``). The abstract stub below declares the + dependency so that mypy recognises it without ``type: ignore[attr-defined]``. + """ + + @abstractmethod + def _parse_descriptor_value(self, data: bytes) -> _RangeValue: + """Parse the descriptor value — implemented by BaseDescriptor.""" def get_min_value(self, data: bytes) -> int | float: """Get the minimum valid value. @@ -150,8 +169,8 @@ def get_min_value(self, data: bytes) -> int | float: Returns: Minimum valid value for the characteristic """ - parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] # Mixin: concrete subclass provides _parse_descriptor_value - return parsed.min_value # type: ignore[no-any-return] # Parsed struct has typed fields + parsed = self._parse_descriptor_value(data) + return parsed.min_value def get_max_value(self, data: bytes) -> int | float: """Get the maximum valid value. @@ -162,8 +181,8 @@ def get_max_value(self, data: bytes) -> int | float: Returns: Maximum valid value for the characteristic """ - parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] # Mixin: concrete subclass provides _parse_descriptor_value - return parsed.max_value # type: ignore[no-any-return] # Parsed struct has typed fields + parsed = self._parse_descriptor_value(data) + return parsed.max_value def is_value_in_range(self, data: bytes, value: float) -> bool: """Check if a value is within the valid range. @@ -175,7 +194,7 @@ def is_value_in_range(self, data: bytes, value: float) -> bool: Returns: True if value is within [min_value, max_value] range """ - parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] + parsed = self._parse_descriptor_value(data) min_val = parsed.min_value max_val = parsed.max_value return bool(min_val <= value <= max_val) diff --git a/src/bluetooth_sig/types/advertising/three_d_information.py b/src/bluetooth_sig/types/advertising/three_d_information.py index 7fa02b39..74f6ed88 100644 --- a/src/bluetooth_sig/types/advertising/three_d_information.py +++ b/src/bluetooth_sig/types/advertising/three_d_information.py @@ -20,9 +20,9 @@ class ThreeDInformationFlags(IntFlag): """ ASSOCIATION_NOTIFICATION = 0x01 # Bit 0: send association notification - BATTERY_LEVEL_REPORTING = 0x02 # Bit 1: device reports battery level - SEND_BATTERY_ON_STARTUP = 0x04 # Bit 2: send battery level on startup - FACTORY_TEST_MODE = 0x80 # Bit 7: factory test mode enabled + BATTERY_LEVEL_REPORTING = 0x02 # Bit 1: device reports battery level + SEND_BATTERY_ON_STARTUP = 0x04 # Bit 2: send battery level on startup + FACTORY_TEST_MODE = 0x80 # Bit 7: factory test mode enabled class ThreeDInformationData(msgspec.Struct, frozen=True, kw_only=True): diff --git a/src/bluetooth_sig/types/advertising/transport_discovery.py b/src/bluetooth_sig/types/advertising/transport_discovery.py index 5d692a18..3655f7b9 100644 --- a/src/bluetooth_sig/types/advertising/transport_discovery.py +++ b/src/bluetooth_sig/types/advertising/transport_discovery.py @@ -26,9 +26,9 @@ class TDSFlags(IntFlag): ROLE_SEEKER = 0x01 ROLE_PROVIDER = 0x02 ROLE_SEEKER_AND_PROVIDER = 0x03 - INCOMPLETE = 0x04 # Bit 2: transport data is incomplete - STATE_OFF = 0x00 # Bits 3-4 = 0b00 - STATE_ON = 0x08 # Bits 3-4 = 0b01 + INCOMPLETE = 0x04 # Bit 2: transport data is incomplete + STATE_OFF = 0x00 # Bits 3-4 = 0b00 + STATE_ON = 0x08 # Bits 3-4 = 0b01 STATE_TEMPORARILY_UNAVAILABLE = 0x10 # Bits 3-4 = 0b10 @@ -110,11 +110,13 @@ def decode(cls, data: bytes | bytearray) -> TransportDiscoveryData: transport_data = bytes(data[offset:end]) offset = end - blocks.append(TransportBlock( - organization_id=org_id, - flags=tds_flags, - transport_data=transport_data, - )) + blocks.append( + TransportBlock( + organization_id=org_id, + flags=tds_flags, + transport_data=transport_data, + ) + ) return cls(blocks=blocks) diff --git a/tests/advertising/test_channel_map_update.py b/tests/advertising/test_channel_map_update.py index d6649f4a..7844c2b8 100644 --- a/tests/advertising/test_channel_map_update.py +++ b/tests/advertising/test_channel_map_update.py @@ -40,21 +40,35 @@ def valid_test_data(self) -> list[ADTypeTestData]: """Standard decode scenarios for channel map update indication.""" return [ ADTypeTestData( - input_data=bytearray([ - 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, # all 37 channels used - 0x64, 0x00, # instant = 100 - ]), + input_data=bytearray( + [ + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x1F, # all 37 channels used + 0x64, + 0x00, # instant = 100 + ] + ), expected_value=ChannelMapUpdateIndication( - channel_map=b"\xFF\xFF\xFF\xFF\x1F", + channel_map=b"\xff\xff\xff\xff\x1f", instant=100, ), description="All channels used, instant = 100", ), ADTypeTestData( - input_data=bytearray([ - 0x00, 0x00, 0x00, 0x00, 0x00, # no channels used - 0x00, 0x00, # instant = 0 - ]), + input_data=bytearray( + [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, # no channels used + 0x00, + 0x00, # instant = 0 + ] + ), expected_value=ChannelMapUpdateIndication( channel_map=b"\x00\x00\x00\x00\x00", instant=0, @@ -62,10 +76,17 @@ def valid_test_data(self) -> list[ADTypeTestData]: description="No channels used, instant = 0", ), ADTypeTestData( - input_data=bytearray([ - 0x01, 0x00, 0x00, 0x00, 0x00, # only channel 0 used - 0xFF, 0xFF, # instant = 65535 (max uint16) - ]), + input_data=bytearray( + [ + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, # only channel 0 used + 0xFF, + 0xFF, # instant = 65535 (max uint16) + ] + ), expected_value=ChannelMapUpdateIndication( channel_map=b"\x01\x00\x00\x00\x00", instant=65535, @@ -82,15 +103,23 @@ def test_decode_valid_data(self, valid_test_data: list[ADTypeTestData]) -> None: def test_decode_extra_bytes_ignored(self) -> None: """Trailing bytes beyond the 7-byte payload are ignored.""" - data = bytearray([ - 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, - 0x0A, 0x00, - 0xDE, 0xAD, # extra - ]) + data = bytearray( + [ + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x1F, + 0x0A, + 0x00, + 0xDE, + 0xAD, # extra + ] + ) result = ChannelMapUpdateIndication.decode(data) assert result.instant == 10 - assert result.channel_map == b"\xFF\xFF\xFF\xFF\x1F" + assert result.channel_map == b"\xff\xff\xff\xff\x1f" class TestChannelMapUpdateErrors: @@ -121,7 +150,7 @@ class TestIsChannelUsed: def all_channels_indication(self) -> ChannelMapUpdateIndication: """Indication with all 37 data channels used.""" return ChannelMapUpdateIndication( - channel_map=b"\xFF\xFF\xFF\xFF\x1F", + channel_map=b"\xff\xff\xff\xff\x1f", instant=0, ) @@ -161,19 +190,19 @@ def test_single_channel_set(self) -> None: def test_channel_negative_raises(self) -> None: """Negative channel number raises ValueError.""" - indication = ChannelMapUpdateIndication(channel_map=b"\xFF\xFF\xFF\xFF\x1F", instant=0) + indication = ChannelMapUpdateIndication(channel_map=b"\xff\xff\xff\xff\x1f", instant=0) with pytest.raises(ValueError, match="Channel must be 0-36"): indication.is_channel_used(-1) def test_channel_37_raises(self) -> None: """Channel 37 (first advertising channel) is out of data channel range.""" - indication = ChannelMapUpdateIndication(channel_map=b"\xFF\xFF\xFF\xFF\x1F", instant=0) + indication = ChannelMapUpdateIndication(channel_map=b"\xff\xff\xff\xff\x1f", instant=0) with pytest.raises(ValueError, match="Channel must be 0-36"): indication.is_channel_used(37) def test_channel_255_raises(self) -> None: """Large channel number raises ValueError.""" - indication = ChannelMapUpdateIndication(channel_map=b"\xFF\xFF\xFF\xFF\x1F", instant=0) + indication = ChannelMapUpdateIndication(channel_map=b"\xff\xff\xff\xff\x1f", instant=0) with pytest.raises(ValueError, match="Channel must be 0-36"): indication.is_channel_used(255) diff --git a/tests/advertising/test_indoor_positioning.py b/tests/advertising/test_indoor_positioning.py index 96e0414f..e18bc62d 100644 --- a/tests/advertising/test_indoor_positioning.py +++ b/tests/advertising/test_indoor_positioning.py @@ -48,12 +48,20 @@ def valid_test_data(self) -> list[ADTypeTestData]: description="Config-only, no optional fields (WGS84 mode)", ), ADTypeTestData( - input_data=bytearray([ - 0x06, # LATITUDE_PRESENT | LONGITUDE_PRESENT - 0x40, 0x42, 0x0F, 0x00, # latitude = 1_000_000 (little-endian) - 0x80, 0x84, 0x1E, 0x00, # longitude = 2_000_000 - 0xAA, # uncertainty - ]), + input_data=bytearray( + [ + 0x06, # LATITUDE_PRESENT | LONGITUDE_PRESENT + 0x40, + 0x42, + 0x0F, + 0x00, # latitude = 1_000_000 (little-endian) + 0x80, + 0x84, + 0x1E, + 0x00, # longitude = 2_000_000 + 0xAA, # uncertainty + ] + ), expected_value=IndoorPositioningData( config=IndoorPositioningConfig.LATITUDE_PRESENT | IndoorPositioningConfig.LONGITUDE_PRESENT, is_local_coordinates=False, @@ -64,12 +72,16 @@ def valid_test_data(self) -> list[ADTypeTestData]: description="WGS84 lat+lon with uncertainty", ), ADTypeTestData( - input_data=bytearray([ - 0x19, # COORDINATE_SYSTEM_LOCAL | LOCAL_NORTH_PRESENT | LOCAL_EAST_PRESENT - 0xE8, 0x03, # local_north = 1000 (0.01 m units) - 0xD0, 0x07, # local_east = 2000 - 0x55, # uncertainty - ]), + input_data=bytearray( + [ + 0x19, # COORDINATE_SYSTEM_LOCAL | LOCAL_NORTH_PRESENT | LOCAL_EAST_PRESENT + 0xE8, + 0x03, # local_north = 1000 (0.01 m units) + 0xD0, + 0x07, # local_east = 2000 + 0x55, # uncertainty + ] + ), expected_value=IndoorPositioningData( config=( IndoorPositioningConfig.COORDINATE_SYSTEM_LOCAL @@ -84,15 +96,24 @@ def valid_test_data(self) -> list[ADTypeTestData]: description="Local coordinate system with north+east and uncertainty", ), ADTypeTestData( - input_data=bytearray([ - 0xE6, # LAT | LON | TX_POWER | FLOOR | ALTITUDE (WGS84) - 0x60, 0x79, 0xFE, 0xFF, # latitude = -100_000 (signed) - 0xC0, 0xF2, 0xFC, 0xFF, # longitude = -200_000 (signed) - 0xEC, # tx_power = -20 dBm (signed) - 0x14, # floor_number = 20 (offset -20 → floor 0) - 0x10, 0x27, # altitude = 10000 (0.01 m units) - 0x42, # uncertainty - ]), + input_data=bytearray( + [ + 0xE6, # LAT | LON | TX_POWER | FLOOR | ALTITUDE (WGS84) + 0x60, + 0x79, + 0xFE, + 0xFF, # latitude = -100_000 (signed) + 0xC0, + 0xF2, + 0xFC, + 0xFF, # longitude = -200_000 (signed) + 0xEC, # tx_power = -20 dBm (signed) + 0x14, # floor_number = 20 (offset -20 → floor 0) + 0x10, + 0x27, # altitude = 10000 (0.01 m units) + 0x42, # uncertainty + ] + ), expected_value=IndoorPositioningData( config=IndoorPositioningConfig(0xE6), is_local_coordinates=False, @@ -115,11 +136,16 @@ def test_decode_valid_data(self, valid_test_data: list[ADTypeTestData]) -> None: def test_decode_wgs84_latitude_only(self) -> None: """Decode WGS84 payload with only latitude present (no longitude).""" - data = bytearray([ - 0x02, # LATITUDE_PRESENT only - 0x00, 0xE1, 0xF5, 0x05, # latitude = 100_000_000 - 0x80, # uncertainty - ]) + data = bytearray( + [ + 0x02, # LATITUDE_PRESENT only + 0x00, + 0xE1, + 0xF5, + 0x05, # latitude = 100_000_000 + 0x80, # uncertainty + ] + ) result = IndoorPositioningData.decode(data) assert result.is_local_coordinates is False @@ -129,11 +155,14 @@ def test_decode_wgs84_latitude_only(self) -> None: def test_decode_local_north_only(self) -> None: """Decode local coordinate payload with only north present.""" - data = bytearray([ - 0x09, # COORDINATE_SYSTEM_LOCAL | LOCAL_NORTH_PRESENT - 0x01, 0x00, # local_north = 1 - 0x00, # uncertainty - ]) + data = bytearray( + [ + 0x09, # COORDINATE_SYSTEM_LOCAL | LOCAL_NORTH_PRESENT + 0x01, + 0x00, # local_north = 1 + 0x00, # uncertainty + ] + ) result = IndoorPositioningData.decode(data) assert result.is_local_coordinates is True @@ -186,10 +215,15 @@ def test_decode_uncertainty_absent_when_data_exhausted(self) -> None: Latitude present → uncertainty expected, but payload is exactly 4 bytes for the coordinate with no trailing uncertainty byte. """ - data = bytearray([ - 0x02, # LATITUDE_PRESENT - 0x40, 0x42, 0x0F, 0x00, # latitude = 1_000_000 - ]) + data = bytearray( + [ + 0x02, # LATITUDE_PRESENT + 0x40, + 0x42, + 0x0F, + 0x00, # latitude = 1_000_000 + ] + ) result = IndoorPositioningData.decode(data) assert result.latitude == 1_000_000 @@ -212,11 +246,16 @@ def test_decode_truncated_latitude_raises(self) -> None: def test_decode_truncated_longitude_raises(self) -> None: """Longitude flag set but no bytes follow latitude.""" - data = bytearray([ - 0x06, # LAT + LON present - 0x40, 0x42, 0x0F, 0x00, # latitude OK - 0x01, # only 1 byte for longitude (need 4) - ]) + data = bytearray( + [ + 0x06, # LAT + LON present + 0x40, + 0x42, + 0x0F, + 0x00, # latitude OK + 0x01, # only 1 byte for longitude (need 4) + ] + ) with pytest.raises(InsufficientDataError): IndoorPositioningData.decode(data) diff --git a/tests/advertising/test_three_d_information.py b/tests/advertising/test_three_d_information.py index f52be12e..9618c0cc 100644 --- a/tests/advertising/test_three_d_information.py +++ b/tests/advertising/test_three_d_information.py @@ -85,8 +85,7 @@ def test_decode_extra_bytes_ignored(self) -> None: result = ThreeDInformationData.decode(data) expected_flags = ( - ThreeDInformationFlags.ASSOCIATION_NOTIFICATION - | ThreeDInformationFlags.BATTERY_LEVEL_REPORTING + ThreeDInformationFlags.ASSOCIATION_NOTIFICATION | ThreeDInformationFlags.BATTERY_LEVEL_REPORTING ) assert result.flags == expected_flags assert result.path_loss_threshold == 20 diff --git a/tests/advertising/test_transport_discovery.py b/tests/advertising/test_transport_discovery.py index 8bb695ff..b1abe0d0 100644 --- a/tests/advertising/test_transport_discovery.py +++ b/tests/advertising/test_transport_discovery.py @@ -40,53 +40,76 @@ def valid_test_data(self) -> list[ADTypeTestData]: """Standard decode scenarios for transport discovery blocks.""" return [ ADTypeTestData( - input_data=bytearray([ - 0x01, # org_id = 1 (Bluetooth SIG) - 0x02, # flags = ROLE_PROVIDER - 0x03, # transport_data_length = 3 - 0xAA, 0xBB, 0xCC, # transport_data - ]), - expected_value=TransportDiscoveryData(blocks=[ - TransportBlock( - organization_id=1, - flags=TDSFlags.ROLE_PROVIDER, - transport_data=b"\xAA\xBB\xCC", - ), - ]), + input_data=bytearray( + [ + 0x01, # org_id = 1 (Bluetooth SIG) + 0x02, # flags = ROLE_PROVIDER + 0x03, # transport_data_length = 3 + 0xAA, + 0xBB, + 0xCC, # transport_data + ] + ), + expected_value=TransportDiscoveryData( + blocks=[ + TransportBlock( + organization_id=1, + flags=TDSFlags.ROLE_PROVIDER, + transport_data=b"\xaa\xbb\xcc", + ), + ] + ), description="Single block, provider role, 3 bytes payload", ), ADTypeTestData( - input_data=bytearray([ - # Block 1 - 0x01, 0x03, 0x01, 0xFF, - # Block 2 - 0x02, 0x08, 0x02, 0x11, 0x22, - ]), - expected_value=TransportDiscoveryData(blocks=[ - TransportBlock( - organization_id=1, - flags=TDSFlags.ROLE_SEEKER_AND_PROVIDER, - transport_data=b"\xFF", - ), - TransportBlock( - organization_id=2, - flags=TDSFlags.STATE_ON, - transport_data=b"\x11\x22", - ), - ]), + input_data=bytearray( + [ + # Block 1 + 0x01, + 0x03, + 0x01, + 0xFF, + # Block 2 + 0x02, + 0x08, + 0x02, + 0x11, + 0x22, + ] + ), + expected_value=TransportDiscoveryData( + blocks=[ + TransportBlock( + organization_id=1, + flags=TDSFlags.ROLE_SEEKER_AND_PROVIDER, + transport_data=b"\xff", + ), + TransportBlock( + organization_id=2, + flags=TDSFlags.STATE_ON, + transport_data=b"\x11\x22", + ), + ] + ), description="Two blocks with different roles and states", ), ADTypeTestData( - input_data=bytearray([ - 0x01, 0x00, 0x00, # org=1, flags=0, data_length=0 - ]), - expected_value=TransportDiscoveryData(blocks=[ - TransportBlock( - organization_id=1, - flags=TDSFlags(0), - transport_data=b"", - ), - ]), + input_data=bytearray( + [ + 0x01, + 0x00, + 0x00, # org=1, flags=0, data_length=0 + ] + ), + expected_value=TransportDiscoveryData( + blocks=[ + TransportBlock( + organization_id=1, + flags=TDSFlags(0), + transport_data=b"", + ), + ] + ), description="Single block with zero-length transport data", ), ] @@ -104,10 +127,15 @@ def test_decode_empty_data_returns_no_blocks(self) -> None: def test_decode_incomplete_trailing_header_skipped(self) -> None: """Fewer than 3 trailing bytes after a valid block are silently ignored.""" - data = bytearray([ - 0x01, 0x02, 0x00, # valid block (0-length payload) - 0xFF, 0xFE, # 2 trailing bytes — not enough for a header - ]) + data = bytearray( + [ + 0x01, + 0x02, + 0x00, # valid block (0-length payload) + 0xFF, + 0xFE, # 2 trailing bytes — not enough for a header + ] + ) result = TransportDiscoveryData.decode(data) assert len(result.blocks) == 1 @@ -115,14 +143,19 @@ def test_decode_incomplete_trailing_header_skipped(self) -> None: def test_decode_truncated_transport_data_clamped(self) -> None: """Transport data length exceeds remaining bytes — clamp to available.""" - data = bytearray([ - 0x01, 0x00, 0x05, # header says 5 bytes of transport data - 0xAA, 0xBB, # only 2 available - ]) + data = bytearray( + [ + 0x01, + 0x00, + 0x05, # header says 5 bytes of transport data + 0xAA, + 0xBB, # only 2 available + ] + ) result = TransportDiscoveryData.decode(data) assert len(result.blocks) == 1 - assert result.blocks[0].transport_data == b"\xAA\xBB" + assert result.blocks[0].transport_data == b"\xaa\xbb" class TestTransportBlockProperties: diff --git a/tests/device/test_peripheral_device.py b/tests/device/test_peripheral_device.py index 20116472..77084bf4 100644 --- a/tests/device/test_peripheral_device.py +++ b/tests/device/test_peripheral_device.py @@ -6,8 +6,6 @@ from __future__ import annotations -from typing import Any - import pytest from bluetooth_sig.device.peripheral import PeripheralManagerProtocol @@ -16,7 +14,6 @@ from bluetooth_sig.types.peripheral_types import CharacteristicDefinition, ServiceDefinition from bluetooth_sig.types.uuid import BluetoothUUID - # --------------------------------------------------------------------------- # Mock backend # --------------------------------------------------------------------------- @@ -433,12 +430,7 @@ def test_with_discoverable_delegates(self) -> None: def test_chaining(self) -> None: device, backend = _make_device() - result = ( - device - .with_tx_power(-5) - .with_connectable(True) - .with_discoverable(False) - ) + result = device.with_tx_power(-5).with_connectable(True).with_discoverable(False) assert result is device assert backend.tx_power == -5 assert backend.is_connectable_config is True diff --git a/tests/docs/conftest.py b/tests/docs/conftest.py index 0e7888f5..a2dbb6f3 100644 --- a/tests/docs/conftest.py +++ b/tests/docs/conftest.py @@ -161,7 +161,7 @@ def pytest_xdist_auto_num_workers(config: pytest.Config) -> int: # Log worker calculation for debugging print( - f"\n🔧 Dynamic worker calculation: {file_count} files, {cpu_count} CPUs → {workers} workers\n", + f"\nDynamic worker calculation: {file_count} files, {cpu_count} CPUs → {workers} workers\n", flush=True, ) @@ -233,7 +233,7 @@ def docs_server_port(worker_id: str) -> int: # Fallback for unexpected worker_id format port = find_available_port() - print(f"🔌 Worker {worker_id} assigned port {port}", flush=True) + print(f"Worker {worker_id} assigned port {port}", flush=True) return port @@ -279,7 +279,7 @@ class ThreadedDocsServer(ThreadingHTTPServer): allow_reuse_address = True daemon_threads = True # Allow clean shutdown - print(f"🚀 Worker {worker_id} starting server on port {docs_server_port}", flush=True) + print(f"Worker {worker_id} starting server on port {docs_server_port}", flush=True) # Start server in a thread with ThreadedDocsServer(("127.0.0.1", docs_server_port), Handler) as server: @@ -295,7 +295,7 @@ class ThreadedDocsServer(ThreadingHTTPServer): import urllib.request with urllib.request.urlopen(f"{base_url}/index.html", timeout=1): - print(f"✅ Worker {worker_id} server ready on port {docs_server_port}", flush=True) + print(f"Worker {worker_id} server ready on port {docs_server_port}", flush=True) break except Exception: time.sleep(SERVER_HEALTH_CHECK_INTERVAL_SECONDS) @@ -307,7 +307,7 @@ class ThreadedDocsServer(ThreadingHTTPServer): yield base_url finally: # Ensure server shuts down even on KeyboardInterrupt (Ctrl+C) - print(f"🛑 Worker {worker_id} shutting down server on port {docs_server_port}", flush=True) + print(f"Worker {worker_id} shutting down server on port {docs_server_port}", flush=True) server.shutdown() server.server_close() @@ -388,7 +388,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: # Report missing files but continue with found files if missing_files: - print(f"⚠️ Skipping {len(missing_files)} non-existent files:") + print(f"Skipping {len(missing_files)} non-existent files:") for missing in missing_files[:5]: print(f" - {missing}") if len(missing_files) > 5: diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py index 75892ae3..799dde8b 100644 --- a/tests/integration/test_examples.py +++ b/tests/integration/test_examples.py @@ -90,7 +90,7 @@ async def test_advertising_parsing_with_mock_data(self, capsys: pytest.CaptureFi await self._run_main_with_args(mock=True) captured = capsys.readouterr() - assert "📝 USING MOCK LEGACY ADVERTISING DATA FOR DEMONSTRATION" in captured.out + assert "USING MOCK LEGACY ADVERTISING DATA FOR DEMONSTRATION" in captured.out assert "Mock BLE Device Results with SIG Parsing:" in captured.out assert "Local Name: Test Device" in captured.out assert "Service UUIDs:" in captured.out @@ -366,17 +366,6 @@ async def test_robust_device_reading_connection_error_handling(self) -> None: with pytest.raises(ConnectionError): # Connection failure should propagate await robust_device_reading("00:11:22:33:44:55", retries=1) - @pytest.mark.asyncio - async def test_robust_service_discovery_canonical_shape(self) -> None: - """Test that robust_service_discovery returns dict of parsed values.""" - from examples.with_bleak_retry import robust_service_discovery - - # Currently returns empty dict, but should maintain canonical shape - result = await robust_service_discovery("00:11:22:33:44:55") - - assert isinstance(result, dict) - # Values are now parsed values directly (not CharacteristicData wrappers) - def test_canonical_shape_imports(self) -> None: """Test that canonical parse exception types are properly imported.""" from bluetooth_sig.gatt.exceptions import CharacteristicParseError, SpecialValueDetectedError From f4aeed186b0856e89a37827ca07a1451567cdd92 Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Mon, 23 Feb 2026 13:32:54 +0000 Subject: [PATCH 8/9] Many new chars added --- .../bluetooth-gatt.instructions.md | 26 +- .../documentation.instructions.md | 7 +- .../python-implementation.instructions.md | 16 +- .github/instructions/testing.instructions.md | 10 + docs/source/how-to/adding-characteristics.md | 1 - examples/advertising_parsing.py | 10 +- examples/pure_sig_parsing.py | 6 +- rework.md | 274 ------------- src/bluetooth_sig/core/encoder.py | 25 +- src/bluetooth_sig/core/query.py | 18 +- src/bluetooth_sig/core/registration.py | 2 +- src/bluetooth_sig/core/service_manager.py | 13 +- src/bluetooth_sig/core/translator.py | 14 +- src/bluetooth_sig/device/characteristic_io.py | 6 +- src/bluetooth_sig/device/peripheral.py | 11 +- .../gatt/characteristics/__init__.py | 224 +++++++++++ .../gatt/characteristics/acceleration_3d.py | 1 - .../gatt/characteristics/altitude.py | 1 - .../gatt/characteristics/appearance.py | 1 - .../barometric_pressure_trend.py | 3 - .../gatt/characteristics/base.py | 142 ++++--- .../characteristics/blood_pressure_common.py | 2 +- .../characteristics/blood_pressure_feature.py | 3 +- .../bond_management_control_point.py | 2 + .../bond_management_feature.py | 2 + .../gatt/characteristics/boolean.py | 2 - .../boot_keyboard_input_report.py | 2 - .../boot_keyboard_output_report.py | 2 - .../boot_mouse_input_report.py | 2 - .../characteristics/characteristic_meta.py | 25 +- .../chromatic_distance_from_planckian.py | 23 ++ .../chromaticity_coordinates.py | 91 +++++ .../chromaticity_in_cct_and_duv_values.py | 100 +++++ .../characteristics/chromaticity_tolerance.py | 18 + .../cie_13_3_1995_color_rendering_index.py | 24 ++ .../gatt/characteristics/co2_concentration.py | 3 +- .../gatt/characteristics/contact_status_8.py | 37 ++ .../characteristics/content_control_id.py | 17 + .../characteristics/cosine_of_the_angle.py | 22 ++ .../gatt/characteristics/country_code.py | 17 + .../gatt/characteristics/current_time.py | 3 +- .../gatt/characteristics/custom.py | 4 +- .../gatt/characteristics/date_time.py | 2 - .../gatt/characteristics/date_utc.py | 19 + .../gatt/characteristics/day_date_time.py | 1 - .../gatt/characteristics/device_name.py | 2 + .../characteristics/door_window_status.py | 33 ++ .../characteristics/electric_current_range.py | 3 +- .../electric_current_specification.py | 3 +- .../electric_current_statistics.py | 3 +- .../gatt/characteristics/elevation.py | 3 +- .../gatt/characteristics/energy.py | 21 + .../gatt/characteristics/energy_32.py | 22 ++ .../energy_in_a_period_of_day.py | 97 +++++ .../characteristics/estimated_service_date.py | 20 + .../gatt/characteristics/event_statistics.py | 123 ++++++ .../gatt/characteristics/exact_time_256.py | 1 - .../gatt/characteristics/fixed_string_16.py | 18 + .../gatt/characteristics/fixed_string_24.py | 18 + .../gatt/characteristics/fixed_string_36.py | 18 + .../gatt/characteristics/fixed_string_64.py | 18 + .../gatt/characteristics/fixed_string_8.py | 18 + .../gatt/characteristics/floor_number.py | 2 + .../gatt/characteristics/generic_level.py | 17 + .../global_trade_item_number.py | 22 ++ .../gatt/characteristics/hid_control_point.py | 2 + .../gatt/characteristics/hid_information.py | 2 + .../gatt/characteristics/high_temperature.py | 22 ++ .../gatt/characteristics/humidity_8.py | 22 ++ .../gatt/characteristics/illuminance_16.py | 21 + .../indoor_positioning_configuration.py | 3 + .../gatt/characteristics/latitude.py | 2 + .../characteristics/light_distribution.py | 39 ++ .../gatt/characteristics/light_output.py | 21 + .../gatt/characteristics/light_source_type.py | 39 ++ .../gatt/characteristics/ln_control_point.py | 3 +- .../gatt/characteristics/ln_feature.py | 3 +- .../characteristics/local_east_coordinate.py | 1 - .../characteristics/local_north_coordinate.py | 1 - .../characteristics/location_and_speed.py | 3 +- .../gatt/characteristics/location_name.py | 2 + .../gatt/characteristics/longitude.py | 2 + .../gatt/characteristics/luminous_efficacy.py | 22 ++ .../gatt/characteristics/luminous_energy.py | 21 + .../gatt/characteristics/luminous_exposure.py | 21 + .../gatt/characteristics/luminous_flux.py | 21 + .../characteristics/luminous_flux_range.py | 87 +++++ .../characteristics/luminous_intensity.py | 21 + .../characteristics/magnetic_declination.py | 3 +- .../magnetic_flux_density_2d.py | 3 +- .../magnetic_flux_density_3d.py | 3 +- .../gatt/characteristics/mass_flow.py | 21 + .../characteristics/methane_concentration.py | 3 +- .../gatt/characteristics/navigation.py | 3 +- .../characteristics/object_first_created.py | 65 ++++ .../gatt/characteristics/object_id.py | 49 +++ .../characteristics/object_last_modified.py | 65 ++++ .../gatt/characteristics/object_name.py | 48 +++ .../gatt/characteristics/object_type.py | 83 ++++ .../characteristics/ozone_concentration.py | 3 +- .../characteristics/perceived_lightness.py | 17 + .../gatt/characteristics/percentage_8.py | 22 ++ .../characteristics/percentage_8_steps.py | 23 ++ ...ipheral_preferred_connection_parameters.py | 1 - .../peripheral_privacy_flag.py | 2 + .../characteristics/pipeline/validation.py | 8 +- .../gatt/characteristics/plx_features.py | 3 + .../plx_spot_check_measurement.py | 2 + .../characteristics/pm10_concentration.py | 3 +- .../gatt/characteristics/pm1_concentration.py | 3 +- .../gatt/characteristics/pnp_id.py | 1 - .../characteristics/pollen_concentration.py | 3 +- .../gatt/characteristics/position_quality.py | 3 +- .../gatt/characteristics/power.py | 22 ++ .../precise_acceleration_3d.py | 69 ++++ .../gatt/characteristics/protocol_mode.py | 2 + .../pulse_oximetry_measurement.py | 2 + .../characteristics/pushbutton_status_8.py | 81 ++++ .../characteristics/reconnection_address.py | 2 + ...in_a_correlated_color_temperature_range.py | 104 +++++ .../relative_runtime_in_a_current_range.py | 107 ++++++ ...lative_runtime_in_a_generic_level_range.py | 106 +++++ .../relative_value_in_a_period_of_day.py | 104 +++++ .../relative_value_in_a_temperature_range.py | 103 +++++ .../relative_value_in_a_voltage_range.py | 108 ++++++ .../relative_value_in_an_illuminance_range.py | 111 ++++++ .../gatt/characteristics/report.py | 2 + .../gatt/characteristics/report_map.py | 2 + .../gatt/characteristics/role_classifier.py | 29 +- .../characteristics/scan_interval_window.py | 1 - .../gatt/characteristics/scan_refresh.py | 2 + .../gatt/characteristics/sensor_location.py | 62 +++ .../gatt/characteristics/service_changed.py | 2 - .../sulfur_dioxide_concentration.py | 3 +- .../sulfur_hexafluoride_concentration.py | 22 ++ .../supported_heart_rate_range.py | 91 +++++ .../supported_inclination_range.py | 110 ++++++ .../characteristics/supported_power_range.py | 3 +- .../supported_resistance_level_range.py | 110 ++++++ .../characteristics/supported_speed_range.py | 103 +++++ .../gatt/characteristics/system_id.py | 1 - .../gatt/characteristics/temperature_8.py | 22 ++ .../temperature_8_in_a_period_of_day.py | 102 +++++ .../temperature_8_statistics.py | 139 +++++++ .../gatt/characteristics/temperature_range.py | 93 +++++ .../characteristics/temperature_statistics.py | 138 +++++++ .../characteristics/templates/__init__.py | 12 + .../gatt/characteristics/templates/base.py | 35 +- .../characteristics/templates/epoch_date.py | 89 +++++ .../gatt/characteristics/templates/flag.py | 187 +++++++++ .../gatt/characteristics/templates/numeric.py | 37 +- .../templates/time_duration.py | 222 +++++++++++ .../gatt/characteristics/time_decihour_8.py | 24 ++ .../characteristics/time_exponential_8.py | 25 ++ .../gatt/characteristics/time_hour_24.py | 23 ++ .../characteristics/time_millisecond_24.py | 24 ++ .../gatt/characteristics/time_second_16.py | 23 ++ .../gatt/characteristics/time_second_32.py | 23 ++ .../gatt/characteristics/time_second_8.py | 23 ++ .../gatt/characteristics/time_zone.py | 3 +- .../gatt/characteristics/torque.py | 23 ++ .../gatt/characteristics/uncertainty.py | 1 - .../gatt/characteristics/unknown.py | 8 +- .../gatt/characteristics/utils/data_parser.py | 25 ++ .../gatt/characteristics/utils/extractors.py | 34 ++ .../characteristics/voltage_specification.py | 3 +- .../characteristics/voltage_statistics.py | 3 +- .../gatt/characteristics/volume_flow.py | 22 ++ src/bluetooth_sig/gatt/constants.py | 1 + src/bluetooth_sig/gatt/resolver.py | 1 + src/bluetooth_sig/gatt/services/base.py | 2 +- src/bluetooth_sig/gatt/services/unknown.py | 4 +- src/bluetooth_sig/gatt/uuid_registry.py | 21 +- src/bluetooth_sig/registry/gss.py | 22 +- src/bluetooth_sig/types/data_types.py | 4 +- src/bluetooth_sig/types/gatt_enums.py | 362 ++++++------------ tests/README.md | 228 ++++++++--- tests/advertising/test_indoor_positioning.py | 2 +- tests/core/test_translator_encoding.py | 10 +- tests/descriptors/test_integration.py | 5 +- tests/diagnostics/test_field_errors.py | 5 +- .../test_field_level_diagnostics.py | 11 +- tests/gatt/characteristics/test_altitude.py | 2 +- .../test_ammonia_concentration.py | 2 +- .../test_barometric_pressure_trend.py | 2 +- .../test_base_characteristic.py | 19 +- .../test_characteristic_role.py | 7 +- .../test_characteristic_test_coverage.py | 1 + .../test_chromatic_distance_from_planckian.py | 46 +++ .../test_chromaticity_coordinates.py | 63 +++ ...test_chromaticity_in_cct_and_duv_values.py | 76 ++++ .../test_chromaticity_tolerance.py | 51 +++ .../test_cie133_color_rendering_index.py | 46 +++ .../characteristics/test_contact_status_8.py | 54 +++ .../test_content_control_id.py | 46 +++ .../test_cosine_of_the_angle.py | 46 +++ .../gatt/characteristics/test_country_code.py | 46 +++ .../test_custom_characteristics.py | 37 +- tests/gatt/characteristics/test_date_utc.py | 43 +++ .../test_door_window_status.py | 47 +++ tests/gatt/characteristics/test_elevation.py | 2 +- tests/gatt/characteristics/test_energy.py | 46 +++ tests/gatt/characteristics/test_energy_32.py | 46 +++ .../test_energy_in_a_period_of_day.py | 64 ++++ .../test_estimated_service_date.py | 43 +++ .../characteristics/test_event_statistics.py | 80 ++++ .../characteristics/test_fixed_string_16.py | 41 ++ .../characteristics/test_fixed_string_24.py | 41 ++ .../characteristics/test_fixed_string_36.py | 41 ++ .../characteristics/test_fixed_string_64.py | 41 ++ .../characteristics/test_fixed_string_8.py | 41 ++ .../characteristics/test_generic_level.py | 46 +++ .../test_global_trade_item_number.py | 41 ++ .../characteristics/test_high_temperature.py | 46 +++ tests/gatt/characteristics/test_humidity_8.py | 46 +++ .../characteristics/test_illuminance_16.py | 46 +++ .../test_light_distribution.py | 47 +++ .../gatt/characteristics/test_light_output.py | 46 +++ .../characteristics/test_light_source_type.py | 47 +++ .../test_local_east_coordinate.py | 2 +- .../test_local_north_coordinate.py | 2 +- .../characteristics/test_luminous_efficacy.py | 46 +++ .../characteristics/test_luminous_energy.py | 46 +++ .../characteristics/test_luminous_exposure.py | 46 +++ .../characteristics/test_luminous_flux.py | 46 +++ .../test_luminous_flux_range.py | 57 +++ .../test_luminous_intensity.py | 46 +++ .../test_magnetic_declination.py | 2 +- .../test_magnetic_flux_density_2d.py | 2 +- .../test_magnetic_flux_density_3d.py | 2 +- tests/gatt/characteristics/test_mass_flow.py | 46 +++ .../test_non_methane_voc_concentration.py | 2 +- .../test_object_first_created.py | 46 +++ tests/gatt/characteristics/test_object_id.py | 49 +++ .../test_object_last_modified.py | 46 +++ .../gatt/characteristics/test_object_name.py | 52 +++ .../gatt/characteristics/test_object_type.py | 52 +++ .../test_perceived_lightness.py | 46 +++ .../gatt/characteristics/test_percentage_8.py | 46 +++ .../test_percentage_8_steps.py | 46 +++ tests/gatt/characteristics/test_power.py | 46 +++ .../test_precise_acceleration_3d.py | 45 +++ .../test_pushbutton_status_8.py | 186 +++++++++ .../test_python_type_auto_resolution.py | 249 ++++++++++++ ...in_a_correlated_color_temperature_range.py | 70 ++++ ...est_relative_runtime_in_a_current_range.py | 70 ++++ ...lative_runtime_in_a_generic_level_range.py | 70 ++++ .../test_relative_value_in_a_period_of_day.py | 61 +++ ...t_relative_value_in_a_temperature_range.py | 70 ++++ .../test_relative_value_in_a_voltage_range.py | 70 ++++ ..._relative_value_in_an_illuminance_range.py | 70 ++++ .../characteristics/test_sensor_location.py | 47 +++ .../test_sulfur_hexafluoride_concentration.py | 41 ++ .../test_supported_heart_rate_range.py | 84 ++++ .../test_supported_inclination_range.py | 82 ++++ .../test_supported_resistance_level_range.py | 77 ++++ .../test_supported_speed_range.py | 76 ++++ .../characteristics/test_temperature_8.py | 46 +++ .../test_temperature_8_in_a_period_of_day.py | 68 ++++ .../test_temperature_8_statistics.py | 78 ++++ .../characteristics/test_temperature_range.py | 54 +++ .../test_temperature_statistics.py | 90 +++++ .../characteristics/test_time_decihour_8.py | 48 +++ .../test_time_exponential_8.py | 43 +++ .../gatt/characteristics/test_time_hour_24.py | 48 +++ .../test_time_millisecond_24.py | 48 +++ .../characteristics/test_time_second_16.py | 48 +++ .../characteristics/test_time_second_32.py | 48 +++ .../characteristics/test_time_second_8.py | 48 +++ tests/gatt/characteristics/test_torque.py | 46 +++ .../gatt/characteristics/test_uncertainty.py | 2 +- .../characteristics/test_voc_concentration.py | 2 +- .../gatt/characteristics/test_volume_flow.py | 46 +++ .../services/test_body_composition_service.py | 11 +- tests/gatt/services/test_custom_services.py | 7 +- .../services/test_weight_scale_service.py | 11 +- tests/gatt/test_context.py | 5 +- tests/gatt/test_resolver.py | 5 +- tests/integration/test_custom_registration.py | 20 +- tests/integration/test_end_to_end.py | 23 +- .../test_format_types_integration.py | 18 +- tests/registry/test_registry_validation.py | 38 +- tests/registry/test_yaml_cross_reference.py | 4 +- tests/types/test_gatt_enums.py | 83 ++-- tests/utils/test_performance_tracking.py | 87 ----- 285 files changed, 9601 insertions(+), 1123 deletions(-) delete mode 100644 rework.md create mode 100644 src/bluetooth_sig/gatt/characteristics/chromatic_distance_from_planckian.py create mode 100644 src/bluetooth_sig/gatt/characteristics/chromaticity_coordinates.py create mode 100644 src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py create mode 100644 src/bluetooth_sig/gatt/characteristics/chromaticity_tolerance.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py create mode 100644 src/bluetooth_sig/gatt/characteristics/contact_status_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/content_control_id.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cosine_of_the_angle.py create mode 100644 src/bluetooth_sig/gatt/characteristics/country_code.py create mode 100644 src/bluetooth_sig/gatt/characteristics/date_utc.py create mode 100644 src/bluetooth_sig/gatt/characteristics/door_window_status.py create mode 100644 src/bluetooth_sig/gatt/characteristics/energy.py create mode 100644 src/bluetooth_sig/gatt/characteristics/energy_32.py create mode 100644 src/bluetooth_sig/gatt/characteristics/energy_in_a_period_of_day.py create mode 100644 src/bluetooth_sig/gatt/characteristics/estimated_service_date.py create mode 100644 src/bluetooth_sig/gatt/characteristics/event_statistics.py create mode 100644 src/bluetooth_sig/gatt/characteristics/fixed_string_16.py create mode 100644 src/bluetooth_sig/gatt/characteristics/fixed_string_24.py create mode 100644 src/bluetooth_sig/gatt/characteristics/fixed_string_36.py create mode 100644 src/bluetooth_sig/gatt/characteristics/fixed_string_64.py create mode 100644 src/bluetooth_sig/gatt/characteristics/fixed_string_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/generic_level.py create mode 100644 src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py create mode 100644 src/bluetooth_sig/gatt/characteristics/high_temperature.py create mode 100644 src/bluetooth_sig/gatt/characteristics/humidity_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/illuminance_16.py create mode 100644 src/bluetooth_sig/gatt/characteristics/light_distribution.py create mode 100644 src/bluetooth_sig/gatt/characteristics/light_output.py create mode 100644 src/bluetooth_sig/gatt/characteristics/light_source_type.py create mode 100644 src/bluetooth_sig/gatt/characteristics/luminous_efficacy.py create mode 100644 src/bluetooth_sig/gatt/characteristics/luminous_energy.py create mode 100644 src/bluetooth_sig/gatt/characteristics/luminous_exposure.py create mode 100644 src/bluetooth_sig/gatt/characteristics/luminous_flux.py create mode 100644 src/bluetooth_sig/gatt/characteristics/luminous_flux_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/luminous_intensity.py create mode 100644 src/bluetooth_sig/gatt/characteristics/mass_flow.py create mode 100644 src/bluetooth_sig/gatt/characteristics/object_first_created.py create mode 100644 src/bluetooth_sig/gatt/characteristics/object_id.py create mode 100644 src/bluetooth_sig/gatt/characteristics/object_last_modified.py create mode 100644 src/bluetooth_sig/gatt/characteristics/object_name.py create mode 100644 src/bluetooth_sig/gatt/characteristics/object_type.py create mode 100644 src/bluetooth_sig/gatt/characteristics/perceived_lightness.py create mode 100644 src/bluetooth_sig/gatt/characteristics/percentage_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/percentage_8_steps.py create mode 100644 src/bluetooth_sig/gatt/characteristics/power.py create mode 100644 src/bluetooth_sig/gatt/characteristics/precise_acceleration_3d.py create mode 100644 src/bluetooth_sig/gatt/characteristics/pushbutton_status_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_correlated_color_temperature_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_current_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_generic_level_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/relative_value_in_a_period_of_day.py create mode 100644 src/bluetooth_sig/gatt/characteristics/relative_value_in_a_temperature_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/relative_value_in_a_voltage_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/relative_value_in_an_illuminance_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/sensor_location.py create mode 100644 src/bluetooth_sig/gatt/characteristics/sulfur_hexafluoride_concentration.py create mode 100644 src/bluetooth_sig/gatt/characteristics/supported_heart_rate_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/supported_inclination_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/supported_resistance_level_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/supported_speed_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/temperature_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/temperature_8_in_a_period_of_day.py create mode 100644 src/bluetooth_sig/gatt/characteristics/temperature_8_statistics.py create mode 100644 src/bluetooth_sig/gatt/characteristics/temperature_range.py create mode 100644 src/bluetooth_sig/gatt/characteristics/temperature_statistics.py create mode 100644 src/bluetooth_sig/gatt/characteristics/templates/epoch_date.py create mode 100644 src/bluetooth_sig/gatt/characteristics/templates/flag.py create mode 100644 src/bluetooth_sig/gatt/characteristics/templates/time_duration.py create mode 100644 src/bluetooth_sig/gatt/characteristics/time_decihour_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/time_exponential_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/time_hour_24.py create mode 100644 src/bluetooth_sig/gatt/characteristics/time_millisecond_24.py create mode 100644 src/bluetooth_sig/gatt/characteristics/time_second_16.py create mode 100644 src/bluetooth_sig/gatt/characteristics/time_second_32.py create mode 100644 src/bluetooth_sig/gatt/characteristics/time_second_8.py create mode 100644 src/bluetooth_sig/gatt/characteristics/torque.py create mode 100644 src/bluetooth_sig/gatt/characteristics/volume_flow.py create mode 100644 tests/gatt/characteristics/test_chromatic_distance_from_planckian.py create mode 100644 tests/gatt/characteristics/test_chromaticity_coordinates.py create mode 100644 tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py create mode 100644 tests/gatt/characteristics/test_chromaticity_tolerance.py create mode 100644 tests/gatt/characteristics/test_cie133_color_rendering_index.py create mode 100644 tests/gatt/characteristics/test_contact_status_8.py create mode 100644 tests/gatt/characteristics/test_content_control_id.py create mode 100644 tests/gatt/characteristics/test_cosine_of_the_angle.py create mode 100644 tests/gatt/characteristics/test_country_code.py create mode 100644 tests/gatt/characteristics/test_date_utc.py create mode 100644 tests/gatt/characteristics/test_door_window_status.py create mode 100644 tests/gatt/characteristics/test_energy.py create mode 100644 tests/gatt/characteristics/test_energy_32.py create mode 100644 tests/gatt/characteristics/test_energy_in_a_period_of_day.py create mode 100644 tests/gatt/characteristics/test_estimated_service_date.py create mode 100644 tests/gatt/characteristics/test_event_statistics.py create mode 100644 tests/gatt/characteristics/test_fixed_string_16.py create mode 100644 tests/gatt/characteristics/test_fixed_string_24.py create mode 100644 tests/gatt/characteristics/test_fixed_string_36.py create mode 100644 tests/gatt/characteristics/test_fixed_string_64.py create mode 100644 tests/gatt/characteristics/test_fixed_string_8.py create mode 100644 tests/gatt/characteristics/test_generic_level.py create mode 100644 tests/gatt/characteristics/test_global_trade_item_number.py create mode 100644 tests/gatt/characteristics/test_high_temperature.py create mode 100644 tests/gatt/characteristics/test_humidity_8.py create mode 100644 tests/gatt/characteristics/test_illuminance_16.py create mode 100644 tests/gatt/characteristics/test_light_distribution.py create mode 100644 tests/gatt/characteristics/test_light_output.py create mode 100644 tests/gatt/characteristics/test_light_source_type.py create mode 100644 tests/gatt/characteristics/test_luminous_efficacy.py create mode 100644 tests/gatt/characteristics/test_luminous_energy.py create mode 100644 tests/gatt/characteristics/test_luminous_exposure.py create mode 100644 tests/gatt/characteristics/test_luminous_flux.py create mode 100644 tests/gatt/characteristics/test_luminous_flux_range.py create mode 100644 tests/gatt/characteristics/test_luminous_intensity.py create mode 100644 tests/gatt/characteristics/test_mass_flow.py create mode 100644 tests/gatt/characteristics/test_object_first_created.py create mode 100644 tests/gatt/characteristics/test_object_id.py create mode 100644 tests/gatt/characteristics/test_object_last_modified.py create mode 100644 tests/gatt/characteristics/test_object_name.py create mode 100644 tests/gatt/characteristics/test_object_type.py create mode 100644 tests/gatt/characteristics/test_perceived_lightness.py create mode 100644 tests/gatt/characteristics/test_percentage_8.py create mode 100644 tests/gatt/characteristics/test_percentage_8_steps.py create mode 100644 tests/gatt/characteristics/test_power.py create mode 100644 tests/gatt/characteristics/test_precise_acceleration_3d.py create mode 100644 tests/gatt/characteristics/test_pushbutton_status_8.py create mode 100644 tests/gatt/characteristics/test_python_type_auto_resolution.py create mode 100644 tests/gatt/characteristics/test_relative_runtime_in_a_correlated_color_temperature_range.py create mode 100644 tests/gatt/characteristics/test_relative_runtime_in_a_current_range.py create mode 100644 tests/gatt/characteristics/test_relative_runtime_in_a_generic_level_range.py create mode 100644 tests/gatt/characteristics/test_relative_value_in_a_period_of_day.py create mode 100644 tests/gatt/characteristics/test_relative_value_in_a_temperature_range.py create mode 100644 tests/gatt/characteristics/test_relative_value_in_a_voltage_range.py create mode 100644 tests/gatt/characteristics/test_relative_value_in_an_illuminance_range.py create mode 100644 tests/gatt/characteristics/test_sensor_location.py create mode 100644 tests/gatt/characteristics/test_sulfur_hexafluoride_concentration.py create mode 100644 tests/gatt/characteristics/test_supported_heart_rate_range.py create mode 100644 tests/gatt/characteristics/test_supported_inclination_range.py create mode 100644 tests/gatt/characteristics/test_supported_resistance_level_range.py create mode 100644 tests/gatt/characteristics/test_supported_speed_range.py create mode 100644 tests/gatt/characteristics/test_temperature_8.py create mode 100644 tests/gatt/characteristics/test_temperature_8_in_a_period_of_day.py create mode 100644 tests/gatt/characteristics/test_temperature_8_statistics.py create mode 100644 tests/gatt/characteristics/test_temperature_range.py create mode 100644 tests/gatt/characteristics/test_temperature_statistics.py create mode 100644 tests/gatt/characteristics/test_time_decihour_8.py create mode 100644 tests/gatt/characteristics/test_time_exponential_8.py create mode 100644 tests/gatt/characteristics/test_time_hour_24.py create mode 100644 tests/gatt/characteristics/test_time_millisecond_24.py create mode 100644 tests/gatt/characteristics/test_time_second_16.py create mode 100644 tests/gatt/characteristics/test_time_second_32.py create mode 100644 tests/gatt/characteristics/test_time_second_8.py create mode 100644 tests/gatt/characteristics/test_torque.py create mode 100644 tests/gatt/characteristics/test_volume_flow.py delete mode 100644 tests/utils/test_performance_tracking.py diff --git a/.github/instructions/bluetooth-gatt.instructions.md b/.github/instructions/bluetooth-gatt.instructions.md index 8e56cd54..b133e1c9 100644 --- a/.github/instructions/bluetooth-gatt.instructions.md +++ b/.github/instructions/bluetooth-gatt.instructions.md @@ -26,12 +26,34 @@ SIG characteristics auto-resolve UUID. Custom require `_info = CharacteristicInf **Your `_decode_value()` only:** Parse bytes using templates, apply scaling, return typed result +**`_python_type` is auto-resolved** from `BaseCharacteristic[T]` generic parameter. Never set `_python_type` on new characteristics — the generic param and template already provide the type. `dict` is banned. + **Patterns:** -- Simple value → Use template (`Uint8Template`, etc.) +- Simple value → Use template from `templates/` package (`Uint8Template`, `ScaledUint16Template`, etc.) - Multi-field → Override `_decode_value()`, return `msgspec.Struct` - Enum/bitfield → Use `IntFlag` +- Parsing pipeline → `pipeline/parse_pipeline.py` for multi-stage decode, `pipeline/encode_pipeline.py` for encode, `pipeline/validation.py` for range/type/length checks + +**Templates package** (`gatt/characteristics/templates/`): +- `numeric.py` — `Uint8Template`, `Uint16Template`, `Sint8Template`, etc. +- `scaled.py` — `ScaledUint8Template(d=N, b=N)`, `ScaledSint16Template`, etc. +- `string.py` — `Utf8StringTemplate` +- `enum.py` — `EnumTemplate` +- `ieee_float.py` — `IEEE11073FloatTemplate`, `IEEE11073SFloatTemplate` +- `composite.py` — `CompositeTemplate` +- `domain.py` — domain-specific templates +- `data_structures.py` — structured data templates +- `base.py` — `CodingTemplate` base class + +**Core decomposition** (`core/`): +- `query.py` — characteristic/service lookup +- `parser.py` — raw bytes → Python values +- `encoder.py` — Python values → raw bytes +- `registration.py` — custom characteristic/service registration +- `service_manager.py` — service lifecycle management +- `translator.py` — public facade (`BluetoothSIGTranslator`) **Standards:** - Multi-byte: little-endian -**Reference:** See `battery_level.py`, `heart_rate_measurement.py`, `templates.py` +**Reference:** See `battery_level.py`, `heart_rate_measurement.py`, `templates/` diff --git a/.github/instructions/documentation.instructions.md b/.github/instructions/documentation.instructions.md index 92943afd..e4693193 100644 --- a/.github/instructions/documentation.instructions.md +++ b/.github/instructions/documentation.instructions.md @@ -7,9 +7,14 @@ applyTo: "docs/**/*.md, docs/*.md" ## Code Samples - Every code block must be runnable and validated -- Use Sphinx cross-references: `:class:`CharacteristicData``, `:meth:`parse_characteristic`` +- Use Sphinx cross-references: `:class:`BluetoothSIGTranslator``, `:meth:`parse_characteristic`` - Pair code with expected output +## Architecture Diagrams + +- Use Mermaid for architecture and flow diagrams in Markdown docs +- Keep diagrams close to the code they describe + ## Style - Google-style docstrings (see python-implementation.instructions.md) diff --git a/.github/instructions/python-implementation.instructions.md b/.github/instructions/python-implementation.instructions.md index d46cd80f..f22c19c5 100644 --- a/.github/instructions/python-implementation.instructions.md +++ b/.github/instructions/python-implementation.instructions.md @@ -9,11 +9,12 @@ applyTo: "**/*.py,**/pyproject.toml,**/requirements*.txt" - Hardcoded UUIDs (use registry resolution) - `from typing import Optional` (use `Type | None`) - `TYPE_CHECKING` blocks -- Lazy/conditional imports in core logic +- Lazy/conditional imports in core logic (deferred imports to break cycles are acceptable with a valid `# NOTE:` comment) - Untyped public function signatures - `hasattr`/`getattr` when direct access is possible - Bare `except:` or silent `pass` - Returning raw `dict` or `tuple` (use `msgspec.Struct`) +- Setting `_python_type` on new characteristics (`BaseCharacteristic[T]` generic param auto-resolves) - Magic numbers without named constants ## Type Safety (MANDATORY) @@ -26,6 +27,19 @@ applyTo: "**/*.py,**/pyproject.toml,**/requirements*.txt" - Use `msgspec.Struct` (frozen, kw_only) +## Characteristic Implementation Patterns + +- **Simple scalars:** Use a template from `templates/` package (`Uint8Template`, `ScaledUint16Template`, etc.) +- **Multi-field composites:** Override `_decode_value()`, use `DataParser` for field extraction, return `msgspec.Struct` +- **Parsing pipeline:** Use `pipeline/parse_pipeline.py` for multi-stage decode with validation +- **Core modules:** `core/encoder.py` for Python→bytes, `core/parser.py` for bytes→Python, `core/query.py` for lookups + +## Peripheral Device Patterns + +- `PeripheralDevice` (in `device/peripheral_device.py`) for server-side BLE +- `PeripheralManagerProtocol` (in `device/peripheral.py`) for adapter abstraction +- Fluent configuration: `device.with_name(...).with_service(...)` + ## Docstrings - Google style (Args, Returns, Raises) diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index d9ab4a53..4ab73ef8 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -22,12 +22,22 @@ Every new function needs: success + 2 failure cases minimum. - Boundaries: min, max, just outside - Sentinels: 0xFF, 0xFFFF, 0x8000 +## Test Directory Structure + +- `tests/gatt/` — characteristic and service tests (the primary pattern) +- `tests/device/` — device, peripheral, advertising tests +- `tests/core/` — translator, encoder, parser, query tests +- `tests/benchmarks/` — performance benchmarks (`test_performance.py`, `test_comparison.py`) +- `tests/static_analysis/` — registry completeness, code quality checks + ## Commands ```bash python -m pytest tests/ -v python -m pytest -k "battery" -v python -m pytest --lf +python -m pytest tests/ --cov=bluetooth_sig --cov-fail-under=85 +python -m pytest tests/benchmarks/ --benchmark-only ``` Tests must be deterministic. See `tests/gatt/` for patterns. diff --git a/docs/source/how-to/adding-characteristics.md b/docs/source/how-to/adding-characteristics.md index 58143e24..bbe51b4b 100644 --- a/docs/source/how-to/adding-characteristics.md +++ b/docs/source/how-to/adding-characteristics.md @@ -52,7 +52,6 @@ from bluetooth_sig.gatt.exceptions import ( ValueRangeError, ) from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID diff --git a/examples/advertising_parsing.py b/examples/advertising_parsing.py index 20b5727f..77667359 100644 --- a/examples/advertising_parsing.py +++ b/examples/advertising_parsing.py @@ -110,14 +110,12 @@ def display_advertising_data( # Positioning and discovery if parsed_data.ad_structures.location.indoor_positioning: - found_fields.append(f"Indoor Positioning: {parsed_data.ad_structures.location.indoor_positioning.hex()}") + found_fields.append(f"Indoor Positioning: {parsed_data.ad_structures.location.indoor_positioning}") else: not_found_fields.append("Indoor Positioning") if parsed_data.ad_structures.location.transport_discovery_data: - found_fields.append( - f"Transport Discovery Data: {parsed_data.ad_structures.location.transport_discovery_data.hex()}" - ) + found_fields.append(f"Transport Discovery Data: {parsed_data.ad_structures.location.transport_discovery_data}") else: not_found_fields.append("Transport Discovery Data") @@ -161,7 +159,7 @@ def display_advertising_data( not_found_fields.append("Electronic Shelf Label") if parsed_data.ad_structures.location.three_d_information: - found_fields.append(f"3D Information: {parsed_data.ad_structures.location.three_d_information.hex()}") + found_fields.append(f"3D Information: {parsed_data.ad_structures.location.three_d_information}") else: not_found_fields.append("3D Information") @@ -291,7 +289,7 @@ def display_advertising_data( if parsed_data.ad_structures.location.channel_map_update_indication: found_fields.append( - f"Channel Map Update Indication: {parsed_data.ad_structures.location.channel_map_update_indication.hex()}" + f"Channel Map Update Indication: {parsed_data.ad_structures.location.channel_map_update_indication}" ) else: not_found_fields.append("Channel Map Update Indication") diff --git a/examples/pure_sig_parsing.py b/examples/pure_sig_parsing.py index 353dba00..758b4819 100644 --- a/examples/pure_sig_parsing.py +++ b/examples/pure_sig_parsing.py @@ -136,8 +136,8 @@ def demonstrate_dynamic_parsing() -> None: info = translator.get_characteristic_info_by_uuid(test_case["uuid"]) unit_str = f" {info.unit}" if info and info.unit else "" print(f" Parsed value: {result}{unit_str}") - if info and info.value_type: - print(f" Value type: {info.value_type}") + if info and info.python_type: + print(f" Python type: {info.python_type}") results[test_case["name"]] = result except Exception as e: print(f" Parse failed: {e}") @@ -171,7 +171,7 @@ def demonstrate_uuid_resolution() -> None: char_info = translator.get_characteristic_info_by_uuid(uuid) if char_info: print(f" Name: {char_info.name}") - print(f" Type: {char_info.value_type}") + print(f" Type: {char_info.python_type}") print(f" Unit: {char_info.unit if char_info.unit else 'N/A'}") else: print(" Not found in characteristic registry") diff --git a/rework.md b/rework.md deleted file mode 100644 index 45cec770..00000000 --- a/rework.md +++ /dev/null @@ -1,274 +0,0 @@ -Plan: bluetooth-sig-python Library — Gap Analysis & Improvement Roadmap -TL;DR: The library has strong foundations (~200 characteristics, full PDU parser, Device abstraction, strict typing) but suffers from God classes, untracked GATT coverage gaps, empty registry stubs, stream/peripheral incompleteness, and misleading documentation. Python minimum bumps to >=3.10, removing TYPE_CHECKING workarounds. Architecture stays pure SIG — no vendor parsers or framework code in core. Work is structured as 5 independent parallel workstreams. - -Workstream 1: Architecture — God Class Decomposition -The four largest files each exceed 1,100 lines with inline TODOs acknowledging the problem. - -1.1 Split BluetoothSIGTranslator (1,359 lines at src/bluetooth_sig/core/translator.py) - -Extract into 5 focused modules under src/bluetooth_sig/core/: - -New Module Methods to Extract Approx Lines -query.py get_value_type, supports, all 8 get_*_info_* methods, list_supported_*, get_service_characteristics ~400 -parser.py parse_characteristic, parse_characteristic_async, parse_characteristics, parse_characteristics_async, all private batch/dependency helpers ~450 -encoder.py encode_characteristic, encode_characteristic_async, create_value, validate_characteristic_data ~200 -registration.py register_custom_characteristic_class, register_custom_service_class ~120 -service_manager.py process_services, get_service_by_uuid, discovered_services, clear_services ~100 -translator.py becomes a thin facade composing these via mixins or delegation. The BluetoothSIG global singleton interface stays intact. - -1.2 Split Device (1,172 lines at src/bluetooth_sig/device/device.py) - -Already partially decomposed (DeviceConnected, DeviceAdvertising). Extract remaining responsibility pockets: - -Dependency resolution logic → src/bluetooth_sig/device/dependency_resolver.py -Connection lifecycle management → keep in connected.py -Device stays as composition root but slims to <400 lines -1.3 Split BaseCharacteristic (1,761 lines at src/bluetooth_sig/gatt/characteristics/base.py) - -Extract the multi-stage parsing pipeline stages into separate modules: - -src/bluetooth_sig/gatt/characteristics/pipeline/validation.py — length validation, range validation, type validation -src/bluetooth_sig/gatt/characteristics/pipeline/extraction.py — integer extraction, special value detection -src/bluetooth_sig/gatt/characteristics/pipeline/decoding.py — decode orchestration -base.py remains the ABC composing pipeline stages, targeting <600 lines -1.4 Split templates.py (1,488 lines at src/bluetooth_sig/gatt/characteristics/templates.py) - -Group templates by domain: - -templates/numeric.py — Uint8Template, Sint16Template, Uint16Template, PercentageTemplate -templates/temporal.py — DateTimeTemplate, ExactTime256Template, CurrentTimeTemplate -templates/enum.py — EnumTemplate -templates/base.py — CodingTemplate[T] base class + extractor/translator pipeline -templates/__init__.py — re-exports for backwards compat -Verification: All existing tests must pass after each split. Run python -m pytest tests/ -v and ./scripts/lint.sh --all after each module extraction. No public API changes — from bluetooth_sig.core.translator import BluetoothSIGTranslator must still work via re-exports. - -Workstream 2: Code Quality & Python 3.10 Upgrade -2.1 Bump minimum Python to >=3.10 - -Update requires-python in pyproject.toml -Update classifiers, Ruff target-version, mypy python_version -Remove all 3 TYPE_CHECKING blocks: encryption.py:15, client.py:18, device_types.py:5 — move guarded imports to top-level -Update CI matrix in test-coverage.yml to test 3.10+3.12 (drop 3.9) -2.2 Reduce # type: ignore comments (20 currently) - -Audit all 20 occurrences. For each, attempt to resolve with proper generics/overloads/protocols instead of suppression. Target: <=5 remaining, all with justification comments. - -2.3 Eliminate silent pass in except blocks (12 occurrences) - -uuid_registry.py L392, L420, L448 — add logger.debug() before pass to make failures visible -translator.py L553, L706 — same treatment -descriptors/registry.py L26 — log registration failures -Remaining: evaluate case-by-case; at minimum add debug logging -2.4 Tighten Any usage - -Audit the heaviest Any usage files (translator.py, device.py, connected.py). Introduce TypeVar bounds or Protocol types where Any is used as a shortcut rather than a necessity. The dynamic dispatch in parse_characteristic(str, bytes) → Any is inherently untyped — that's fine — but internal helper returns should be tightened. - -Verification: ./scripts/lint.sh --all and python -m pytest tests/ -v pass. mypy --strict remains clean. - -Workstream 3: Completeness — Missing Features & Stubs - -Gap analysis revealed that 2 original items were infeasible (3.6 auxiliary packets require radio -access; 3.3 SDP is irrelevant to BLE) and 1 was based on a false assumption about the YAML data -(3.2 profile YAMLs contain codec/param enums, not mandatory/optional service lists). The plan is -revised below. Implementation order follows priority (value ÷ risk). - -3.1 Fix PeripheralDevice + Add Tests — ✅ DONE (commit 39a53b6) - -peripheral_device.py was scaffolded but had a dead `translator` parameter — `SIGTranslatorProtocol` -is parse-only and encoding is already handled directly by `BaseCharacteristic.build_value()` on the -stored characteristic instances. The `_translator` field was never referenced. - -Completed: -- Removed `translator` parameter from `PeripheralDevice.__init__`; updated docstrings -- Added `Any` import justification comment (heterogeneous characteristic dict) -- Wrote 29 tests in tests/device/test_peripheral_device.py across 8 test classes: - - TestPeripheralDeviceInit (5 tests) — constructor, properties, empty state - - TestAddCharacteristic (4 tests) — registration, auto-service creation, duplicate service - - TestLifecycle (5 tests) — start flushes services, stop clears advertising, start-twice, - stop-when-not-started, add-after-start raises RuntimeError - - TestUpdateValue (5 tests) — encode + push, notify flag, unknown UUID KeyError - - TestUpdateRaw (1 test) — raw bytes push - - TestGetCurrentValue (3 tests) — initial value, latest value, unknown UUID KeyError - - TestFluentConfiguration (7 tests) — method chaining for manufacturer data, tx power, - connectable, discoverable - - TestAddService (1 test) — pre-built ServiceDefinition -- All tests pass, lint clean - -3.2 Profile Parameter Registries (Redesigned) - -Original plan proposed a monolithic `Profile` struct with mandatory/optional services. The 44 YAML -files across 14 profile subdirectories actually contain 5 distinct structural patterns: simple -name/value lookups, permitted-characteristics lists, codec parameters, protocol parameters, and -LTV structures. No YAML contains profile-level service requirements. - -Redesigned as per-category registries under registry/profiles/: - -a) PermittedCharacteristicsRegistry — loads ESS, UDS, IMDS permitted_characteristics YAMLs. - Query: get_permitted_characteristics("ess") → list of characteristic identifiers. - Struct: PermittedCharacteristicEntry(service: str, characteristics: list[str]). - Extends BaseGenericRegistry[PermittedCharacteristicEntry]. - -b) ProfileLookupRegistry — loads simple name/value files (A2DP codecs, TDS org IDs, ESL display - types, HFP bearer technologies, AVRCP types, MAP chat states, etc.). Single registry keyed - by YAML top-level key. - Query: get_entries("audio_codec_id") → list[ProfileLookupEntry]. - Struct: ProfileLookupEntry(name: str, value: int, metadata: dict[str, str]). - Extends BaseGenericRegistry[list[ProfileLookupEntry]]. - -c) ServiceDiscoveryAttributeRegistry — loads the 26 attribute_ids/*.yaml files plus - protocol_parameters.yaml and attribute_id_offsets_for_strings.yaml. - Query: get_attribute_ids("universal_attributes") → list[AttributeIdEntry]. - Struct: AttributeIdEntry(name: str, value: int). - -d) Defer generic_audio/ LTV structures — polymorphic nested schemas need a dedicated LTV codec - framework. Mark as follow-up. - -New files: -- src/bluetooth_sig/types/registry/profile_types.py (msgspec.Struct types) -- src/bluetooth_sig/registry/profiles/permitted_characteristics.py -- src/bluetooth_sig/registry/profiles/profile_lookup.py -- src/bluetooth_sig/registry/service_discovery/attribute_ids.py -- Tests for each registry - -3.3 — REMOVED (SDP irrelevant to BLE) - -SDP is classic Bluetooth (BR/EDR). This library is BLE-focused. The service_class.yaml UUIDs are -already accessible via the existing ServiceClassesRegistry. The attribute_ids/*.yaml loading is -rolled into 3.2c above. No standalone ServiceDiscoveryRegistry needed. - -3.4 GATT Coverage Gap Tracking - -Existing static analysis tests check consistency (implementation → enum → YAML) but not coverage -(YAML → implementation). This adds the reverse direction. - -New file: tests/static_analysis/test_yaml_implementation_coverage.py -- Load all UUIDs from characteristic_uuids.yaml (~481 entries) -- Compare against CharacteristicRegistry.get_instance()._get_sig_classes_map() keys -- Same for services (service_uuids.yaml vs GattServiceRegistry) -- Same for descriptors (descriptors.yaml vs DescriptorRegistry) -- Output as pytest warnings, not failures — the test reports coverage % without failing CI -- Print summary to stdout for CI visibility - -Verification: python -m pytest tests/static_analysis/test_yaml_implementation_coverage.py -v runs -and produces coverage report without failing. - -3.5 Advertising Location Struct Parsing — ✅ DONE (commit 31bf76a) - -The PDU parser stored Indoor Positioning, Transport Discovery Data, 3D Information, and Channel -Map Update Indication as raw bytes. All 4 are now parsed into typed structs. - -Completed (one file per type, not monolithic location.py as originally planned): -- src/bluetooth_sig/types/advertising/indoor_positioning.py - IndoorPositioningConfig(IntFlag) + IndoorPositioningData(msgspec.Struct) - Flag-driven WGS84/local coords, DataParser for all fields, optional uncertainty guard -- src/bluetooth_sig/types/advertising/transport_discovery.py - TDSFlags(IntFlag) + TransportBlock + TransportDiscoveryData - Multi-block iteration, role/state/incomplete as properties on TransportBlock -- src/bluetooth_sig/types/advertising/three_d_information.py - ThreeDInformationFlags(IntFlag) + ThreeDInformationData - Boolean properties for flag accessors (single source of truth — no duplicate fields) -- src/bluetooth_sig/types/advertising/channel_map_update.py - ChannelMapUpdateIndication with is_channel_used(channel) method, named constants -- ad_structures.py LocationAndSensingData fields changed from bytes to typed | None -- pdu_parser.py _handle_location_ad_types calls .decode() instead of raw assignment -- __init__.py exports updated for all new types -- 58 tests across 4 test files (tests/advertising/test_{indoor_positioning,transport_discovery, - three_d_information,channel_map_update}.py) covering decode, errors, properties, constants -- Patterns followed: IntFlag for all flags, DataParser for all parsing (auto InsufficientDataError), - msgspec.Struct frozen=True kw_only=True, one file per type -- All 5523 tests pass, lint clean - -3.6 — REMOVED (Auxiliary packet parsing is physically impossible) - -The _parse_auxiliary_packets stub is correct. The AuxiliaryPointer is a radio scheduling -instruction (channel, offset, PHY), not data. Resolving auxiliary chains requires real-time radio -access — impossible in a pure parser. If a PDU stream correlator is ever needed, it belongs in the -stream module, matching AuxiliaryPointer fields across separately captured PDUs. - -3.7 Stream Module: TTL Eviction + Stats - -TTL eviction prevents memory leaks in long-running processes (e.g. Home Assistant running for -months). A device that sends a glucose measurement but never the context leaves an incomplete -group in _buffer forever. The async variant is deferred — the sync callback pattern works well -with async callers already. - -Changes to DependencyPairingBuffer: -- Add max_age_seconds: float | None = None parameter to __init__ -- Store _group_timestamps: dict[Hashable, float] for first-seen time per group -- In ingest(), call _evict_stale() before processing (removes groups older than max_age_seconds) -- Add stats() → BufferStats(pending: int, completed: int, evicted: int) dataclass -- Track _completed_count and _evicted_count as instance counters - -Tests in tests/stream/test_pairing.py (extend existing file): -- TTL eviction: ingest partial group, advance time past TTL, verify stale group evicted -- Stats: verify counters after completions and evictions -- No-TTL default: existing tests pass unchanged - -Verification: python -m pytest tests/stream/ -v passes. No breaking changes. - -Implementation Priority: - -| # | Item | Effort | Value | Risk | Status | -|---|------|--------|-------|------|--------| -| 1 | 3.1 Fix PeripheralDevice + tests | Low | Medium | Low | ✅ DONE | -| 2 | 3.5 Location AD struct parsing | Medium | High | Low | ✅ DONE | -| 3 | 3.7 Stream TTL + stats | Low | Medium | Low | Not started | -| 4 | 3.4 GATT coverage gap tracking | Low | Medium | None | Not started | -| 5 | 3.2 Profile parameter registries | Medium | Medium | Medium | Not started | - -Verification: Each feature has its own test file with success + failure cases. All quality gates pass. - -Workstream 4: Testing & Quality Infrastructure -4.1 Add property-based testing with Hypothesis - -Add hypothesis to [project.optional-dependencies.test] in pyproject.toml -Target: parsing round-trip invariant — for every characteristic that supports parse_value + build_value, parse(build(x)) == x -Start with numeric templates (Uint8Template, Sint16Template, PercentageTemplate) — generate random valid ranges -Add fuzz tests for PDU parser: random bytes should never crash (return error, not exception) -4.2 Raise coverage threshold - -Current: 70% in test-coverage.yml -Target: 85% -Identify uncovered paths using pytest --cov-report=html and write targeted tests -4.3 Enable benchmarks in CI - -Add a separate CI job in test-coverage.yml that runs pytest tests/benchmarks/ --benchmark-only -Store benchmark results as CI artefacts -Add regression detection: fail if any benchmark regresses >20% from baseline -Use scripts/update_benchmark_history.py to track trends -4.4 Add integration test for round-trip encoding - -New test: tests/integration/test_round_trip.py - -For every characteristic that implements both parse_value and build_value, verify parse(build(x)) == x -Systematic coverage, not just spot-checks -Verification: python -m pytest tests/ -v --cov --cov-fail-under=85 passes. Benchmark job runs in CI. - -Workstream 5: Documentation & Examples -5.1 Fix misleading README code samples - -The README.md "Translator API (Device Scanning)" section shows result.info.name and result.value — but parse_characteristic returns the parsed value directly, not a wrapper object. Fix the code sample to match actual API. - -5.2 Document undocumented features in README - -Add sections for: - -Stream module (DependencyPairingBuffer) -PeripheralManagerProtocol (and PeripheralDevice once built) -EAD encryption/decryption support -Async session API (AsyncParsingSession) -5.3 Generate static CHANGELOG.md - -git-cliff is configured but no CHANGELOG file exists. Add a just changelog command and generate CHANGELOG.md from git history. Add to CI release workflow. - -5.4 Clean up examples - -Remove stubs in with_bleak_retry.py (robust_service_discovery and notification_monitoring that print "not yet implemented") -Either implement or remove incomplete examples -Remove emoji from output strings (keep professional) -5.5 Update copilot instructions - -Remove TYPE_CHECKING prohibition (it was already violated in 3 places; with Python >=3.10, the need disappears anyway) -Update Python version references -Add the new module boundaries from Workstream 1 to architecture docs -Verification: pytest tests/docs/test_docs_code_blocks.py passes (validates code samples in docs). Link checking on all markdown files. \ No newline at end of file diff --git a/src/bluetooth_sig/core/encoder.py b/src/bluetooth_sig/core/encoder.py index 732cc1d2..71df21b1 100644 --- a/src/bluetooth_sig/core/encoder.py +++ b/src/bluetooth_sig/core/encoder.py @@ -22,7 +22,6 @@ from ..types import ( ValidationResult, ) -from ..types.gatt_enums import ValueType from ..types.uuid import BluetoothUUID from .parser import CharacteristicParser @@ -166,11 +165,11 @@ def _get_characteristic_value_type_class( # pylint: disable=too-many-return-sta ): return return_annotation # type: ignore[no-any-return] # Dynamic introspection fallback via inspect.signature - # Try to get from _manual_value_type attribute - if hasattr(characteristic, "_manual_value_type"): - manual_type = characteristic._manual_value_type # pylint: disable=protected-access - if manual_type and isinstance(manual_type, str) and hasattr(templates, manual_type): - return getattr(templates, manual_type) # type: ignore[no-any-return] # Runtime template lookup by string name + manual_type = characteristic._python_type # pylint: disable=protected-access + if manual_type and isinstance(manual_type, type): + return manual_type + if manual_type and isinstance(manual_type, str) and hasattr(templates, manual_type): + return getattr(templates, manual_type) # type: ignore[no-any-return] # Runtime template lookup by string name # Try to get from template if hasattr(characteristic, "_template") and characteristic._template: # pylint: disable=protected-access @@ -180,18 +179,10 @@ def _get_characteristic_value_type_class( # pylint: disable=too-many-return-sta if args: return args[0] # type: ignore[no-any-return] # Generic type arg extraction from __orig_class__ - # For simple types, check info.value_type + # For simple types, check info.python_type info = characteristic.info - if info.value_type == ValueType.INT: - return int - if info.value_type == ValueType.FLOAT: - return float - if info.value_type == ValueType.STRING: - return str - if info.value_type == ValueType.BOOL: - return bool - if info.value_type == ValueType.BYTES: - return bytes + if isinstance(info.python_type, type) and info.python_type in (int, float, str, bool, bytes): + return info.python_type return None diff --git a/src/bluetooth_sig/core/query.py b/src/bluetooth_sig/core/query.py index 61cbd9ef..a37de577 100644 --- a/src/bluetooth_sig/core/query.py +++ b/src/bluetooth_sig/core/query.py @@ -17,7 +17,7 @@ ServiceInfo, SIGInfo, ) -from ..types.gatt_enums import CharacteristicName, ValueType +from ..types.gatt_enums import CharacteristicName from ..types.uuid import BluetoothUUID logger = logging.getLogger(__name__) @@ -48,18 +48,18 @@ def supports(self, uuid: str) -> bool: else: return char_class is not None - def get_value_type(self, uuid: str) -> ValueType | None: - """Get the expected value type for a characteristic. + def get_value_type(self, uuid: str) -> type | str | None: + """Get the expected Python type for a characteristic. Args: uuid: The characteristic UUID (16-bit short form or full 128-bit) Returns: - ValueType enum if characteristic is found, None otherwise + Python type if characteristic is found, None otherwise """ info = self.get_characteristic_info_by_uuid(uuid) - return info.value_type if info else None + return info.python_type if info else None def get_characteristic_info_by_uuid(self, uuid: str) -> CharacteristicInfo | None: """Get information about a characteristic by UUID. @@ -268,16 +268,10 @@ def get_sig_info_by_name(self, name: str) -> SIGInfo | None: try: char_info = uuid_registry.get_characteristic_info(name) if char_info: - value_type = ValueType.UNKNOWN - if char_info.value_type: - try: - value_type = ValueType(char_info.value_type) - except (ValueError, KeyError): - value_type = ValueType.UNKNOWN return CharacteristicInfo( uuid=char_info.uuid, name=char_info.name, - value_type=value_type, + python_type=char_info.python_type, unit=char_info.unit or "", ) except (KeyError, ValueError, AttributeError): diff --git a/src/bluetooth_sig/core/registration.py b/src/bluetooth_sig/core/registration.py index 853f223a..262b0017 100644 --- a/src/bluetooth_sig/core/registration.py +++ b/src/bluetooth_sig/core/registration.py @@ -54,7 +54,7 @@ def register_custom_characteristic_class( name=info.name or cls.__name__, identifier=info.id, unit=info.unit, - value_type=info.value_type, + python_type=info.python_type, override=override, ) diff --git a/src/bluetooth_sig/core/service_manager.py b/src/bluetooth_sig/core/service_manager.py index cbdff317..33697aa4 100644 --- a/src/bluetooth_sig/core/service_manager.py +++ b/src/bluetooth_sig/core/service_manager.py @@ -11,7 +11,7 @@ from ..gatt.services.base import BaseGattService from ..gatt.services.registry import GattServiceRegistry from ..types import CharacteristicInfo -from ..types.gatt_enums import ValueType +from ..types.gatt_enums import WIRE_TYPE_MAP from ..types.uuid import BluetoothUUID # Type alias for characteristic data in process_services @@ -43,17 +43,16 @@ def process_services(self, services: dict[str, dict[str, CharacteristicDataDict] for char_uuid_str, char_data in service_data.get("characteristics", {}).items(): char_uuid = BluetoothUUID(char_uuid_str) vtype_raw = char_data.get("value_type", "bytes") + python_type: type | None = None if isinstance(vtype_raw, str): - value_type = ValueType(vtype_raw) - elif isinstance(vtype_raw, ValueType): - value_type = vtype_raw - else: - value_type = ValueType.BYTES + python_type = WIRE_TYPE_MAP.get(vtype_raw.lower()) + elif isinstance(vtype_raw, type): + python_type = vtype_raw characteristics[char_uuid] = CharacteristicInfo( uuid=char_uuid, name=char_data.get("name", ""), unit=char_data.get("unit", ""), - value_type=value_type, + python_type=python_type, ) service = GattServiceRegistry.create_service(uuid, characteristics) if service: diff --git a/src/bluetooth_sig/core/translator.py b/src/bluetooth_sig/core/translator.py index 2600127e..3e5c5871 100644 --- a/src/bluetooth_sig/core/translator.py +++ b/src/bluetooth_sig/core/translator.py @@ -28,7 +28,7 @@ SIGInfo, ValidationResult, ) -from ..types.gatt_enums import CharacteristicName, ValueType +from ..types.gatt_enums import CharacteristicName from ..types.uuid import BluetoothUUID from .encoder import CharacteristicEncoder from .parser import CharacteristicParser @@ -301,14 +301,14 @@ def create_value(self, uuid: str, **kwargs: Any) -> Any: # noqa: ANN401 # Query / Info # ------------------------------------------------------------------------- - def get_value_type(self, uuid: str) -> ValueType | None: - """Get the expected value type for a characteristic. + def get_value_type(self, uuid: str) -> type | str | None: + """Get the expected Python type for a characteristic. Args: uuid: The characteristic UUID (16-bit short form or full 128-bit) Returns: - ValueType enum if characteristic is found, None otherwise + Python type if characteristic is found, None otherwise """ return self._query.get_value_type(uuid) @@ -518,7 +518,7 @@ def register_custom_characteristic_class( Args: uuid_or_name: The characteristic UUID or name cls: The characteristic class to register - info: Optional CharacteristicInfo with metadata (name, unit, value_type) + info: Optional CharacteristicInfo with metadata (name, unit, python_type) override: Whether to override existing registrations Raises: @@ -527,7 +527,7 @@ def register_custom_characteristic_class( Example:: - from bluetooth_sig import BluetoothSIGTranslator, CharacteristicInfo, ValueType + from bluetooth_sig import BluetoothSIGTranslator, CharacteristicInfo from bluetooth_sig.types import BluetoothUUID translator = BluetoothSIGTranslator() @@ -535,7 +535,7 @@ def register_custom_characteristic_class( uuid=BluetoothUUID("12345678-1234-1234-1234-123456789abc"), name="Custom Temperature", unit="°C", - value_type=ValueType.FLOAT, + python_type=float, ) translator.register_custom_characteristic_class(str(info.uuid), MyCustomChar, info=info) diff --git a/src/bluetooth_sig/device/characteristic_io.py b/src/bluetooth_sig/device/characteristic_io.py index c0194c94..09e717bf 100644 --- a/src/bluetooth_sig/device/characteristic_io.py +++ b/src/bluetooth_sig/device/characteristic_io.py @@ -8,7 +8,7 @@ import logging from collections.abc import Callable -from typing import TYPE_CHECKING, Any, TypeVar, cast, overload +from typing import Any, TypeVar, cast, overload from ..gatt.characteristics import CharacteristicName from ..gatt.characteristics.base import BaseCharacteristic @@ -17,9 +17,7 @@ from ..types.uuid import BluetoothUUID from .client import ClientManagerProtocol from .dependency_resolver import DependencyResolutionMode, DependencyResolver - -if TYPE_CHECKING: - from .protocols import SIGTranslatorProtocol +from .protocols import SIGTranslatorProtocol logger = logging.getLogger(__name__) diff --git a/src/bluetooth_sig/device/peripheral.py b/src/bluetooth_sig/device/peripheral.py index 972d314c..8355fed4 100644 --- a/src/bluetooth_sig/device/peripheral.py +++ b/src/bluetooth_sig/device/peripheral.py @@ -10,11 +10,12 @@ Adapters must provide async implementations of all abstract methods below. -TODO: Create PeripheralDevice class (analogous to Device) that provides: - - High-level abstraction over PeripheralManagerProtocol - - SIG translator integration for encoding values - - Hosted service state management (like DeviceConnected for clients) - - See device.py for the client-side pattern to follow +TODO: PeripheralDevice exists in peripheral_device.py with core functionality. + Remaining gaps to address (see ROADMAP.md Workstream F): + - Subscription management (on_subscribe/on_unsubscribe, subscribed_clients tracking) + - Client event callbacks (on_client_connected/on_client_disconnected) + - Read/write request handling (typed on_read_request/on_write_request) + - Descriptor hosting (CCCD, User Description, Presentation Format) """ from __future__ import annotations diff --git a/src/bluetooth_sig/gatt/characteristics/__init__.py b/src/bluetooth_sig/gatt/characteristics/__init__.py index 09cdfbd2..ca8eab8f 100644 --- a/src/bluetooth_sig/gatt/characteristics/__init__.py +++ b/src/bluetooth_sig/gatt/characteristics/__init__.py @@ -56,12 +56,24 @@ from .boot_mouse_input_report import BootMouseInputReportCharacteristic, BootMouseInputReportData, MouseButtons from .caloric_intake import CaloricIntakeCharacteristic from .carbon_monoxide_concentration import CarbonMonoxideConcentrationCharacteristic +from .chromatic_distance_from_planckian import ChromaticDistanceFromPlanckianCharacteristic from .chromaticity_coordinate import ChromaticityCoordinateCharacteristic +from .chromaticity_coordinates import ChromaticityCoordinatesCharacteristic, ChromaticityCoordinatesData +from .chromaticity_in_cct_and_duv_values import ( + ChromaticityInCCTAndDuvData, + ChromaticityInCCTAndDuvValuesCharacteristic, +) +from .chromaticity_tolerance import ChromaticityToleranceCharacteristic +from .cie_13_3_1995_color_rendering_index import CIE133ColorRenderingIndexCharacteristic from .co2_concentration import CO2ConcentrationCharacteristic from .coefficient import CoefficientCharacteristic +from .contact_status_8 import ContactStatus, ContactStatus8Characteristic +from .content_control_id import ContentControlIdCharacteristic from .correlated_color_temperature import CorrelatedColorTemperatureCharacteristic +from .cosine_of_the_angle import CosineOfTheAngleCharacteristic from .count_16 import Count16Characteristic from .count_24 import Count24Characteristic +from .country_code import CountryCodeCharacteristic from .csc_feature import CSCFeatureCharacteristic from .csc_measurement import CSCMeasurementCharacteristic from .current_time import CurrentTimeCharacteristic @@ -73,11 +85,13 @@ from .date_of_birth import DateOfBirthCharacteristic from .date_of_threshold_assessment import DateOfThresholdAssessmentCharacteristic from .date_time import DateTimeCharacteristic +from .date_utc import DateUtcCharacteristic from .day_date_time import DayDateTimeCharacteristic, DayDateTimeData from .day_of_week import DayOfWeekCharacteristic from .device_name import DeviceNameCharacteristic from .device_wearing_position import DeviceWearingPositionCharacteristic from .dew_point import DewPointCharacteristic +from .door_window_status import DoorWindowOpenStatus, DoorWindowStatusCharacteristic from .dst_offset import DstOffsetCharacteristic from .electric_current import ElectricCurrentCharacteristic from .electric_current_range import ElectricCurrentRangeCharacteristic @@ -85,15 +99,30 @@ from .electric_current_statistics import ElectricCurrentStatisticsCharacteristic from .elevation import ElevationCharacteristic from .email_address import EmailAddressCharacteristic +from .energy import EnergyCharacteristic +from .energy_32 import Energy32Characteristic +from .energy_in_a_period_of_day import ( + EnergyInAPeriodOfDayCharacteristic, + EnergyInAPeriodOfDayData, +) +from .estimated_service_date import EstimatedServiceDateCharacteristic +from .event_statistics import EventStatisticsCharacteristic, EventStatisticsData from .exact_time_256 import ExactTime256Characteristic, ExactTime256Data from .fat_burn_heart_rate_lower_limit import FatBurnHeartRateLowerLimitCharacteristic from .fat_burn_heart_rate_upper_limit import FatBurnHeartRateUpperLimitCharacteristic from .firmware_revision_string import FirmwareRevisionStringCharacteristic from .first_name import FirstNameCharacteristic from .five_zone_heart_rate_limits import FiveZoneHeartRateLimitsCharacteristic +from .fixed_string_8 import FixedString8Characteristic +from .fixed_string_16 import FixedString16Characteristic +from .fixed_string_24 import FixedString24Characteristic +from .fixed_string_36 import FixedString36Characteristic +from .fixed_string_64 import FixedString64Characteristic from .force import ForceCharacteristic from .four_zone_heart_rate_limits import FourZoneHeartRateLimitsCharacteristic from .gender import Gender, GenderCharacteristic +from .generic_level import GenericLevelCharacteristic +from .global_trade_item_number import GlobalTradeItemNumberCharacteristic from .glucose_feature import GlucoseFeatureCharacteristic, GlucoseFeatures from .glucose_measurement import GlucoseMeasurementCharacteristic, GlucoseMeasurementFlags from .glucose_measurement_context import GlucoseMeasurementContextCharacteristic, GlucoseMeasurementContextFlags @@ -107,16 +136,22 @@ from .height import HeightCharacteristic from .high_intensity_exercise_threshold import HighIntensityExerciseThresholdCharacteristic from .high_resolution_height import HighResolutionHeightCharacteristic +from .high_temperature import HighTemperatureCharacteristic from .high_voltage import HighVoltageCharacteristic from .hip_circumference import HipCircumferenceCharacteristic from .humidity import HumidityCharacteristic +from .humidity_8 import Humidity8Characteristic from .illuminance import IlluminanceCharacteristic +from .illuminance_16 import Illuminance16Characteristic from .indoor_positioning_configuration import IndoorPositioningConfigurationCharacteristic from .intermediate_temperature import IntermediateTemperatureCharacteristic from .irradiance import IrradianceCharacteristic from .language import LanguageCharacteristic from .last_name import LastNameCharacteristic from .latitude import LatitudeCharacteristic +from .light_distribution import LightDistributionCharacteristic, LightDistributionType +from .light_output import LightOutputCharacteristic +from .light_source_type import LightSourceTypeCharacteristic, LightSourceTypeValue from .linear_position import LinearPositionCharacteristic from .ln_control_point import LNControlPointCharacteristic from .ln_feature import LNFeatureCharacteristic @@ -126,10 +161,17 @@ from .location_and_speed import LocationAndSpeedCharacteristic from .location_name import LocationNameCharacteristic from .longitude import LongitudeCharacteristic +from .luminous_efficacy import LuminousEfficacyCharacteristic +from .luminous_energy import LuminousEnergyCharacteristic +from .luminous_exposure import LuminousExposureCharacteristic +from .luminous_flux import LuminousFluxCharacteristic +from .luminous_flux_range import LuminousFluxRangeCharacteristic, LuminousFluxRangeData +from .luminous_intensity import LuminousIntensityCharacteristic from .magnetic_declination import MagneticDeclinationCharacteristic from .magnetic_flux_density_2d import MagneticFluxDensity2DCharacteristic from .magnetic_flux_density_3d import MagneticFluxDensity3DCharacteristic from .manufacturer_name_string import ManufacturerNameStringCharacteristic +from .mass_flow import MassFlowCharacteristic from .maximum_recommended_heart_rate import MaximumRecommendedHeartRateCharacteristic from .measurement_interval import MeasurementIntervalCharacteristic from .methane_concentration import MethaneConcentrationCharacteristic @@ -140,7 +182,15 @@ from .nitrogen_dioxide_concentration import NitrogenDioxideConcentrationCharacteristic from .noise import NoiseCharacteristic from .non_methane_voc_concentration import NonMethaneVOCConcentrationCharacteristic +from .object_first_created import ObjectFirstCreatedCharacteristic +from .object_id import ObjectIdCharacteristic +from .object_last_modified import ObjectLastModifiedCharacteristic +from .object_name import ObjectNameCharacteristic +from .object_type import ObjectTypeCharacteristic from .ozone_concentration import OzoneConcentrationCharacteristic +from .perceived_lightness import PerceivedLightnessCharacteristic +from .percentage_8 import Percentage8Characteristic +from .percentage_8_steps import Percentage8StepsCharacteristic from .peripheral_preferred_connection_parameters import ( ConnectionParametersData, PeripheralPreferredConnectionParametersCharacteristic, @@ -153,14 +203,49 @@ from .pnp_id import PnpIdCharacteristic, PnpIdData from .pollen_concentration import PollenConcentrationCharacteristic from .position_quality import PositionQualityCharacteristic +from .power import PowerCharacteristic from .power_specification import PowerSpecificationCharacteristic +from .precise_acceleration_3d import PreciseAcceleration3DCharacteristic from .preferred_units import PreferredUnitsCharacteristic, PreferredUnitsData from .pressure import PressureCharacteristic from .pulse_oximetry_measurement import PulseOximetryMeasurementCharacteristic +from .pushbutton_status_8 import ( + ButtonStatus, + PushbuttonStatus8Characteristic, + PushbuttonStatus8Data, +) from .rainfall import RainfallCharacteristic from .reconnection_address import ReconnectionAddressCharacteristic from .reference_time_information import ReferenceTimeInformationCharacteristic from .registry import CharacteristicName, CharacteristicRegistry, get_characteristic_class_map +from .relative_runtime_in_a_correlated_color_temperature_range import ( + RelativeRuntimeInACCTRangeData, + RelativeRuntimeInACorrelatedColorTemperatureRangeCharacteristic, +) +from .relative_runtime_in_a_current_range import ( + RelativeRuntimeInACurrentRangeCharacteristic, + RelativeRuntimeInACurrentRangeData, +) +from .relative_runtime_in_a_generic_level_range import ( + RelativeRuntimeInAGenericLevelRangeCharacteristic, + RelativeRuntimeInAGenericLevelRangeData, +) +from .relative_value_in_a_period_of_day import ( + RelativeValueInAPeriodOfDayCharacteristic, + RelativeValueInAPeriodOfDayData, +) +from .relative_value_in_a_temperature_range import ( + RelativeValueInATemperatureRangeCharacteristic, + RelativeValueInATemperatureRangeData, +) +from .relative_value_in_a_voltage_range import ( + RelativeValueInAVoltageRangeCharacteristic, + RelativeValueInAVoltageRangeData, +) +from .relative_value_in_an_illuminance_range import ( + RelativeValueInAnIlluminanceRangeCharacteristic, + RelativeValueInAnIlluminanceRangeData, +) from .resting_heart_rate import RestingHeartRateCharacteristic from .rotational_speed import RotationalSpeedCharacteristic from .rsc_feature import RSCFeatureCharacteristic @@ -168,6 +253,7 @@ from .scan_interval_window import ScanIntervalWindowCharacteristic from .scan_refresh import ScanRefreshCharacteristic from .sedentary_interval_notification import SedentaryIntervalNotificationCharacteristic +from .sensor_location import SensorLocationCharacteristic, SensorLocationValue from .serial_number_string import SerialNumberStringCharacteristic from .service_changed import ServiceChangedCharacteristic, ServiceChangedData from .software_revision_string import SoftwareRevisionStringCharacteristic @@ -177,20 +263,56 @@ ) from .stride_length import StrideLengthCharacteristic from .sulfur_dioxide_concentration import SulfurDioxideConcentrationCharacteristic +from .sulfur_hexafluoride_concentration import SulfurHexafluorideConcentrationCharacteristic +from .supported_heart_rate_range import ( + SupportedHeartRateRangeCharacteristic, + SupportedHeartRateRangeData, +) +from .supported_inclination_range import ( + SupportedInclinationRangeCharacteristic, + SupportedInclinationRangeData, +) from .supported_new_alert_category import SupportedNewAlertCategoryCharacteristic from .supported_power_range import SupportedPowerRangeCharacteristic +from .supported_resistance_level_range import ( + SupportedResistanceLevelRangeCharacteristic, + SupportedResistanceLevelRangeData, +) +from .supported_speed_range import SupportedSpeedRangeCharacteristic, SupportedSpeedRangeData from .supported_unread_alert_category import SupportedUnreadAlertCategoryCharacteristic from .system_id import SystemIdCharacteristic, SystemIdData from .temperature import TemperatureCharacteristic +from .temperature_8 import Temperature8Characteristic +from .temperature_8_in_a_period_of_day import ( + Temperature8InAPeriodOfDayCharacteristic, + Temperature8InAPeriodOfDayData, +) +from .temperature_8_statistics import ( + Temperature8StatisticsCharacteristic, + Temperature8StatisticsData, +) from .temperature_measurement import TemperatureMeasurementCharacteristic +from .temperature_range import TemperatureRangeCharacteristic, TemperatureRangeData +from .temperature_statistics import ( + TemperatureStatisticsCharacteristic, + TemperatureStatisticsData, +) from .temperature_type import TemperatureTypeCharacteristic from .three_zone_heart_rate_limits import ThreeZoneHeartRateLimitsCharacteristic from .time_accuracy import TimeAccuracyCharacteristic +from .time_decihour_8 import TimeDecihour8Characteristic +from .time_exponential_8 import TimeExponential8Characteristic +from .time_hour_24 import TimeHour24Characteristic +from .time_millisecond_24 import TimeMillisecond24Characteristic +from .time_second_8 import TimeSecond8Characteristic +from .time_second_16 import TimeSecond16Characteristic +from .time_second_32 import TimeSecond32Characteristic from .time_source import TimeSourceCharacteristic from .time_update_control_point import TimeUpdateControlPointCharacteristic from .time_update_state import TimeUpdateCurrentState, TimeUpdateResult, TimeUpdateState, TimeUpdateStateCharacteristic from .time_with_dst import TimeWithDstCharacteristic from .time_zone import TimeZoneCharacteristic +from .torque import TorqueCharacteristic from .true_wind_direction import TrueWindDirectionCharacteristic from .true_wind_speed import TrueWindSpeedCharacteristic from .two_zone_heart_rate_limits import TwoZoneHeartRateLimitsCharacteristic @@ -205,6 +327,7 @@ from .voltage_frequency import VoltageFrequencyCharacteristic from .voltage_specification import VoltageSpecificationCharacteristic from .voltage_statistics import VoltageStatisticsCharacteristic +from .volume_flow import VolumeFlowCharacteristic from .waist_circumference import WaistCircumferenceCharacteristic from .weight import WeightCharacteristic from .weight_measurement import WeightMeasurementCharacteristic @@ -266,11 +389,23 @@ "CharacteristicName", "CharacteristicRegistry", "ChromaticityCoordinateCharacteristic", + "ChromaticityCoordinatesCharacteristic", + "ChromaticityCoordinatesData", + "ChromaticityInCCTAndDuvData", + "ChromaticityInCCTAndDuvValuesCharacteristic", + "ChromaticityToleranceCharacteristic", + "ChromaticDistanceFromPlanckianCharacteristic", + "CIE133ColorRenderingIndexCharacteristic", "CoefficientCharacteristic", "ConnectionParametersData", + "ContactStatus", + "ContactStatus8Characteristic", + "ContentControlIdCharacteristic", "CorrelatedColorTemperatureCharacteristic", + "CosineOfTheAngleCharacteristic", "Count16Characteristic", "Count24Characteristic", + "CountryCodeCharacteristic", "CurrentTimeCharacteristic", "CyclingPowerControlPointCharacteristic", "CyclingPowerFeatureCharacteristic", @@ -280,12 +415,15 @@ "DateOfBirthCharacteristic", "DateOfThresholdAssessmentCharacteristic", "DateTimeCharacteristic", + "DateUtcCharacteristic", "DayDateTimeCharacteristic", "DayDateTimeData", "DayOfWeekCharacteristic", "DeviceNameCharacteristic", "DeviceWearingPositionCharacteristic", "DewPointCharacteristic", + "DoorWindowOpenStatus", + "DoorWindowStatusCharacteristic", "DstOffsetCharacteristic", "ElectricCurrentCharacteristic", "ElectricCurrentRangeCharacteristic", @@ -293,17 +431,31 @@ "ElectricCurrentStatisticsCharacteristic", "ElevationCharacteristic", "EmailAddressCharacteristic", + "EnergyCharacteristic", + "Energy32Characteristic", + "EnergyInAPeriodOfDayCharacteristic", + "EnergyInAPeriodOfDayData", + "EstimatedServiceDateCharacteristic", "ExactTime256Characteristic", "ExactTime256Data", + "EventStatisticsCharacteristic", + "EventStatisticsData", "FatBurnHeartRateLowerLimitCharacteristic", "FatBurnHeartRateUpperLimitCharacteristic", "FirmwareRevisionStringCharacteristic", "FirstNameCharacteristic", "FiveZoneHeartRateLimitsCharacteristic", + "FixedString8Characteristic", + "FixedString16Characteristic", + "FixedString24Characteristic", + "FixedString36Characteristic", + "FixedString64Characteristic", "ForceCharacteristic", "FourZoneHeartRateLimitsCharacteristic", "Gender", "GenderCharacteristic", + "GenericLevelCharacteristic", + "GlobalTradeItemNumberCharacteristic", "GlucoseFeatureCharacteristic", "GlucoseFeatures", "GlucoseMeasurementCharacteristic", @@ -321,10 +473,13 @@ "HeightCharacteristic", "HighIntensityExerciseThresholdCharacteristic", "HighResolutionHeightCharacteristic", + "HighTemperatureCharacteristic", "HighVoltageCharacteristic", "HipCircumferenceCharacteristic", "HumidityCharacteristic", + "Humidity8Characteristic", "IlluminanceCharacteristic", + "Illuminance16Characteristic", "IndoorPositioningConfigurationCharacteristic", "IntermediateTemperatureCharacteristic", "IrradianceCharacteristic", @@ -333,6 +488,11 @@ "LanguageCharacteristic", "LastNameCharacteristic", "LatitudeCharacteristic", + "LightDistributionCharacteristic", + "LightDistributionType", + "LightOutputCharacteristic", + "LightSourceTypeCharacteristic", + "LightSourceTypeValue", "LinearPositionCharacteristic", "LocalEastCoordinateCharacteristic", "LocalNorthCoordinateCharacteristic", @@ -340,10 +500,18 @@ "LocationAndSpeedCharacteristic", "LocationNameCharacteristic", "LongitudeCharacteristic", + "LuminousEfficacyCharacteristic", + "LuminousEnergyCharacteristic", + "LuminousExposureCharacteristic", + "LuminousFluxCharacteristic", + "LuminousFluxRangeCharacteristic", + "LuminousFluxRangeData", + "LuminousIntensityCharacteristic", "MagneticDeclinationCharacteristic", "MagneticFluxDensity2DCharacteristic", "MagneticFluxDensity3DCharacteristic", "ManufacturerNameStringCharacteristic", + "MassFlowCharacteristic", "MaximumRecommendedHeartRateCharacteristic", "MeasurementIntervalCharacteristic", "MethaneConcentrationCharacteristic", @@ -354,6 +522,11 @@ "NitrogenDioxideConcentrationCharacteristic", "NoiseCharacteristic", "NonMethaneVOCConcentrationCharacteristic", + "ObjectFirstCreatedCharacteristic", + "ObjectIdCharacteristic", + "ObjectLastModifiedCharacteristic", + "ObjectNameCharacteristic", + "ObjectTypeCharacteristic", "OzoneConcentrationCharacteristic", "PLXFeatureFlags", "PLXFeaturesCharacteristic", @@ -362,25 +535,49 @@ "PM25ConcentrationCharacteristic", "PeripheralPreferredConnectionParametersCharacteristic", "PeripheralPrivacyFlagCharacteristic", + "PerceivedLightnessCharacteristic", + "Percentage8Characteristic", + "Percentage8StepsCharacteristic", "PnpIdCharacteristic", "PnpIdData", "PollenConcentrationCharacteristic", "PositionQualityCharacteristic", + "PowerCharacteristic", "PowerSpecificationCharacteristic", + "PreciseAcceleration3DCharacteristic", "PreferredUnitsCharacteristic", "PreferredUnitsData", "PressureCharacteristic", + "PushbuttonStatus8Characteristic", + "PushbuttonStatus8Data", + "ButtonStatus", "PulseOximetryMeasurementCharacteristic", "RSCFeatureCharacteristic", "RSCMeasurementCharacteristic", "RainfallCharacteristic", "ReconnectionAddressCharacteristic", "ReferenceTimeInformationCharacteristic", + "RelativeRuntimeInACCTRangeData", + "RelativeRuntimeInACorrelatedColorTemperatureRangeCharacteristic", + "RelativeRuntimeInACurrentRangeCharacteristic", + "RelativeRuntimeInACurrentRangeData", + "RelativeRuntimeInAGenericLevelRangeCharacteristic", + "RelativeRuntimeInAGenericLevelRangeData", + "RelativeValueInAPeriodOfDayCharacteristic", + "RelativeValueInAPeriodOfDayData", + "RelativeValueInATemperatureRangeCharacteristic", + "RelativeValueInATemperatureRangeData", + "RelativeValueInAVoltageRangeCharacteristic", + "RelativeValueInAVoltageRangeData", + "RelativeValueInAnIlluminanceRangeCharacteristic", + "RelativeValueInAnIlluminanceRangeData", "RestingHeartRateCharacteristic", "RotationalSpeedCharacteristic", "ScanIntervalWindowCharacteristic", "ScanRefreshCharacteristic", "SedentaryIntervalNotificationCharacteristic", + "SensorLocationCharacteristic", + "SensorLocationValue", "SerialNumberStringCharacteristic", "ServiceChangedCharacteristic", "ServiceChangedData", @@ -389,16 +586,41 @@ "SportTypeForAerobicAndAnaerobicThresholdsCharacteristic", "StrideLengthCharacteristic", "SulfurDioxideConcentrationCharacteristic", + "SulfurHexafluorideConcentrationCharacteristic", + "SupportedHeartRateRangeCharacteristic", + "SupportedHeartRateRangeData", + "SupportedInclinationRangeCharacteristic", + "SupportedInclinationRangeData", "SupportedNewAlertCategoryCharacteristic", "SupportedPowerRangeCharacteristic", + "SupportedResistanceLevelRangeCharacteristic", + "SupportedResistanceLevelRangeData", + "SupportedSpeedRangeCharacteristic", + "SupportedSpeedRangeData", "SupportedUnreadAlertCategoryCharacteristic", "SystemIdCharacteristic", "SystemIdData", "TemperatureCharacteristic", + "Temperature8Characteristic", + "Temperature8InAPeriodOfDayCharacteristic", + "Temperature8InAPeriodOfDayData", + "Temperature8StatisticsCharacteristic", + "Temperature8StatisticsData", + "TemperatureRangeCharacteristic", + "TemperatureRangeData", + "TemperatureStatisticsCharacteristic", + "TemperatureStatisticsData", "TemperatureMeasurementCharacteristic", "TemperatureTypeCharacteristic", "ThreeZoneHeartRateLimitsCharacteristic", "TimeAccuracyCharacteristic", + "TimeDecihour8Characteristic", + "TimeExponential8Characteristic", + "TimeHour24Characteristic", + "TimeMillisecond24Characteristic", + "TimeSecond8Characteristic", + "TimeSecond16Characteristic", + "TimeSecond32Characteristic", "TimeSourceCharacteristic", "TimeUpdateControlPointCharacteristic", "TimeUpdateCurrentState", @@ -407,6 +629,7 @@ "TimeUpdateStateCharacteristic", "TimeWithDstCharacteristic", "TimeZoneCharacteristic", + "TorqueCharacteristic", "TrueWindDirectionCharacteristic", "TrueWindSpeedCharacteristic", "TwoZoneHeartRateLimitsCharacteristic", @@ -421,6 +644,7 @@ "VoltageFrequencyCharacteristic", "VoltageSpecificationCharacteristic", "VoltageStatisticsCharacteristic", + "VolumeFlowCharacteristic", "WaistCircumferenceCharacteristic", "WeightCharacteristic", "WeightMeasurementCharacteristic", diff --git a/src/bluetooth_sig/gatt/characteristics/acceleration_3d.py b/src/bluetooth_sig/gatt/characteristics/acceleration_3d.py index 5454f611..ab66993f 100644 --- a/src/bluetooth_sig/gatt/characteristics/acceleration_3d.py +++ b/src/bluetooth_sig/gatt/characteristics/acceleration_3d.py @@ -18,7 +18,6 @@ class Acceleration3DCharacteristic(BaseCharacteristic[VectorData]): _characteristic_name: str | None = "Acceleration 3D" resolution: float = 0.01 - _manual_value_type = "VectorData" # BaseCharacteristic handles validation expected_length = 3 diff --git a/src/bluetooth_sig/gatt/characteristics/altitude.py b/src/bluetooth_sig/gatt/characteristics/altitude.py index b9dc81a9..f37c5c30 100644 --- a/src/bluetooth_sig/gatt/characteristics/altitude.py +++ b/src/bluetooth_sig/gatt/characteristics/altitude.py @@ -17,7 +17,6 @@ class AltitudeCharacteristic(BaseCharacteristic[float]): # Validation attributes # Manual overrides required as Bluetooth SIG registry doesn't provide unit/value type _manual_unit = "m" - _manual_value_type = "float" # SIG spec: sint16, resolution 0.1 m → fixed 2-byte payload # No GSS YAML available, so set explicit length from spec expected_length = 2 diff --git a/src/bluetooth_sig/gatt/characteristics/appearance.py b/src/bluetooth_sig/gatt/characteristics/appearance.py index 76a230bf..b3c0e6fe 100644 --- a/src/bluetooth_sig/gatt/characteristics/appearance.py +++ b/src/bluetooth_sig/gatt/characteristics/appearance.py @@ -17,7 +17,6 @@ class AppearanceCharacteristic(BaseCharacteristic[AppearanceData]): Appearance characteristic with human-readable device type information. """ - _manual_value_type = "AppearanceData" expected_length = 2 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/barometric_pressure_trend.py b/src/bluetooth_sig/gatt/characteristics/barometric_pressure_trend.py index 1ff0e66c..5e0e2e94 100644 --- a/src/bluetooth_sig/gatt/characteristics/barometric_pressure_trend.py +++ b/src/bluetooth_sig/gatt/characteristics/barometric_pressure_trend.py @@ -59,6 +59,3 @@ class BarometricPressureTrendCharacteristic(BaseCharacteristic[BarometricPressur """ _template = EnumTemplate.uint8(BarometricPressureTrend) - - # Manual override: YAML indicates uint8->int but we return enum - _manual_value_type = "BarometricPressureTrend" diff --git a/src/bluetooth_sig/gatt/characteristics/base.py b/src/bluetooth_sig/gatt/characteristics/base.py index 3b77497a..27f72728 100644 --- a/src/bluetooth_sig/gatt/characteristics/base.py +++ b/src/bluetooth_sig/gatt/characteristics/base.py @@ -14,7 +14,7 @@ import logging from abc import ABC from functools import cached_property -from typing import Any, ClassVar, Generic, TypeVar +from typing import Any, ClassVar, Generic, TypeVar, get_args from ...types import ( CharacteristicInfo, @@ -23,7 +23,7 @@ SpecialValueType, classify_special_value, ) -from ...types.gatt_enums import CharacteristicRole, GattProperty, ValueType +from ...types.gatt_enums import CharacteristicRole, GattProperty from ...types.registry import CharacteristicSpec from ...types.uuid import BluetoothUUID from ..context import CharacteristicContext @@ -42,15 +42,18 @@ # Type variable for generic characteristic return types T = TypeVar("T") +# Sentinel for per-class cache (distinguishes None from "not yet resolved") +_SENTINEL = object() + class BaseCharacteristic(ContextLookupMixin, DescriptorMixin, ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods """Base class for all GATT characteristics. Generic over *T*, the return type of ``_decode_value()``. - Automatically resolves UUID, unit, and value_type from Bluetooth SIG YAML + Automatically resolves UUID, unit, and python_type from Bluetooth SIG YAML specifications. Supports manual overrides via ``_manual_unit`` and - ``_manual_value_type`` attributes. + ``_python_type`` attributes. Validation Attributes (optional class-level declarations): min_value / max_value: Allowed numeric range. @@ -62,7 +65,8 @@ class BaseCharacteristic(ContextLookupMixin, DescriptorMixin, ABC, Generic[T], m # Explicit class attributes with defaults (replaces getattr usage) _characteristic_name: str | None = None _manual_unit: str | None = None - _manual_value_type: ValueType | str | None = None + _python_type: type | str | None = None + _is_bitfield: bool = False _manual_size: int | None = None _is_template: bool = False @@ -124,8 +128,6 @@ def __init__( # Manual overrides with proper types (using explicit class attributes) self._manual_unit: str | None = self.__class__._manual_unit - self._manual_value_type: ValueType | str | None = self.__class__._manual_value_type - self.value_type: ValueType = ValueType.UNKNOWN # Set validation attributes from ValidationConfig or class defaults if validation: @@ -193,72 +195,57 @@ def __post_init__(self) -> None: # Apply manual overrides to _info (single source of truth) if self._manual_unit is not None: self._info.unit = self._manual_unit - if self._manual_value_type is not None: - # Handle both ValueType enum and string manual overrides - if isinstance(self._manual_value_type, ValueType): - self._info.value_type = self._manual_value_type - else: - # Map string value types to ValueType enum - string_to_value_type_map = { - "string": ValueType.STRING, - "int": ValueType.INT, - "float": ValueType.FLOAT, - "bytes": ValueType.BYTES, - "bool": ValueType.BOOL, - "datetime": ValueType.DATETIME, - "uuid": ValueType.UUID, - "dict": ValueType.DICT, - "various": ValueType.VARIOUS, - "unknown": ValueType.UNKNOWN, - # Custom type strings that should map to basic types - "BarometricPressureTrend": ValueType.INT, # IntEnum -> int - } - - try: - # First try direct ValueType enum construction - self._info.value_type = ValueType(self._manual_value_type) - except ValueError: - # Fall back to string mapping - self._info.value_type = string_to_value_type_map.get(self._manual_value_type, ValueType.VARIOUS) - - # Set value_type from resolved info - self.value_type = self._info.value_type - - # If value_type is still UNKNOWN after resolution and no manual override, - # try to infer from characteristic patterns - if self.value_type == ValueType.UNKNOWN and self._manual_value_type is None: - inferred_type = self._infer_value_type_from_patterns() - if inferred_type != ValueType.UNKNOWN: - self._info.value_type = inferred_type - self.value_type = inferred_type - - def _infer_value_type_from_patterns(self) -> ValueType: - """Infer value type from characteristic naming patterns and class structure. - - This provides a fallback when SIG resolution fails to determine proper value types. - """ - class_name = self.__class__.__name__ - char_name = self._characteristic_name or class_name - # Feature characteristics are bitfields and should be BITFIELD - if "Feature" in class_name or "Feature" in char_name: - return ValueType.BITFIELD - - # Check if this is a multi-field characteristic (complex structure) - if self._spec and hasattr(self._spec, "structure") and len(self._spec.structure) > 1: - return ValueType.VARIOUS - - # Common simple value characteristics - simple_int_patterns = ["Level", "Count", "Index", "ID", "Appearance"] - if any(pattern in class_name or pattern in char_name for pattern in simple_int_patterns): - return ValueType.INT + # Auto-resolve python_type from template generic parameter. + # Templates carry their decoded type (e.g. ScaledUint16Template → float), + # which is more accurate than the YAML wire type (uint16 → int). + if self._template is not None: + template_type = type(self._template).resolve_python_type() + if template_type is not None: + self._info.python_type = template_type + + # Auto-resolve python_type from the class generic parameter. + # BaseCharacteristic[T] already declares the decoded type (e.g. + # BaseCharacteristic[PushbuttonStatus8Data]). This is the most + # authoritative source — it overrides both YAML and template since + # the class signature is the contract for what _decode_value returns. + generic_type = self._resolve_generic_python_type() + if generic_type is not None: + self._info.python_type = generic_type + + # Manual _python_type override wins over all auto-resolution. + # Use sparingly — only when no other mechanism can express the correct type. + if self.__class__._python_type is not None: + self._info.python_type = self.__class__._python_type + if self.__class__._is_bitfield: + self._info.is_bitfield = True - simple_string_patterns = ["Name", "Description", "Text", "String"] - if any(pattern in class_name or pattern in char_name for pattern in simple_string_patterns): - return ValueType.STRING + @classmethod + def _resolve_generic_python_type(cls) -> type | None: + """Resolve python_type from the class generic parameter BaseCharacteristic[T]. - # Default fallback for complex characteristics - return ValueType.VARIOUS + Walks the MRO to find the concrete type bound to ``BaseCharacteristic[T]``. + Returns ``None`` for unbound TypeVars, ``Any``, or forward references. + Caches the result per-class in ``_cached_generic_python_type``. + """ + cached = cls.__dict__.get("_cached_generic_python_type", _SENTINEL) + if cached is not _SENTINEL: + return cached # type: ignore[no-any-return] + + resolved: type | None = None + for klass in cls.__mro__: + for base in getattr(klass, "__orig_bases__", ()): + origin = getattr(base, "__origin__", None) + if origin is BaseCharacteristic: + args = get_args(base) + if args and isinstance(args[0], type) and args[0] is not Any: + resolved = args[0] + break + if resolved is not None: + break + + cls._cached_generic_python_type = resolved # type: ignore[attr-defined] + return resolved def _resolve_yaml_spec(self) -> CharacteristicSpec | None: """Resolve specification using YAML cross-reference system.""" @@ -303,7 +290,9 @@ def role(self) -> CharacteristicRole: if cls._manual_role is not None: cls._cached_role = cls._manual_role else: - cls._cached_role = classify_role(self.name, self.value_type, self.unit, self._spec) + cls._cached_role = classify_role( + self.name, self._info.python_type, self._info.is_bitfield, self.unit, self._spec + ) return cls._cached_role @property @@ -661,9 +650,14 @@ def size(self) -> int | None: return None @property - def value_type_resolved(self) -> ValueType: - """Get the value type from _info.""" - return self._info.value_type + def python_type(self) -> type | str | None: + """Get the resolved Python type for this characteristic's values.""" + return self._info.python_type + + @property + def is_bitfield(self) -> bool: + """Whether this characteristic's value is a bitfield.""" + return self._info.is_bitfield # YAML automation helper methods def get_yaml_data_type(self) -> str | None: diff --git a/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py b/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py index f5d138d9..b95d067c 100644 --- a/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py +++ b/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py @@ -51,7 +51,7 @@ class BaseBloodPressureCharacteristic(BaseCharacteristic[Any]): _is_base_class = True # Exclude from characteristic discovery - _manual_value_type = "string" # Override since decode_value returns dataclass + _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] diff --git a/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py b/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py index 945850c9..f585bdb6 100644 --- a/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py +++ b/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py @@ -6,7 +6,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -44,7 +43,7 @@ class BloodPressureFeatureCharacteristic(BaseCharacteristic[BloodPressureFeature available. """ - _manual_value_type: ValueType | str | None = ValueType.DICT # Override since decode_value returns dataclass + _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 diff --git a/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py b/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py index 93f76747..b5f22567 100644 --- a/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py +++ b/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py @@ -25,6 +25,8 @@ class BondManagementControlPointCharacteristic(BaseCharacteristic[int]): Variable length, starting with command byte. """ + _python_type: type | str | None = int + min_length = 1 allow_variable_length = True _template = EnumTemplate.uint8(BondManagementCommand) diff --git a/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py b/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py index 3d616e4f..b4a4ea26 100644 --- a/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py +++ b/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py @@ -26,6 +26,8 @@ class BondManagementFeatureCharacteristic(BaseCharacteristic[BondManagementFeatu 3 bytes containing boolean flags for supported operations. """ + _python_type: type | str | None = "BondManagementFeatureData" + # SIG spec: three uint8 feature flags → fixed 3-byte payload; no GSS YAML expected_length = 3 min_length = 3 diff --git a/src/bluetooth_sig/gatt/characteristics/boolean.py b/src/bluetooth_sig/gatt/characteristics/boolean.py index 718c013e..7d5f9522 100644 --- a/src/bluetooth_sig/gatt/characteristics/boolean.py +++ b/src/bluetooth_sig/gatt/characteristics/boolean.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -15,7 +14,6 @@ class BooleanCharacteristic(BaseCharacteristic[bool]): The Boolean characteristic is used to represent predefined Boolean values (0 or 1). """ - _manual_value_type = ValueType.BOOL expected_length = 1 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py b/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py index d0e87571..591c969b 100644 --- a/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py +++ b/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py @@ -50,8 +50,6 @@ class BootKeyboardInputReportCharacteristic(BaseCharacteristic[BootKeyboardInput USB HID Specification v1.11, Appendix B - Boot Interface Descriptors """ - _manual_value_type = "BootKeyboardInputReportData" - min_length = 1 max_length = 8 allow_variable_length = True diff --git a/src/bluetooth_sig/gatt/characteristics/boot_keyboard_output_report.py b/src/bluetooth_sig/gatt/characteristics/boot_keyboard_output_report.py index d4313c54..44691a11 100644 --- a/src/bluetooth_sig/gatt/characteristics/boot_keyboard_output_report.py +++ b/src/bluetooth_sig/gatt/characteristics/boot_keyboard_output_report.py @@ -30,8 +30,6 @@ class BootKeyboardOutputReportCharacteristic(BaseCharacteristic[KeyboardLEDs]): USB HID Specification v1.11, Appendix B - Boot Interface Descriptors """ - _manual_value_type = "KeyboardLEDs" - min_length = 1 max_length = 1 allow_variable_length = False diff --git a/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py b/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py index 5f405907..b985cf78 100644 --- a/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py +++ b/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py @@ -48,8 +48,6 @@ class BootMouseInputReportCharacteristic(BaseCharacteristic[BootMouseInputReport USB HID Specification v1.11, Appendix B - Boot Interface Descriptors """ - _manual_value_type = "BootMouseInputReportData" - min_length = 3 max_length = 4 allow_variable_length = True diff --git a/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py b/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py index 63933db2..71373509 100644 --- a/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py +++ b/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py @@ -14,7 +14,7 @@ from ...registry.uuids.units import units_registry from ...types import CharacteristicInfo -from ...types.gatt_enums import DataType +from ...types.gatt_enums import WIRE_TYPE_MAP from ...types.registry import CharacteristicSpec from ..exceptions import UUIDResolutionError from ..resolver import CharacteristicRegistrySearch, NameNormalizer, NameVariantGenerator @@ -96,7 +96,25 @@ def resolve_yaml_spec_for_class(char_class: type) -> CharacteristicSpec | None: @staticmethod def _create_info_from_yaml(yaml_spec: CharacteristicSpec, char_class: type) -> CharacteristicInfo: """Create CharacteristicInfo from YAML spec, resolving metadata via registry classes.""" - value_type = DataType.from_string(yaml_spec.data_type).to_value_type() + python_type: type | None = None + is_bitfield = False + + if yaml_spec.data_type: + raw_dt = yaml_spec.data_type.lower().strip() + + # boolean[N] (N > 1) → bitfield stored as int + if raw_dt.startswith("boolean["): + python_type = int + is_bitfield = True + elif raw_dt == "struct": + python_type = dict + else: + # Strip range/array qualifiers: "uint16 [1-256]" → "uint16" + base_dt = raw_dt.split("[")[0].split(" ")[0] + python_type = WIRE_TYPE_MAP.get(base_dt) + elif yaml_spec.structure and len(yaml_spec.structure) > 1: + # Multi-field characteristics decode to dict + python_type = dict unit_info = None unit_name = getattr(yaml_spec, "unit_symbol", None) or getattr(yaml_spec, "unit", None) @@ -111,7 +129,8 @@ def _create_info_from_yaml(yaml_spec: CharacteristicSpec, char_class: type) -> C uuid=yaml_spec.uuid, name=yaml_spec.name or char_class.__name__, unit=unit_symbol, - value_type=value_type, + python_type=python_type, + is_bitfield=is_bitfield, ) @staticmethod diff --git a/src/bluetooth_sig/gatt/characteristics/chromatic_distance_from_planckian.py b/src/bluetooth_sig/gatt/characteristics/chromatic_distance_from_planckian.py new file mode 100644 index 00000000..94c7b9c7 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/chromatic_distance_from_planckian.py @@ -0,0 +1,23 @@ +"""Chromatic Distance from Planckian characteristic (0x2AE3).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledSint16Template + + +class ChromaticDistanceFromPlanckianCharacteristic(BaseCharacteristic[float]): + """Chromatic Distance from Planckian characteristic (0x2AE3). + + org.bluetooth.characteristic.chromatic_distance_from_planckian + + Unitless distance from the Planckian locus. + M=1, d=-5, b=0 -> resolution 0.00001 (-0.05 to 0.05). + A value of 0x7FFF represents 'value is not valid'. + A value of 0x7FFE represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0x7FFF). + """ + + _template = ScaledSint16Template.from_letter_method(M=1, d=-5, b=0) diff --git a/src/bluetooth_sig/gatt/characteristics/chromaticity_coordinates.py b/src/bluetooth_sig/gatt/characteristics/chromaticity_coordinates.py new file mode 100644 index 00000000..d98c9439 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/chromaticity_coordinates.py @@ -0,0 +1,91 @@ +"""Chromaticity Coordinates characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +# Resolution: 1/65535 (same as Chromaticity Coordinate characteristic) +_RESOLUTION = 2**-16 + + +class ChromaticityCoordinatesData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for chromaticity coordinates. + + Each coordinate is a CIE 1931 chromaticity value in the range [0, 1). + """ + + x: float # Chromaticity x-coordinate + y: float # Chromaticity y-coordinate + + def __post_init__(self) -> None: + """Validate chromaticity coordinate data.""" + max_value = UINT16_MAX * _RESOLUTION + for name, val in [("x", self.x), ("y", self.y)]: + if not 0.0 <= val <= max_value: + raise ValueError(f"Chromaticity {name}-coordinate {val} is outside valid range (0.0 to {max_value})") + + +class ChromaticityCoordinatesCharacteristic(BaseCharacteristic[ChromaticityCoordinatesData]): + """Chromaticity Coordinates characteristic (0x2AE4). + + org.bluetooth.characteristic.chromaticity_coordinates + + Represents a pair of CIE 1931 chromaticity coordinates (x, y). + Each coordinate is a uint16 with resolution 1/65535. + """ + + # Validation attributes + expected_length: int = 4 # 2 x uint16 + min_length: int = 4 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> ChromaticityCoordinatesData: + """Parse chromaticity coordinates data (2 x uint16, resolution 1/65535). + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + ChromaticityCoordinatesData with x and y coordinate values. + + """ + x_raw = DataParser.parse_int16(data, 0, signed=False) + y_raw = DataParser.parse_int16(data, 2, signed=False) + + return ChromaticityCoordinatesData( + x=x_raw * _RESOLUTION, + y=y_raw * _RESOLUTION, + ) + + def _encode_value(self, data: ChromaticityCoordinatesData) -> bytearray: + """Encode chromaticity coordinates to bytes. + + Args: + data: ChromaticityCoordinatesData instance. + + Returns: + Encoded bytes (2 x uint16, little-endian). + + """ + if not isinstance(data, ChromaticityCoordinatesData): + raise TypeError(f"Expected ChromaticityCoordinatesData, got {type(data).__name__}") + + x_raw = round(data.x / _RESOLUTION) + y_raw = round(data.y / _RESOLUTION) + + for name, value in [("x", x_raw), ("y", y_raw)]: + if not 0 <= value <= UINT16_MAX: + raise ValueError(f"Chromaticity {name}-coordinate raw value {value} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int16(x_raw, signed=False)) + result.extend(DataParser.encode_int16(y_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py b/src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py new file mode 100644 index 00000000..248f6184 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py @@ -0,0 +1,100 @@ +"""Chromaticity in CCT and Duv Values characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +# Duv resolution: M=1, d=-5, b=0 → 0.00001 +_DUV_RESOLUTION = 1e-5 + + +class ChromaticityInCCTAndDuvData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for Chromaticity in CCT and Duv Values. + + Combines Correlated Color Temperature (Kelvin) with Chromatic + Distance from Planckian (unitless Duv). + """ + + correlated_color_temperature: int # Kelvin (uint16, raw) + chromaticity_distance_from_planckian: float # Unitless Duv (sint16, scaled) + + def __post_init__(self) -> None: + """Validate CCT and Duv data.""" + if not 0 <= self.correlated_color_temperature <= UINT16_MAX: + raise ValueError(f"CCT {self.correlated_color_temperature} K is outside valid range (0 to {UINT16_MAX})") + duv_min = SINT16_MIN * _DUV_RESOLUTION + duv_max = SINT16_MAX * _DUV_RESOLUTION + if not duv_min <= self.chromaticity_distance_from_planckian <= duv_max: + raise ValueError( + f"Duv {self.chromaticity_distance_from_planckian} is outside valid range ({duv_min} to {duv_max})" + ) + + +class ChromaticityInCCTAndDuvValuesCharacteristic(BaseCharacteristic[ChromaticityInCCTAndDuvData]): + """Chromaticity in CCT and Duv Values characteristic (0x2AE5). + + org.bluetooth.characteristic.chromaticity_in_cct_and_duv_values + + Combines Correlated Color Temperature and Chromatic Distance from + Planckian into a single composite characteristic. + + Field 1: CCT — uint16, raw Kelvin (references Correlated Color Temperature). + Field 2: Duv — sint16, M=1 d=-5 b=0 (references Chromatic Distance From Planckian). + """ + + # Validation attributes + expected_length: int = 4 # uint16 + sint16 + min_length: int = 4 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> ChromaticityInCCTAndDuvData: + """Parse CCT and Duv values. + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + ChromaticityInCCTAndDuvData with CCT and Duv fields. + + """ + cct_raw = DataParser.parse_int16(data, 0, signed=False) + duv_raw = DataParser.parse_int16(data, 2, signed=True) + + return ChromaticityInCCTAndDuvData( + correlated_color_temperature=cct_raw, + chromaticity_distance_from_planckian=duv_raw * _DUV_RESOLUTION, + ) + + def _encode_value(self, data: ChromaticityInCCTAndDuvData) -> bytearray: + """Encode CCT and Duv values to bytes. + + Args: + data: ChromaticityInCCTAndDuvData instance. + + Returns: + Encoded bytes (uint16 + sint16, little-endian). + + """ + if not isinstance(data, ChromaticityInCCTAndDuvData): + raise TypeError(f"Expected ChromaticityInCCTAndDuvData, got {type(data).__name__}") + + cct_raw = data.correlated_color_temperature + duv_raw = round(data.chromaticity_distance_from_planckian / _DUV_RESOLUTION) + + if not 0 <= cct_raw <= UINT16_MAX: + raise ValueError(f"CCT raw value {cct_raw} exceeds uint16 range") + if not SINT16_MIN <= duv_raw <= SINT16_MAX: + raise ValueError(f"Duv raw value {duv_raw} exceeds sint16 range") + + result = bytearray() + result.extend(DataParser.encode_int16(cct_raw, signed=False)) + result.extend(DataParser.encode_int16(duv_raw, signed=True)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/chromaticity_tolerance.py b/src/bluetooth_sig/gatt/characteristics/chromaticity_tolerance.py new file mode 100644 index 00000000..f28fa242 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/chromaticity_tolerance.py @@ -0,0 +1,18 @@ +"""Chromaticity Tolerance characteristic (0x2AE6).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledUint8Template + + +class ChromaticityToleranceCharacteristic(BaseCharacteristic[float]): + """Chromaticity Tolerance characteristic (0x2AE6). + + org.bluetooth.characteristic.chromaticity_tolerance + + Unitless chromaticity tolerance value. + M=1, d=-4, b=0 -> resolution 0.0001 (0-0.0255). + """ + + _template = ScaledUint8Template.from_letter_method(M=1, d=-4, b=0) diff --git a/src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py b/src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py new file mode 100644 index 00000000..3af0bf65 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py @@ -0,0 +1,24 @@ +"""CIE 13.3-1995 Color Rendering Index characteristic (0x2AE7).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Sint8Template + + +class CIE133ColorRenderingIndexCharacteristic(BaseCharacteristic[int]): + """CIE 13.3-1995 Color Rendering Index characteristic (0x2AE7). + + org.bluetooth.characteristic.cie_13.3-1995_color_rendering_index + + Unitless color rendering index (CRI) value. + M=1, d=0, b=0 — no scaling; plain signed 8-bit integer. + Range: -128 to 100. + A value of 127 represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 127). + """ + + _characteristic_name = "CIE 13.3-1995 Color Rendering Index" + _template = Sint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/co2_concentration.py b/src/bluetooth_sig/gatt/characteristics/co2_concentration.py index 79349f72..6cee2422 100644 --- a/src/bluetooth_sig/gatt/characteristics/co2_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/co2_concentration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import ConcentrationTemplate @@ -23,7 +22,7 @@ class CO2ConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _manual_value_type: ValueType | str | None = ValueType.INT + _python_type: type | str | None = int _manual_unit: str | None = "ppm" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/contact_status_8.py b/src/bluetooth_sig/gatt/characteristics/contact_status_8.py new file mode 100644 index 00000000..4d3bb50d --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/contact_status_8.py @@ -0,0 +1,37 @@ +"""Contact Status 8 characteristic (0x2C10).""" + +from __future__ import annotations + +from enum import IntFlag + +from .base import BaseCharacteristic +from .templates import FlagTemplate + + +class ContactStatus(IntFlag): + """Individual contact status flags for Contact Status 8. + + Each flag represents one contact input: + 0 = no contact, 1 = contact detected. + """ + + CONTACT_0 = 0x01 + CONTACT_1 = 0x02 + CONTACT_2 = 0x04 + CONTACT_3 = 0x08 + CONTACT_4 = 0x10 + CONTACT_5 = 0x20 + CONTACT_6 = 0x40 + CONTACT_7 = 0x80 + + +class ContactStatus8Characteristic(BaseCharacteristic[ContactStatus]): + """Contact Status 8 characteristic (0x2C10). + + org.bluetooth.characteristic.contact_status_8 + + Eight independent contact status bits packed in a single byte. + Each bit represents one contact: 0 = no contact, 1 = contact detected. + """ + + _template = FlagTemplate.uint8(ContactStatus) diff --git a/src/bluetooth_sig/gatt/characteristics/content_control_id.py b/src/bluetooth_sig/gatt/characteristics/content_control_id.py new file mode 100644 index 00000000..3719f3ae --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/content_control_id.py @@ -0,0 +1,17 @@ +"""Content Control ID characteristic (0x2BBA).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint8Template + + +class ContentControlIdCharacteristic(BaseCharacteristic[int]): + """Content Control ID characteristic (0x2BBA). + + org.bluetooth.characteristic.content_control_id + + The ID of the content control service instance. + """ + + _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/cosine_of_the_angle.py b/src/bluetooth_sig/gatt/characteristics/cosine_of_the_angle.py new file mode 100644 index 00000000..e5b3ea11 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cosine_of_the_angle.py @@ -0,0 +1,22 @@ +"""Cosine of the Angle characteristic (0x2AE8).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledSint8Template + + +class CosineOfTheAngleCharacteristic(BaseCharacteristic[float]): + """Cosine of the Angle characteristic (0x2AE8). + + org.bluetooth.characteristic.cosine_of_the_angle + + Unitless cosine value expressed as cos(theta) x 100. + M=1, d=-2, b=0 -> resolution 0.01 (-1.00 to 1.00). + A raw value of 0x7F represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0x7F). + """ + + _template = ScaledSint8Template.from_letter_method(M=1, d=-2, b=0) diff --git a/src/bluetooth_sig/gatt/characteristics/country_code.py b/src/bluetooth_sig/gatt/characteristics/country_code.py new file mode 100644 index 00000000..51596025 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/country_code.py @@ -0,0 +1,17 @@ +"""Country Code characteristic (0x2C13).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint16Template + + +class CountryCodeCharacteristic(BaseCharacteristic[int]): + """Country Code characteristic (0x2C13). + + org.bluetooth.characteristic.country_code + + ISO 3166-1 numeric country code. + """ + + _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/current_time.py b/src/bluetooth_sig/gatt/characteristics/current_time.py index e6277346..c56579a4 100644 --- a/src/bluetooth_sig/gatt/characteristics/current_time.py +++ b/src/bluetooth_sig/gatt/characteristics/current_time.py @@ -13,7 +13,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import TimeData, TimeDataTemplate @@ -37,7 +36,7 @@ class CurrentTimeCharacteristic(BaseCharacteristic[TimeData]): """ # Validation attributes - _manual_value_type: ValueType | str | None = ValueType.DICT + _python_type: type | str | None = dict def __init__(self) -> None: """Initialize the Current Time characteristic.""" diff --git a/src/bluetooth_sig/gatt/characteristics/custom.py b/src/bluetooth_sig/gatt/characteristics/custom.py index 2eb53f7e..d96b2a21 100644 --- a/src/bluetooth_sig/gatt/characteristics/custom.py +++ b/src/bluetooth_sig/gatt/characteristics/custom.py @@ -118,8 +118,8 @@ def __init__( # Auto-register if requested and not already registered if auto_register: - # TODO - # NOTE: Import here to avoid circular import (translator imports characteristics) + # TODO: Refactor to eliminate circular dependency (translator ↔ characteristics). + # Deferred import used as workaround — consider a registration broker pattern. from ...core.translator import ( # noqa: PLC0415 BluetoothSIGTranslator, # pylint: disable=import-outside-toplevel ) diff --git a/src/bluetooth_sig/gatt/characteristics/date_time.py b/src/bluetooth_sig/gatt/characteristics/date_time.py index 127b70e2..70930a7f 100644 --- a/src/bluetooth_sig/gatt/characteristics/date_time.py +++ b/src/bluetooth_sig/gatt/characteristics/date_time.py @@ -4,7 +4,6 @@ from datetime import datetime -from ...types.gatt_enums import ValueType from ..context import CharacteristicContext from .base import BaseCharacteristic from .utils import DataParser @@ -18,7 +17,6 @@ class DateTimeCharacteristic(BaseCharacteristic[datetime]): Represents date and time in 7-byte format: year(2), month(1), day(1), hours(1), minutes(1), seconds(1). """ - _manual_value_type = ValueType.DATETIME expected_length = 7 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/date_utc.py b/src/bluetooth_sig/gatt/characteristics/date_utc.py new file mode 100644 index 00000000..95c4f087 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/date_utc.py @@ -0,0 +1,19 @@ +"""Date UTC characteristic (0x2AED).""" + +from __future__ import annotations + +from datetime import date + +from .base import BaseCharacteristic +from .templates import EpochDateTemplate + + +class DateUtcCharacteristic(BaseCharacteristic[date]): + """Date UTC characteristic (0x2AED). + + org.bluetooth.characteristic.date_utc + + Number of days elapsed since the Epoch (Jan 1, 1970) in UTC. + """ + + _template = EpochDateTemplate() diff --git a/src/bluetooth_sig/gatt/characteristics/day_date_time.py b/src/bluetooth_sig/gatt/characteristics/day_date_time.py index f125d1a4..25fe116c 100644 --- a/src/bluetooth_sig/gatt/characteristics/day_date_time.py +++ b/src/bluetooth_sig/gatt/characteristics/day_date_time.py @@ -31,7 +31,6 @@ class DayDateTimeCharacteristic(BaseCharacteristic[DayDateTimeData]): Represents date, time and day of week in 8-byte format. """ - _manual_value_type = "DayDateTimeData" expected_length = 8 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/device_name.py b/src/bluetooth_sig/gatt/characteristics/device_name.py index c6f0a5d6..1f23aea2 100644 --- a/src/bluetooth_sig/gatt/characteristics/device_name.py +++ b/src/bluetooth_sig/gatt/characteristics/device_name.py @@ -14,4 +14,6 @@ class DeviceNameCharacteristic(BaseCharacteristic[str]): Represents the device name as a UTF-8 string. """ + _python_type: type | str | None = str + _template = Utf8StringTemplate() diff --git a/src/bluetooth_sig/gatt/characteristics/door_window_status.py b/src/bluetooth_sig/gatt/characteristics/door_window_status.py new file mode 100644 index 00000000..4e1470cc --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/door_window_status.py @@ -0,0 +1,33 @@ +"""Door Window Status characteristic (0x2C12).""" + +from __future__ import annotations + +from enum import IntEnum + +from .base import BaseCharacteristic +from .templates import EnumTemplate + + +class DoorWindowOpenStatus(IntEnum): + """Door/window open status values. + + Values: + OPEN: Door/window is open (0x00) + CLOSED: Door/window is closed (0x01) + TILTED_AJAR: Door/window is tilted or ajar (0x02) + """ + + OPEN = 0x00 + CLOSED = 0x01 + TILTED_AJAR = 0x02 + + +class DoorWindowStatusCharacteristic(BaseCharacteristic[DoorWindowOpenStatus]): + """Door Window Status characteristic (0x2C12). + + org.bluetooth.characteristic.door_window_status + + Reports the open/closed/tilted status of a door or window. + """ + + _template = EnumTemplate.uint8(DoorWindowOpenStatus) diff --git a/src/bluetooth_sig/gatt/characteristics/electric_current_range.py b/src/bluetooth_sig/gatt/characteristics/electric_current_range.py index c8929cd3..597b1f13 100644 --- a/src/bluetooth_sig/gatt/characteristics/electric_current_range.py +++ b/src/bluetooth_sig/gatt/characteristics/electric_current_range.py @@ -4,7 +4,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -45,7 +44,7 @@ class ElectricCurrentRangeCharacteristic(BaseCharacteristic[ElectricCurrentRange min_length: int = 4 # Override since decode_value returns structured ElectricCurrentRangeData - _manual_value_type: ValueType | str | None = ValueType.DICT + _python_type: type | str | None = dict def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py b/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py index 0f677e6b..0ea4c1c5 100644 --- a/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py +++ b/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py @@ -4,7 +4,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -46,7 +45,7 @@ class ElectricCurrentSpecificationCharacteristic(BaseCharacteristic[ElectricCurr min_length: int = 4 # Override since decode_value returns structured ElectricCurrentSpecificationData - _manual_value_type: ValueType | str | None = ValueType.DICT + _python_type: type | str | None = dict def _decode_value( self, data: bytearray, _ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py b/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py index d42c2cfd..281cc02c 100644 --- a/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py +++ b/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py @@ -4,7 +4,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -57,7 +56,7 @@ class ElectricCurrentStatisticsCharacteristic(BaseCharacteristic[ElectricCurrent min_length: int = 6 # Override since decode_value returns structured ElectricCurrentStatisticsData - _manual_value_type: ValueType | str | None = ValueType.DICT + _python_type: type | str | None = dict def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/elevation.py b/src/bluetooth_sig/gatt/characteristics/elevation.py index f519d842..d1302463 100644 --- a/src/bluetooth_sig/gatt/characteristics/elevation.py +++ b/src/bluetooth_sig/gatt/characteristics/elevation.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from ...types.units import LengthUnit from .base import BaseCharacteristic from .templates import ScaledSint24Template @@ -23,6 +22,6 @@ class ElevationCharacteristic(BaseCharacteristic[float]): _template = ScaledSint24Template(scale_factor=0.01) - _manual_value_type: ValueType | str | None = "float" # Override YAML int type since decode_value returns float + _python_type: type | str | None = float # Override YAML int type since decode_value returns float _manual_unit: str | None = LengthUnit.METERS.value # Override template's "units" default resolution: float = 0.01 diff --git a/src/bluetooth_sig/gatt/characteristics/energy.py b/src/bluetooth_sig/gatt/characteristics/energy.py new file mode 100644 index 00000000..a48303e5 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/energy.py @@ -0,0 +1,21 @@ +"""Energy characteristic (0x2AF1).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint24Template + + +class EnergyCharacteristic(BaseCharacteristic[int]): + """Energy characteristic (0x2AF1). + + org.bluetooth.characteristic.energy + + Energy in kilowatt-hours with a resolution of 1. + A value of 0xFFFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFF). + """ + + _template = Uint24Template() diff --git a/src/bluetooth_sig/gatt/characteristics/energy_32.py b/src/bluetooth_sig/gatt/characteristics/energy_32.py new file mode 100644 index 00000000..0678cc1d --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/energy_32.py @@ -0,0 +1,22 @@ +"""Energy 32 characteristic (0x2AF2).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint32Template + + +class Energy32Characteristic(BaseCharacteristic[int]): + """Energy 32 characteristic (0x2AF2). + + org.bluetooth.characteristic.energy_32 + + Energy in watt-hours with a resolution of 1 Wh. + A value of 0xFFFFFFFE represents 'value is not valid'. + A value of 0xFFFFFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFFFF). + """ + + _template = Uint32Template() diff --git a/src/bluetooth_sig/gatt/characteristics/energy_in_a_period_of_day.py b/src/bluetooth_sig/gatt/characteristics/energy_in_a_period_of_day.py new file mode 100644 index 00000000..7e3ef97c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/energy_in_a_period_of_day.py @@ -0,0 +1,97 @@ +"""Energy in a Period of Day characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX, UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_TIME_DECIHOUR_RESOLUTION = 0.1 # Time Decihour 8: M=1, d=-1, b=0 -> 0.1 hr +# Energy: M=1, d=0, b=0 -> 1 kWh per raw uint24 unit + + +class EnergyInAPeriodOfDayData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for energy in a period of day. + + Energy value in kWh (integer, uint24) with a time-of-day range + (0.1 hr resolution). + """ + + energy: int # Energy in kWh (integer, uint24) + start_time: float # Start time in hours (0.1 hr resolution) + end_time: float # End time in hours (0.1 hr resolution) + + def __post_init__(self) -> None: + """Validate data fields.""" + if not 0 <= self.energy <= UINT24_MAX: + raise ValueError(f"Energy {self.energy} kWh is outside valid range (0 to {UINT24_MAX})") + max_time = UINT8_MAX * _TIME_DECIHOUR_RESOLUTION + for name, val in [("start_time", self.start_time), ("end_time", self.end_time)]: + if not 0.0 <= val <= max_time: + raise ValueError(f"{name} {val} hr is outside valid range (0.0 to {max_time})") + + +class EnergyInAPeriodOfDayCharacteristic( + BaseCharacteristic[EnergyInAPeriodOfDayData], +): + """Energy in a Period of Day characteristic (0x2AF3). + + org.bluetooth.characteristic.energy_in_a_period_of_day + + Represents an energy measurement within a time-of-day range. Fields: + Energy (uint24, 1 kWh), start time (uint8, 0.1 hr), + end time (uint8, 0.1 hr). + """ + + expected_length: int = 5 # uint24 + 2 x uint8 + min_length: int = 5 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> EnergyInAPeriodOfDayData: + """Parse energy in a period of day. + + Args: + data: Raw bytes (5 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + EnergyInAPeriodOfDayData. + + """ + energy = DataParser.parse_int24(data, 0, signed=False) + start_raw = DataParser.parse_int8(data, 3, signed=False) + end_raw = DataParser.parse_int8(data, 4, signed=False) + + return EnergyInAPeriodOfDayData( + energy=energy, + start_time=start_raw * _TIME_DECIHOUR_RESOLUTION, + end_time=end_raw * _TIME_DECIHOUR_RESOLUTION, + ) + + def _encode_value(self, data: EnergyInAPeriodOfDayData) -> bytearray: + """Encode energy in a period of day. + + Args: + data: EnergyInAPeriodOfDayData instance. + + Returns: + Encoded bytes (5 bytes). + + """ + start_raw = round(data.start_time / _TIME_DECIHOUR_RESOLUTION) + end_raw = round(data.end_time / _TIME_DECIHOUR_RESOLUTION) + + for name, value in [("start_time", start_raw), ("end_time", end_raw)]: + if not 0 <= value <= UINT8_MAX: + raise ValueError(f"{name} raw value {value} exceeds uint8 range") + + result = bytearray() + result.extend(DataParser.encode_int24(data.energy, signed=False)) + result.extend(DataParser.encode_int8(start_raw, signed=False)) + result.extend(DataParser.encode_int8(end_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/estimated_service_date.py b/src/bluetooth_sig/gatt/characteristics/estimated_service_date.py new file mode 100644 index 00000000..4f6687b3 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/estimated_service_date.py @@ -0,0 +1,20 @@ +"""Estimated Service Date characteristic (0x2A86).""" + +from __future__ import annotations + +from datetime import date + +from .base import BaseCharacteristic +from .templates import EpochDateTemplate + + +class EstimatedServiceDateCharacteristic(BaseCharacteristic[date]): + """Estimated Service Date characteristic (0x2A86). + + org.bluetooth.characteristic.estimated_service_date + + Days elapsed since the Epoch (Jan 1, 1970) in UTC. + Same encoding as Date UTC. + """ + + _template = EpochDateTemplate() diff --git a/src/bluetooth_sig/gatt/characteristics/event_statistics.py b/src/bluetooth_sig/gatt/characteristics/event_statistics.py new file mode 100644 index 00000000..11cfc5ed --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/event_statistics.py @@ -0,0 +1,123 @@ +"""Event Statistics characteristic implementation.""" + +from __future__ import annotations + +import math + +import msgspec + +from ..constants import UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_TIME_EXP_BASE = 1.1 +_TIME_EXP_OFFSET = 64 + + +def _decode_time_exponential(raw: int) -> float: + """Decode Time Exponential 8 raw value to seconds.""" + if raw == 0: + return 0.0 + return _TIME_EXP_BASE ** (raw - _TIME_EXP_OFFSET) + + +def _encode_time_exponential(seconds: float) -> int: + """Encode seconds to Time Exponential 8 raw value.""" + if seconds <= 0.0: + return 0 + n = round(math.log(seconds) / math.log(_TIME_EXP_BASE) + _TIME_EXP_OFFSET) + return max(1, min(n, 0xFD)) + + +class EventStatisticsData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for event statistics. + + Event count (uint16), average event duration (uint16, seconds), + time elapsed since last event (Time Exponential 8, seconds), + and sensing duration (Time Exponential 8, seconds). + """ + + number_of_events: int # Count 16 (unitless) + average_event_duration: int # Time Second 16 (seconds, integer) + time_elapsed_since_last_event: float # Time Exponential 8 (seconds) + sensing_duration: float # Time Exponential 8 (seconds) + + def __post_init__(self) -> None: + """Validate data fields.""" + if not 0 <= self.number_of_events <= UINT16_MAX: + raise ValueError(f"Number of events {self.number_of_events} is outside valid range (0 to {UINT16_MAX})") + if not 0 <= self.average_event_duration <= UINT16_MAX: + raise ValueError( + f"Average event duration {self.average_event_duration} s is outside valid range (0 to {UINT16_MAX})" + ) + if self.time_elapsed_since_last_event < 0.0: + raise ValueError(f"Time elapsed {self.time_elapsed_since_last_event} s cannot be negative") + if self.sensing_duration < 0.0: + raise ValueError(f"Sensing duration {self.sensing_duration} s cannot be negative") + + +class EventStatisticsCharacteristic(BaseCharacteristic[EventStatisticsData]): + """Event Statistics characteristic (0x2AF4). + + org.bluetooth.characteristic.event_statistics + + Statistics for events: count (uint16), average duration (uint16, 1 s), + time since last event (Time Exponential 8), sensing duration + (Time Exponential 8). + """ + + expected_length: int = 6 # 2 x uint16 + 2 x uint8 + min_length: int = 6 + expected_type = EventStatisticsData + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> EventStatisticsData: + """Parse event statistics. + + Args: + data: Raw bytes (6 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + EventStatisticsData. + + """ + count = DataParser.parse_int16(data, 0, signed=False) + avg_dur = DataParser.parse_int16(data, 2, signed=False) + elapsed_raw = DataParser.parse_int8(data, 4, signed=False) + duration_raw = DataParser.parse_int8(data, 5, signed=False) + + return EventStatisticsData( + number_of_events=count, + average_event_duration=avg_dur, + time_elapsed_since_last_event=_decode_time_exponential(elapsed_raw), + sensing_duration=_decode_time_exponential(duration_raw), + ) + + def _encode_value(self, data: EventStatisticsData) -> bytearray: + """Encode event statistics. + + Args: + data: EventStatisticsData instance. + + Returns: + Encoded bytes (6 bytes). + + """ + elapsed_raw = _encode_time_exponential(data.time_elapsed_since_last_event) + duration_raw = _encode_time_exponential(data.sensing_duration) + + if not 0 <= data.number_of_events <= UINT16_MAX: + raise ValueError(f"Event count {data.number_of_events} exceeds uint16 range") + if not 0 <= data.average_event_duration <= UINT16_MAX: + raise ValueError(f"Average duration {data.average_event_duration} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int16(data.number_of_events, signed=False)) + result.extend(DataParser.encode_int16(data.average_event_duration, signed=False)) + result.extend(DataParser.encode_int8(elapsed_raw, signed=False)) + result.extend(DataParser.encode_int8(duration_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/exact_time_256.py b/src/bluetooth_sig/gatt/characteristics/exact_time_256.py index dfdecce9..01c0b45d 100644 --- a/src/bluetooth_sig/gatt/characteristics/exact_time_256.py +++ b/src/bluetooth_sig/gatt/characteristics/exact_time_256.py @@ -33,7 +33,6 @@ class ExactTime256Characteristic(BaseCharacteristic[ExactTime256Data]): Represents exact time with 1/256 second resolution in 9-byte format. """ - _manual_value_type = "ExactTime256Data" expected_length = 9 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/fixed_string_16.py b/src/bluetooth_sig/gatt/characteristics/fixed_string_16.py new file mode 100644 index 00000000..543392a2 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/fixed_string_16.py @@ -0,0 +1,18 @@ +"""Fixed String 16 characteristic (0x2AF7).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Utf8StringTemplate + + +class FixedString16Characteristic(BaseCharacteristic[str]): + """Fixed String 16 characteristic (0x2AF7). + + org.bluetooth.characteristic.fixed_string_16 + + Fixed-length 16-octet UTF-8 string. + """ + + _template = Utf8StringTemplate(max_length=16) + expected_length = 16 diff --git a/src/bluetooth_sig/gatt/characteristics/fixed_string_24.py b/src/bluetooth_sig/gatt/characteristics/fixed_string_24.py new file mode 100644 index 00000000..087d2c37 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/fixed_string_24.py @@ -0,0 +1,18 @@ +"""Fixed String 24 characteristic (0x2AF6).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Utf8StringTemplate + + +class FixedString24Characteristic(BaseCharacteristic[str]): + """Fixed String 24 characteristic (0x2AF6). + + org.bluetooth.characteristic.fixed_string_24 + + Fixed-length 24-octet UTF-8 string. + """ + + _template = Utf8StringTemplate(max_length=24) + expected_length = 24 diff --git a/src/bluetooth_sig/gatt/characteristics/fixed_string_36.py b/src/bluetooth_sig/gatt/characteristics/fixed_string_36.py new file mode 100644 index 00000000..83248b75 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/fixed_string_36.py @@ -0,0 +1,18 @@ +"""Fixed String 36 characteristic (0x2AF5).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Utf8StringTemplate + + +class FixedString36Characteristic(BaseCharacteristic[str]): + """Fixed String 36 characteristic (0x2AF5). + + org.bluetooth.characteristic.fixed_string_36 + + Fixed-length 36-octet UTF-8 string. + """ + + _template = Utf8StringTemplate(max_length=36) + expected_length = 36 diff --git a/src/bluetooth_sig/gatt/characteristics/fixed_string_64.py b/src/bluetooth_sig/gatt/characteristics/fixed_string_64.py new file mode 100644 index 00000000..ce342256 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/fixed_string_64.py @@ -0,0 +1,18 @@ +"""Fixed String 64 characteristic (0x2AF4).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Utf8StringTemplate + + +class FixedString64Characteristic(BaseCharacteristic[str]): + """Fixed String 64 characteristic (0x2AF4). + + org.bluetooth.characteristic.fixed_string_64 + + Fixed-length 64-octet UTF-8 string. + """ + + _template = Utf8StringTemplate(max_length=64) + expected_length = 64 diff --git a/src/bluetooth_sig/gatt/characteristics/fixed_string_8.py b/src/bluetooth_sig/gatt/characteristics/fixed_string_8.py new file mode 100644 index 00000000..29213745 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/fixed_string_8.py @@ -0,0 +1,18 @@ +"""Fixed String 8 characteristic (0x2AF8).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Utf8StringTemplate + + +class FixedString8Characteristic(BaseCharacteristic[str]): + """Fixed String 8 characteristic (0x2AF8). + + org.bluetooth.characteristic.fixed_string_8 + + Fixed-length 8-octet UTF-8 string. + """ + + _template = Utf8StringTemplate(max_length=8) + expected_length = 8 diff --git a/src/bluetooth_sig/gatt/characteristics/floor_number.py b/src/bluetooth_sig/gatt/characteristics/floor_number.py index 6e081ca8..d98ef4a0 100644 --- a/src/bluetooth_sig/gatt/characteristics/floor_number.py +++ b/src/bluetooth_sig/gatt/characteristics/floor_number.py @@ -14,6 +14,8 @@ class FloorNumberCharacteristic(BaseCharacteristic[int]): Floor Number characteristic. """ + _python_type: type | str | None = int + # SIG spec: sint8 floor index → fixed 1-byte payload; no GSS YAML expected_length = 1 min_length = 1 diff --git a/src/bluetooth_sig/gatt/characteristics/generic_level.py b/src/bluetooth_sig/gatt/characteristics/generic_level.py new file mode 100644 index 00000000..54379e2b --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/generic_level.py @@ -0,0 +1,17 @@ +"""Generic Level characteristic (0x2AF9).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint16Template + + +class GenericLevelCharacteristic(BaseCharacteristic[int]): + """Generic Level characteristic (0x2AF9). + + org.bluetooth.characteristic.generic_level + + Unitless 16-bit level value (0-65535). + """ + + _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py b/src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py new file mode 100644 index 00000000..8b5f0abd --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py @@ -0,0 +1,22 @@ +"""Global Trade Item Number characteristic (0x2AFA).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint48Template + + +class GlobalTradeItemNumberCharacteristic(BaseCharacteristic[int]): + """Global Trade Item Number characteristic (0x2AFA). + + org.bluetooth.characteristic.global_trade_item_number + + An identifier for trade items, defined by GS1. + Encoded as a 48-bit unsigned integer (6 bytes). + A value of 0x000000000000 represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0x000000000000). + """ + + _template = Uint48Template() diff --git a/src/bluetooth_sig/gatt/characteristics/hid_control_point.py b/src/bluetooth_sig/gatt/characteristics/hid_control_point.py index 3994fff4..a4ea1b4e 100644 --- a/src/bluetooth_sig/gatt/characteristics/hid_control_point.py +++ b/src/bluetooth_sig/gatt/characteristics/hid_control_point.py @@ -26,5 +26,7 @@ class HidControlPointCharacteristic(BaseCharacteristic[int]): HID Control Point characteristic. """ + _python_type: type | str | None = int + _template = EnumTemplate.uint8(HidControlPointCommand) expected_length = HID_CONTROL_POINT_DATA_LENGTH diff --git a/src/bluetooth_sig/gatt/characteristics/hid_information.py b/src/bluetooth_sig/gatt/characteristics/hid_information.py index 1997d76d..40768138 100644 --- a/src/bluetooth_sig/gatt/characteristics/hid_information.py +++ b/src/bluetooth_sig/gatt/characteristics/hid_information.py @@ -53,6 +53,8 @@ class HidInformationCharacteristic(BaseCharacteristic[HidInformationData]): HID Information characteristic. """ + _python_type: type | str | None = "HidInformationData" + expected_length: int = 4 # bcdHID(2) + bCountryCode(1) + Flags(1) min_length: int = 4 max_length: int = 4 diff --git a/src/bluetooth_sig/gatt/characteristics/high_temperature.py b/src/bluetooth_sig/gatt/characteristics/high_temperature.py new file mode 100644 index 00000000..fb938155 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/high_temperature.py @@ -0,0 +1,22 @@ +"""High Temperature characteristic (0x2A45).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledSint16Template + + +class HighTemperatureCharacteristic(BaseCharacteristic[float]): + """High Temperature characteristic (0x2A45). + + org.bluetooth.characteristic.high_temperature + + Temperature in degrees Celsius with a resolution of 0.5. + M=1, d=0, b=-1 → scale factor 0.5 (binary exponent 2^-1). + A value of 0x8001 represents 'value is not valid'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0x8001). + """ + + _template = ScaledSint16Template(scale_factor=0.5) diff --git a/src/bluetooth_sig/gatt/characteristics/humidity_8.py b/src/bluetooth_sig/gatt/characteristics/humidity_8.py new file mode 100644 index 00000000..07ae1d09 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/humidity_8.py @@ -0,0 +1,22 @@ +"""Humidity 8 characteristic (0x2B23).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledUint8Template + + +class Humidity8Characteristic(BaseCharacteristic[float]): + """Humidity 8 characteristic (0x2B23). + + org.bluetooth.characteristic.humidity_8 + + Humidity as a percentage with a resolution of 0.5. + M=1, d=0, b=-1 → scale factor 0.5 (binary exponent 2^-1). + Range: 0-100%. A value of 0xFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFF). + """ + + _template = ScaledUint8Template(scale_factor=0.5) diff --git a/src/bluetooth_sig/gatt/characteristics/illuminance_16.py b/src/bluetooth_sig/gatt/characteristics/illuminance_16.py new file mode 100644 index 00000000..6810ee48 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/illuminance_16.py @@ -0,0 +1,21 @@ +"""Illuminance 16 characteristic (0x2C15).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint16Template + + +class Illuminance16Characteristic(BaseCharacteristic[int]): + """Illuminance 16 characteristic (0x2C15). + + org.bluetooth.characteristic.illuminance_16 + + Illuminance in lux with a resolution of 1. + A value of 0xFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFF). + """ + + _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py b/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py index b432928d..281a4f8c 100644 --- a/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py +++ b/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py @@ -14,4 +14,7 @@ class IndoorPositioningConfigurationCharacteristic(BaseCharacteristic[int]): Indoor Positioning Configuration characteristic. """ + _python_type: type | str | None = int + _is_bitfield = True + _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/latitude.py b/src/bluetooth_sig/gatt/characteristics/latitude.py index dc9ecaf9..b48640ee 100644 --- a/src/bluetooth_sig/gatt/characteristics/latitude.py +++ b/src/bluetooth_sig/gatt/characteristics/latitude.py @@ -15,6 +15,8 @@ class LatitudeCharacteristic(BaseCharacteristic[float]): Encoded as sint32 with scale factor 1e-7 degrees per unit. """ + _python_type: type | str | None = float + # Geographic coordinate constants DEGREE_SCALING_FACTOR = 1e-7 # 10^-7 degrees per unit diff --git a/src/bluetooth_sig/gatt/characteristics/light_distribution.py b/src/bluetooth_sig/gatt/characteristics/light_distribution.py new file mode 100644 index 00000000..7ec99948 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/light_distribution.py @@ -0,0 +1,39 @@ +"""Light Distribution characteristic (0x2BE3).""" + +from __future__ import annotations + +from enum import IntEnum + +from .base import BaseCharacteristic +from .templates import EnumTemplate + + +class LightDistributionType(IntEnum): + """Light distribution type values. + + Values: + NOT_SPECIFIED: Type not specified (0x00) + TYPE_I: Type I distribution (0x01) + TYPE_II: Type II distribution (0x02) + TYPE_III: Type III distribution (0x03) + TYPE_IV: Type IV distribution (0x04) + TYPE_V: Type V distribution (0x05) + """ + + NOT_SPECIFIED = 0x00 + TYPE_I = 0x01 + TYPE_II = 0x02 + TYPE_III = 0x03 + TYPE_IV = 0x04 + TYPE_V = 0x05 + + +class LightDistributionCharacteristic(BaseCharacteristic[LightDistributionType]): + """Light Distribution characteristic (0x2BE3). + + org.bluetooth.characteristic.light_distribution + + Type of light distribution pattern. + """ + + _template = EnumTemplate.uint8(LightDistributionType) diff --git a/src/bluetooth_sig/gatt/characteristics/light_output.py b/src/bluetooth_sig/gatt/characteristics/light_output.py new file mode 100644 index 00000000..667edf01 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/light_output.py @@ -0,0 +1,21 @@ +"""Light Output characteristic (0x2BF0).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint24Template + + +class LightOutputCharacteristic(BaseCharacteristic[int]): + """Light Output characteristic (0x2BF0). + + org.bluetooth.characteristic.light_output + + Light output in lumens with a resolution of 1. + A value of 0xFFFFFE represents 'value is not valid'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFE). + """ + + _template = Uint24Template() diff --git a/src/bluetooth_sig/gatt/characteristics/light_source_type.py b/src/bluetooth_sig/gatt/characteristics/light_source_type.py new file mode 100644 index 00000000..0a10e03d --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/light_source_type.py @@ -0,0 +1,39 @@ +"""Light Source Type characteristic (0x2BE4).""" + +from __future__ import annotations + +from enum import IntEnum + +from .base import BaseCharacteristic +from .templates import EnumTemplate + + +class LightSourceTypeValue(IntEnum): + """Light source type values. + + Values: + NOT_SPECIFIED: Type not specified (0x00) + LOW_PRESSURE_FLUORESCENT: Low pressure fluorescent (0x01) + HID: High intensity discharge (0x02) + LOW_VOLTAGE_HALOGEN: Low voltage halogen (0x03) + INCANDESCENT: Incandescent (0x04) + LED: Light emitting diode (0x05) + """ + + NOT_SPECIFIED = 0x00 + LOW_PRESSURE_FLUORESCENT = 0x01 + HID = 0x02 + LOW_VOLTAGE_HALOGEN = 0x03 + INCANDESCENT = 0x04 + LED = 0x05 + + +class LightSourceTypeCharacteristic(BaseCharacteristic[LightSourceTypeValue]): + """Light Source Type characteristic (0x2BE4). + + org.bluetooth.characteristic.light_source_type + + Type of light source (LED, fluorescent, incandescent, etc.). + """ + + _template = EnumTemplate.uint8(LightSourceTypeValue) diff --git a/src/bluetooth_sig/gatt/characteristics/ln_control_point.py b/src/bluetooth_sig/gatt/characteristics/ln_control_point.py index 05c09c73..1d928290 100644 --- a/src/bluetooth_sig/gatt/characteristics/ln_control_point.py +++ b/src/bluetooth_sig/gatt/characteristics/ln_control_point.py @@ -7,7 +7,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import UINT8_MAX, UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -112,7 +111,7 @@ class LNControlPointCharacteristic(BaseCharacteristic[LNControlPointData]): Used to enable device-specific procedures related to the exchange of location and navigation information. """ - _manual_value_type: ValueType | str | None = ValueType.DICT # Override since decode_value returns dataclass + _python_type: type | str | None = dict # Override since decode_value returns dataclass min_length = 1 # Op Code(1) minimum max_length = 18 # Op Code(1) + Parameter(max 17) maximum diff --git a/src/bluetooth_sig/gatt/characteristics/ln_feature.py b/src/bluetooth_sig/gatt/characteristics/ln_feature.py index b939824b..c591cd69 100644 --- a/src/bluetooth_sig/gatt/characteristics/ln_feature.py +++ b/src/bluetooth_sig/gatt/characteristics/ln_feature.py @@ -6,7 +6,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..context import CharacteristicContext from .base import BaseCharacteristic from .utils import DataParser @@ -72,7 +71,7 @@ class LNFeatureCharacteristic(BaseCharacteristic[LNFeatureData]): """ min_length = 4 - _manual_value_type: ValueType | str | None = ValueType.DICT # Override since decode_value returns dataclass + _python_type: type | str | None = dict # Override since decode_value returns dataclass def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/local_east_coordinate.py b/src/bluetooth_sig/gatt/characteristics/local_east_coordinate.py index 4f544430..5ec21fcf 100644 --- a/src/bluetooth_sig/gatt/characteristics/local_east_coordinate.py +++ b/src/bluetooth_sig/gatt/characteristics/local_east_coordinate.py @@ -16,7 +16,6 @@ class LocalEastCoordinateCharacteristic(BaseCharacteristic[float]): # Manual overrides required as Bluetooth SIG registry doesn't provide unit/value type _manual_unit = "m" - _manual_value_type = "float" # SIG spec: sint24 with 0.1 m resolution → fixed 3-byte payload; no GSS YAML expected_length = 3 min_length = 3 diff --git a/src/bluetooth_sig/gatt/characteristics/local_north_coordinate.py b/src/bluetooth_sig/gatt/characteristics/local_north_coordinate.py index b272a547..88c50f89 100644 --- a/src/bluetooth_sig/gatt/characteristics/local_north_coordinate.py +++ b/src/bluetooth_sig/gatt/characteristics/local_north_coordinate.py @@ -16,7 +16,6 @@ class LocalNorthCoordinateCharacteristic(BaseCharacteristic[float]): # Manual overrides required as Bluetooth SIG registry doesn't provide unit/value type _manual_unit = "m" - _manual_value_type = "float" # SIG spec: sint24 with 0.1 m resolution → fixed 3-byte payload; no GSS YAML expected_length = 3 min_length = 3 diff --git a/src/bluetooth_sig/gatt/characteristics/location_and_speed.py b/src/bluetooth_sig/gatt/characteristics/location_and_speed.py index 2515bd75..8450647e 100644 --- a/src/bluetooth_sig/gatt/characteristics/location_and_speed.py +++ b/src/bluetooth_sig/gatt/characteristics/location_and_speed.py @@ -7,7 +7,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ...types.location import PositionStatus from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -74,7 +73,7 @@ class LocationAndSpeedCharacteristic(BaseCharacteristic[LocationAndSpeedData]): Note that it is possible for this characteristic to exceed the default LE ATT_MTU size. """ - _manual_value_type: ValueType | str | None = ValueType.DICT # Override since decode_value returns dataclass + _python_type: type | str | None = dict # Override since decode_value returns dataclass min_length = 2 # Flags(2) minimum max_length = 28 # Flags(2) + InstantaneousSpeed(2) + TotalDistance(3) + Location(8) + diff --git a/src/bluetooth_sig/gatt/characteristics/location_name.py b/src/bluetooth_sig/gatt/characteristics/location_name.py index e8bae348..17343830 100644 --- a/src/bluetooth_sig/gatt/characteristics/location_name.py +++ b/src/bluetooth_sig/gatt/characteristics/location_name.py @@ -14,5 +14,7 @@ class LocationNameCharacteristic(BaseCharacteristic[str]): Location Name characteristic. """ + _python_type: type | str | None = str + _template = Utf8StringTemplate() min_length = 0 diff --git a/src/bluetooth_sig/gatt/characteristics/longitude.py b/src/bluetooth_sig/gatt/characteristics/longitude.py index f02ad3b0..645e3cb7 100644 --- a/src/bluetooth_sig/gatt/characteristics/longitude.py +++ b/src/bluetooth_sig/gatt/characteristics/longitude.py @@ -15,6 +15,8 @@ class LongitudeCharacteristic(BaseCharacteristic[float]): Encoded as sint32 with scale factor 1e-7 degrees per unit. """ + _python_type: type | str | None = float + # Geographic coordinate constants DEGREE_SCALING_FACTOR = 1e-7 # 10^-7 degrees per unit diff --git a/src/bluetooth_sig/gatt/characteristics/luminous_efficacy.py b/src/bluetooth_sig/gatt/characteristics/luminous_efficacy.py new file mode 100644 index 00000000..33cf3743 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/luminous_efficacy.py @@ -0,0 +1,22 @@ +"""Luminous Efficacy characteristic (0x2BF6).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledUint16Template + + +class LuminousEfficacyCharacteristic(BaseCharacteristic[float]): + """Luminous Efficacy characteristic (0x2BF6). + + org.bluetooth.characteristic.luminous_efficacy + + Luminous efficacy in lumens per watt with a resolution of 0.1. + M=1, d=-1, b=0 → scale factor 0.1. + Range: 0-1800. A value of 0xFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFF). + """ + + _template = ScaledUint16Template.from_letter_method(M=1, d=-1, b=0) diff --git a/src/bluetooth_sig/gatt/characteristics/luminous_energy.py b/src/bluetooth_sig/gatt/characteristics/luminous_energy.py new file mode 100644 index 00000000..ccdd80ed --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/luminous_energy.py @@ -0,0 +1,21 @@ +"""Luminous Energy characteristic (0x2BF2).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint24Template + + +class LuminousEnergyCharacteristic(BaseCharacteristic[int]): + """Luminous Energy characteristic (0x2BF2). + + org.bluetooth.characteristic.luminous_energy + + Luminous energy in lumen hours with a resolution of 1000. + A value of 0xFFFFFE represents 'value is not valid'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFE). + """ + + _template = Uint24Template() diff --git a/src/bluetooth_sig/gatt/characteristics/luminous_exposure.py b/src/bluetooth_sig/gatt/characteristics/luminous_exposure.py new file mode 100644 index 00000000..79ccad5e --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/luminous_exposure.py @@ -0,0 +1,21 @@ +"""Luminous Exposure characteristic (0x2BF3).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint24Template + + +class LuminousExposureCharacteristic(BaseCharacteristic[int]): + """Luminous Exposure characteristic (0x2BF3). + + org.bluetooth.characteristic.luminous_exposure + + Luminous exposure in lux hours with a resolution of 1000. + A value of 0xFFFFFE represents 'value is not valid'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFE). + """ + + _template = Uint24Template() diff --git a/src/bluetooth_sig/gatt/characteristics/luminous_flux.py b/src/bluetooth_sig/gatt/characteristics/luminous_flux.py new file mode 100644 index 00000000..befbf0f0 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/luminous_flux.py @@ -0,0 +1,21 @@ +"""Luminous Flux characteristic (0x2BF4).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint16Template + + +class LuminousFluxCharacteristic(BaseCharacteristic[int]): + """Luminous Flux characteristic (0x2BF4). + + org.bluetooth.characteristic.luminous_flux + + Luminous flux in lumens with a resolution of 1. + A value of 0xFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFF). + """ + + _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/luminous_flux_range.py b/src/bluetooth_sig/gatt/characteristics/luminous_flux_range.py new file mode 100644 index 00000000..6c7c6981 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/luminous_flux_range.py @@ -0,0 +1,87 @@ +"""Luminous Flux Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class LuminousFluxRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for luminous flux range. + + Each value is a luminous flux in lumens (resolution 1 lm). + """ + + minimum: int # Minimum luminous flux in lumens + maximum: int # Maximum luminous flux in lumens + + def __post_init__(self) -> None: + """Validate luminous flux range data.""" + if self.minimum > self.maximum: + raise ValueError( + f"Minimum luminous flux {self.minimum} lm cannot be greater than maximum {self.maximum} lm" + ) + for name, val in [("minimum", self.minimum), ("maximum", self.maximum)]: + if not 0 <= val <= UINT16_MAX: + raise ValueError( + f"{name.capitalize()} luminous flux {val} lm is outside valid range (0 to {UINT16_MAX})" + ) + + +class LuminousFluxRangeCharacteristic(BaseCharacteristic[LuminousFluxRangeData]): + """Luminous Flux Range characteristic (0x2B00). + + org.bluetooth.characteristic.luminous_flux_range + + Represents a luminous flux range as a pair of Luminous Flux values. + Each field is a uint16 with resolution 1 lumen. + """ + + # Validation attributes + expected_length: int = 4 # 2 x uint16 + min_length: int = 4 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> LuminousFluxRangeData: + """Parse luminous flux range data (2 x uint16, resolution 1 lm). + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + LuminousFluxRangeData with minimum and maximum luminous flux in lumens. + + """ + min_raw = DataParser.parse_int16(data, 0, signed=False) + max_raw = DataParser.parse_int16(data, 2, signed=False) + + return LuminousFluxRangeData(minimum=min_raw, maximum=max_raw) + + def _encode_value(self, data: LuminousFluxRangeData) -> bytearray: + """Encode luminous flux range to bytes. + + Args: + data: LuminousFluxRangeData instance. + + Returns: + Encoded bytes (2 x uint16, little-endian). + + """ + if not isinstance(data, LuminousFluxRangeData): + raise TypeError(f"Expected LuminousFluxRangeData, got {type(data).__name__}") + + for name, value in [("minimum", data.minimum), ("maximum", data.maximum)]: + if not 0 <= value <= UINT16_MAX: + raise ValueError(f"Luminous flux {name} value {value} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int16(data.minimum, signed=False)) + result.extend(DataParser.encode_int16(data.maximum, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/luminous_intensity.py b/src/bluetooth_sig/gatt/characteristics/luminous_intensity.py new file mode 100644 index 00000000..dbe15d2c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/luminous_intensity.py @@ -0,0 +1,21 @@ +"""Luminous Intensity characteristic (0x2BF5).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint16Template + + +class LuminousIntensityCharacteristic(BaseCharacteristic[int]): + """Luminous Intensity characteristic (0x2BF5). + + org.bluetooth.characteristic.luminous_intensity + + Luminous intensity in candela with a resolution of 1. + A value of 0xFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFF). + """ + + _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py b/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py index 225a80af..937aff93 100644 --- a/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py +++ b/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from ...types.units import AngleUnit from .base import BaseCharacteristic from .templates import ScaledUint16Template @@ -24,7 +23,7 @@ class MagneticDeclinationCharacteristic(BaseCharacteristic[float]): _characteristic_name: str = "Magnetic Declination" # Override YAML int type since decode_value returns float - _manual_value_type: ValueType | str | None = ValueType.FLOAT + _python_type: type | str | None = float _manual_unit: str = AngleUnit.DEGREES.value # Override template's "units" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py index 3482619f..e40bb8ae 100644 --- a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py +++ b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py @@ -4,7 +4,6 @@ from typing import ClassVar -from ...types.gatt_enums import ValueType from ...types.units import PhysicalUnit from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -27,7 +26,7 @@ class MagneticFluxDensity2DCharacteristic(BaseCharacteristic[Vector2DData]): _characteristic_name: str | None = "Magnetic Flux Density - 2D" # Override YAML since decode_value returns structured dict - _manual_value_type: ValueType | str | None = ValueType.STRING # Override since decode_value returns dict + _python_type: type | str | None = str # Override since decode_value returns dict _manual_unit: str | None = PhysicalUnit.TESLA.value # Tesla _vector_components: ClassVar[list[str]] = ["x_axis", "y_axis"] diff --git a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py index c21acff9..44ed834b 100644 --- a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py +++ b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py @@ -4,7 +4,6 @@ from typing import ClassVar -from ...types.gatt_enums import ValueType from ...types.units import PhysicalUnit from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -27,7 +26,7 @@ class MagneticFluxDensity3DCharacteristic(BaseCharacteristic[VectorData]): """ _characteristic_name: str | None = "Magnetic Flux Density - 3D" - _manual_value_type: ValueType | str | None = ValueType.STRING # Override since decode_value returns dict + _python_type: type | str | None = str # Override since decode_value returns dict _manual_unit: str | None = PhysicalUnit.TESLA.value # Override template's "units" default _vector_components: ClassVar[list[str]] = ["x_axis", "y_axis", "z_axis"] diff --git a/src/bluetooth_sig/gatt/characteristics/mass_flow.py b/src/bluetooth_sig/gatt/characteristics/mass_flow.py new file mode 100644 index 00000000..8d0255a4 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/mass_flow.py @@ -0,0 +1,21 @@ +"""Mass Flow characteristic (0x2C17).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint16Template + + +class MassFlowCharacteristic(BaseCharacteristic[int]): + """Mass Flow characteristic (0x2C17). + + org.bluetooth.characteristic.mass_flow + + Mass flow in grams per second with a resolution of 1. + A value of 0xFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFF). + """ + + _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/methane_concentration.py b/src/bluetooth_sig/gatt/characteristics/methane_concentration.py index 6e85362f..f68ed5f9 100644 --- a/src/bluetooth_sig/gatt/characteristics/methane_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/methane_concentration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import ConcentrationTemplate @@ -16,7 +15,7 @@ class MethaneConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _manual_value_type: ValueType | str | None = "int" + _python_type: type | str | None = int _manual_unit: str = "ppm" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/navigation.py b/src/bluetooth_sig/gatt/characteristics/navigation.py index 026f5705..7429a142 100644 --- a/src/bluetooth_sig/gatt/characteristics/navigation.py +++ b/src/bluetooth_sig/gatt/characteristics/navigation.py @@ -7,7 +7,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ...types.location import PositionStatus from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -58,7 +57,7 @@ class NavigationCharacteristic(BaseCharacteristic[NavigationData]): Used to represent data related to a navigation sensor. """ - _manual_value_type: ValueType | str | None = ValueType.DICT # Override since decode_value returns dataclass + _python_type: type | str | None = dict # Override since decode_value returns dataclass min_length = 6 # Flags(2) + Bearing(2) + Heading(2) minimum max_length = 19 # + RemainingDistance(3) + RemainingVerticalDistance(3) + EstimatedTimeOfArrival(7) maximum diff --git a/src/bluetooth_sig/gatt/characteristics/object_first_created.py b/src/bluetooth_sig/gatt/characteristics/object_first_created.py new file mode 100644 index 00000000..0c28bbac --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/object_first_created.py @@ -0,0 +1,65 @@ +"""Object First-Created characteristic implementation.""" + +from __future__ import annotations + +from datetime import datetime + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class ObjectFirstCreatedCharacteristic(BaseCharacteristic[datetime]): + """Object First-Created characteristic (0x2AC1). + + org.bluetooth.characteristic.object_first_created + + Represents the date/time when an OTS object was first created. + Uses the standard Date Time format: year (uint16), month (uint8), + day (uint8), hours (uint8), minutes (uint8), seconds (uint8). + """ + + expected_length: int = 7 + min_length: int = 7 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> datetime: + """Parse object first-created date/time. + + Args: + data: Raw bytes (7 bytes, Date Time format). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + Python datetime object. + + """ + return datetime( + year=DataParser.parse_int16(data, 0, signed=False), + month=data[2], + day=data[3], + hour=data[4], + minute=data[5], + second=data[6], + ) + + def _encode_value(self, data: datetime) -> bytearray: + """Encode datetime to Date Time format bytes. + + Args: + data: Python datetime object. + + Returns: + Encoded bytes (7 bytes). + + """ + result = bytearray() + result.extend(DataParser.encode_int16(data.year, signed=False)) + result.append(data.month) + result.append(data.day) + result.append(data.hour) + result.append(data.minute) + result.append(data.second) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/object_id.py b/src/bluetooth_sig/gatt/characteristics/object_id.py new file mode 100644 index 00000000..576fa868 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/object_id.py @@ -0,0 +1,49 @@ +"""Object ID characteristic implementation.""" + +from __future__ import annotations + +from ..constants import UINT48_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class ObjectIdCharacteristic(BaseCharacteristic[int]): + """Object ID characteristic (0x2AC3). + + org.bluetooth.characteristic.object_id + + A 48-bit locally unique object identifier used by the Object + Transfer Service (OTS). + """ + + expected_length: int = 6 # uint48 + min_length: int = 6 + min_value = 0 + max_value = UINT48_MAX + + def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: + """Parse object ID (uint48). + + Args: + data: Raw bytes (6 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + Object ID as integer. + + """ + return DataParser.parse_int48(data, 0, signed=False) + + def _encode_value(self, data: int) -> bytearray: + """Encode object ID to bytes. + + Args: + data: Object ID as integer (0 to 2^48-1). + + Returns: + Encoded bytes (6 bytes). + + """ + return DataParser.encode_int48(data, signed=False) diff --git a/src/bluetooth_sig/gatt/characteristics/object_last_modified.py b/src/bluetooth_sig/gatt/characteristics/object_last_modified.py new file mode 100644 index 00000000..1c0c51dc --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/object_last_modified.py @@ -0,0 +1,65 @@ +"""Object Last-Modified characteristic implementation.""" + +from __future__ import annotations + +from datetime import datetime + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class ObjectLastModifiedCharacteristic(BaseCharacteristic[datetime]): + """Object Last-Modified characteristic (0x2AC2). + + org.bluetooth.characteristic.object_last_modified + + Represents the date/time when an OTS object was last modified. + Uses the standard Date Time format: year (uint16), month (uint8), + day (uint8), hours (uint8), minutes (uint8), seconds (uint8). + """ + + expected_length: int = 7 + min_length: int = 7 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> datetime: + """Parse object last-modified date/time. + + Args: + data: Raw bytes (7 bytes, Date Time format). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + Python datetime object. + + """ + return datetime( + year=DataParser.parse_int16(data, 0, signed=False), + month=data[2], + day=data[3], + hour=data[4], + minute=data[5], + second=data[6], + ) + + def _encode_value(self, data: datetime) -> bytearray: + """Encode datetime to Date Time format bytes. + + Args: + data: Python datetime object. + + Returns: + Encoded bytes (7 bytes). + + """ + result = bytearray() + result.extend(DataParser.encode_int16(data.year, signed=False)) + result.append(data.month) + result.append(data.day) + result.append(data.hour) + result.append(data.minute) + result.append(data.second) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/object_name.py b/src/bluetooth_sig/gatt/characteristics/object_name.py new file mode 100644 index 00000000..fa13d9b9 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/object_name.py @@ -0,0 +1,48 @@ +"""Object Name characteristic implementation.""" + +from __future__ import annotations + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_MAX_NAME_LENGTH = 120 # Maximum UTF-8 string length per OTS spec + + +class ObjectNameCharacteristic(BaseCharacteristic[str]): + """Object Name characteristic (0x2ABE). + + org.bluetooth.characteristic.object_name + + A UTF-8 string (0-120 bytes) representing the name of an object + in the Object Transfer Service (OTS). + """ + + min_length: int = 0 + max_length: int = _MAX_NAME_LENGTH + + def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: + """Parse object name (UTF-8 string). + + Args: + data: Raw bytes (0-120 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + Object name as string. + + """ + return DataParser.parse_utf8_string(data) + + def _encode_value(self, data: str) -> bytearray: + """Encode object name to bytes. + + Args: + data: Object name as string (0-120 bytes when encoded). + + Returns: + Encoded bytes. + + """ + return bytearray(data.encode("utf-8")) diff --git a/src/bluetooth_sig/gatt/characteristics/object_type.py b/src/bluetooth_sig/gatt/characteristics/object_type.py new file mode 100644 index 00000000..37f85d29 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/object_type.py @@ -0,0 +1,83 @@ +"""Object Type characteristic implementation.""" + +from __future__ import annotations + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_UUID_16BIT_BYTES = 2 +_UUID_128BIT_BYTES = 16 +_UUID_16BIT_HEX_CHARS = 4 +_UUID_128BIT_HEX_CHARS = 32 + + +class ObjectTypeCharacteristic(BaseCharacteristic[str]): + """Object Type characteristic (0x2ABF). + + org.bluetooth.characteristic.object_type + + A GATT UUID identifying the type of an object in the Object Transfer + Service (OTS). May be a 16-bit (2 bytes) or 128-bit (16 bytes) UUID. + """ + + min_length: int = 2 + max_length: int = 16 + + def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: + """Parse object type UUID. + + Args: + data: Raw bytes (2 or 16 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + UUID as uppercase hex string (e.g. "2AC3" or full 128-bit). + + """ + if len(data) == _UUID_16BIT_BYTES: + raw = DataParser.parse_int16(data, 0, signed=False) + return f"{raw:04X}" + + # 128-bit UUID: formatted as standard UUID string + # Bytes are little-endian, standard UUID format is big-endian groups + parts = [ + data[3::-1].hex(), # time_low (4 bytes, reversed) + data[5:3:-1].hex(), # time_mid (2 bytes, reversed) + data[7:5:-1].hex(), # time_hi_and_version (2 bytes, reversed) + data[8:10].hex(), # clock_seq (2 bytes, big-endian) + data[10:16].hex(), # node (6 bytes, big-endian) + ] + return "-".join(parts).upper() + + def _encode_value(self, data: str) -> bytearray: + """Encode object type UUID to bytes. + + Args: + data: UUID as hex string (4 chars for 16-bit, or + standard UUID format for 128-bit). + + Returns: + Encoded bytes (2 or 16 bytes). + + """ + clean = data.replace("-", "").replace(" ", "").upper() + + if len(clean) == _UUID_16BIT_HEX_CHARS: + # 16-bit UUID + value = int(clean, 16) + return DataParser.encode_int16(value, signed=False) + + if len(clean) == _UUID_128BIT_HEX_CHARS: + # 128-bit UUID: reverse byte order for BLE little-endian groups + raw = bytes.fromhex(clean) + result = bytearray() + result.extend(raw[0:4][::-1]) # time_low reversed + result.extend(raw[4:6][::-1]) # time_mid reversed + result.extend(raw[6:8][::-1]) # time_hi_and_version reversed + result.extend(raw[8:10]) # clock_seq big-endian + result.extend(raw[10:16]) # node big-endian + return result + + raise ValueError(f"UUID must be 4 hex chars (16-bit) or 32 hex chars (128-bit), got {len(clean)} chars") diff --git a/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py b/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py index cc4a641f..34e8caae 100644 --- a/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py @@ -3,7 +3,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import ConcentrationTemplate @@ -17,7 +16,7 @@ class OzoneConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _manual_value_type: ValueType | str | None = ValueType.INT # Manual override needed as no YAML available + _python_type: type | str | None = int # Manual override needed as no YAML available _manual_unit: str = "ppb" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/perceived_lightness.py b/src/bluetooth_sig/gatt/characteristics/perceived_lightness.py new file mode 100644 index 00000000..1b870eba --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/perceived_lightness.py @@ -0,0 +1,17 @@ +"""Perceived Lightness characteristic (0x2B03).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint16Template + + +class PerceivedLightnessCharacteristic(BaseCharacteristic[int]): + """Perceived Lightness characteristic (0x2B03). + + org.bluetooth.characteristic.perceived_lightness + + Unitless perceived lightness value (0-65535). + """ + + _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/percentage_8.py b/src/bluetooth_sig/gatt/characteristics/percentage_8.py new file mode 100644 index 00000000..ef0bcab4 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/percentage_8.py @@ -0,0 +1,22 @@ +"""Percentage 8 characteristic (0x2B04).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledUint8Template + + +class Percentage8Characteristic(BaseCharacteristic[float]): + """Percentage 8 characteristic (0x2B04). + + org.bluetooth.characteristic.percentage_8 + + Percentage with a resolution of 0.5. + M=1, d=0, b=-1 → scale factor 0.5 (binary exponent 2^-1). + Range: 0-100%. A value of 0xFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFF). + """ + + _template = ScaledUint8Template(scale_factor=0.5) diff --git a/src/bluetooth_sig/gatt/characteristics/percentage_8_steps.py b/src/bluetooth_sig/gatt/characteristics/percentage_8_steps.py new file mode 100644 index 00000000..6a11fd6f --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/percentage_8_steps.py @@ -0,0 +1,23 @@ +"""Percentage 8 Steps characteristic (0x2C05).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import Uint8Template + + +class Percentage8StepsCharacteristic(BaseCharacteristic[int]): + """Percentage 8 Steps characteristic (0x2C05). + + org.bluetooth.characteristic.percentage_8_steps + + Number of steps from minimum to maximum value. + M=1, d=0, b=0 — no scaling; plain unsigned 8-bit integer. + Range: 1-200 (unitless). + A value of 0xFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFF). + """ + + _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py b/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py index 9794f94d..38b2d07e 100644 --- a/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py +++ b/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py @@ -26,7 +26,6 @@ class PeripheralPreferredConnectionParametersCharacteristic(BaseCharacteristic[C Contains the preferred connection parameters (8 bytes). """ - _manual_value_type = "ConnectionParametersData" expected_length = 8 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py b/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py index b69c99f3..cf849d91 100644 --- a/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py +++ b/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py @@ -15,6 +15,8 @@ class PeripheralPrivacyFlagCharacteristic(BaseCharacteristic[bool]): Indicates whether privacy is enabled (True) or disabled (False). """ + _python_type: type | str | None = bool + def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True ) -> bool: diff --git a/src/bluetooth_sig/gatt/characteristics/pipeline/validation.py b/src/bluetooth_sig/gatt/characteristics/pipeline/validation.py index d64d6b63..155792b2 100644 --- a/src/bluetooth_sig/gatt/characteristics/pipeline/validation.py +++ b/src/bluetooth_sig/gatt/characteristics/pipeline/validation.py @@ -6,16 +6,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from ....types import SpecialValueResult from ....types.data_types import ValidationAccumulator +from ....types.registry import CharacteristicSpec +from ...context import CharacteristicContext from ...descriptor_utils import get_valid_range_from_context as _get_valid_range -if TYPE_CHECKING: - from ....types.registry import CharacteristicSpec - from ...context import CharacteristicContext - class CharacteristicValidator: """Validates characteristic values against range, type, and length constraints. diff --git a/src/bluetooth_sig/gatt/characteristics/plx_features.py b/src/bluetooth_sig/gatt/characteristics/plx_features.py index 4f837da5..20b865a4 100644 --- a/src/bluetooth_sig/gatt/characteristics/plx_features.py +++ b/src/bluetooth_sig/gatt/characteristics/plx_features.py @@ -36,6 +36,9 @@ class PLXFeaturesCharacteristic(BaseCharacteristic[PLXFeatureFlags]): Spec: Bluetooth SIG Assigned Numbers, PLX Features characteristic """ + _python_type: type | str | None = int + _is_bitfield = True + expected_length: int | None = 2 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py b/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py index 01cf7889..7bc948b4 100644 --- a/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py +++ b/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py @@ -80,6 +80,8 @@ class PLXSpotCheckMeasurementCharacteristic(BaseCharacteristic[PLXSpotCheckData] measurements from spot-check readings. """ + _python_type: type | str | None = dict + _characteristic_name: str = "PLX Spot-Check Measurement" _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [PLXFeaturesCharacteristic] diff --git a/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py b/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py index 9946c21f..76f5134d 100644 --- a/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import ConcentrationTemplate @@ -17,7 +16,7 @@ class PM10ConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() _characteristic_name: str = "Particulate Matter - PM10 Concentration" - _manual_value_type: ValueType | str | None = ValueType.INT + _python_type: type | str | None = int _manual_unit: str = "µg/m³" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py b/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py index 1fd55e47..d1941bb2 100644 --- a/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import ConcentrationTemplate @@ -21,7 +20,7 @@ class PM1ConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() _characteristic_name: str = "Particulate Matter - PM1 Concentration" - _manual_value_type: ValueType | str | None = ValueType.INT + _python_type: type | str | None = int _manual_unit: str = "µg/m³" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/pnp_id.py b/src/bluetooth_sig/gatt/characteristics/pnp_id.py index abd4e60a..8c480cae 100644 --- a/src/bluetooth_sig/gatt/characteristics/pnp_id.py +++ b/src/bluetooth_sig/gatt/characteristics/pnp_id.py @@ -46,7 +46,6 @@ class PnpIdCharacteristic(BaseCharacteristic[PnpIdData]): Contains PnP ID information (7 bytes). """ - _manual_value_type = "PnpIdData" expected_length = 7 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py b/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py index 436488f6..aa671d5e 100644 --- a/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import ScaledUint24Template @@ -16,7 +15,7 @@ class PollenConcentrationCharacteristic(BaseCharacteristic[float]): _template = ScaledUint24Template(scale_factor=1.0) - _manual_value_type: ValueType | str | None = "float" # Override YAML spec since decode_value returns float + _python_type: type | str | None = float # Override YAML spec since decode_value returns float _manual_unit: str = "grains/m³" # Override template's "units" default # SIG specification configuration diff --git a/src/bluetooth_sig/gatt/characteristics/position_quality.py b/src/bluetooth_sig/gatt/characteristics/position_quality.py index f409bc42..0b9178af 100644 --- a/src/bluetooth_sig/gatt/characteristics/position_quality.py +++ b/src/bluetooth_sig/gatt/characteristics/position_quality.py @@ -6,7 +6,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ...types.location import PositionStatus from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -45,7 +44,7 @@ class PositionQualityCharacteristic(BaseCharacteristic[PositionQualityData]): Used to represent data related to the quality of a position measurement. """ - _manual_value_type: ValueType | str | None = ValueType.DICT # Override since decode_value returns dataclass + _python_type: type | str | None = dict # Override since decode_value returns dataclass min_length = 2 # Flags(2) minimum max_length = 16 # Flags(2) + NumberOfBeaconsInSolution(1) + NumberOfBeaconsInView(1) + diff --git a/src/bluetooth_sig/gatt/characteristics/power.py b/src/bluetooth_sig/gatt/characteristics/power.py new file mode 100644 index 00000000..729eebc5 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/power.py @@ -0,0 +1,22 @@ +"""Power characteristic (0x2B05).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledUint24Template + + +class PowerCharacteristic(BaseCharacteristic[float]): + """Power characteristic (0x2B05). + + org.bluetooth.characteristic.power + + Power in watts with a resolution of 0.1. + M=1, d=-1, b=0 → scale factor 0.1. + Range: 0-1677721.3 W. A value of 0xFFFFFE represents 'value is not valid'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFE). + """ + + _template = ScaledUint24Template.from_letter_method(M=1, d=-1, b=0) diff --git a/src/bluetooth_sig/gatt/characteristics/precise_acceleration_3d.py b/src/bluetooth_sig/gatt/characteristics/precise_acceleration_3d.py new file mode 100644 index 00000000..271c2a3b --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/precise_acceleration_3d.py @@ -0,0 +1,69 @@ +"""Precise Acceleration 3D characteristic implementation.""" + +from __future__ import annotations + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .templates import VectorData +from .utils import DataParser + +_RESOLUTION = 0.001 # M=1, d=-3, b=0 -> 0.001 standard gravity + + +class PreciseAcceleration3DCharacteristic(BaseCharacteristic[VectorData]): + """Precise Acceleration 3D characteristic (0x2C1E). + + org.bluetooth.characteristic.precise_acceleration_3d + + Represents a precise 3D acceleration measurement in standard gravity. + Three fields: x, y, z (sint16 each, 0.001 gn resolution). + """ + + _characteristic_name: str | None = "Precise Acceleration 3D" + resolution: float = _RESOLUTION + expected_length: int = 6 # 3 x sint16 + min_length: int = 6 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> VectorData: + """Parse precise 3D acceleration (3 x sint16). + + Args: + data: Raw bytes (6 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + VectorData with x, y, z axis values in gn (standard gravity). + + """ + x_raw = DataParser.parse_int16(data, 0, signed=True) + y_raw = DataParser.parse_int16(data, 2, signed=True) + z_raw = DataParser.parse_int16(data, 4, signed=True) + + return VectorData( + x_axis=x_raw * _RESOLUTION, + y_axis=y_raw * _RESOLUTION, + z_axis=z_raw * _RESOLUTION, + ) + + def _encode_value(self, data: VectorData) -> bytearray: + """Encode precise 3D acceleration. + + Args: + data: VectorData with x, y, z axis values in gn. + + Returns: + Encoded bytes (6 bytes). + + """ + x_raw = round(data.x_axis / _RESOLUTION) + y_raw = round(data.y_axis / _RESOLUTION) + z_raw = round(data.z_axis / _RESOLUTION) + + result = bytearray() + result.extend(DataParser.encode_int16(x_raw, signed=True)) + result.extend(DataParser.encode_int16(y_raw, signed=True)) + result.extend(DataParser.encode_int16(z_raw, signed=True)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/protocol_mode.py b/src/bluetooth_sig/gatt/characteristics/protocol_mode.py index 810f1dea..d5ff46ee 100644 --- a/src/bluetooth_sig/gatt/characteristics/protocol_mode.py +++ b/src/bluetooth_sig/gatt/characteristics/protocol_mode.py @@ -23,6 +23,8 @@ class ProtocolModeCharacteristic(BaseCharacteristic[int]): Protocol Mode characteristic. """ + _python_type: type | str | None = int + _template = EnumTemplate.uint8(ProtocolMode) # SIG spec: uint8 enumerated mode value → fixed 1-byte payload; no GSS YAML diff --git a/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py b/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py index 680038d6..de1dcc35 100644 --- a/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py +++ b/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py @@ -56,6 +56,8 @@ class PulseOximetryMeasurementCharacteristic(BaseCharacteristic[PulseOximetryDat measurements. """ + _python_type: type | str | None = dict + _characteristic_name: str = "PLX Continuous Measurement" _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [PLXFeaturesCharacteristic] diff --git a/src/bluetooth_sig/gatt/characteristics/pushbutton_status_8.py b/src/bluetooth_sig/gatt/characteristics/pushbutton_status_8.py new file mode 100644 index 00000000..6efdf7c0 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/pushbutton_status_8.py @@ -0,0 +1,81 @@ +"""Pushbutton Status 8 characteristic (0x2C21). + +Represents the status of up to 4 pushbuttons packed into a single byte. +Each button occupies 2 bits, yielding four independent status values. +""" + +from __future__ import annotations + +from enum import IntEnum + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils.data_parser import DataParser + +_BUTTON_MASK = 0x03 + + +class ButtonStatus(IntEnum): + """Status of an individual pushbutton. + + Values: + NOT_ACTUATED: Button not actuated or not in use (0) + PRESSED: Button pressed (1) + RELEASED: Button released (2) + RESERVED: Reserved for future use (3) + """ + + NOT_ACTUATED = 0 + PRESSED = 1 + RELEASED = 2 + RESERVED = 3 + + +class PushbuttonStatus8Data(msgspec.Struct, frozen=True, kw_only=True): + """Decoded pushbutton status for four buttons.""" + + button_0: ButtonStatus + button_1: ButtonStatus + button_2: ButtonStatus + button_3: ButtonStatus + + +class PushbuttonStatus8Characteristic(BaseCharacteristic[PushbuttonStatus8Data]): + """Pushbutton Status 8 characteristic (0x2C21). + + org.bluetooth.characteristic.pushbutton_status_8 + + Four independent 2-bit button status fields packed into a single byte. + Bits [1:0] → Button 0, [3:2] → Button 1, [5:4] → Button 2, [7:6] → Button 3. + """ + + expected_length = 1 + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> PushbuttonStatus8Data: + """Decode four 2-bit button status fields from a single byte.""" + raw = DataParser.parse_int8(data, 0, signed=False) + + return PushbuttonStatus8Data( + button_0=ButtonStatus((raw >> 0) & _BUTTON_MASK), + button_1=ButtonStatus((raw >> 2) & _BUTTON_MASK), + button_2=ButtonStatus((raw >> 4) & _BUTTON_MASK), + button_3=ButtonStatus((raw >> 6) & _BUTTON_MASK), + ) + + def _encode_value(self, data: PushbuttonStatus8Data) -> bytearray: + """Encode four button statuses into a single byte.""" + encoded = ( + (data.button_0 & _BUTTON_MASK) + | ((data.button_1 & _BUTTON_MASK) << 2) + | ((data.button_2 & _BUTTON_MASK) << 4) + | ((data.button_3 & _BUTTON_MASK) << 6) + ) + return DataParser.encode_int8(encoded, signed=False) diff --git a/src/bluetooth_sig/gatt/characteristics/reconnection_address.py b/src/bluetooth_sig/gatt/characteristics/reconnection_address.py index f562c9ba..6d423c58 100644 --- a/src/bluetooth_sig/gatt/characteristics/reconnection_address.py +++ b/src/bluetooth_sig/gatt/characteristics/reconnection_address.py @@ -15,6 +15,8 @@ class ReconnectionAddressCharacteristic(BaseCharacteristic[str]): Contains a 48-bit Bluetooth device address for reconnection. """ + _python_type: type | str | None = str + expected_length = 6 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: diff --git a/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_correlated_color_temperature_range.py b/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_correlated_color_temperature_range.py new file mode 100644 index 00000000..bfcdc625 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_correlated_color_temperature_range.py @@ -0,0 +1,104 @@ +"""Relative Runtime in a Correlated Color Temperature Range characteristic.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_PERCENTAGE_RESOLUTION = 0.5 # Percentage 8: M=1, d=0, b=-1 -> 0.5% +# Correlated Color Temperature: 1 Kelvin per raw unit (no scaling) + + +class RelativeRuntimeInACCTRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for relative runtime in a correlated color temperature range. + + Combines a percentage (0.5% resolution) with a CCT range + (min/max in Kelvin, 1 K resolution). + """ + + relative_runtime: float # Percentage (0.5% resolution) + minimum_cct: int # Minimum correlated color temperature in K + maximum_cct: int # Maximum correlated color temperature in K + + def __post_init__(self) -> None: + """Validate data fields.""" + max_pct = UINT8_MAX * _PERCENTAGE_RESOLUTION + if not 0.0 <= self.relative_runtime <= max_pct: + raise ValueError(f"Relative runtime {self.relative_runtime}% is outside valid range (0.0 to {max_pct})") + if self.minimum_cct > self.maximum_cct: + raise ValueError(f"Minimum CCT {self.minimum_cct} K cannot exceed maximum {self.maximum_cct} K") + for name, val in [ + ("minimum_cct", self.minimum_cct), + ("maximum_cct", self.maximum_cct), + ]: + if not 0 <= val <= UINT16_MAX: + raise ValueError(f"{name} {val} K is outside valid range (0 to {UINT16_MAX})") + + +class RelativeRuntimeInACorrelatedColorTemperatureRangeCharacteristic( + BaseCharacteristic[RelativeRuntimeInACCTRangeData], +): + """Relative Runtime in a Correlated Color Temperature Range (0x2BE5). + + org.bluetooth.characteristic.relative_runtime_in_a_correlated_color_temperature_range + + Represents relative runtime within a CCT range. Fields: + Percentage 8 (uint8, 0.5%), min CCT (uint16, 1 K), + max CCT (uint16, 1 K). + """ + + expected_length: int = 5 # uint8 + 2 x uint16 + min_length: int = 5 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> RelativeRuntimeInACCTRangeData: + """Parse relative runtime in a CCT range. + + Args: + data: Raw bytes (5 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + RelativeRuntimeInACCTRangeData. + + """ + pct_raw = DataParser.parse_int8(data, 0, signed=False) + min_raw = DataParser.parse_int16(data, 1, signed=False) + max_raw = DataParser.parse_int16(data, 3, signed=False) + + return RelativeRuntimeInACCTRangeData( + relative_runtime=pct_raw * _PERCENTAGE_RESOLUTION, + minimum_cct=min_raw, + maximum_cct=max_raw, + ) + + def _encode_value(self, data: RelativeRuntimeInACCTRangeData) -> bytearray: + """Encode relative runtime in a CCT range. + + Args: + data: RelativeRuntimeInACCTRangeData instance. + + Returns: + Encoded bytes (5 bytes). + + """ + pct_raw = round(data.relative_runtime / _PERCENTAGE_RESOLUTION) + + if not 0 <= pct_raw <= UINT8_MAX: + raise ValueError(f"Percentage raw {pct_raw} exceeds uint8 range") + if not 0 <= data.minimum_cct <= UINT16_MAX: + raise ValueError(f"Min CCT {data.minimum_cct} exceeds uint16 range") + if not 0 <= data.maximum_cct <= UINT16_MAX: + raise ValueError(f"Max CCT {data.maximum_cct} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int8(pct_raw, signed=False)) + result.extend(DataParser.encode_int16(data.minimum_cct, signed=False)) + result.extend(DataParser.encode_int16(data.maximum_cct, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_current_range.py b/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_current_range.py new file mode 100644 index 00000000..8f285e4d --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_current_range.py @@ -0,0 +1,107 @@ +"""Relative Runtime in a Current Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_PERCENTAGE_RESOLUTION = 0.5 # Percentage 8: M=1, d=0, b=-1 -> 0.5% +_CURRENT_RESOLUTION = 0.01 # Electric Current: M=1, d=-2, b=0 -> 0.01 A + + +class RelativeRuntimeInACurrentRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for relative runtime in a current range. + + Combines a percentage (0.5% resolution) with a current range + (min/max in amperes, 0.01 A resolution). + """ + + relative_runtime: float # Percentage (0.5% resolution) + minimum_current: float # Minimum current in A + maximum_current: float # Maximum current in A + + def __post_init__(self) -> None: + """Validate data fields.""" + max_pct = UINT8_MAX * _PERCENTAGE_RESOLUTION + if not 0.0 <= self.relative_runtime <= max_pct: + raise ValueError(f"Relative runtime {self.relative_runtime}% is outside valid range (0.0 to {max_pct})") + if self.minimum_current > self.maximum_current: + raise ValueError(f"Minimum current {self.minimum_current} A cannot exceed maximum {self.maximum_current} A") + max_current = UINT16_MAX * _CURRENT_RESOLUTION + for name, val in [ + ("minimum_current", self.minimum_current), + ("maximum_current", self.maximum_current), + ]: + if not 0.0 <= val <= max_current: + raise ValueError(f"{name} {val} A is outside valid range (0.0 to {max_current})") + + +class RelativeRuntimeInACurrentRangeCharacteristic( + BaseCharacteristic[RelativeRuntimeInACurrentRangeData], +): + """Relative Runtime in a Current Range characteristic (0x2B07). + + org.bluetooth.characteristic.relative_runtime_in_a_current_range + + Represents relative runtime within an electric current range. Fields: + Percentage 8 (uint8, 0.5%), min current (uint16, 0.01 A), + max current (uint16, 0.01 A). + """ + + expected_length: int = 5 # uint8 + 2 x uint16 + min_length: int = 5 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> RelativeRuntimeInACurrentRangeData: + """Parse relative runtime in a current range. + + Args: + data: Raw bytes (5 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + RelativeRuntimeInACurrentRangeData. + + """ + pct_raw = DataParser.parse_int8(data, 0, signed=False) + min_raw = DataParser.parse_int16(data, 1, signed=False) + max_raw = DataParser.parse_int16(data, 3, signed=False) + + return RelativeRuntimeInACurrentRangeData( + relative_runtime=pct_raw * _PERCENTAGE_RESOLUTION, + minimum_current=min_raw * _CURRENT_RESOLUTION, + maximum_current=max_raw * _CURRENT_RESOLUTION, + ) + + def _encode_value(self, data: RelativeRuntimeInACurrentRangeData) -> bytearray: + """Encode relative runtime in a current range. + + Args: + data: RelativeRuntimeInACurrentRangeData instance. + + Returns: + Encoded bytes (5 bytes). + + """ + pct_raw = round(data.relative_runtime / _PERCENTAGE_RESOLUTION) + min_raw = round(data.minimum_current / _CURRENT_RESOLUTION) + max_raw = round(data.maximum_current / _CURRENT_RESOLUTION) + + if not 0 <= pct_raw <= UINT8_MAX: + raise ValueError(f"Percentage raw {pct_raw} exceeds uint8 range") + if not 0 <= min_raw <= UINT16_MAX: + raise ValueError(f"Min current raw {min_raw} exceeds uint16 range") + if not 0 <= max_raw <= UINT16_MAX: + raise ValueError(f"Max current raw {max_raw} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int8(pct_raw, signed=False)) + result.extend(DataParser.encode_int16(min_raw, signed=False)) + result.extend(DataParser.encode_int16(max_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_generic_level_range.py b/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_generic_level_range.py new file mode 100644 index 00000000..030f876c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/relative_runtime_in_a_generic_level_range.py @@ -0,0 +1,106 @@ +"""Relative Runtime in a Generic Level Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_PERCENTAGE_RESOLUTION = 0.5 # Percentage 8: M=1, d=0, b=-1 -> 0.5% +# Generic Level: M=1, d=0, b=0 -> unitless, no scaling + + +class RelativeRuntimeInAGenericLevelRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for relative runtime in a generic level range. + + Combines a percentage (0.5% resolution) with a generic level range + (min/max as raw uint16 values, unitless). + """ + + relative_value: float # Percentage (0.5% resolution) + minimum_generic_level: int # Minimum generic level (unitless) + maximum_generic_level: int # Maximum generic level (unitless) + + def __post_init__(self) -> None: + """Validate data fields.""" + max_pct = UINT8_MAX * _PERCENTAGE_RESOLUTION + if not 0.0 <= self.relative_value <= max_pct: + raise ValueError(f"Relative value {self.relative_value}% is outside valid range (0.0 to {max_pct})") + if self.minimum_generic_level > self.maximum_generic_level: + raise ValueError( + f"Minimum generic level {self.minimum_generic_level} cannot exceed maximum {self.maximum_generic_level}" + ) + for name, val in [ + ("minimum_generic_level", self.minimum_generic_level), + ("maximum_generic_level", self.maximum_generic_level), + ]: + if not 0 <= val <= UINT16_MAX: + raise ValueError(f"{name} {val} is outside valid range (0 to {UINT16_MAX})") + + +class RelativeRuntimeInAGenericLevelRangeCharacteristic( + BaseCharacteristic[RelativeRuntimeInAGenericLevelRangeData], +): + """Relative Runtime in a Generic Level Range characteristic (0x2B08). + + org.bluetooth.characteristic.relative_runtime_in_a_generic_level_range + + Represents relative runtime within a generic level range. Fields: + Percentage 8 (uint8, 0.5%), min level (uint16, unitless), + max level (uint16, unitless). + """ + + expected_length: int = 5 # uint8 + 2 x uint16 + min_length: int = 5 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> RelativeRuntimeInAGenericLevelRangeData: + """Parse relative runtime in a generic level range. + + Args: + data: Raw bytes (5 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + RelativeRuntimeInAGenericLevelRangeData. + + """ + pct_raw = DataParser.parse_int8(data, 0, signed=False) + min_raw = DataParser.parse_int16(data, 1, signed=False) + max_raw = DataParser.parse_int16(data, 3, signed=False) + + return RelativeRuntimeInAGenericLevelRangeData( + relative_value=pct_raw * _PERCENTAGE_RESOLUTION, + minimum_generic_level=min_raw, + maximum_generic_level=max_raw, + ) + + def _encode_value(self, data: RelativeRuntimeInAGenericLevelRangeData) -> bytearray: + """Encode relative runtime in a generic level range. + + Args: + data: RelativeRuntimeInAGenericLevelRangeData instance. + + Returns: + Encoded bytes (5 bytes). + + """ + pct_raw = round(data.relative_value / _PERCENTAGE_RESOLUTION) + + if not 0 <= pct_raw <= UINT8_MAX: + raise ValueError(f"Percentage raw {pct_raw} exceeds uint8 range") + if not 0 <= data.minimum_generic_level <= UINT16_MAX: + raise ValueError(f"Min level {data.minimum_generic_level} exceeds uint16 range") + if not 0 <= data.maximum_generic_level <= UINT16_MAX: + raise ValueError(f"Max level {data.maximum_generic_level} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int8(pct_raw, signed=False)) + result.extend(DataParser.encode_int16(data.minimum_generic_level, signed=False)) + result.extend(DataParser.encode_int16(data.maximum_generic_level, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_period_of_day.py b/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_period_of_day.py new file mode 100644 index 00000000..bf918e6c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_period_of_day.py @@ -0,0 +1,104 @@ +"""Relative Value in a Period of Day characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_PERCENTAGE_RESOLUTION = 0.5 # Percentage 8: M=1, d=0, b=-1 -> 0.5% +_TIME_DECIHOUR_RESOLUTION = 0.1 # Time Decihour 8: M=1, d=-1, b=0 -> 0.1 hr + + +class RelativeValueInAPeriodOfDayData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for relative value in a period of day. + + Combines a percentage (0.5% resolution) with a time-of-day range + (start/end in hours, 0.1 hr resolution). + """ + + relative_value: float # Percentage (0.5% resolution) + start_time: float # Start time in hours (0.1 hr resolution) + end_time: float # End time in hours (0.1 hr resolution) + + def __post_init__(self) -> None: + """Validate data fields.""" + max_pct = UINT8_MAX * _PERCENTAGE_RESOLUTION + if not 0.0 <= self.relative_value <= max_pct: + raise ValueError(f"Relative value {self.relative_value}% is outside valid range (0.0 to {max_pct})") + max_time = UINT8_MAX * _TIME_DECIHOUR_RESOLUTION + for name, val in [("start_time", self.start_time), ("end_time", self.end_time)]: + if not 0.0 <= val <= max_time: + raise ValueError(f"{name} {val} hr is outside valid range (0.0 to {max_time})") + + +class RelativeValueInAPeriodOfDayCharacteristic( + BaseCharacteristic[RelativeValueInAPeriodOfDayData], +): + """Relative Value in a Period of Day characteristic (0x2B0B). + + org.bluetooth.characteristic.relative_value_in_a_period_of_day + + Represents a relative value within a period of the day. Fields: + Percentage 8 (uint8, 0.5%), start time (uint8, 0.1 hr), + end time (uint8, 0.1 hr). + """ + + expected_length: int = 3 # 3 x uint8 + min_length: int = 3 + expected_type = RelativeValueInAPeriodOfDayData + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> RelativeValueInAPeriodOfDayData: + """Parse relative value in a period of day. + + Args: + data: Raw bytes (3 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + RelativeValueInAPeriodOfDayData. + + """ + pct_raw = DataParser.parse_int8(data, 0, signed=False) + start_raw = DataParser.parse_int8(data, 1, signed=False) + end_raw = DataParser.parse_int8(data, 2, signed=False) + + return RelativeValueInAPeriodOfDayData( + relative_value=pct_raw * _PERCENTAGE_RESOLUTION, + start_time=start_raw * _TIME_DECIHOUR_RESOLUTION, + end_time=end_raw * _TIME_DECIHOUR_RESOLUTION, + ) + + def _encode_value(self, data: RelativeValueInAPeriodOfDayData) -> bytearray: + """Encode relative value in a period of day. + + Args: + data: RelativeValueInAPeriodOfDayData instance. + + Returns: + Encoded bytes (3 bytes). + + """ + pct_raw = round(data.relative_value / _PERCENTAGE_RESOLUTION) + start_raw = round(data.start_time / _TIME_DECIHOUR_RESOLUTION) + end_raw = round(data.end_time / _TIME_DECIHOUR_RESOLUTION) + + for name, value in [ + ("percentage", pct_raw), + ("start_time", start_raw), + ("end_time", end_raw), + ]: + if not 0 <= value <= UINT8_MAX: + raise ValueError(f"{name} raw value {value} exceeds uint8 range") + + result = bytearray() + result.extend(DataParser.encode_int8(pct_raw, signed=False)) + result.extend(DataParser.encode_int8(start_raw, signed=False)) + result.extend(DataParser.encode_int8(end_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_temperature_range.py b/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_temperature_range.py new file mode 100644 index 00000000..f8f9cfb1 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_temperature_range.py @@ -0,0 +1,103 @@ +"""Relative Value in a Temperature Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, UINT8_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_PERCENTAGE_RESOLUTION = 0.5 # Percentage 8: M=1, d=0, b=-1 -> 0.5% +_TEMPERATURE_RESOLUTION = 0.01 # Temperature: M=1, d=-2, b=0 -> 0.01 C + + +class RelativeValueInATemperatureRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for relative value in a temperature range. + + Combines a percentage (0.5% resolution) with a temperature range + (min/max in degrees Celsius, 0.01 C resolution). + """ + + relative_value: float # Percentage (0.5% resolution) + minimum_temperature: float # Minimum temperature in C + maximum_temperature: float # Maximum temperature in C + + def __post_init__(self) -> None: + """Validate data fields.""" + max_pct = UINT8_MAX * _PERCENTAGE_RESOLUTION + if not 0.0 <= self.relative_value <= max_pct: + raise ValueError(f"Relative value {self.relative_value}% is outside valid range (0.0 to {max_pct})") + if self.minimum_temperature > self.maximum_temperature: + raise ValueError( + f"Minimum temperature {self.minimum_temperature} C cannot exceed maximum {self.maximum_temperature} C" + ) + + +class RelativeValueInATemperatureRangeCharacteristic( + BaseCharacteristic[RelativeValueInATemperatureRangeData], +): + """Relative Value in a Temperature Range characteristic (0x2B0C). + + org.bluetooth.characteristic.relative_value_in_a_temperature_range + + Represents a relative value within a temperature range. Fields: + Percentage 8 (uint8, 0.5%), min temperature (sint16, 0.01 C), + max temperature (sint16, 0.01 C). + """ + + expected_length: int = 5 # uint8 + 2 x sint16 + min_length: int = 5 + expected_type = RelativeValueInATemperatureRangeData + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> RelativeValueInATemperatureRangeData: + """Parse relative value in a temperature range. + + Args: + data: Raw bytes (5 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + RelativeValueInATemperatureRangeData. + + """ + pct_raw = DataParser.parse_int8(data, 0, signed=False) + min_raw = DataParser.parse_int16(data, 1, signed=True) + max_raw = DataParser.parse_int16(data, 3, signed=True) + + return RelativeValueInATemperatureRangeData( + relative_value=pct_raw * _PERCENTAGE_RESOLUTION, + minimum_temperature=min_raw * _TEMPERATURE_RESOLUTION, + maximum_temperature=max_raw * _TEMPERATURE_RESOLUTION, + ) + + def _encode_value(self, data: RelativeValueInATemperatureRangeData) -> bytearray: + """Encode relative value in a temperature range. + + Args: + data: RelativeValueInATemperatureRangeData instance. + + Returns: + Encoded bytes (5 bytes). + + """ + pct_raw = round(data.relative_value / _PERCENTAGE_RESOLUTION) + min_raw = round(data.minimum_temperature / _TEMPERATURE_RESOLUTION) + max_raw = round(data.maximum_temperature / _TEMPERATURE_RESOLUTION) + + if not 0 <= pct_raw <= UINT8_MAX: + raise ValueError(f"Percentage raw {pct_raw} exceeds uint8 range") + if not SINT16_MIN <= min_raw <= SINT16_MAX: + raise ValueError(f"Min temperature raw {min_raw} exceeds sint16 range") + if not SINT16_MIN <= max_raw <= SINT16_MAX: + raise ValueError(f"Max temperature raw {max_raw} exceeds sint16 range") + + result = bytearray() + result.extend(DataParser.encode_int8(pct_raw, signed=False)) + result.extend(DataParser.encode_int16(min_raw, signed=True)) + result.extend(DataParser.encode_int16(max_raw, signed=True)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_voltage_range.py b/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_voltage_range.py new file mode 100644 index 00000000..5a66b3e3 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/relative_value_in_a_voltage_range.py @@ -0,0 +1,108 @@ +"""Relative Value in a Voltage Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_PERCENTAGE_RESOLUTION = 0.5 # Percentage 8: M=1, d=0, b=-1 -> 0.5% +_VOLTAGE_RESOLUTION = 1 / 64 # Voltage: M=1, d=0, b=-6 -> 1/64 V + + +class RelativeValueInAVoltageRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for relative value in a voltage range. + + Combines a percentage (0.5% resolution) with a voltage range + (min/max in volts, 1/64 V resolution). + """ + + relative_value: float # Percentage (0.5% resolution) + minimum_voltage: float # Minimum voltage in V + maximum_voltage: float # Maximum voltage in V + + def __post_init__(self) -> None: + """Validate data fields.""" + max_pct = UINT8_MAX * _PERCENTAGE_RESOLUTION + if not 0.0 <= self.relative_value <= max_pct: + raise ValueError(f"Relative value {self.relative_value}% is outside valid range (0.0 to {max_pct})") + if self.minimum_voltage > self.maximum_voltage: + raise ValueError(f"Minimum voltage {self.minimum_voltage} V cannot exceed maximum {self.maximum_voltage} V") + max_voltage = UINT16_MAX * _VOLTAGE_RESOLUTION + for name, val in [ + ("minimum_voltage", self.minimum_voltage), + ("maximum_voltage", self.maximum_voltage), + ]: + if not 0.0 <= val <= max_voltage: + raise ValueError(f"{name} {val} V is outside valid range (0.0 to {max_voltage})") + + +class RelativeValueInAVoltageRangeCharacteristic( + BaseCharacteristic[RelativeValueInAVoltageRangeData], +): + """Relative Value in a Voltage Range characteristic (0x2B09). + + org.bluetooth.characteristic.relative_value_in_a_voltage_range + + Represents a relative value within a voltage range. Fields: + Percentage 8 (uint8, 0.5%), min voltage (uint16, 1/64 V), + max voltage (uint16, 1/64 V). + """ + + expected_length: int = 5 # uint8 + 2 x uint16 + min_length: int = 5 + expected_type = RelativeValueInAVoltageRangeData + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> RelativeValueInAVoltageRangeData: + """Parse relative value in a voltage range. + + Args: + data: Raw bytes (5 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + RelativeValueInAVoltageRangeData. + + """ + pct_raw = DataParser.parse_int8(data, 0, signed=False) + min_raw = DataParser.parse_int16(data, 1, signed=False) + max_raw = DataParser.parse_int16(data, 3, signed=False) + + return RelativeValueInAVoltageRangeData( + relative_value=pct_raw * _PERCENTAGE_RESOLUTION, + minimum_voltage=min_raw * _VOLTAGE_RESOLUTION, + maximum_voltage=max_raw * _VOLTAGE_RESOLUTION, + ) + + def _encode_value(self, data: RelativeValueInAVoltageRangeData) -> bytearray: + """Encode relative value in a voltage range. + + Args: + data: RelativeValueInAVoltageRangeData instance. + + Returns: + Encoded bytes (5 bytes). + + """ + pct_raw = round(data.relative_value / _PERCENTAGE_RESOLUTION) + min_raw = round(data.minimum_voltage / _VOLTAGE_RESOLUTION) + max_raw = round(data.maximum_voltage / _VOLTAGE_RESOLUTION) + + if not 0 <= pct_raw <= UINT8_MAX: + raise ValueError(f"Percentage raw {pct_raw} exceeds uint8 range") + if not 0 <= min_raw <= UINT16_MAX: + raise ValueError(f"Min voltage raw {min_raw} exceeds uint16 range") + if not 0 <= max_raw <= UINT16_MAX: + raise ValueError(f"Max voltage raw {max_raw} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int8(pct_raw, signed=False)) + result.extend(DataParser.encode_int16(min_raw, signed=False)) + result.extend(DataParser.encode_int16(max_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/relative_value_in_an_illuminance_range.py b/src/bluetooth_sig/gatt/characteristics/relative_value_in_an_illuminance_range.py new file mode 100644 index 00000000..fe86fb85 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/relative_value_in_an_illuminance_range.py @@ -0,0 +1,111 @@ +"""Relative Value in an Illuminance Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX, UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_PERCENTAGE_RESOLUTION = 0.5 # Percentage 8: M=1, d=0, b=-1 -> 0.5% +_ILLUMINANCE_RESOLUTION = 0.01 # Illuminance: M=1, d=-2, b=0 -> 0.01 lux + + +class RelativeValueInAnIlluminanceRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for relative value in an illuminance range. + + Combines a percentage (0.5% resolution) with an illuminance range + (min/max in lux, 0.01 lux resolution). + """ + + relative_value: float # Percentage (0.5% resolution) + minimum_illuminance: float # Minimum illuminance in lux + maximum_illuminance: float # Maximum illuminance in lux + + def __post_init__(self) -> None: + """Validate data fields.""" + max_pct = UINT8_MAX * _PERCENTAGE_RESOLUTION + if not 0.0 <= self.relative_value <= max_pct: + raise ValueError(f"Relative value {self.relative_value}% is outside valid range (0.0 to {max_pct})") + if self.minimum_illuminance > self.maximum_illuminance: + raise ValueError( + f"Minimum illuminance {self.minimum_illuminance} lux " + f"cannot exceed maximum {self.maximum_illuminance} lux" + ) + max_lux = UINT24_MAX * _ILLUMINANCE_RESOLUTION + for name, val in [ + ("minimum_illuminance", self.minimum_illuminance), + ("maximum_illuminance", self.maximum_illuminance), + ]: + if not 0.0 <= val <= max_lux: + raise ValueError(f"{name} {val} lux is outside valid range (0.0 to {max_lux})") + + +class RelativeValueInAnIlluminanceRangeCharacteristic( + BaseCharacteristic[RelativeValueInAnIlluminanceRangeData], +): + """Relative Value in an Illuminance Range characteristic (0x2B0A). + + org.bluetooth.characteristic.relative_value_in_an_illuminance_range + + Represents a relative value within an illuminance range. Fields: + Percentage 8 (uint8, 0.5%), min illuminance (uint24, 0.01 lux), + max illuminance (uint24, 0.01 lux). + """ + + expected_length: int = 7 # uint8 + 2 x uint24 + min_length: int = 7 + expected_type = RelativeValueInAnIlluminanceRangeData + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> RelativeValueInAnIlluminanceRangeData: + """Parse relative value in an illuminance range. + + Args: + data: Raw bytes (7 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + RelativeValueInAnIlluminanceRangeData. + + """ + pct_raw = DataParser.parse_int8(data, 0, signed=False) + min_raw = DataParser.parse_int24(data, 1, signed=False) + max_raw = DataParser.parse_int24(data, 4, signed=False) + + return RelativeValueInAnIlluminanceRangeData( + relative_value=pct_raw * _PERCENTAGE_RESOLUTION, + minimum_illuminance=min_raw * _ILLUMINANCE_RESOLUTION, + maximum_illuminance=max_raw * _ILLUMINANCE_RESOLUTION, + ) + + def _encode_value(self, data: RelativeValueInAnIlluminanceRangeData) -> bytearray: + """Encode relative value in an illuminance range. + + Args: + data: RelativeValueInAnIlluminanceRangeData instance. + + Returns: + Encoded bytes (7 bytes). + + """ + pct_raw = round(data.relative_value / _PERCENTAGE_RESOLUTION) + min_raw = round(data.minimum_illuminance / _ILLUMINANCE_RESOLUTION) + max_raw = round(data.maximum_illuminance / _ILLUMINANCE_RESOLUTION) + + if not 0 <= pct_raw <= UINT8_MAX: + raise ValueError(f"Percentage raw {pct_raw} exceeds uint8 range") + if not 0 <= min_raw <= UINT24_MAX: + raise ValueError(f"Min illuminance raw {min_raw} exceeds uint24 range") + if not 0 <= max_raw <= UINT24_MAX: + raise ValueError(f"Max illuminance raw {max_raw} exceeds uint24 range") + + result = bytearray() + result.extend(DataParser.encode_int8(pct_raw, signed=False)) + result.extend(DataParser.encode_int24(min_raw, signed=False)) + result.extend(DataParser.encode_int24(max_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/report.py b/src/bluetooth_sig/gatt/characteristics/report.py index 17680f66..309ce158 100644 --- a/src/bluetooth_sig/gatt/characteristics/report.py +++ b/src/bluetooth_sig/gatt/characteristics/report.py @@ -26,6 +26,8 @@ class ReportCharacteristic(BaseCharacteristic[ReportData]): Report characteristic. """ + _python_type: type | str | None = "ReportData" + min_length = 1 expected_type = bytes diff --git a/src/bluetooth_sig/gatt/characteristics/report_map.py b/src/bluetooth_sig/gatt/characteristics/report_map.py index bbb51616..ae9dffef 100644 --- a/src/bluetooth_sig/gatt/characteristics/report_map.py +++ b/src/bluetooth_sig/gatt/characteristics/report_map.py @@ -26,6 +26,8 @@ class ReportMapCharacteristic(BaseCharacteristic[ReportMapData]): Report Map characteristic. """ + _python_type: type | str | None = "ReportMapData" + min_length = 1 expected_type = bytes diff --git a/src/bluetooth_sig/gatt/characteristics/role_classifier.py b/src/bluetooth_sig/gatt/characteristics/role_classifier.py index e26622e1..f5dff322 100644 --- a/src/bluetooth_sig/gatt/characteristics/role_classifier.py +++ b/src/bluetooth_sig/gatt/characteristics/role_classifier.py @@ -7,13 +7,14 @@ from __future__ import annotations -from ...types.gatt_enums import CharacteristicRole, ValueType +from ...types.gatt_enums import CharacteristicRole from ...types.registry import CharacteristicSpec def classify_role( char_name: str, - value_type: ValueType, + python_type: type | str | None, + is_bitfield: bool, unit: str, spec: CharacteristicSpec | None, ) -> CharacteristicRole: @@ -22,19 +23,20 @@ def classify_role( Classification priority (first match wins): 1. Name contains *Control Point* → CONTROL 2. Name ends with *Feature(s)* or - ``value_type`` is BITFIELD → FEATURE + ``is_bitfield`` is True → FEATURE 3. Name contains *Measurement* → MEASUREMENT - 4. Numeric type (INT / FLOAT) with a unit → MEASUREMENT - 5. Compound type (VARIOUS / DICT) with a + 4. Numeric type (int / float) with a unit → MEASUREMENT + 5. Compound type (struct, dict, etc.) with a unit or field-level ``unit_id`` → MEASUREMENT 6. Name ends with *Data* → MEASUREMENT 7. Name contains *Status* → STATUS - 8. ``value_type`` is STRING → INFO + 8. ``python_type`` is str → INFO 9. Otherwise → UNKNOWN Args: char_name: Display name of the characteristic. - value_type: Resolved value type enum. + python_type: Resolved Python type (int, float, str, etc.) or None. + is_bitfield: Whether the characteristic is a bitfield. unit: Unit string (empty string if not applicable). spec: Resolved YAML spec (may be None). @@ -47,7 +49,8 @@ def classify_role( return CharacteristicRole.CONTROL # 2. Feature / capability bitfields describe device capabilities - if char_name.endswith("Feature") or char_name.endswith("Features") or value_type == ValueType.BITFIELD: + is_feature_name = char_name.endswith("Feature") or char_name.endswith("Features") + if is_feature_name or (is_bitfield and "Status" not in char_name): return CharacteristicRole.FEATURE # 3. Explicit measurement by SIG naming convention @@ -55,11 +58,13 @@ def classify_role( return CharacteristicRole.MEASUREMENT # 4. Numeric scalar with a physical unit - if value_type in (ValueType.INT, ValueType.FLOAT) and unit: + if python_type in (int, float) and unit: return CharacteristicRole.MEASUREMENT - # 5. Compound value with unit metadata (char-level or per-field) - if value_type in (ValueType.VARIOUS, ValueType.DICT) and (unit or _spec_has_unit_fields(spec)): + # 5. Compound type with unit metadata (char-level or per-field) + scalar_types = (int, float, str, bool, bytes) + is_compound = isinstance(python_type, type) and python_type not in scalar_types + if is_compound and (unit or _spec_has_unit_fields(spec)): return CharacteristicRole.MEASUREMENT # 6. SIG *Data* characteristics (Treadmill Data, Indoor Bike Data, …) @@ -71,7 +76,7 @@ def classify_role( return CharacteristicRole.STATUS # 8. Pure string metadata (device name, revision strings, …) - if value_type == ValueType.STRING: + if python_type is str: return CharacteristicRole.INFO return CharacteristicRole.UNKNOWN diff --git a/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py b/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py index 6b586a76..a1d84915 100644 --- a/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py +++ b/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py @@ -24,7 +24,6 @@ class ScanIntervalWindowCharacteristic(BaseCharacteristic[ScanIntervalWindowData """ _characteristic_name = "Scan Interval Window" - _manual_value_type = "ScanIntervalWindowData" # Override since decode_value returns structured data min_length = 4 # Scan Interval(2) + Scan Window(2) max_length = 4 # Fixed length diff --git a/src/bluetooth_sig/gatt/characteristics/scan_refresh.py b/src/bluetooth_sig/gatt/characteristics/scan_refresh.py index 23bc9515..b6fe68c2 100644 --- a/src/bluetooth_sig/gatt/characteristics/scan_refresh.py +++ b/src/bluetooth_sig/gatt/characteristics/scan_refresh.py @@ -14,4 +14,6 @@ class ScanRefreshCharacteristic(BaseCharacteristic[int]): Requests the server to refresh the scan. """ + _python_type: type | str | None = int + _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/sensor_location.py b/src/bluetooth_sig/gatt/characteristics/sensor_location.py new file mode 100644 index 00000000..a5bf3436 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/sensor_location.py @@ -0,0 +1,62 @@ +"""Sensor Location characteristic (0x2A5D).""" + +from __future__ import annotations + +from enum import IntEnum + +from .base import BaseCharacteristic +from .templates import EnumTemplate + + +class SensorLocationValue(IntEnum): + """Sensor body location values. + + Values: + OTHER: Other location (0) + TOP_OF_SHOE: Top of shoe (1) + IN_SHOE: In shoe (2) + HIP: Hip (3) + FRONT_WHEEL: Front wheel (4) + LEFT_CRANK: Left crank (5) + RIGHT_CRANK: Right crank (6) + LEFT_PEDAL: Left pedal (7) + RIGHT_PEDAL: Right pedal (8) + FRONT_HUB: Front hub (9) + REAR_DROPOUT: Rear dropout (10) + CHAINSTAY: Chainstay (11) + REAR_WHEEL: Rear wheel (12) + REAR_HUB: Rear hub (13) + CHEST: Chest (14) + SPIDER: Spider (15) + CHAIN_RING: Chain ring (16) + """ + + OTHER = 0 + TOP_OF_SHOE = 1 + IN_SHOE = 2 + HIP = 3 + FRONT_WHEEL = 4 + LEFT_CRANK = 5 + RIGHT_CRANK = 6 + LEFT_PEDAL = 7 + RIGHT_PEDAL = 8 + FRONT_HUB = 9 + REAR_DROPOUT = 10 + CHAINSTAY = 11 + REAR_WHEEL = 12 + REAR_HUB = 13 + CHEST = 14 + SPIDER = 15 + CHAIN_RING = 16 + + +class SensorLocationCharacteristic(BaseCharacteristic[SensorLocationValue]): + """Sensor Location characteristic (0x2A5D). + + org.bluetooth.characteristic.sensor_location + + Body location of a sensor (17 named positions). + Values 17-255 are reserved for future use. + """ + + _template = EnumTemplate.uint8(SensorLocationValue) diff --git a/src/bluetooth_sig/gatt/characteristics/service_changed.py b/src/bluetooth_sig/gatt/characteristics/service_changed.py index ac55bb2b..51109ed1 100644 --- a/src/bluetooth_sig/gatt/characteristics/service_changed.py +++ b/src/bluetooth_sig/gatt/characteristics/service_changed.py @@ -29,8 +29,6 @@ class ServiceChangedCharacteristic(BaseCharacteristic[ServiceChangedData]): Service Changed characteristic. """ - _manual_value_type = "ServiceChangedData" - expected_length = 4 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py b/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py index 340991c1..a51eb5c9 100644 --- a/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from .base import BaseCharacteristic from .templates import ConcentrationTemplate @@ -20,7 +19,7 @@ class SulfurDioxideConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _manual_value_type: ValueType | str | None = ValueType.INT + _python_type: type | str | None = int _manual_unit: str = "ppb" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/sulfur_hexafluoride_concentration.py b/src/bluetooth_sig/gatt/characteristics/sulfur_hexafluoride_concentration.py new file mode 100644 index 00000000..8fce2976 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/sulfur_hexafluoride_concentration.py @@ -0,0 +1,22 @@ +"""Sulfur Hexafluoride Concentration characteristic (0x2BD9).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import IEEE11073FloatTemplate + + +class SulfurHexafluorideConcentrationCharacteristic(BaseCharacteristic[float]): + """Sulfur Hexafluoride Concentration characteristic (0x2BD9). + + org.bluetooth.characteristic.sulfur_hexafluoride_concentration + + Concentration in kg/m³ using IEEE 11073 SFLOAT format (medfloat16). + Special IEEE 11073 values NRes (out of range) and NaN (invalid/missing) are + supported via the SFLOAT encoding. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (NaN, NRes). + """ + + _template = IEEE11073FloatTemplate() diff --git a/src/bluetooth_sig/gatt/characteristics/supported_heart_rate_range.py b/src/bluetooth_sig/gatt/characteristics/supported_heart_rate_range.py new file mode 100644 index 00000000..d6dabc8c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/supported_heart_rate_range.py @@ -0,0 +1,91 @@ +"""Supported Heart Rate Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class SupportedHeartRateRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for supported heart rate range. + + All values are in beats per minute (BPM), integer precision. + """ + + minimum: int # Minimum heart rate in BPM + maximum: int # Maximum heart rate in BPM + minimum_increment: int # Minimum increment in BPM + + def __post_init__(self) -> None: + """Validate heart rate range data.""" + if self.minimum > self.maximum: + raise ValueError(f"Minimum heart rate {self.minimum} BPM cannot be greater than maximum {self.maximum} BPM") + for name, val in [ + ("minimum", self.minimum), + ("maximum", self.maximum), + ("minimum_increment", self.minimum_increment), + ]: + if not 0 <= val <= UINT8_MAX: + raise ValueError(f"{name} {val} BPM is outside valid range (0 to {UINT8_MAX})") + + +class SupportedHeartRateRangeCharacteristic(BaseCharacteristic[SupportedHeartRateRangeData]): + """Supported Heart Rate Range characteristic (0x2AD7). + + org.bluetooth.characteristic.supported_heart_rate_range + + Represents the heart rate range supported by a fitness machine. + Three fields: minimum heart rate, maximum heart rate, and minimum + increment. Each is a uint8 in beats per minute (no scaling). + """ + + # Validation attributes + expected_length: int = 3 # 3 x uint8 + min_length: int = 3 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> SupportedHeartRateRangeData: + """Parse supported heart rate range data. + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + SupportedHeartRateRangeData with minimum, maximum, and increment. + + """ + min_raw = DataParser.parse_int8(data, 0, signed=False) + max_raw = DataParser.parse_int8(data, 1, signed=False) + inc_raw = DataParser.parse_int8(data, 2, signed=False) + + return SupportedHeartRateRangeData( + minimum=min_raw, + maximum=max_raw, + minimum_increment=inc_raw, + ) + + def _encode_value(self, data: SupportedHeartRateRangeData) -> bytearray: + """Encode supported heart rate range to bytes. + + Args: + data: SupportedHeartRateRangeData instance. + + Returns: + Encoded bytes (3 x uint8). + + """ + if not isinstance(data, SupportedHeartRateRangeData): + raise TypeError(f"Expected SupportedHeartRateRangeData, got {type(data).__name__}") + + result = bytearray() + result.extend(DataParser.encode_int8(data.minimum, signed=False)) + result.extend(DataParser.encode_int8(data.maximum, signed=False)) + result.extend(DataParser.encode_int8(data.minimum_increment, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/supported_inclination_range.py b/src/bluetooth_sig/gatt/characteristics/supported_inclination_range.py new file mode 100644 index 00000000..cdcec7f2 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/supported_inclination_range.py @@ -0,0 +1,110 @@ +"""Supported Inclination Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +# Resolution: M=1, d=-1, b=0 -> 0.1 percentage points +_RESOLUTION = 0.1 + + +class SupportedInclinationRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for supported inclination range. + + All values are in percentage with 0.1% resolution. + Min/max may be negative (decline). + """ + + minimum: float # Minimum inclination in % + maximum: float # Maximum inclination in % + minimum_increment: float # Minimum increment in % + + def __post_init__(self) -> None: + """Validate inclination range data.""" + if self.minimum > self.maximum: + raise ValueError(f"Minimum inclination {self.minimum}% cannot be greater than maximum {self.maximum}%") + min_value = SINT16_MIN * _RESOLUTION + max_value = SINT16_MAX * _RESOLUTION + for name, val in [("minimum", self.minimum), ("maximum", self.maximum)]: + if not min_value <= val <= max_value: + raise ValueError( + f"{name.capitalize()} inclination {val}% is outside valid range ({min_value} to {max_value})" + ) + inc_max = UINT16_MAX * _RESOLUTION + if not 0.0 <= self.minimum_increment <= inc_max: + raise ValueError(f"Minimum increment {self.minimum_increment}% is outside valid range (0.0 to {inc_max})") + + +class SupportedInclinationRangeCharacteristic(BaseCharacteristic[SupportedInclinationRangeData]): + """Supported Inclination Range characteristic (0x2AD5). + + org.bluetooth.characteristic.supported_inclination_range + + Represents the inclination range supported by a fitness machine. + Three fields: minimum inclination (sint16), maximum inclination (sint16), + and minimum increment (uint16). All scaled M=1, d=-1, b=0 (0.1% resolution). + """ + + # Validation attributes + expected_length: int = 6 # 2 x sint16 + 1 x uint16 + min_length: int = 6 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> SupportedInclinationRangeData: + """Parse supported inclination range data. + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + SupportedInclinationRangeData with minimum, maximum, and increment. + + """ + min_raw = DataParser.parse_int16(data, 0, signed=True) + max_raw = DataParser.parse_int16(data, 2, signed=True) + inc_raw = DataParser.parse_int16(data, 4, signed=False) + + return SupportedInclinationRangeData( + minimum=min_raw * _RESOLUTION, + maximum=max_raw * _RESOLUTION, + minimum_increment=inc_raw * _RESOLUTION, + ) + + def _encode_value(self, data: SupportedInclinationRangeData) -> bytearray: + """Encode supported inclination range to bytes. + + Args: + data: SupportedInclinationRangeData instance. + + Returns: + Encoded bytes (2 x sint16 + 1 x uint16, little-endian). + + """ + if not isinstance(data, SupportedInclinationRangeData): + raise TypeError(f"Expected SupportedInclinationRangeData, got {type(data).__name__}") + + min_raw = round(data.minimum / _RESOLUTION) + max_raw = round(data.maximum / _RESOLUTION) + inc_raw = round(data.minimum_increment / _RESOLUTION) + + for name, value, lo, hi in [ + ("minimum", min_raw, SINT16_MIN, SINT16_MAX), + ("maximum", max_raw, SINT16_MIN, SINT16_MAX), + ("increment", inc_raw, 0, UINT16_MAX), + ]: + if not lo <= value <= hi: + raise ValueError(f"Inclination {name} raw value {value} exceeds range ({lo} to {hi})") + + result = bytearray() + result.extend(DataParser.encode_int16(min_raw, signed=True)) + result.extend(DataParser.encode_int16(max_raw, signed=True)) + result.extend(DataParser.encode_int16(inc_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/supported_power_range.py b/src/bluetooth_sig/gatt/characteristics/supported_power_range.py index c1ffef60..6c7a12dc 100644 --- a/src/bluetooth_sig/gatt/characteristics/supported_power_range.py +++ b/src/bluetooth_sig/gatt/characteristics/supported_power_range.py @@ -4,7 +4,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import SINT16_MAX, SINT16_MIN from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -43,7 +42,7 @@ class SupportedPowerRangeCharacteristic(BaseCharacteristic[SupportedPowerRangeDa min_length = 4 _characteristic_name: str = "Supported Power Range" # Override since decode_value returns structured SupportedPowerRangeData - _manual_value_type: ValueType | str | None = ValueType.DICT + _python_type: type | str | None = dict def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/supported_resistance_level_range.py b/src/bluetooth_sig/gatt/characteristics/supported_resistance_level_range.py new file mode 100644 index 00000000..c9238d35 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/supported_resistance_level_range.py @@ -0,0 +1,110 @@ +"""Supported Resistance Level Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT8_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +# Resolution: M=1, d=1, b=0 -> 10 unitless per raw unit +_RESOLUTION = 10.0 + + +class SupportedResistanceLevelRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for supported resistance level range. + + All values are unitless with resolution of 10 per raw unit. + """ + + minimum: float # Minimum resistance level (unitless) + maximum: float # Maximum resistance level (unitless) + minimum_increment: float # Minimum increment (unitless) + + def __post_init__(self) -> None: + """Validate resistance level range data.""" + if self.minimum > self.maximum: + raise ValueError(f"Minimum resistance level {self.minimum} cannot be greater than maximum {self.maximum}") + max_value = UINT8_MAX * _RESOLUTION + for name, val in [ + ("minimum", self.minimum), + ("maximum", self.maximum), + ("minimum_increment", self.minimum_increment), + ]: + if not 0.0 <= val <= max_value: + raise ValueError(f"{name} {val} is outside valid range (0.0 to {max_value})") + + +class SupportedResistanceLevelRangeCharacteristic( + BaseCharacteristic[SupportedResistanceLevelRangeData], +): + """Supported Resistance Level Range characteristic (0x2AD6). + + org.bluetooth.characteristic.supported_resistance_level_range + + Represents the resistance level range supported by a fitness machine. + Three fields: minimum resistance level, maximum resistance level, + and minimum increment. Each is a uint8 with M=1, d=1, b=0 (resolution 10). + """ + + # Validation attributes + expected_length: int = 3 # 3 x uint8 + min_length: int = 3 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> SupportedResistanceLevelRangeData: + """Parse supported resistance level range data. + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + SupportedResistanceLevelRangeData with minimum, maximum, and + increment values. + + """ + min_raw = DataParser.parse_int8(data, 0, signed=False) + max_raw = DataParser.parse_int8(data, 1, signed=False) + inc_raw = DataParser.parse_int8(data, 2, signed=False) + + return SupportedResistanceLevelRangeData( + minimum=min_raw * _RESOLUTION, + maximum=max_raw * _RESOLUTION, + minimum_increment=inc_raw * _RESOLUTION, + ) + + def _encode_value(self, data: SupportedResistanceLevelRangeData) -> bytearray: + """Encode supported resistance level range to bytes. + + Args: + data: SupportedResistanceLevelRangeData instance. + + Returns: + Encoded bytes (3 x uint8). + + """ + if not isinstance(data, SupportedResistanceLevelRangeData): + raise TypeError(f"Expected SupportedResistanceLevelRangeData, got {type(data).__name__}") + + min_raw = round(data.minimum / _RESOLUTION) + max_raw = round(data.maximum / _RESOLUTION) + inc_raw = round(data.minimum_increment / _RESOLUTION) + + for name, value in [ + ("minimum", min_raw), + ("maximum", max_raw), + ("increment", inc_raw), + ]: + if not 0 <= value <= UINT8_MAX: + raise ValueError(f"Resistance {name} raw value {value} exceeds uint8 range (0 to {UINT8_MAX})") + + result = bytearray() + result.extend(DataParser.encode_int8(min_raw, signed=False)) + result.extend(DataParser.encode_int8(max_raw, signed=False)) + result.extend(DataParser.encode_int8(inc_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/supported_speed_range.py b/src/bluetooth_sig/gatt/characteristics/supported_speed_range.py new file mode 100644 index 00000000..86d6591d --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/supported_speed_range.py @@ -0,0 +1,103 @@ +"""Supported Speed Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +# Resolution: M=1, d=-2, b=0 → 0.01 km/h +_RESOLUTION = 0.01 + + +class SupportedSpeedRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for supported speed range. + + All values are in kilometres per hour with 0.01 km/h resolution. + """ + + minimum: float # Minimum speed in km/h + maximum: float # Maximum speed in km/h + minimum_increment: float # Minimum increment in km/h + + def __post_init__(self) -> None: + """Validate speed range data.""" + if self.minimum > self.maximum: + raise ValueError(f"Minimum speed {self.minimum} km/h cannot be greater than maximum {self.maximum} km/h") + max_value = UINT16_MAX * _RESOLUTION + for name, val in [ + ("minimum", self.minimum), + ("maximum", self.maximum), + ("minimum_increment", self.minimum_increment), + ]: + if not 0.0 <= val <= max_value: + raise ValueError(f"{name} {val} km/h is outside valid range (0.0 to {max_value})") + + +class SupportedSpeedRangeCharacteristic(BaseCharacteristic[SupportedSpeedRangeData]): + """Supported Speed Range characteristic (0x2AD4). + + org.bluetooth.characteristic.supported_speed_range + + Represents the speed range supported by a fitness machine. + Three fields: minimum speed, maximum speed, and minimum increment. + Each is a uint16 with M=1, d=-2, b=0 (0.01 km/h resolution). + """ + + # Validation attributes + expected_length: int = 6 # 3 x uint16 + min_length: int = 6 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> SupportedSpeedRangeData: + """Parse supported speed range data (3 x uint16, 0.01 km/h resolution). + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + SupportedSpeedRangeData with minimum, maximum, and increment values. + + """ + min_raw = DataParser.parse_int16(data, 0, signed=False) + max_raw = DataParser.parse_int16(data, 2, signed=False) + inc_raw = DataParser.parse_int16(data, 4, signed=False) + + return SupportedSpeedRangeData( + minimum=min_raw * _RESOLUTION, + maximum=max_raw * _RESOLUTION, + minimum_increment=inc_raw * _RESOLUTION, + ) + + def _encode_value(self, data: SupportedSpeedRangeData) -> bytearray: + """Encode supported speed range to bytes. + + Args: + data: SupportedSpeedRangeData instance. + + Returns: + Encoded bytes (3 x uint16, little-endian). + + """ + if not isinstance(data, SupportedSpeedRangeData): + raise TypeError(f"Expected SupportedSpeedRangeData, got {type(data).__name__}") + + min_raw = round(data.minimum / _RESOLUTION) + max_raw = round(data.maximum / _RESOLUTION) + inc_raw = round(data.minimum_increment / _RESOLUTION) + + for name, value in [("minimum", min_raw), ("maximum", max_raw), ("increment", inc_raw)]: + if not 0 <= value <= UINT16_MAX: + raise ValueError(f"Speed {name} raw value {value} exceeds uint16 range") + + result = bytearray() + result.extend(DataParser.encode_int16(min_raw, signed=False)) + result.extend(DataParser.encode_int16(max_raw, signed=False)) + result.extend(DataParser.encode_int16(inc_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/system_id.py b/src/bluetooth_sig/gatt/characteristics/system_id.py index 5f72782d..898ba9e4 100644 --- a/src/bluetooth_sig/gatt/characteristics/system_id.py +++ b/src/bluetooth_sig/gatt/characteristics/system_id.py @@ -28,7 +28,6 @@ class SystemIdCharacteristic(BaseCharacteristic[SystemIdData]): Represents a 64-bit system identifier: 40-bit manufacturer ID + 24-bit organizationally unique ID. """ - _manual_value_type = "SystemIdData" expected_length = 8 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/temperature_8.py b/src/bluetooth_sig/gatt/characteristics/temperature_8.py new file mode 100644 index 00000000..d8e12f17 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/temperature_8.py @@ -0,0 +1,22 @@ +"""Temperature 8 characteristic (0x2B0D).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledSint8Template + + +class Temperature8Characteristic(BaseCharacteristic[float]): + """Temperature 8 characteristic (0x2B0D). + + org.bluetooth.characteristic.temperature_8 + + Temperature in degrees Celsius with a resolution of 0.5. + M=1, d=0, b=-1 → scale factor 0.5 (binary exponent 2^-1). + Range: -64.0 to 63.0. A value of 0x7F represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0x7F). + """ + + _template = ScaledSint8Template(scale_factor=0.5) diff --git a/src/bluetooth_sig/gatt/characteristics/temperature_8_in_a_period_of_day.py b/src/bluetooth_sig/gatt/characteristics/temperature_8_in_a_period_of_day.py new file mode 100644 index 00000000..2450475c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/temperature_8_in_a_period_of_day.py @@ -0,0 +1,102 @@ +"""Temperature 8 in a Period of Day characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import SINT8_MAX, SINT8_MIN, UINT8_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_TEMPERATURE_8_RESOLUTION = 0.5 # Temperature 8: M=1, d=0, b=-1 -> 0.5 C +_TIME_DECIHOUR_RESOLUTION = 0.1 # Time Decihour 8: M=1, d=-1, b=0 -> 0.1 hr + + +class Temperature8InAPeriodOfDayData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for temperature 8 in a period of day. + + Temperature in degrees Celsius (0.5 C resolution) with a + time-of-day range (0.1 hr resolution). + """ + + temperature: float # Temperature in C (0.5 C resolution) + start_time: float # Start time in hours (0.1 hr resolution) + end_time: float # End time in hours (0.1 hr resolution) + + def __post_init__(self) -> None: + """Validate data fields.""" + min_temp = SINT8_MIN * _TEMPERATURE_8_RESOLUTION + max_temp = SINT8_MAX * _TEMPERATURE_8_RESOLUTION + if not min_temp <= self.temperature <= max_temp: + raise ValueError(f"Temperature {self.temperature} C is outside valid range ({min_temp} to {max_temp})") + max_time = UINT8_MAX * _TIME_DECIHOUR_RESOLUTION + for name, val in [("start_time", self.start_time), ("end_time", self.end_time)]: + if not 0.0 <= val <= max_time: + raise ValueError(f"{name} {val} hr is outside valid range (0.0 to {max_time})") + + +class Temperature8InAPeriodOfDayCharacteristic( + BaseCharacteristic[Temperature8InAPeriodOfDayData], +): + """Temperature 8 in a Period of Day characteristic (0x2B0E). + + org.bluetooth.characteristic.temperature_8_in_a_period_of_day + + Represents a temperature reading within a time-of-day range. Fields: + Temperature 8 (sint8, 0.5 C), start time (uint8, 0.1 hr), + end time (uint8, 0.1 hr). + """ + + expected_length: int = 3 # sint8 + 2 x uint8 + min_length: int = 3 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> Temperature8InAPeriodOfDayData: + """Parse temperature 8 in a period of day. + + Args: + data: Raw bytes (3 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + Temperature8InAPeriodOfDayData. + + """ + temp_raw = DataParser.parse_int8(data, 0, signed=True) + start_raw = DataParser.parse_int8(data, 1, signed=False) + end_raw = DataParser.parse_int8(data, 2, signed=False) + + return Temperature8InAPeriodOfDayData( + temperature=temp_raw * _TEMPERATURE_8_RESOLUTION, + start_time=start_raw * _TIME_DECIHOUR_RESOLUTION, + end_time=end_raw * _TIME_DECIHOUR_RESOLUTION, + ) + + def _encode_value(self, data: Temperature8InAPeriodOfDayData) -> bytearray: + """Encode temperature 8 in a period of day. + + Args: + data: Temperature8InAPeriodOfDayData instance. + + Returns: + Encoded bytes (3 bytes). + + """ + temp_raw = round(data.temperature / _TEMPERATURE_8_RESOLUTION) + start_raw = round(data.start_time / _TIME_DECIHOUR_RESOLUTION) + end_raw = round(data.end_time / _TIME_DECIHOUR_RESOLUTION) + + if not SINT8_MIN <= temp_raw <= SINT8_MAX: + raise ValueError(f"Temperature raw {temp_raw} exceeds sint8 range") + for name, value in [("start_time", start_raw), ("end_time", end_raw)]: + if not 0 <= value <= UINT8_MAX: + raise ValueError(f"{name} raw value {value} exceeds uint8 range") + + result = bytearray() + result.extend(DataParser.encode_int8(temp_raw, signed=True)) + result.extend(DataParser.encode_int8(start_raw, signed=False)) + result.extend(DataParser.encode_int8(end_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/temperature_8_statistics.py b/src/bluetooth_sig/gatt/characteristics/temperature_8_statistics.py new file mode 100644 index 00000000..9c589814 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/temperature_8_statistics.py @@ -0,0 +1,139 @@ +"""Temperature 8 Statistics characteristic implementation.""" + +from __future__ import annotations + +import math + +import msgspec + +from ..constants import SINT8_MAX, SINT8_MIN, UINT8_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_TEMPERATURE_8_RESOLUTION = 0.5 # Temperature 8: M=1, d=0, b=-1 -> 0.5 C +_TIME_EXP_BASE = 1.1 +_TIME_EXP_OFFSET = 64 + + +def _decode_time_exponential(raw: int) -> float: + """Decode Time Exponential 8 raw value to seconds.""" + if raw == 0: + return 0.0 + return _TIME_EXP_BASE ** (raw - _TIME_EXP_OFFSET) + + +def _encode_time_exponential(seconds: float) -> int: + """Encode seconds to Time Exponential 8 raw value.""" + if seconds <= 0.0: + return 0 + n = round(math.log(seconds) / math.log(_TIME_EXP_BASE) + _TIME_EXP_OFFSET) + return max(1, min(n, 0xFD)) + + +class Temperature8StatisticsData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for temperature 8 statistics. + + Four temperature values (0.5 C resolution) and a sensing duration + encoded as Time Exponential 8 (seconds). + """ + + average: float # Average temperature in C + standard_deviation: float # Standard deviation in C + minimum: float # Minimum temperature in C + maximum: float # Maximum temperature in C + sensing_duration: float # Sensing duration in seconds (exponential encoding) + + def __post_init__(self) -> None: + """Validate data fields.""" + min_temp = SINT8_MIN * _TEMPERATURE_8_RESOLUTION + max_temp = SINT8_MAX * _TEMPERATURE_8_RESOLUTION + for name, val in [ + ("average", self.average), + ("standard_deviation", self.standard_deviation), + ("minimum", self.minimum), + ("maximum", self.maximum), + ]: + if not min_temp <= val <= max_temp: + raise ValueError(f"{name} {val} C is outside valid range ({min_temp} to {max_temp})") + if self.sensing_duration < 0.0: + raise ValueError(f"Sensing duration {self.sensing_duration} s cannot be negative") + + +class Temperature8StatisticsCharacteristic( + BaseCharacteristic[Temperature8StatisticsData], +): + """Temperature 8 Statistics characteristic (0x2B0F). + + org.bluetooth.characteristic.temperature_8_statistics + + Statistics for Temperature 8 measurements: average, standard deviation, + minimum, maximum (all sint8, 0.5 C), and sensing duration + (Time Exponential 8). + """ + + expected_length: int = 5 # 4 x sint8 + uint8 + min_length: int = 5 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> Temperature8StatisticsData: + """Parse temperature 8 statistics. + + Args: + data: Raw bytes (5 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + Temperature8StatisticsData. + + """ + avg_raw = DataParser.parse_int8(data, 0, signed=True) + std_raw = DataParser.parse_int8(data, 1, signed=True) + min_raw = DataParser.parse_int8(data, 2, signed=True) + max_raw = DataParser.parse_int8(data, 3, signed=True) + dur_raw = DataParser.parse_int8(data, 4, signed=False) + + return Temperature8StatisticsData( + average=avg_raw * _TEMPERATURE_8_RESOLUTION, + standard_deviation=std_raw * _TEMPERATURE_8_RESOLUTION, + minimum=min_raw * _TEMPERATURE_8_RESOLUTION, + maximum=max_raw * _TEMPERATURE_8_RESOLUTION, + sensing_duration=_decode_time_exponential(dur_raw), + ) + + def _encode_value(self, data: Temperature8StatisticsData) -> bytearray: + """Encode temperature 8 statistics. + + Args: + data: Temperature8StatisticsData instance. + + Returns: + Encoded bytes (5 bytes). + + """ + avg_raw = round(data.average / _TEMPERATURE_8_RESOLUTION) + std_raw = round(data.standard_deviation / _TEMPERATURE_8_RESOLUTION) + min_raw = round(data.minimum / _TEMPERATURE_8_RESOLUTION) + max_raw = round(data.maximum / _TEMPERATURE_8_RESOLUTION) + dur_raw = _encode_time_exponential(data.sensing_duration) + + for name, value in [ + ("average", avg_raw), + ("standard_deviation", std_raw), + ("minimum", min_raw), + ("maximum", max_raw), + ]: + if not SINT8_MIN <= value <= SINT8_MAX: + raise ValueError(f"{name} raw {value} exceeds sint8 range") + if not 0 <= dur_raw <= UINT8_MAX: + raise ValueError(f"Duration raw {dur_raw} exceeds uint8 range") + + result = bytearray() + result.extend(DataParser.encode_int8(avg_raw, signed=True)) + result.extend(DataParser.encode_int8(std_raw, signed=True)) + result.extend(DataParser.encode_int8(min_raw, signed=True)) + result.extend(DataParser.encode_int8(max_raw, signed=True)) + result.extend(DataParser.encode_int8(dur_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/temperature_range.py b/src/bluetooth_sig/gatt/characteristics/temperature_range.py new file mode 100644 index 00000000..fed21bde --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/temperature_range.py @@ -0,0 +1,93 @@ +"""Temperature Range characteristic implementation.""" + +from __future__ import annotations + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, TEMPERATURE_RESOLUTION +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class TemperatureRangeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for temperature range. + + Each value is a temperature in degrees Celsius with 0.01°C resolution. + """ + + minimum: float # Minimum temperature in °C + maximum: float # Maximum temperature in °C + + def __post_init__(self) -> None: + """Validate temperature range data.""" + if self.minimum > self.maximum: + raise ValueError(f"Minimum temperature {self.minimum} °C cannot be greater than maximum {self.maximum} °C") + temp_min = SINT16_MIN * TEMPERATURE_RESOLUTION + temp_max = SINT16_MAX * TEMPERATURE_RESOLUTION + for name, val in [("minimum", self.minimum), ("maximum", self.maximum)]: + if not temp_min <= val <= temp_max: + raise ValueError( + f"{name.capitalize()} temperature {val} °C is outside valid range ({temp_min} to {temp_max})" + ) + + +class TemperatureRangeCharacteristic(BaseCharacteristic[TemperatureRangeData]): + """Temperature Range characteristic (0x2B10). + + org.bluetooth.characteristic.temperature_range + + Represents a temperature range as a pair of Temperature values. + Each field is a sint16, M=1 d=-2 b=0 (resolution 0.01°C). + """ + + # Validation attributes + expected_length: int = 4 # 2 x sint16 + min_length: int = 4 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> TemperatureRangeData: + """Parse temperature range data (2 x sint16, 0.01°C resolution). + + Args: + data: Raw bytes from the characteristic read. + ctx: Optional CharacteristicContext (may be None). + validate: Whether to validate ranges (default True). + + Returns: + TemperatureRangeData with minimum and maximum temperature values in °C. + + """ + min_raw = DataParser.parse_int16(data, 0, signed=True) + max_raw = DataParser.parse_int16(data, 2, signed=True) + + return TemperatureRangeData( + minimum=min_raw * TEMPERATURE_RESOLUTION, + maximum=max_raw * TEMPERATURE_RESOLUTION, + ) + + def _encode_value(self, data: TemperatureRangeData) -> bytearray: + """Encode temperature range to bytes. + + Args: + data: TemperatureRangeData instance. + + Returns: + Encoded bytes (2 x sint16, little-endian). + + """ + if not isinstance(data, TemperatureRangeData): + raise TypeError(f"Expected TemperatureRangeData, got {type(data).__name__}") + + min_raw = round(data.minimum / TEMPERATURE_RESOLUTION) + max_raw = round(data.maximum / TEMPERATURE_RESOLUTION) + + for name, value in [("minimum", min_raw), ("maximum", max_raw)]: + if not SINT16_MIN <= value <= SINT16_MAX: + raise ValueError(f"Temperature {name} raw value {value} exceeds sint16 range") + + result = bytearray() + result.extend(DataParser.encode_int16(min_raw, signed=True)) + result.extend(DataParser.encode_int16(max_raw, signed=True)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/temperature_statistics.py b/src/bluetooth_sig/gatt/characteristics/temperature_statistics.py new file mode 100644 index 00000000..75e37cef --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/temperature_statistics.py @@ -0,0 +1,138 @@ +"""Temperature Statistics characteristic implementation.""" + +from __future__ import annotations + +import math + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, TEMPERATURE_RESOLUTION, UINT8_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_TIME_EXP_BASE = 1.1 +_TIME_EXP_OFFSET = 64 + + +def _decode_time_exponential(raw: int) -> float: + """Decode Time Exponential 8 raw value to seconds.""" + if raw == 0: + return 0.0 + return _TIME_EXP_BASE ** (raw - _TIME_EXP_OFFSET) + + +def _encode_time_exponential(seconds: float) -> int: + """Encode seconds to Time Exponential 8 raw value.""" + if seconds <= 0.0: + return 0 + n = round(math.log(seconds) / math.log(_TIME_EXP_BASE) + _TIME_EXP_OFFSET) + return max(1, min(n, 0xFD)) + + +class TemperatureStatisticsData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Data class for temperature statistics. + + Four temperature values (0.01 C resolution) and a sensing duration + encoded as Time Exponential 8 (seconds). + """ + + average: float # Average temperature in C + standard_deviation: float # Standard deviation in C + minimum: float # Minimum temperature in C + maximum: float # Maximum temperature in C + sensing_duration: float # Sensing duration in seconds (exponential encoding) + + def __post_init__(self) -> None: + """Validate data fields.""" + min_temp = SINT16_MIN * TEMPERATURE_RESOLUTION + max_temp = SINT16_MAX * TEMPERATURE_RESOLUTION + for name, val in [ + ("average", self.average), + ("standard_deviation", self.standard_deviation), + ("minimum", self.minimum), + ("maximum", self.maximum), + ]: + if not min_temp <= val <= max_temp: + raise ValueError(f"{name} {val} C is outside valid range ({min_temp} to {max_temp})") + if self.sensing_duration < 0.0: + raise ValueError(f"Sensing duration {self.sensing_duration} s cannot be negative") + + +class TemperatureStatisticsCharacteristic( + BaseCharacteristic[TemperatureStatisticsData], +): + """Temperature Statistics characteristic (0x2B11). + + org.bluetooth.characteristic.temperature_statistics + + Statistics for Temperature measurements: average, standard deviation, + minimum, maximum (all sint16, 0.01 C), and sensing duration + (Time Exponential 8). + """ + + expected_length: int = 9 # 4 x sint16 + uint8 + min_length: int = 9 + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> TemperatureStatisticsData: + """Parse temperature statistics. + + Args: + data: Raw bytes (9 bytes). + ctx: Optional CharacteristicContext. + validate: Whether to validate ranges (default True). + + Returns: + TemperatureStatisticsData. + + """ + avg_raw = DataParser.parse_int16(data, 0, signed=True) + std_raw = DataParser.parse_int16(data, 2, signed=True) + min_raw = DataParser.parse_int16(data, 4, signed=True) + max_raw = DataParser.parse_int16(data, 6, signed=True) + dur_raw = DataParser.parse_int8(data, 8, signed=False) + + return TemperatureStatisticsData( + average=avg_raw * TEMPERATURE_RESOLUTION, + standard_deviation=std_raw * TEMPERATURE_RESOLUTION, + minimum=min_raw * TEMPERATURE_RESOLUTION, + maximum=max_raw * TEMPERATURE_RESOLUTION, + sensing_duration=_decode_time_exponential(dur_raw), + ) + + def _encode_value(self, data: TemperatureStatisticsData) -> bytearray: + """Encode temperature statistics. + + Args: + data: TemperatureStatisticsData instance. + + Returns: + Encoded bytes (9 bytes). + + """ + avg_raw = round(data.average / TEMPERATURE_RESOLUTION) + std_raw = round(data.standard_deviation / TEMPERATURE_RESOLUTION) + min_raw = round(data.minimum / TEMPERATURE_RESOLUTION) + max_raw = round(data.maximum / TEMPERATURE_RESOLUTION) + dur_raw = _encode_time_exponential(data.sensing_duration) + + for name, value in [ + ("average", avg_raw), + ("standard_deviation", std_raw), + ("minimum", min_raw), + ("maximum", max_raw), + ]: + if not SINT16_MIN <= value <= SINT16_MAX: + raise ValueError(f"{name} raw {value} exceeds sint16 range") + if not 0 <= dur_raw <= UINT8_MAX: + raise ValueError(f"Duration raw {dur_raw} exceeds uint8 range") + + result = bytearray() + result.extend(DataParser.encode_int16(avg_raw, signed=True)) + result.extend(DataParser.encode_int16(std_raw, signed=True)) + result.extend(DataParser.encode_int16(min_raw, signed=True)) + result.extend(DataParser.encode_int16(max_raw, signed=True)) + result.extend(DataParser.encode_int8(dur_raw, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/templates/__init__.py b/src/bluetooth_sig/gatt/characteristics/templates/__init__.py index bcb8dd61..7667d50f 100644 --- a/src/bluetooth_sig/gatt/characteristics/templates/__init__.py +++ b/src/bluetooth_sig/gatt/characteristics/templates/__init__.py @@ -20,6 +20,8 @@ from .data_structures import TimeData, Vector2DData, VectorData from .domain import ConcentrationTemplate, PressureTemplate, TemperatureTemplate from .enum import EnumTemplate +from .epoch_date import EpochDateTemplate +from .flag import FlagTemplate from .ieee_float import Float32Template, IEEE11073FloatTemplate from .numeric import ( Sint8Template, @@ -28,6 +30,7 @@ Uint16Template, Uint24Template, Uint32Template, + Uint48Template, ) from .scaled import ( PercentageTemplate, @@ -42,6 +45,7 @@ ScaledUint32Template, ) from .string import Utf8StringTemplate, Utf16StringTemplate +from .time_duration import TimeDurationTemplate, TimeExponentialTemplate __all__ = [ # Protocol @@ -49,6 +53,10 @@ "ConcentrationTemplate", # Enum template "EnumTemplate", + # Date template + "EpochDateTemplate", + # Flag template + "FlagTemplate", "Float32Template", "IEEE11073FloatTemplate", # Domain-specific templates @@ -69,11 +77,15 @@ "TemperatureTemplate", "TimeData", "TimeDataTemplate", + # Time-duration templates + "TimeDurationTemplate", + "TimeExponentialTemplate", # Basic integer templates "Uint8Template", "Uint16Template", "Uint24Template", "Uint32Template", + "Uint48Template", # String templates "Utf8StringTemplate", "Utf16StringTemplate", diff --git a/src/bluetooth_sig/gatt/characteristics/templates/base.py b/src/bluetooth_sig/gatt/characteristics/templates/base.py index 97bd7938..f717f033 100644 --- a/src/bluetooth_sig/gatt/characteristics/templates/base.py +++ b/src/bluetooth_sig/gatt/characteristics/templates/base.py @@ -7,7 +7,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, get_args from ....types.gatt_enums import AdjustReason, DayOfWeek # noqa: F401 # Re-export for sub-modules from ...context import CharacteristicContext @@ -26,6 +26,9 @@ _RESOLUTION_TENTH = 0.1 # 0.1 resolution (10^-1) _RESOLUTION_HUNDREDTH = 0.01 # 0.01 resolution (10^-2) +# Sentinel for per-class cache of resolved python_type (distinguishes None from "not yet resolved") +_SENTINEL = object() + # ============================================================================= # LEVEL 4 BASE CLASS @@ -97,3 +100,33 @@ def translator(self) -> ValueTranslator[Any] | None: Returns None for complex templates where translation isn't separable. """ return None + + @classmethod + def resolve_python_type(cls) -> type | None: + """Resolve the decoded Python type from the template's generic parameter. + + Walks the MRO to find the concrete type argument bound to + ``CodingTemplate[T_co]``. Returns ``None`` when the parameter is + still an unbound ``TypeVar`` (e.g. ``EnumTemplate[T]`` before + instantiation with a concrete enum). + + The result is cached per-class in ``_resolved_python_type`` to avoid + repeated MRO introspection. + """ + cached = cls.__dict__.get("_resolved_python_type", _SENTINEL) + if cached is not _SENTINEL: + return cached # type: ignore[no-any-return] + + resolved: type | None = None + for klass in cls.__mro__: + for base in getattr(klass, "__orig_bases__", ()): + if getattr(base, "__origin__", None) is CodingTemplate: + args = get_args(base) + if args and not isinstance(args[0], TypeVar): + resolved = args[0] + break + if resolved is not None: + break + + cls._resolved_python_type = resolved # type: ignore[attr-defined] + return resolved diff --git a/src/bluetooth_sig/gatt/characteristics/templates/epoch_date.py b/src/bluetooth_sig/gatt/characteristics/templates/epoch_date.py new file mode 100644 index 00000000..f3e828dd --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/epoch_date.py @@ -0,0 +1,89 @@ +"""Epoch-date template returning ``datetime.date`` for BLE date characteristics. + +Wraps a 24-bit unsigned integer (days since 1970-01-01) and converts to/from +:class:`datetime.date` so callers receive a proper Python date type. +""" + +from __future__ import annotations + +from datetime import date, timedelta + +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError +from ..utils.extractors import UINT24, RawExtractor +from .base import CodingTemplate + +_EPOCH = date(1970, 1, 1) + + +class EpochDateTemplate(CodingTemplate[date]): + """Template for epoch-day date characteristics that return ``datetime.date``. + + The raw wire value is a 24-bit unsigned integer counting the number of + days elapsed since 1970-01-01 (the Unix epoch). + + Pipeline Integration: + bytes → [UINT24 extractor] → day_count → date(1970,1,1) + timedelta(days=…) + + Examples: + >>> template = EpochDateTemplate() + >>> template.decode_value(bytearray([0x61, 0x4D, 0x00])) + datetime.date(2024, 2, 19) + """ + + @property + def data_size(self) -> int: + """Size: 3 bytes (uint24).""" + return 3 + + @property + def extractor(self) -> RawExtractor: + """Return uint24 extractor for pipeline access.""" + return UINT24 + + def decode_value( + self, + data: bytearray, + offset: int = 0, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> date: + """Decode 24-bit day count to ``datetime.date``. + + Args: + data: Raw bytes from BLE characteristic. + offset: Starting offset in data buffer. + ctx: Optional context for parsing. + validate: Whether to validate data length (default True). + + Returns: + ``datetime.date`` representing the decoded date. + + Raises: + InsufficientDataError: If data too short. + + """ + if validate and len(data) < offset + self.data_size: + raise InsufficientDataError("EpochDate", data[offset:], self.data_size) + + days = UINT24.extract(data, offset) + return _EPOCH + timedelta(days=days) + + def encode_value(self, value: date | int, *, validate: bool = True) -> bytearray: + """Encode ``datetime.date`` (or raw day count) to 3 bytes. + + Args: + value: ``datetime.date`` or integer day count since epoch. + validate: Whether to validate (default True). + + Returns: + Encoded bytes (3 bytes, little-endian). + + """ + days = (value - _EPOCH).days if isinstance(value, date) else int(value) + + if validate and days < 0: + raise ValueError(f"Date {value} is before epoch (1970-01-01)") + + return UINT24.pack(days) diff --git a/src/bluetooth_sig/gatt/characteristics/templates/flag.py b/src/bluetooth_sig/gatt/characteristics/templates/flag.py new file mode 100644 index 00000000..77359985 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/flag.py @@ -0,0 +1,187 @@ +"""Flag template for IntFlag encoding/decoding with configurable byte size.""" + +from __future__ import annotations + +from enum import IntFlag +from typing import TypeVar + +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError, ValueRangeError +from ..utils.extractors import ( + UINT8, + UINT16, + UINT32, + RawExtractor, +) +from ..utils.translators import ( + IDENTITY, + ValueTranslator, +) +from .base import CodingTemplate + +# Type variable for FlagTemplate - bound to IntFlag +F = TypeVar("F", bound=IntFlag) + + +class FlagTemplate(CodingTemplate[F]): + """Template for IntFlag encoding/decoding with configurable byte size. + + Maps raw integer bytes to Python IntFlag instances through extraction and + validation. Unlike EnumTemplate (which expects exact enum membership), + FlagTemplate accepts any bitwise OR combination of the defined flag members. + + Pipeline Integration: + bytes → [extractor] → raw_int → [IDENTITY translator] → int → flag constructor + + Examples: + >>> class ContactStatus(IntFlag): + ... CONTACT_0 = 0x01 + ... CONTACT_1 = 0x02 + ... CONTACT_2 = 0x04 + >>> + >>> template = FlagTemplate.uint8(ContactStatus) + >>> + >>> # Decode from bytes — any combination is valid + >>> flags = template.decode_value(bytearray([0x05])) + >>> # ContactStatus.CONTACT_0 | ContactStatus.CONTACT_2 + >>> + >>> # Encode flags to bytes + >>> data = template.encode_value(ContactStatus.CONTACT_0 | ContactStatus.CONTACT_2) # bytearray([0x05]) + """ + + def __init__(self, flag_class: type[F], extractor: RawExtractor) -> None: + """Initialise with flag class and extractor. + + Args: + flag_class: IntFlag subclass to encode/decode. + extractor: Raw extractor defining byte size and signedness. + + """ + self._flag_class = flag_class + self._extractor = extractor + # Pre-compute the bitmask of all defined members for validation. + self._valid_mask: int = 0 + for member in flag_class: + self._valid_mask |= member.value + + @property + def data_size(self) -> int: + """Return byte size required for encoding.""" + return self._extractor.byte_size + + @property + def extractor(self) -> RawExtractor: + """Return extractor for pipeline access.""" + return self._extractor + + @property + def translator(self) -> ValueTranslator[int]: + """Get IDENTITY translator for flags (no scaling needed).""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> F: + """Decode bytes to flag instance. + + Args: + data: Raw bytes from BLE characteristic. + offset: Starting offset in data buffer. + ctx: Optional context for parsing. + validate: Whether to validate against defined flag bits (default True). + + Returns: + Flag instance of type F. + + Raises: + InsufficientDataError: If data too short for required byte size. + ValueRangeError: If raw value contains undefined bits and ``validate=True``. + + """ + if validate and len(data) < offset + self.data_size: + raise InsufficientDataError(self._flag_class.__name__, data[offset:], self.data_size) + + raw_value = self._extractor.extract(data, offset) + + if validate and (raw_value & ~self._valid_mask): + raise ValueRangeError( + self._flag_class.__name__, + raw_value, + 0, + self._valid_mask, + ) + + return self._flag_class(raw_value) + + def encode_value(self, value: F | int, *, validate: bool = True) -> bytearray: + """Encode flag instance or int to bytes. + + Args: + value: Flag instance or integer value to encode. + validate: Whether to validate against defined flag bits (default True). + + Returns: + Encoded bytes. + + Raises: + ValueError: If value contains undefined bits and ``validate=True``. + + """ + int_value = value.value if isinstance(value, self._flag_class) else int(value) + + if validate and (int_value & ~self._valid_mask): + raise ValueError( + f"{self._flag_class.__name__} value 0x{int_value:02X} contains " + f"undefined bits (valid mask: 0x{self._valid_mask:02X})" + ) + + return self._extractor.pack(int_value) + + # ----------------------------------------------------------------- + # Factory methods + # ----------------------------------------------------------------- + + @classmethod + def uint8(cls, flag_class: type[F]) -> FlagTemplate[F]: + """Create FlagTemplate for 1-byte unsigned flag field. + + Args: + flag_class: IntFlag subclass with bit values in 0-255. + + Returns: + Configured FlagTemplate instance. + + Example:: + >>> class Status(IntFlag): + ... BIT_0 = 0x01 + ... BIT_1 = 0x02 + >>> template = FlagTemplate.uint8(Status) + + """ + return cls(flag_class, UINT8) + + @classmethod + def uint16(cls, flag_class: type[F]) -> FlagTemplate[F]: + """Create FlagTemplate for 2-byte unsigned flag field. + + Args: + flag_class: IntFlag subclass with bit values in 0-65535. + + Returns: + Configured FlagTemplate instance. + + """ + return cls(flag_class, UINT16) + + @classmethod + def uint32(cls, flag_class: type[F]) -> FlagTemplate[F]: + """Create FlagTemplate for 4-byte unsigned flag field. + + Args: + flag_class: IntFlag subclass with bit values in 0-4294967295. + + Returns: + Configured FlagTemplate instance. + + """ + return cls(flag_class, UINT32) diff --git a/src/bluetooth_sig/gatt/characteristics/templates/numeric.py b/src/bluetooth_sig/gatt/characteristics/templates/numeric.py index 278afb04..9e786b96 100644 --- a/src/bluetooth_sig/gatt/characteristics/templates/numeric.py +++ b/src/bluetooth_sig/gatt/characteristics/templates/numeric.py @@ -1,6 +1,6 @@ """Basic integer templates for unsigned and signed integer parsing. -Covers Uint8, Sint8, Uint16, Sint16, Uint24, Uint32 templates. +Covers Uint8, Sint8, Uint16, Sint16, Uint24, Uint32, Uint48 templates. """ from __future__ import annotations @@ -14,6 +14,7 @@ UINT16_MAX, UINT24_MAX, UINT32_MAX, + UINT48_MAX, ) from ...context import CharacteristicContext from ...exceptions import InsufficientDataError @@ -24,6 +25,7 @@ UINT16, UINT24, UINT32, + UINT48, RawExtractor, ) from ..utils.translators import ( @@ -229,3 +231,36 @@ def encode_value(self, value: int, *, validate: bool = True) -> bytearray: if validate and not 0 <= value <= UINT32_MAX: raise ValueError(f"Value {value} out of range for uint32 (0-{UINT32_MAX})") return self.extractor.pack(value) + + +class Uint48Template(CodingTemplate[int]): + """Template for 48-bit unsigned integer parsing (0-281474976710655).""" + + @property + def data_size(self) -> int: + """Size: 6 bytes.""" + return 6 + + @property + def extractor(self) -> RawExtractor: + """Get uint48 extractor.""" + return UINT48 + + @property + def translator(self) -> ValueTranslator[int]: + """Return identity translator for no scaling.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse 48-bit unsigned integer.""" + if validate and len(data) < offset + 6: + raise InsufficientDataError("uint48", data[offset:], 6) + return self.extractor.extract(data, offset) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode uint48 value to bytes.""" + if validate and not 0 <= value <= UINT48_MAX: + raise ValueError(f"Value {value} out of range for uint48 (0-{UINT48_MAX})") + return self.extractor.pack(value) diff --git a/src/bluetooth_sig/gatt/characteristics/templates/time_duration.py b/src/bluetooth_sig/gatt/characteristics/templates/time_duration.py new file mode 100644 index 00000000..0cd75bfb --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/time_duration.py @@ -0,0 +1,222 @@ +"""Time-duration template returning ``timedelta`` for BLE time characteristics. + +Wraps a numeric extractor and converts raw integer counts (seconds, +milliseconds, hours, …) into :class:`datetime.timedelta` instances so +that callers receive a proper Python time type instead of a plain ``int``. +""" + +from __future__ import annotations + +import math +from datetime import timedelta + +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError +from ..utils.extractors import ( + UINT8, + UINT16, + UINT24, + UINT32, + RawExtractor, +) +from .base import CodingTemplate + + +class TimeDurationTemplate(CodingTemplate[timedelta]): + r"""Template for time-duration characteristics that return ``timedelta``. + + Encodes/decodes a raw integer count in a given time unit (seconds, + milliseconds, hours, ...) to/from a ``timedelta``. + + Pipeline Integration: + bytes -> [extractor] -> raw_int -> x scale -> timedelta(seconds=...) + + Examples: + >>> template = TimeDurationTemplate.seconds_uint16() + >>> template.decode_value(bytearray([0x2A, 0x00])) + datetime.timedelta(seconds=42) + >>> + >>> template.encode_value(timedelta(seconds=42)) + bytearray(b'*\\x00') + """ + + def __init__( + self, + extractor: RawExtractor, + *, + seconds_per_unit: float = 1.0, + ) -> None: + """Initialise with extractor and time-unit conversion factor. + + Args: + extractor: Raw extractor defining byte size and signedness. + seconds_per_unit: How many seconds one raw count represents. + E.g. ``1.0`` for seconds, ``0.001`` for ms, + ``3600.0`` for hours, ``360.0`` for deci-hours. + + """ + self._extractor = extractor + self._seconds_per_unit = seconds_per_unit + + @property + def data_size(self) -> int: + """Return byte size required for encoding.""" + return self._extractor.byte_size + + @property + def extractor(self) -> RawExtractor: + """Return extractor for pipeline access.""" + return self._extractor + + def decode_value( + self, + data: bytearray, + offset: int = 0, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> timedelta: + """Decode bytes to ``timedelta``. + + Args: + data: Raw bytes from BLE characteristic. + offset: Starting offset in data buffer. + ctx: Optional context for parsing. + validate: Whether to validate data length (default True). + + Returns: + ``timedelta`` representing the decoded duration. + + Raises: + InsufficientDataError: If data too short for required byte size. + + """ + if validate and len(data) < offset + self.data_size: + raise InsufficientDataError("TimeDuration", data[offset:], self.data_size) + + raw = self._extractor.extract(data, offset) + return timedelta(seconds=raw * self._seconds_per_unit) + + def encode_value(self, value: timedelta | int | float, *, validate: bool = True) -> bytearray: + """Encode ``timedelta`` (or numeric seconds) to bytes. + + Args: + value: ``timedelta``, or a numeric value treated as the raw count. + validate: Whether to validate (default True). + + Returns: + Encoded bytes. + + """ + raw = round(value.total_seconds() / self._seconds_per_unit) if isinstance(value, timedelta) else int(value) + + return self._extractor.pack(raw) + + # ----------------------------------------------------------------- + # Factory methods + # ----------------------------------------------------------------- + + @classmethod + def seconds_uint8(cls) -> TimeDurationTemplate: + """1-byte unsigned, 1-second resolution.""" + return cls(UINT8, seconds_per_unit=1.0) + + @classmethod + def seconds_uint16(cls) -> TimeDurationTemplate: + """2-byte unsigned, 1-second resolution.""" + return cls(UINT16, seconds_per_unit=1.0) + + @classmethod + def seconds_uint24(cls) -> TimeDurationTemplate: + """3-byte unsigned, 1-second resolution.""" + return cls(UINT24, seconds_per_unit=1.0) + + @classmethod + def seconds_uint32(cls) -> TimeDurationTemplate: + """4-byte unsigned, 1-second resolution.""" + return cls(UINT32, seconds_per_unit=1.0) + + @classmethod + def milliseconds_uint24(cls) -> TimeDurationTemplate: + """3-byte unsigned, 1-millisecond resolution.""" + return cls(UINT24, seconds_per_unit=0.001) + + @classmethod + def hours_uint24(cls) -> TimeDurationTemplate: + """3-byte unsigned, 1-hour resolution.""" + return cls(UINT24, seconds_per_unit=3600.0) + + @classmethod + def decihours_uint8(cls) -> TimeDurationTemplate: + """1-byte unsigned, 0.1-hour (6-minute) resolution.""" + return cls(UINT8, seconds_per_unit=360.0) + + +class TimeExponentialTemplate(CodingTemplate[timedelta]): + """Template for exponentially-encoded time (Time Exponential 8). + + Encoding: ``value = 1.1^(N - 64)`` seconds. + Special values: ``0x00`` = 0 s, ``0xFE`` = device lifetime, ``0xFF`` = unknown. + """ + + @property + def data_size(self) -> int: + """Size: 1 byte.""" + return 1 + + @property + def extractor(self) -> RawExtractor: + """Return uint8 extractor for pipeline access.""" + return UINT8 + + def decode_value( + self, + data: bytearray, + offset: int = 0, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> timedelta: + """Decode exponentially-encoded time to ``timedelta``. + + Args: + data: Raw bytes from BLE characteristic. + offset: Starting offset in data buffer. + ctx: Optional context for parsing. + validate: Whether to validate data length (default True). + + Returns: + ``timedelta`` representing the decoded duration. + + Raises: + InsufficientDataError: If data too short. + + """ + if validate and len(data) < offset + self.data_size: + raise InsufficientDataError("TimeExponential8", data[offset:], 1) + + raw = UINT8.extract(data, offset) + if raw == 0: + return timedelta(seconds=0) + seconds = 1.1 ** (raw - 64) + return timedelta(seconds=seconds) + + def encode_value(self, value: timedelta | float, *, validate: bool = True) -> bytearray: + """Encode a time duration using exponential encoding. + + Args: + value: ``timedelta`` or numeric seconds. + validate: Whether to validate (default True). + + Returns: + Encoded byte. + + """ + seconds = value.total_seconds() if isinstance(value, timedelta) else float(value) + + if seconds <= 0.0: + return UINT8.pack(0) + + n = round(math.log(seconds) / math.log(1.1) + 64) + n = max(1, min(n, 0xFD)) + return UINT8.pack(n) diff --git a/src/bluetooth_sig/gatt/characteristics/time_decihour_8.py b/src/bluetooth_sig/gatt/characteristics/time_decihour_8.py new file mode 100644 index 00000000..2fe0bf22 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/time_decihour_8.py @@ -0,0 +1,24 @@ +"""Time Decihour 8 characteristic (0x2B12).""" + +from __future__ import annotations + +from datetime import timedelta + +from .base import BaseCharacteristic +from .templates import TimeDurationTemplate + + +class TimeDecihour8Characteristic(BaseCharacteristic[timedelta]): + """Time Decihour 8 characteristic (0x2B12). + + org.bluetooth.characteristic.time_decihour_8 + + Time in hours with a resolution of 0.1 (deci-hours). + M=1, d=-1, b=0 → scale factor 0.1. + Range: 0.0-23.9. A value of 0xFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFF). + """ + + _template = TimeDurationTemplate.decihours_uint8() diff --git a/src/bluetooth_sig/gatt/characteristics/time_exponential_8.py b/src/bluetooth_sig/gatt/characteristics/time_exponential_8.py new file mode 100644 index 00000000..8ec83ae5 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/time_exponential_8.py @@ -0,0 +1,25 @@ +"""Time Exponential 8 characteristic (0x2B13).""" + +from __future__ import annotations + +from datetime import timedelta + +from .base import BaseCharacteristic +from .templates import TimeExponentialTemplate + + +class TimeExponential8Characteristic(BaseCharacteristic[timedelta]): + """Time Exponential 8 characteristic (0x2B13). + + org.bluetooth.characteristic.time_exponential_8 + + Time duration using exponential encoding: value = 1.1^(N - 64) seconds. + - Raw 0x00 represents 0 seconds. + - Raw 0xFE represents total life of the device. + - Raw 0xFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFE, 0xFF). + """ + + _template = TimeExponentialTemplate() diff --git a/src/bluetooth_sig/gatt/characteristics/time_hour_24.py b/src/bluetooth_sig/gatt/characteristics/time_hour_24.py new file mode 100644 index 00000000..9938c8ac --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/time_hour_24.py @@ -0,0 +1,23 @@ +"""Time Hour 24 characteristic (0x2B14).""" + +from __future__ import annotations + +from datetime import timedelta + +from .base import BaseCharacteristic +from .templates import TimeDurationTemplate + + +class TimeHour24Characteristic(BaseCharacteristic[timedelta]): + """Time Hour 24 characteristic (0x2B14). + + org.bluetooth.characteristic.time_hour_24 + + Time in hours with a resolution of 1. + A value of 0xFFFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFF). + """ + + _template = TimeDurationTemplate.hours_uint24() diff --git a/src/bluetooth_sig/gatt/characteristics/time_millisecond_24.py b/src/bluetooth_sig/gatt/characteristics/time_millisecond_24.py new file mode 100644 index 00000000..6d417582 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/time_millisecond_24.py @@ -0,0 +1,24 @@ +"""Time Millisecond 24 characteristic (0x2B15).""" + +from __future__ import annotations + +from datetime import timedelta + +from .base import BaseCharacteristic +from .templates import TimeDurationTemplate + + +class TimeMillisecond24Characteristic(BaseCharacteristic[timedelta]): + """Time Millisecond 24 characteristic (0x2B15). + + org.bluetooth.characteristic.time_millisecond_24 + + Time in milliseconds encoded as a 24-bit unsigned integer. + Resolution is 0.001 seconds (1 ms). + A value of 0xFFFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFF). + """ + + _template = TimeDurationTemplate.milliseconds_uint24() diff --git a/src/bluetooth_sig/gatt/characteristics/time_second_16.py b/src/bluetooth_sig/gatt/characteristics/time_second_16.py new file mode 100644 index 00000000..70dfaf17 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/time_second_16.py @@ -0,0 +1,23 @@ +"""Time Second 16 characteristic (0x2B17).""" + +from __future__ import annotations + +from datetime import timedelta + +from .base import BaseCharacteristic +from .templates import TimeDurationTemplate + + +class TimeSecond16Characteristic(BaseCharacteristic[timedelta]): + """Time Second 16 characteristic (0x2B17). + + org.bluetooth.characteristic.time_second_16 + + Time in seconds with a resolution of 1 (0-65534). + A value of 0xFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFF). + """ + + _template = TimeDurationTemplate.seconds_uint16() diff --git a/src/bluetooth_sig/gatt/characteristics/time_second_32.py b/src/bluetooth_sig/gatt/characteristics/time_second_32.py new file mode 100644 index 00000000..d3c593ab --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/time_second_32.py @@ -0,0 +1,23 @@ +"""Time Second 32 characteristic (0x2B18).""" + +from __future__ import annotations + +from datetime import timedelta + +from .base import BaseCharacteristic +from .templates import TimeDurationTemplate + + +class TimeSecond32Characteristic(BaseCharacteristic[timedelta]): + """Time Second 32 characteristic (0x2B18). + + org.bluetooth.characteristic.time_second_32 + + Time in seconds with a resolution of 1 (0-4294967294). + A value of 0xFFFFFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFFFFFF). + """ + + _template = TimeDurationTemplate.seconds_uint32() diff --git a/src/bluetooth_sig/gatt/characteristics/time_second_8.py b/src/bluetooth_sig/gatt/characteristics/time_second_8.py new file mode 100644 index 00000000..379a4c87 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/time_second_8.py @@ -0,0 +1,23 @@ +"""Time Second 8 characteristic (0x2B16).""" + +from __future__ import annotations + +from datetime import timedelta + +from .base import BaseCharacteristic +from .templates import TimeDurationTemplate + + +class TimeSecond8Characteristic(BaseCharacteristic[timedelta]): + """Time Second 8 characteristic (0x2B16). + + org.bluetooth.characteristic.time_second_8 + + Time in seconds with a resolution of 1 (0-254). + A value of 0xFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFF). + """ + + _template = TimeDurationTemplate.seconds_uint8() diff --git a/src/bluetooth_sig/gatt/characteristics/time_zone.py b/src/bluetooth_sig/gatt/characteristics/time_zone.py index da74e553..ceadf208 100644 --- a/src/bluetooth_sig/gatt/characteristics/time_zone.py +++ b/src/bluetooth_sig/gatt/characteristics/time_zone.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ...types.gatt_enums import ValueType from ..constants import SINT8_MIN from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -25,7 +24,7 @@ class TimeZoneCharacteristic(BaseCharacteristic[str]): """ # Manual override: YAML indicates sint8->int but we return descriptive strings - _manual_value_type: ValueType | str | None = ValueType.STRING + _python_type: type | str | None = str min_length: int = 1 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: diff --git a/src/bluetooth_sig/gatt/characteristics/torque.py b/src/bluetooth_sig/gatt/characteristics/torque.py new file mode 100644 index 00000000..5269b82c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/torque.py @@ -0,0 +1,23 @@ +"""Torque characteristic (0x2B21).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledSint32Template + + +class TorqueCharacteristic(BaseCharacteristic[float]): + """Torque characteristic (0x2B21). + + org.bluetooth.characteristic.torque + + Torque in Newton metres with a resolution of 0.01 Nm. + M=1, d=-2, b=0 → scale factor 0.01. + Positive = clockwise around the given axis. + A value of 0x7FFFFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0x7FFFFFFF). + """ + + _template = ScaledSint32Template.from_letter_method(M=1, d=-2, b=0) diff --git a/src/bluetooth_sig/gatt/characteristics/uncertainty.py b/src/bluetooth_sig/gatt/characteristics/uncertainty.py index 32f50dc7..b580c460 100644 --- a/src/bluetooth_sig/gatt/characteristics/uncertainty.py +++ b/src/bluetooth_sig/gatt/characteristics/uncertainty.py @@ -42,5 +42,4 @@ class UncertaintyCharacteristic(BaseCharacteristic[float]): # Manual overrides required as Bluetooth SIG registry doesn't provide unit/value type _manual_unit = "m" - _manual_value_type = "float" _template = ScaledUint8Template(scale_factor=0.1) diff --git a/src/bluetooth_sig/gatt/characteristics/unknown.py b/src/bluetooth_sig/gatt/characteristics/unknown.py index 2a99bb3b..e4f98693 100644 --- a/src/bluetooth_sig/gatt/characteristics/unknown.py +++ b/src/bluetooth_sig/gatt/characteristics/unknown.py @@ -18,8 +18,8 @@ class UnknownCharacteristic(BaseCharacteristic[bytes]): attempting to parse it into structured types. """ - # TODO handle better - _is_base_class = True # Exclude from registry validation tests (requires info parameter) + # NOTE: Exempt from registry validation — UnknownCharacteristic has no fixed UUID + _is_base_class = True def __init__( self, @@ -29,7 +29,7 @@ def __init__( """Initialize an unknown characteristic. Args: - info: CharacteristicInfo object with UUID, name, unit, value_type + info: CharacteristicInfo object with UUID, name, unit, python_type properties: Runtime BLE properties discovered from device (optional) Raises: @@ -42,7 +42,7 @@ def __init__( uuid=info.uuid, name=f"Unknown Characteristic ({info.uuid})", unit=info.unit or "", - value_type=info.value_type, + python_type=info.python_type, ) super().__init__(info=info, properties=properties) diff --git a/src/bluetooth_sig/gatt/characteristics/utils/data_parser.py b/src/bluetooth_sig/gatt/characteristics/utils/data_parser.py index 2b8b536f..ec77c967 100644 --- a/src/bluetooth_sig/gatt/characteristics/utils/data_parser.py +++ b/src/bluetooth_sig/gatt/characteristics/utils/data_parser.py @@ -18,6 +18,7 @@ UINT16_MAX, UINT24_MAX, UINT32_MAX, + UINT48_MAX, ) from ...exceptions import InsufficientDataError, ValueRangeError @@ -78,6 +79,18 @@ def parse_int24( raise InsufficientDataError("int24", data[offset:], 3) return int.from_bytes(data[offset : offset + 3], byteorder=endian, signed=signed) + @staticmethod + def parse_int48( + data: bytes | bytearray, + offset: int = 0, + signed: bool = False, + endian: Literal["little", "big"] = "little", + ) -> int: + """Parse 48-bit integer with configurable endianness and signed interpretation.""" + if len(data) < offset + 6: + raise InsufficientDataError("int48", data[offset:], 6) + return int.from_bytes(data[offset : offset + 6], byteorder=endian, signed=signed) + @staticmethod def parse_float32(data: bytearray, offset: int = 0) -> float: """Parse IEEE-754 32-bit float (little-endian).""" @@ -147,6 +160,18 @@ def encode_int24(value: int, signed: bool = False, endian: Literal["little", "bi raise ValueRangeError("uint24", value, 0, UINT24_MAX) return bytearray(value.to_bytes(3, byteorder=endian, signed=signed)) + @staticmethod + def encode_int48(value: int, signed: bool = False, endian: Literal["little", "big"] = "little") -> bytearray: + """Encode 48-bit integer with configurable endianness and signed/unsigned validation.""" + if signed: + min_val = -(1 << 47) + max_val = (1 << 47) - 1 + if not min_val <= value <= max_val: + raise ValueRangeError("sint48", value, min_val, max_val) + elif not 0 <= value <= UINT48_MAX: + raise ValueRangeError("uint48", value, 0, UINT48_MAX) + return bytearray(value.to_bytes(6, byteorder=endian, signed=signed)) + @staticmethod def encode_float32(value: float) -> bytearray: """Encode IEEE-754 32-bit float (little-endian).""" diff --git a/src/bluetooth_sig/gatt/characteristics/utils/extractors.py b/src/bluetooth_sig/gatt/characteristics/utils/extractors.py index 27620781..ca4823ad 100644 --- a/src/bluetooth_sig/gatt/characteristics/utils/extractors.py +++ b/src/bluetooth_sig/gatt/characteristics/utils/extractors.py @@ -313,6 +313,38 @@ def pack(self, raw: int) -> bytearray: return DataParser.encode_int32(raw, signed=True, endian=self._endian) +class Uint48Extractor(RawExtractor): + """Extract/pack unsigned 48-bit integers (0 to 281474976710655).""" + + __slots__ = ("_endian",) + + def __init__(self, endian: Literal["little", "big"] = "little") -> None: + """Initialize with endianness. + + Args: + endian: Byte order, defaults to little-endian per BLE spec. + """ + self._endian: Literal["little", "big"] = endian + + @property + def byte_size(self) -> int: + """Size: 6 bytes.""" + return 6 + + @property + def signed(self) -> bool: + """Unsigned type.""" + return False + + def extract(self, data: bytes | bytearray, offset: int = 0) -> int: + """Extract uint48 from bytes.""" + return DataParser.parse_int48(data, offset, signed=False, endian=self._endian) + + def pack(self, raw: int) -> bytearray: + """Pack uint48 to bytes.""" + return DataParser.encode_int48(raw, signed=False, endian=self._endian) + + class Float32Extractor(RawExtractor): """Extract/pack IEEE-754 32-bit floats. @@ -364,6 +396,7 @@ def pack_float(self, value: float) -> bytearray: SINT24 = Sint24Extractor() UINT32 = Uint32Extractor() SINT32 = Sint32Extractor() +UINT48 = Uint48Extractor() FLOAT32 = Float32Extractor() # Mapping from GSS type strings to extractor instances @@ -376,6 +409,7 @@ def pack_float(self, value: float) -> bytearray: "sint24": SINT24, "uint32": UINT32, "sint32": SINT32, + "uint48": UINT48, "float32": FLOAT32, "int16": SINT16, } diff --git a/src/bluetooth_sig/gatt/characteristics/voltage_specification.py b/src/bluetooth_sig/gatt/characteristics/voltage_specification.py index 23afa192..0a0a8c5a 100644 --- a/src/bluetooth_sig/gatt/characteristics/voltage_specification.py +++ b/src/bluetooth_sig/gatt/characteristics/voltage_specification.py @@ -4,7 +4,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -47,7 +46,7 @@ class VoltageSpecificationCharacteristic(BaseCharacteristic[VoltageSpecification min_length = 4 # Override since decode_value returns structured VoltageSpecificationData - _manual_value_type: ValueType | str | None = ValueType.DICT + _python_type: type | str | None = dict def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py b/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py index cd279a50..efb28191 100644 --- a/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py +++ b/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py @@ -4,7 +4,6 @@ import msgspec -from ...types.gatt_enums import ValueType from ..constants import UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -53,7 +52,7 @@ class VoltageStatisticsCharacteristic(BaseCharacteristic[VoltageStatisticsData]) """ # Override since decode_value returns structured VoltageStatisticsData - _manual_value_type: ValueType | str | None = ValueType.DICT + _python_type: type | str | None = dict expected_length: int = 6 # Minimum(2) + Maximum(2) + Average(2) min_length: int = 6 diff --git a/src/bluetooth_sig/gatt/characteristics/volume_flow.py b/src/bluetooth_sig/gatt/characteristics/volume_flow.py new file mode 100644 index 00000000..ba75f5d0 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/volume_flow.py @@ -0,0 +1,22 @@ +"""Volume Flow characteristic (0x2B22).""" + +from __future__ import annotations + +from .base import BaseCharacteristic +from .templates import ScaledUint16Template + + +class VolumeFlowCharacteristic(BaseCharacteristic[float]): + """Volume Flow characteristic (0x2B22). + + org.bluetooth.characteristic.volume_flow + + Volume flow in litres per second with a resolution of 0.001 (1 mL). + M=1, d=-3, b=0 → scale factor 0.001. + A value of 0xFFFF represents 'value is not known'. + + Raises: + SpecialValueDetectedError: If raw value is a sentinel (e.g. 0xFFFF). + """ + + _template = ScaledUint16Template.from_letter_method(M=1, d=-3, b=0) diff --git a/src/bluetooth_sig/gatt/constants.py b/src/bluetooth_sig/gatt/constants.py index 20dfdc85..71bb9ec0 100644 --- a/src/bluetooth_sig/gatt/constants.py +++ b/src/bluetooth_sig/gatt/constants.py @@ -11,6 +11,7 @@ UINT16_MAX = (1 << 16) - 1 # 65535 UINT24_MAX = (1 << 24) - 1 # 16777215 UINT32_MAX = (1 << 32) - 1 # 4294967295 +UINT48_MAX = (1 << 48) - 1 # 281474976710655 SINT8_MIN = -(1 << 7) # -128 SINT8_MAX = (1 << 7) - 1 # 127 diff --git a/src/bluetooth_sig/gatt/resolver.py b/src/bluetooth_sig/gatt/resolver.py index 21247775..fc2f5de7 100644 --- a/src/bluetooth_sig/gatt/resolver.py +++ b/src/bluetooth_sig/gatt/resolver.py @@ -95,6 +95,7 @@ def snake_case_to_camel_case(s: str) -> str: "pm25", "voc", "rsc", + "cct", "cccd", "ccc", "2d", diff --git a/src/bluetooth_sig/gatt/services/base.py b/src/bluetooth_sig/gatt/services/base.py index 642bb412..4ee84ae8 100644 --- a/src/bluetooth_sig/gatt/services/base.py +++ b/src/bluetooth_sig/gatt/services/base.py @@ -390,7 +390,7 @@ def process_characteristics(self, characteristics: ServiceDiscoveryData) -> None uuid=uuid_obj, name=char_info.name or f"Unknown Characteristic ({uuid_obj})", unit=char_info.unit or "", - value_type=char_info.value_type, + python_type=char_info.python_type, ), properties=[], ) diff --git a/src/bluetooth_sig/gatt/services/unknown.py b/src/bluetooth_sig/gatt/services/unknown.py index f0df531e..77aa13a6 100644 --- a/src/bluetooth_sig/gatt/services/unknown.py +++ b/src/bluetooth_sig/gatt/services/unknown.py @@ -15,8 +15,8 @@ class UnknownService(BaseGattService): basic functionality while allowing characteristic processing. """ - # TODO - _is_base_class = True # Exclude from registry validation tests (requires uuid parameter) + # NOTE: Exempt from registry validation — UnknownService has no fixed UUID + _is_base_class = True def __init__(self, uuid: BluetoothUUID, name: str | None = None) -> None: """Initialize an unknown service with minimal info. diff --git a/src/bluetooth_sig/gatt/uuid_registry.py b/src/bluetooth_sig/gatt/uuid_registry.py index 0b5b39cb..f53ec709 100644 --- a/src/bluetooth_sig/gatt/uuid_registry.py +++ b/src/bluetooth_sig/gatt/uuid_registry.py @@ -10,7 +10,6 @@ from bluetooth_sig.registry.uuids.units import UnitsRegistry from bluetooth_sig.types import CharacteristicInfo, ServiceInfo from bluetooth_sig.types.base_types import SIGInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.registry.descriptor_types import DescriptorInfo from bluetooth_sig.types.registry.gss_characteristic import GssCharacteristicSpec from bluetooth_sig.types.uuid import BluetoothUUID @@ -164,7 +163,6 @@ def _load_uuids(self) -> None: # pylint: disable=too-many-branches name=uuid_info["name"], id=uuid_info.get("id", ""), unit="", # Will be set from unit mappings if available - value_type=ValueType.UNKNOWN, ) self._store_characteristic(char_info) @@ -218,7 +216,7 @@ def _load_gss_characteristic_info(self) -> None: self._update_characteristic_with_gss_info(spec.name, spec.identifier, unit, value_type) def _update_characteristic_with_gss_info( - self, char_name: str, char_id: str, unit: str | None, value_type: str | None + self, char_name: str, char_id: str, unit: str | None, python_type: type | None ) -> None: """Update existing characteristic with GSS info.""" with self._lock: @@ -235,13 +233,8 @@ def _update_characteristic_with_gss_info( # Get existing info and create updated version existing_info = self._characteristics[canonical_uuid] - # Convert value_type string to ValueType enum if provided - new_value_type = existing_info.value_type - if value_type: - try: - new_value_type = ValueType(value_type) - except (ValueError, KeyError): - new_value_type = existing_info.value_type + # Use provided python_type or keep existing + new_python_type = python_type if python_type is not None else existing_info.python_type # Create updated CharacteristicInfo (immutable, so create new instance) updated_info = CharacteristicInfo( @@ -249,7 +242,7 @@ def _update_characteristic_with_gss_info( name=existing_info.name, id=existing_info.id, unit=unit or existing_info.unit, - value_type=new_value_type, + python_type=new_python_type, ) # Update canonical store (aliases remain the same since UUID/name/id unchanged) @@ -280,7 +273,7 @@ def register_characteristic( # pylint: disable=too-many-arguments,too-many-posi name: str, identifier: str | None = None, unit: str | None = None, - value_type: ValueType | None = None, + python_type: type | str | None = None, override: bool = False, ) -> None: """Register a custom characteristic at runtime. @@ -290,7 +283,7 @@ def register_characteristic( # pylint: disable=too-many-arguments,too-many-posi name: Human-readable name identifier: Optional identifier (auto-generated if not provided) unit: Optional unit of measurement - value_type: Optional value type + python_type: Optional Python type for the value override: If True, allow overriding existing entries """ with self._lock: @@ -318,7 +311,7 @@ def register_characteristic( # pylint: disable=too-many-arguments,too-many-posi name=name, id=identifier or f"runtime.characteristic.{name.lower().replace(' ', '_')}", unit=unit or "", - value_type=value_type or ValueType.UNKNOWN, + python_type=python_type, ) # Track as runtime-registered UUID diff --git a/src/bluetooth_sig/registry/gss.py b/src/bluetooth_sig/registry/gss.py index b20b7897..2e2849e6 100644 --- a/src/bluetooth_sig/registry/gss.py +++ b/src/bluetooth_sig/registry/gss.py @@ -15,7 +15,7 @@ from bluetooth_sig.registry.base import BaseGenericRegistry from bluetooth_sig.registry.uuids.units import UnitsRegistry -from bluetooth_sig.types.gatt_enums import DataType +from bluetooth_sig.types.gatt_enums import WIRE_TYPE_MAP from bluetooth_sig.types.registry.gss_characteristic import ( FieldSpec, GssCharacteristicSpec, @@ -159,14 +159,14 @@ def get_all_specs(self) -> dict[str, GssCharacteristicSpec]: with self._lock: return dict(self._specs) - def extract_info_from_gss(self, char_data: dict[str, Any]) -> tuple[str | None, str | None]: - """Extract unit and value_type from GSS characteristic structure. + def extract_info_from_gss(self, char_data: dict[str, Any]) -> tuple[str | None, type | None]: + """Extract unit and python_type from GSS characteristic structure. Args: char_data: Raw characteristic data from YAML Returns: - Tuple of (unit_symbol, value_type) or (None, None) if not found + Tuple of (unit_symbol, python_type) or (None, None) if not found """ structure = char_data.get("structure", []) if not isinstance(structure, list) or not structure: @@ -181,14 +181,14 @@ def extract_info_from_gss(self, char_data: dict[str, Any]) -> tuple[str | None, return None, None unit = None - value_type = None + python_type: type | None = None for field in typed_structure: field_dict: dict[str, Any] = field - if not value_type and isinstance(field_dict.get("type"), str): + if not python_type and isinstance(field_dict.get("type"), str): yaml_type_value = cast("str", field_dict["type"]) - value_type = self._convert_yaml_type_to_python_type(yaml_type_value) + python_type = self._convert_yaml_type_to_python_type(yaml_type_value) description_value = field_dict.get("description", "") if not isinstance(description_value, str): @@ -198,7 +198,7 @@ def extract_info_from_gss(self, char_data: dict[str, Any]) -> tuple[str | None, if not unit and ("Base Unit:" in description_value or "Unit:" in description_value): unit = self._extract_unit_from_description(description_value) - return unit, value_type + return unit, python_type def _extract_unit_from_description(self, description: str) -> str | None: """Extract unit symbol from GSS field description. @@ -250,9 +250,9 @@ def _extract_unit_id_and_line(self, description: str) -> tuple[str | None, str | return None, None - def _convert_yaml_type_to_python_type(self, yaml_type: str) -> str: - """Convert YAML type to Python type string.""" - return DataType.from_string(yaml_type).to_python_type() + def _convert_yaml_type_to_python_type(self, yaml_type: str) -> type | None: + """Convert YAML wire type string to a Python type.""" + return WIRE_TYPE_MAP.get(yaml_type.lower()) def _convert_bluetooth_unit_to_readable(self, unit_spec: str) -> str: """Convert Bluetooth SIG unit specification to human-readable symbol. diff --git a/src/bluetooth_sig/types/data_types.py b/src/bluetooth_sig/types/data_types.py index 5b16d8e9..47a1b94a 100644 --- a/src/bluetooth_sig/types/data_types.py +++ b/src/bluetooth_sig/types/data_types.py @@ -7,7 +7,6 @@ import msgspec from .base_types import SIGInfo -from .gatt_enums import ValueType class ParseFieldError(msgspec.Struct, frozen=True, kw_only=True): @@ -54,7 +53,8 @@ class CharacteristicInfo(SIGInfo): on the BaseCharacteristic instance as they're discovered from the actual device. """ - value_type: ValueType = ValueType.UNKNOWN + python_type: type | str | None = None + is_bitfield: bool = False unit: str = "" diff --git a/src/bluetooth_sig/types/gatt_enums.py b/src/bluetooth_sig/types/gatt_enums.py index 7b75ad7b..4757a0dc 100644 --- a/src/bluetooth_sig/types/gatt_enums.py +++ b/src/bluetooth_sig/types/gatt_enums.py @@ -79,27 +79,11 @@ class GattProperty(IntFlag): AUTH_NOTIFY = 0x8000 -class ValueType(Enum): - """Data types for characteristic values.""" - - STRING = "string" - INT = "int" - FLOAT = "float" - BYTES = "bytes" - BITFIELD = "bitfield" - BOOL = "bool" - DATETIME = "datetime" - UUID = "uuid" - DICT = "dict" - VARIOUS = "various" - UNKNOWN = "unknown" - - class CharacteristicRole(Enum): """Inferred purpose of a GATT characteristic. Derived algorithmically from SIG spec metadata (name patterns, - value_type, unit presence, field structure). No per-characteristic + python_type, unit presence, field structure). No per-characteristic maintenance is required — the classification is computed at instantiation time from data already parsed from the SIG YAML specs. @@ -126,133 +110,33 @@ class CharacteristicRole(Enum): UNKNOWN = "unknown" -class DataType(Enum): - """Bluetooth SIG data types from GATT specifications.""" - - BOOLEAN = "boolean" - UINT8 = "uint8" - UINT16 = "uint16" - UINT24 = "uint24" - UINT32 = "uint32" - UINT64 = "uint64" - SINT8 = "sint8" - SINT16 = "sint16" - SINT24 = "sint24" - SINT32 = "sint32" - SINT64 = "sint64" - FLOAT32 = "float32" - FLOAT64 = "float64" - UTF8S = "utf8s" - UTF16S = "utf16s" - STRUCT = "struct" - MEDFLOAT16 = "medfloat16" - MEDFLOAT32 = "medfloat32" - VARIOUS = "various" - UNKNOWN = "unknown" - - @classmethod - def from_string(cls, type_str: str | None) -> DataType: - """Convert string representation to DataType enum. - - Args: - type_str: String representation of data type (case-insensitive) - - Returns: - Corresponding DataType enum, or DataType.UNKNOWN if not found - """ - if not type_str: - return cls.UNKNOWN - - # Handle common aliases - type_str = type_str.lower() - aliases = { - "utf16s": cls.UTF16S, # UTF-16 string support - "sfloat": cls.MEDFLOAT16, # IEEE-11073 16-bit SFLOAT - "float": cls.FLOAT32, # IEEE-11073 32-bit FLOAT - "variable": cls.STRUCT, # variable maps to STRUCT - } - - if type_str in aliases: - return aliases[type_str] - - # Try direct match - for member in cls: - if member.value == type_str: - return member - - return cls.UNKNOWN - - def to_value_type(self) -> ValueType: - """Convert DataType to internal ValueType enum. - - Returns: - Corresponding ValueType for this data type - """ - mapping = { - # Integer types - self.SINT8: ValueType.INT, - self.UINT8: ValueType.INT, - self.SINT16: ValueType.INT, - self.UINT16: ValueType.INT, - self.SINT24: ValueType.INT, - self.UINT24: ValueType.INT, - self.SINT32: ValueType.INT, - self.UINT32: ValueType.INT, - self.UINT64: ValueType.INT, - self.SINT64: ValueType.INT, - # Float types - self.FLOAT32: ValueType.FLOAT, - self.FLOAT64: ValueType.FLOAT, - self.MEDFLOAT16: ValueType.FLOAT, - self.MEDFLOAT32: ValueType.FLOAT, - # String types - self.UTF8S: ValueType.STRING, - self.UTF16S: ValueType.STRING, - # Boolean type - self.BOOLEAN: ValueType.BOOL, - # Struct/opaque data - self.STRUCT: ValueType.BYTES, - # Meta types - self.VARIOUS: ValueType.VARIOUS, - self.UNKNOWN: ValueType.UNKNOWN, - } - return mapping.get(self, ValueType.UNKNOWN) - - def to_python_type(self) -> str: - """Convert DataType to Python type string. - - Returns: - Corresponding Python type string - """ - mapping = { - # Integer types - self.UINT8: "int", - self.UINT16: "int", - self.UINT24: "int", - self.UINT32: "int", - self.UINT64: "int", - self.SINT8: "int", - self.SINT16: "int", - self.SINT24: "int", - self.SINT32: "int", - self.SINT64: "int", - # Float types - self.FLOAT32: "float", - self.FLOAT64: "float", - self.MEDFLOAT16: "float", - self.MEDFLOAT32: "float", - # String types - self.UTF8S: "string", - self.UTF16S: "string", - # Boolean type - self.BOOLEAN: "boolean", - # Struct/opaque data - self.STRUCT: "bytes", - # Meta types - self.VARIOUS: "various", - self.UNKNOWN: "unknown", - } - return mapping.get(self, "bytes") +# Wire-type lookup: maps YAML/GSS data type strings to Python types. +WIRE_TYPE_MAP: dict[str, type] = { + # Integer types + "uint8": int, + "uint16": int, + "uint24": int, + "uint32": int, + "uint64": int, + "sint8": int, + "sint16": int, + "sint24": int, + "sint32": int, + "sint64": int, + # Boolean + "boolean": bool, + # Float types (including IEEE-11073 medical floats) + "float32": float, + "float64": float, + "medfloat16": float, + "medfloat32": float, + # Aliases + "sfloat": float, + "float": float, + # String types + "utf8s": str, + "utf16s": str, +} class CharacteristicName(Enum): @@ -260,14 +144,19 @@ class CharacteristicName(Enum): BATTERY_LEVEL = "Battery Level" BATTERY_LEVEL_STATUS = "Battery Level Status" + BATTERY_CRITICAL_STATUS = "Battery Critical Status" TEMPERATURE = "Temperature" TEMPERATURE_MEASUREMENT = "Temperature Measurement" + TEMPERATURE_TYPE = "Temperature Type" + INTERMEDIATE_TEMPERATURE = "Intermediate Temperature" + MEASUREMENT_INTERVAL = "Measurement Interval" HUMIDITY = "Humidity" PRESSURE = "Pressure" UV_INDEX = "UV Index" ILLUMINANCE = "Illuminance" POWER_SPECIFICATION = "Power Specification" HEART_RATE_MEASUREMENT = "Heart Rate Measurement" + HEART_RATE_CONTROL_POINT = "Heart Rate Control Point" BLOOD_PRESSURE_MEASUREMENT = "Blood Pressure Measurement" INTERMEDIATE_CUFF_PRESSURE = "Intermediate Cuff Pressure" BLOOD_PRESSURE_FEATURE = "Blood Pressure Feature" @@ -288,6 +177,8 @@ class CharacteristicName(Enum): FIRMWARE_REVISION_STRING = "Firmware Revision String" HARDWARE_REVISION_STRING = "Hardware Revision String" SOFTWARE_REVISION_STRING = "Software Revision String" + SYSTEM_ID = "System ID" + PNP_ID = "PnP ID" DEVICE_NAME = "Device Name" APPEARANCE = "Appearance" WEIGHT_MEASUREMENT = "Weight Measurement" @@ -296,24 +187,34 @@ class CharacteristicName(Enum): BODY_COMPOSITION_FEATURE = "Body Composition Feature" BODY_SENSOR_LOCATION = "Body Sensor Location" # Environmental characteristics + ACCELERATION = "Acceleration" + ACCELERATION_3D = "Acceleration 3D" + ACCELERATION_DETECTION_STATUS = "Acceleration Detection Status" + ALTITUDE = "Altitude" DEW_POINT = "Dew Point" + ELEVATION = "Elevation" + FORCE = "Force" + GUST_FACTOR = "Gust Factor" HEAT_INDEX = "Heat Index" + IRRADIANCE = "Irradiance" + LINEAR_POSITION = "Linear Position" WIND_CHILL = "Wind Chill" TRUE_WIND_SPEED = "True Wind Speed" TRUE_WIND_DIRECTION = "True Wind Direction" APPARENT_WIND_SPEED = "Apparent Wind Speed" APPARENT_WIND_DIRECTION = "Apparent Wind Direction" MAGNETIC_DECLINATION = "Magnetic Declination" - ELEVATION = "Elevation" MAGNETIC_FLUX_DENSITY_2D = "Magnetic Flux Density - 2D" MAGNETIC_FLUX_DENSITY_3D = "Magnetic Flux Density - 3D" BAROMETRIC_PRESSURE_TREND = "Barometric Pressure Trend" POLLEN_CONCENTRATION = "Pollen Concentration" RAINFALL = "Rainfall" + ROTATIONAL_SPEED = "Rotational Speed" TIME_ZONE = "Time Zone" LOCAL_TIME_INFORMATION = "Local Time Information" # Gas sensor characteristics AMMONIA_CONCENTRATION = "Ammonia Concentration" + CARBON_MONOXIDE_CONCENTRATION = "Carbon Monoxide Concentration" CO2_CONCENTRATION = r"CO\textsubscript{2} Concentration" METHANE_CONCENTRATION = "Methane Concentration" NITROGEN_DIOXIDE_CONCENTRATION = "Nitrogen Dioxide Concentration" @@ -325,6 +226,8 @@ class CharacteristicName(Enum): SULFUR_DIOXIDE_CONCENTRATION = "Sulfur Dioxide Concentration" VOC_CONCENTRATION = "VOC Concentration" # Power characteristics + APPARENT_ENERGY_32 = "Apparent Energy 32" + APPARENT_POWER = "Apparent Power" ELECTRIC_CURRENT = "Electric Current" ELECTRIC_CURRENT_RANGE = "Electric Current Range" ELECTRIC_CURRENT_SPECIFICATION = "Electric Current Specification" @@ -363,21 +266,40 @@ class CharacteristicName(Enum): ALERT_NOTIFICATION_CONTROL_POINT = "Alert Notification Control Point" # Time characteristics CURRENT_TIME = "Current Time" + DATE_TIME = "Date Time" + DAY_DATE_TIME = "Day Date Time" + DAY_OF_WEEK = "Day of Week" + DST_OFFSET = "DST Offset" + EXACT_TIME_256 = "Exact Time 256" REFERENCE_TIME_INFORMATION = "Reference Time Information" + TIME_ACCURACY = "Time Accuracy" + TIME_SOURCE = "Time Source" TIME_WITH_DST = "Time with DST" TIME_UPDATE_CONTROL_POINT = "Time Update Control Point" TIME_UPDATE_STATE = "Time Update State" # Power level TX_POWER_LEVEL = "Tx Power Level" SCAN_INTERVAL_WINDOW = "Scan Interval Window" + SCAN_REFRESH = "Scan Refresh" BOND_MANAGEMENT_FEATURE = "Bond Management Feature" BOND_MANAGEMENT_CONTROL_POINT = "Bond Management Control Point" + # GAP characteristics + PERIPHERAL_PREFERRED_CONNECTION_PARAMETERS = "Peripheral Preferred Connection Parameters" + PERIPHERAL_PRIVACY_FLAG = "Peripheral Privacy Flag" + RECONNECTION_ADDRESS = "Reconnection Address" # Indoor positioning characteristics INDOOR_POSITIONING_CONFIGURATION = "Indoor Positioning Configuration" LATITUDE = "Latitude" + LOCAL_EAST_COORDINATE = "Local East Coordinate" + LOCAL_NORTH_COORDINATE = "Local North Coordinate" LONGITUDE = "Longitude" FLOOR_NUMBER = "Floor Number" LOCATION_NAME = "Location Name" + UNCERTAINTY = "Uncertainty" + # HID characteristics + BOOT_KEYBOARD_INPUT_REPORT = "Boot Keyboard Input Report" + BOOT_KEYBOARD_OUTPUT_REPORT = "Boot Keyboard Output Report" + BOOT_MOUSE_INPUT_REPORT = "Boot Mouse Input Report" HID_INFORMATION = "HID Information" REPORT_MAP = "Report Map" HID_CONTROL_POINT = "HID Control Point" @@ -397,6 +319,9 @@ class CharacteristicName(Enum): SUPPORTED_HEART_RATE_RANGE = "Supported Heart Rate Range" FITNESS_MACHINE_CONTROL_POINT = "Fitness Machine Control Point" FITNESS_MACHINE_STATUS = "Fitness Machine Status" + # Lighting characteristics + CHROMATICITY_COORDINATE = "Chromaticity Coordinate" + CORRELATED_COLOR_TEMPERATURE = "Correlated Color Temperature" # User Data Service characteristics ACTIVITY_GOAL = "Activity Goal" AEROBIC_HEART_RATE_LOWER_LIMIT = "Aerobic Heart Rate Lower Limit" @@ -407,6 +332,7 @@ class CharacteristicName(Enum): ANAEROBIC_HEART_RATE_UPPER_LIMIT = "Anaerobic Heart Rate Upper Limit" ANAEROBIC_THRESHOLD = "Anaerobic Threshold" CALORIC_INTAKE = "Caloric Intake" + DATABASE_CHANGE_INCREMENT = "Database Change Increment" DATE_OF_BIRTH = "Date of Birth" DATE_OF_THRESHOLD_ASSESSMENT = "Date of Threshold Assessment" DEVICE_WEARING_POSITION = "Device Wearing Position" @@ -434,9 +360,63 @@ class CharacteristicName(Enum): STRIDE_LENGTH = "Stride Length" THREE_ZONE_HEART_RATE_LIMITS = "Three Zone Heart Rate Limits" TWO_ZONE_HEART_RATE_LIMITS = "Two Zone Heart Rate Limits" + USER_INDEX = "User Index" VO2_MAX = "VO2 Max" WAIST_CIRCUMFERENCE = "Waist Circumference" WEIGHT = "Weight" + # Generic value characteristics + BOOLEAN = "Boolean" + COEFFICIENT = "Coefficient" + COUNT_16 = "Count 16" + COUNT_24 = "Count 24" + CHROMATICITY_TOLERANCE = "Chromaticity Tolerance" + CHROMATIC_DISTANCE_FROM_PLANCKIAN = "Chromatic Distance from Planckian" + CIE_13_3_1995_COLOR_RENDERING_INDEX = "CIE 13.3-1995 Color Rendering Index" + CONTACT_STATUS_8 = "Contact Status 8" + CONTENT_CONTROL_ID = "Content Control ID" + COSINE_OF_THE_ANGLE = "Cosine of the Angle" + COUNTRY_CODE = "Country Code" + DATE_UTC = "Date UTC" + DOOR_WINDOW_STATUS = "Door/Window Status" + ENERGY = "Energy" + ENERGY_32 = "Energy 32" + ESTIMATED_SERVICE_DATE = "Estimated Service Date" + FIXED_STRING_8 = "Fixed String 8" + FIXED_STRING_16 = "Fixed String 16" + FIXED_STRING_24 = "Fixed String 24" + FIXED_STRING_36 = "Fixed String 36" + FIXED_STRING_64 = "Fixed String 64" + GENERIC_LEVEL = "Generic Level" + GLOBAL_TRADE_ITEM_NUMBER = "Global Trade Item Number" + HIGH_TEMPERATURE = "High Temperature" + HUMIDITY_8 = "Humidity 8" + ILLUMINANCE_16 = "Illuminance 16" + LIGHT_DISTRIBUTION = "Light Distribution" + LIGHT_OUTPUT = "Light Output" + LIGHT_SOURCE_TYPE = "Light Source Type" + LUMINOUS_EFFICACY = "Luminous Efficacy" + LUMINOUS_ENERGY = "Luminous Energy" + LUMINOUS_EXPOSURE = "Luminous Exposure" + LUMINOUS_FLUX = "Luminous Flux" + LUMINOUS_INTENSITY = "Luminous Intensity" + MASS_FLOW = "Mass Flow" + PERCEIVED_LIGHTNESS = "Perceived Lightness" + PERCENTAGE_8 = "Percentage 8" + PERCENTAGE_8_STEPS = "Percentage 8 Steps" + POWER = "Power" + PUSHBUTTON_STATUS_8 = "Pushbutton Status 8" + SENSOR_LOCATION = "Sensor Location" + SULFUR_HEXAFLUORIDE_CONCENTRATION = "Sulfur Hexafluoride Concentration" + TEMPERATURE_8 = "Temperature 8" + TIME_DECIHOUR_8 = "Time Decihour 8" + TIME_EXPONENTIAL_8 = "Time Exponential 8" + TIME_HOUR_24 = "Time Hour 24" + TIME_MILLISECOND_24 = "Time Millisecond 24" + TIME_SECOND_8 = "Time Second 8" + TIME_SECOND_16 = "Time Second 16" + TIME_SECOND_32 = "Time Second 32" + TORQUE = "Torque" + VOLUME_FLOW = "Volume Flow" # Not implemented characteristics - listed for completeness ACS_CONTROL_POINT = "ACS Control Point" @@ -446,9 +426,6 @@ class CharacteristicName(Enum): ACS_STATUS = "ACS Status" AP_SYNC_KEY_MATERIAL = "AP Sync Key Material" ASE_CONTROL_POINT = "ASE Control Point" - ACCELERATION = "Acceleration" - ACCELERATION_3D = "Acceleration 3D" - ACCELERATION_DETECTION_STATUS = "Acceleration Detection Status" ACTIVE_PRESET_INDEX = "Active Preset Index" ADVERTISING_CONSTANT_TONE_EXTENSION_INTERVAL = "Advertising Constant Tone Extension Interval" ADVERTISING_CONSTANT_TONE_EXTENSION_MINIMUM_LENGTH = "Advertising Constant Tone Extension Minimum Length" @@ -458,9 +435,6 @@ class CharacteristicName(Enum): ADVERTISING_CONSTANT_TONE_EXTENSION_PHY = "Advertising Constant Tone Extension PHY" ADVERTISING_CONSTANT_TONE_EXTENSION_TRANSMIT_DURATION = "Advertising Constant Tone Extension Transmit Duration" AGGREGATE = "Aggregate" - ALTITUDE = "Altitude" - APPARENT_ENERGY_32 = "Apparent Energy 32" - APPARENT_POWER = "Apparent Power" AUDIO_INPUT_CONTROL_POINT = "Audio Input Control Point" AUDIO_INPUT_DESCRIPTION = "Audio Input Description" AUDIO_INPUT_STATE = "Audio Input State" @@ -474,7 +448,6 @@ class CharacteristicName(Enum): BR_EDR_HANDOVER_DATA = "BR-EDR Handover Data" BSS_CONTROL_POINT = "BSS Control Point" BSS_RESPONSE = "BSS Response" - BATTERY_CRITICAL_STATUS = "Battery Critical Status" BATTERY_ENERGY_STATUS = "Battery Energy Status" BATTERY_HEALTH_INFORMATION = "Battery Health Information" BATTERY_HEALTH_STATUS = "Battery Health Status" @@ -489,10 +462,6 @@ class CharacteristicName(Enum): BEARER_URI_SCHEMES_SUPPORTED_LIST = "Bearer URI Schemes Supported List" BLOOD_PRESSURE_RECORD = "Blood Pressure Record" BLUETOOTH_SIG_DATA = "Bluetooth SIG Data" - BOOLEAN = "Boolean" - BOOT_KEYBOARD_INPUT_REPORT = "Boot Keyboard Input Report" - BOOT_KEYBOARD_OUTPUT_REPORT = "Boot Keyboard Output Report" - BOOT_MOUSE_INPUT_REPORT = "Boot Mouse Input Report" BROADCAST_AUDIO_SCAN_CONTROL_POINT = "Broadcast Audio Scan Control Point" BROADCAST_RECEIVE_STATE = "Broadcast Receive State" CGM_FEATURE = "CGM Feature" @@ -501,48 +470,28 @@ class CharacteristicName(Enum): CGM_SESSION_START_TIME = "CGM Session Start Time" CGM_SPECIFIC_OPS_CONTROL_POINT = "CGM Specific Ops Control Point" CGM_STATUS = "CGM Status" - CIE_13_3_1995_COLOR_RENDERING_INDEX = "CIE 13.3-1995 Color Rendering Index" CALL_CONTROL_POINT = "Call Control Point" CALL_CONTROL_POINT_OPTIONAL_OPCODES = "Call Control Point Optional Opcodes" CALL_FRIENDLY_NAME = "Call Friendly Name" CALL_STATE = "Call State" - CARBON_MONOXIDE_CONCENTRATION = "Carbon Monoxide Concentration" CARDIORESPIRATORY_ACTIVITY_INSTANTANEOUS_DATA = "CardioRespiratory Activity Instantaneous Data" CARDIORESPIRATORY_ACTIVITY_SUMMARY_DATA = "CardioRespiratory Activity Summary Data" CENTRAL_ADDRESS_RESOLUTION = "Central Address Resolution" - CHROMATIC_DISTANCE_FROM_PLANCKIAN = "Chromatic Distance from Planckian" - CHROMATICITY_COORDINATE = "Chromaticity Coordinate" CHROMATICITY_COORDINATES = "Chromaticity Coordinates" - CHROMATICITY_TOLERANCE = "Chromaticity Tolerance" CHROMATICITY_IN_CCT_AND_DUV_VALUES = "Chromaticity in CCT and Duv Values" CLIENT_SUPPORTED_FEATURES = "Client Supported Features" - COEFFICIENT = "Coefficient" CONSTANT_TONE_EXTENSION_ENABLE = "Constant Tone Extension Enable" - CONTACT_STATUS_8 = "Contact Status 8" - CONTENT_CONTROL_ID = "Content Control ID" COORDINATED_SET_SIZE = "Coordinated Set Size" - CORRELATED_COLOR_TEMPERATURE = "Correlated Color Temperature" - COSINE_OF_THE_ANGLE = "Cosine of the Angle" - COUNT_16 = "Count 16" - COUNT_24 = "Count 24" - COUNTRY_CODE = "Country Code" CURRENT_ELAPSED_TIME = "Current Elapsed Time" CURRENT_GROUP_OBJECT_ID = "Current Group Object ID" CURRENT_TRACK_OBJECT_ID = "Current Track Object ID" CURRENT_TRACK_SEGMENTS_OBJECT_ID = "Current Track Segments Object ID" - DST_OFFSET = "DST Offset" - DATABASE_CHANGE_INCREMENT = "Database Change Increment" DATABASE_HASH = "Database Hash" - DATE_TIME = "Date Time" - DATE_UTC = "Date UTC" - DAY_DATE_TIME = "Day Date Time" - DAY_OF_WEEK = "Day of Week" DESCRIPTOR_VALUE_CHANGED = "Descriptor Value Changed" DEVICE_TIME = "Device Time" DEVICE_TIME_CONTROL_POINT = "Device Time Control Point" DEVICE_TIME_FEATURE = "Device Time Feature" DEVICE_TIME_PARAMETERS = "Device Time Parameters" - DOOR_WINDOW_STATUS = "Door/Window Status" ESL_ADDRESS = "ESL Address" ESL_CONTROL_POINT = "ESL Control Point" ESL_CURRENT_ABSOLUTE_TIME = "ESL Current Absolute Time" @@ -554,29 +503,16 @@ class CharacteristicName(Enum): EMERGENCY_ID = "Emergency ID" EMERGENCY_TEXT = "Emergency Text" ENCRYPTED_DATA_KEY_MATERIAL = "Encrypted Data Key Material" - ENERGY = "Energy" - ENERGY_32 = "Energy 32" ENERGY_IN_A_PERIOD_OF_DAY = "Energy in a Period of Day" ENHANCED_BLOOD_PRESSURE_MEASUREMENT = "Enhanced Blood Pressure Measurement" ENHANCED_INTERMEDIATE_CUFF_PRESSURE = "Enhanced Intermediate Cuff Pressure" - ESTIMATED_SERVICE_DATE = "Estimated Service Date" EVENT_STATISTICS = "Event Statistics" - EXACT_TIME_256 = "Exact Time 256" FIRST_USE_DATE = "First Use Date" - FIXED_STRING_16 = "Fixed String 16" - FIXED_STRING_24 = "Fixed String 24" - FIXED_STRING_36 = "Fixed String 36" - FIXED_STRING_64 = "Fixed String 64" - FIXED_STRING_8 = "Fixed String 8" - FORCE = "Force" GHS_CONTROL_POINT = "GHS Control Point" GMAP_ROLE = "GMAP Role" GAIN_SETTINGS_ATTRIBUTE = "Gain Settings Attribute" GENERAL_ACTIVITY_INSTANTANEOUS_DATA = "General Activity Instantaneous Data" GENERAL_ACTIVITY_SUMMARY_DATA = "General Activity Summary Data" - GENERIC_LEVEL = "Generic Level" - GLOBAL_TRADE_ITEM_NUMBER = "Global Trade Item Number" - GUST_FACTOR = "Gust Factor" HID_ISO_PROPERTIES = "HID ISO Properties" HTTP_CONTROL_POINT = "HTTP Control Point" HTTP_ENTITY_BODY = "HTTP Entity Body" @@ -586,9 +522,6 @@ class CharacteristicName(Enum): HEALTH_SENSOR_FEATURES = "Health Sensor Features" HEARING_AID_FEATURES = "Hearing Aid Features" HEARING_AID_PRESET_CONTROL_POINT = "Hearing Aid Preset Control Point" - HEART_RATE_CONTROL_POINT = "Heart Rate Control Point" - HIGH_TEMPERATURE = "High Temperature" - HUMIDITY_8 = "Humidity 8" IDD_ANNUNCIATION_STATUS = "IDD Annunciation Status" IDD_COMMAND_CONTROL_POINT = "IDD Command Control Point" IDD_COMMAND_DATA = "IDD Command Data" @@ -603,30 +536,14 @@ class CharacteristicName(Enum): IMD_HISTORICAL_DATA = "IMD Historical Data" IMD_STATUS = "IMD Status" IMDS_DESCRIPTOR_VALUE_CHANGED = "IMDS Descriptor Value Changed" - ILLUMINANCE_16 = "Illuminance 16" INCOMING_CALL = "Incoming Call" INCOMING_CALL_TARGET_BEARER_URI = "Incoming Call Target Bearer URI" - INTERMEDIATE_TEMPERATURE = "Intermediate Temperature" - IRRADIANCE = "Irradiance" LE_GATT_SECURITY_LEVELS = "LE GATT Security Levels" LE_HID_OPERATION_MODE = "LE HID Operation Mode" LENGTH = "Length" LIFE_CYCLE_DATA = "Life Cycle Data" - LIGHT_DISTRIBUTION = "Light Distribution" - LIGHT_OUTPUT = "Light Output" - LIGHT_SOURCE_TYPE = "Light Source Type" - LINEAR_POSITION = "Linear Position" LIVE_HEALTH_OBSERVATIONS = "Live Health Observations" - LOCAL_EAST_COORDINATE = "Local East Coordinate" - LOCAL_NORTH_COORDINATE = "Local North Coordinate" - LUMINOUS_EFFICACY = "Luminous Efficacy" - LUMINOUS_ENERGY = "Luminous Energy" - LUMINOUS_EXPOSURE = "Luminous Exposure" - LUMINOUS_FLUX = "Luminous Flux" LUMINOUS_FLUX_RANGE = "Luminous Flux Range" - LUMINOUS_INTENSITY = "Luminous Intensity" - MASS_FLOW = "Mass Flow" - MEASUREMENT_INTERVAL = "Measurement Interval" MEDIA_CONTROL_POINT = "Media Control Point" MEDIA_CONTROL_POINT_OPCODES_SUPPORTED = "Media Control Point Opcodes Supported" MEDIA_PLAYER_ICON_OBJECT_ID = "Media Player Icon Object ID" @@ -654,11 +571,6 @@ class CharacteristicName(Enum): OBSERVATION_SCHEDULE_CHANGED = "Observation Schedule Changed" ON_DEMAND_RANGING_DATA = "On-demand Ranging Data" PARENT_GROUP_OBJECT_ID = "Parent Group Object ID" - PERCEIVED_LIGHTNESS = "Perceived Lightness" - PERCENTAGE_8 = "Percentage 8" - PERCENTAGE_8_STEPS = "Percentage 8 Steps" - PERIPHERAL_PREFERRED_CONNECTION_PARAMETERS = "Peripheral Preferred Connection Parameters" - PERIPHERAL_PRIVACY_FLAG = "Peripheral Privacy Flag" PHYSICAL_ACTIVITY_CURRENT_SESSION = "Physical Activity Current Session" PHYSICAL_ACTIVITY_MONITOR_CONTROL_POINT = "Physical Activity Monitor Control Point" PHYSICAL_ACTIVITY_MONITOR_FEATURES = "Physical Activity Monitor Features" @@ -666,10 +578,7 @@ class CharacteristicName(Enum): PLAYBACK_SPEED = "Playback Speed" PLAYING_ORDER = "Playing Order" PLAYING_ORDERS_SUPPORTED = "Playing Orders Supported" - PNP_ID = "PnP ID" - POWER = "Power" PRECISE_ACCELERATION_3D = "Precise Acceleration 3D" - PUSHBUTTON_STATUS_8 = "Pushbutton Status 8" RAS_CONTROL_POINT = "RAS Control Point" RAS_FEATURES = "RAS Features" RC_FEATURE = "RC Feature" @@ -677,7 +586,6 @@ class CharacteristicName(Enum): RANGING_DATA_OVERWRITTEN = "Ranging Data Overwritten" RANGING_DATA_READY = "Ranging Data Ready" REAL_TIME_RANGING_DATA = "Real-time Ranging Data" - RECONNECTION_ADDRESS = "Reconnection Address" RECONNECTION_CONFIGURATION_CONTROL_POINT = "Reconnection Configuration Control Point" RECORD_ACCESS_CONTROL_POINT = "Record Access Control Point" REGISTERED_USER = "Registered User" @@ -691,13 +599,10 @@ class CharacteristicName(Enum): RELATIVE_VALUE_IN_A_VOLTAGE_RANGE = "Relative Value in a Voltage Range" RELATIVE_VALUE_IN_AN_ILLUMINANCE_RANGE = "Relative Value in an Illuminance Range" RESOLVABLE_PRIVATE_ADDRESS_ONLY = "Resolvable Private Address Only" - ROTATIONAL_SPEED = "Rotational Speed" SC_CONTROL_POINT = "SC Control Point" - SCAN_REFRESH = "Scan Refresh" SEARCH_CONTROL_POINT = "Search Control Point" SEARCH_RESULTS_OBJECT_ID = "Search Results Object ID" SEEKING_SPEED = "Seeking Speed" - SENSOR_LOCATION = "Sensor Location" SERVER_SUPPORTED_FEATURES = "Server Supported Features" SERVICE_CYCLE_DATA = "Service Cycle Data" SET_IDENTITY_RESOLVING_KEY = "Set Identity Resolving Key" @@ -714,29 +619,15 @@ class CharacteristicName(Enum): STATUS_FLAGS = "Status Flags" STEP_COUNTER_ACTIVITY_SUMMARY_DATA = "Step Counter Activity Summary Data" STORED_HEALTH_OBSERVATIONS = "Stored Health Observations" - SULFUR_HEXAFLUORIDE_CONCENTRATION = "Sulfur Hexafluoride Concentration" SUPPORTED_AUDIO_CONTEXTS = "Supported Audio Contexts" - SYSTEM_ID = "System ID" TDS_CONTROL_POINT = "TDS Control Point" TMAP_ROLE = "TMAP Role" - TEMPERATURE_8 = "Temperature 8" TEMPERATURE_8_STATISTICS = "Temperature 8 Statistics" TEMPERATURE_8_IN_A_PERIOD_OF_DAY = "Temperature 8 in a Period of Day" TEMPERATURE_RANGE = "Temperature Range" TEMPERATURE_STATISTICS = "Temperature Statistics" - TEMPERATURE_TYPE = "Temperature Type" TERMINATION_REASON = "Termination Reason" - TIME_ACCURACY = "Time Accuracy" TIME_CHANGE_LOG_DATA = "Time Change Log Data" - TIME_DECIHOUR_8 = "Time Decihour 8" - TIME_EXPONENTIAL_8 = "Time Exponential 8" - TIME_HOUR_24 = "Time Hour 24" - TIME_MILLISECOND_24 = "Time Millisecond 24" - TIME_SECOND_16 = "Time Second 16" - TIME_SECOND_32 = "Time Second 32" - TIME_SECOND_8 = "Time Second 8" - TIME_SOURCE = "Time Source" - TORQUE = "Torque" TRACK_CHANGED = "Track Changed" TRACK_DURATION = "Track Duration" TRACK_POSITION = "Track Position" @@ -745,12 +636,9 @@ class CharacteristicName(Enum): UGG_FEATURES = "UGG Features" UGT_FEATURES = "UGT Features" URI = "URI" - UNCERTAINTY = "Uncertainty" USER_CONTROL_POINT = "User Control Point" - USER_INDEX = "User Index" VOLUME_CONTROL_POINT = "Volume Control Point" VOLUME_FLAGS = "Volume Flags" - VOLUME_FLOW = "Volume Flow" VOLUME_OFFSET_CONTROL_POINT = "Volume Offset Control Point" VOLUME_OFFSET_STATE = "Volume Offset State" VOLUME_STATE = "Volume State" diff --git a/tests/README.md b/tests/README.md index ea2ca21c..16920ca3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,75 +8,183 @@ The tests are organized into logical directories that map directly to the source ```text tests/ -├── conftest.py # Pytest configuration and fixtures +├── conftest.py # Pytest configuration and fixtures +├── test_browse_groups_registry.py # Browse group registry validation +├── test_declarations_registry.py # Declarations registry validation +├── test_members_registry.py # Members registry validation +├── test_mesh_profiles_registry.py # Mesh profiles registry validation +├── test_object_types_registry.py # Object types registry validation +├── test_sdo_uuids_registry.py # SDO UUIDs registry validation +├── test_service_classes_registry.py # Service classes registry validation +├── test_units_registry.py # Units registry validation │ -├── core/ # Core framework functionality -│ └── test_translator.py # BluetoothSIGTranslator tests +├── advertising/ # BLE advertising data tests +│ ├── test_advertisement_builder.py +│ ├── test_advertising_framework.py +│ ├── test_channel_map_update.py +│ ├── test_ead_decryption.py +│ ├── test_ead_decryptor_class.py +│ ├── test_encryption_provider.py +│ ├── test_exceptions.py +│ ├── test_indoor_positioning.py +│ ├── test_payload_interpreter.py +│ ├── test_pdu_parser_32bit_uuids.py +│ ├── test_pdu_parser_solicited.py +│ ├── test_service_data_parser.py +│ ├── test_service_resolver.py +│ ├── test_sig_characteristic_interpreter.py +│ ├── test_state.py +│ ├── test_three_d_information.py +│ └── test_transport_discovery.py │ -├── gatt/ # GATT layer tests -│ ├── characteristics/ # Individual characteristic implementations -│ │ ├── test_base_characteristic.py -│ │ ├── test_battery_power_state.py -│ │ ├── test_custom_characteristics.py -│ │ ├── test_cycling_power.py -│ │ ├── test_environmental_sensors.py -│ │ ├── test_gas_sensors.py -│ │ ├── test_glucose_monitoring.py -│ │ └── test_new_sensors.py -│ ├── services/ # Service implementations -│ │ ├── test_body_composition_service.py -│ │ ├── test_custom_services.py -│ │ └── test_weight_scale_service.py -│ ├── test_context.py # GATT context parsing +├── benchmarks/ # Performance benchmarks (--benchmark-only) +│ ├── conftest.py +│ ├── test_comparison.py # Library vs manual parsing +│ └── test_performance.py # UUID resolution, parsing throughput +│ +├── core/ # Core framework functionality +│ ├── test_async_translator.py # Async translator tests +│ ├── test_translator.py # BluetoothSIGTranslator tests +│ └── test_translator_encoding.py # Encoding round-trip tests +│ +├── descriptors/ # GATT descriptor implementations +│ ├── conftest.py +│ ├── test_aggregate_format.py +│ ├── test_base.py +│ ├── test_cccd.py +│ ├── test_complete_br_edr_transport_block.py +│ ├── test_environmental_sensing.py +│ ├── test_extended_properties.py +│ ├── test_external_report_reference.py +│ ├── test_imd_trigger_setting.py +│ ├── test_integration.py +│ ├── test_manufacturer_limits.py +│ ├── test_measurement_description.py +│ ├── test_number_of_digitals.py +│ ├── test_observation_schedule.py +│ ├── test_presentation_format.py +│ ├── test_process_tolerances.py +│ ├── test_registry.py +│ ├── test_report_reference.py +│ ├── test_sccd.py +│ ├── test_time_trigger_setting.py +│ ├── test_user_description.py +│ ├── test_valid_range.py +│ ├── test_valid_range_and_accuracy.py +│ ├── test_value_trigger_setting.py +│ └── test_writability.py +│ +├── device/ # Device management and advertising +│ ├── test_advertising_appearance.py # Appearance parsing +│ ├── test_advertising_cod_integration.py # Class of Device integration +│ ├── test_advertising_parser.py # BLE advertising data parsing +│ ├── test_device.py # Device class functionality +│ ├── test_device_advertising.py # Device advertising features +│ ├── test_device_async_methods.py # Async device methods +│ ├── test_device_batch_ops.py # Batch operations +│ ├── test_device_types.py # Device type definitions +│ ├── test_peripheral_device.py # Peripheral device (GATT server) +│ └── test_peripheral_types.py # Peripheral type definitions +│ +├── diagnostics/ # Logging and diagnostics +│ ├── test_field_errors.py # Field-level error handling +│ ├── test_field_level_diagnostics.py +│ └── test_logging.py # General logging functionality +│ +├── docs/ # Documentation tests +│ ├── conftest.py # Shared fixtures for docs tests +│ ├── test_docs_code_blocks.py # Code block validation +│ ├── test_generate_diagrams.py # Diagram generation tests +│ ├── test_readme_badges.py # README badge validation +│ ├── test_sidebar_structure.py # Sidebar structure validation +│ ├── test_source_content.py # Markdown source validation +│ ├── html/ # Static HTML validation (BeautifulSoup) +│ │ ├── conftest.py +│ │ ├── test_accessibility_static.py +│ │ ├── test_content_quality_static.py +│ │ └── test_structure_static.py +│ └── playwright_tests/ # Interactive browser tests (Chromium) +│ ├── conftest.py +│ ├── test_accessibility.py +│ ├── test_diataxis_structure.py +│ ├── test_documentation_quality.py +│ ├── test_navigation.py +│ └── test_sidebar_content.py +│ +├── gatt/ # GATT layer tests +│ ├── test_context.py # GATT context parsing │ ├── test_exceptions.py # GATT exceptions │ ├── test_resolver.py # UUID resolution │ ├── test_service_validation.py # Service validation │ ├── test_service_validation_extended.py +│ ├── test_special_values_resolver.py # Special values handling +│ ├── test_special_values_resolver_priority.py │ ├── test_uuid_registry.py # UUID registry functionality -│ └── test_validation.py # General GATT validation +│ ├── test_validation.py # General GATT validation +│ ├── characteristics/ # Individual characteristic tests (194 files) +│ │ ├── conftest.py +│ │ ├── test_base_characteristic.py +│ │ ├── test_characteristic_common.py +│ │ ├── test_characteristic_role.py +│ │ ├── test_characteristic_test_coverage.py +│ │ ├── test_custom_characteristics.py +│ │ ├── test_templates.py +│ │ ├── test_.py # One file per implemented characteristic +│ │ └── utils/ # Characteristic utility tests +│ │ ├── test_bit_field_utils.py +│ │ ├── test_data_parser.py +│ │ ├── test_data_validator.py +│ │ └── test_ieee11073_parser.py +│ └── services/ # Service implementation tests (30 files) +│ ├── test_service_common.py +│ ├── test_service_coverage.py +│ ├── test_custom_services.py +│ └── test__service.py # One file per implemented service │ -├── device/ # Device management and advertising -│ ├── test_advertising_parser.py # BLE advertising data parsing -│ ├── test_device.py # Device class functionality -│ └── test_device_types.py # Device type definitions +├── integration/ # End-to-end and comprehensive scenarios +│ ├── test_auto_registration.py # Auto-registration tests +│ ├── test_basic_scenarios.py # Basic integration scenarios +│ ├── test_connection_managers.py # Connection manager tests +│ ├── test_custom_registration.py # Custom characteristic/service registration +│ ├── test_end_to_end.py # Multi-characteristic parsing +│ ├── test_examples.py # Example usage scenarios +│ └── test_format_types_integration.py # Format type integration │ -├── registry/ # YAML registry and UUID resolution +├── registry/ # YAML registry and UUID resolution +│ ├── core/ # Core registry type tests +│ │ ├── test_coding_format.py +│ │ ├── test_formattypes.py +│ │ ├── test_namespace_description.py +│ │ └── test_uri_schemes.py +│ ├── test_ad_types.py # AD type registry +│ ├── test_appearance_values.py # Appearance value lookups +│ ├── test_class_of_device.py # Class of Device parsing +│ ├── test_company_identifiers.py # Company identifier lookups +│ ├── test_permitted_characteristics.py +│ ├── test_profile_lookup.py # Profile lookups +│ ├── test_protocol_identifiers.py # Protocol identifier lookups +│ ├── test_registry_accessors.py # Registry accessor patterns │ ├── test_registry_validation.py # Registry validation +│ ├── test_service_discovery_attributes.py +│ ├── test_uri_schemes.py # URI scheme lookups +│ ├── test_utils.py # Registry utilities │ ├── test_yaml_cross_reference.py # YAML cross-reference functionality │ └── test_yaml_units.py # YAML unit definitions │ -├── utils/ # Utility functions and helpers -│ ├── test_bit_field_utils.py # Bit field manipulation utilities -│ ├── test_performance_tracking.py # Performance monitoring -│ └── test_profiling.py # Profiling utilities +├── static_analysis/ # Static analysis and completeness checks +│ ├── test_characteristic_registry_completeness.py +│ ├── test_service_registry_completeness.py +│ └── test_yaml_implementation_coverage.py │ -├── integration/ # End-to-end and comprehensive scenarios -│ ├── test_basic_scenarios.py # Basic integration scenarios -│ ├── test_custom_registration.py # Custom characteristic/service registration -│ ├── test_end_to_end.py # Multi-characteristic parsing -│ ├── test_examples.py # Example usage scenarios -│ └── test_round_trip.py # Round-trip encoding/decoding +├── stream/ # Stream processing tests +│ └── test_pairing.py │ -├── diagnostics/ # Logging and diagnostics -│ ├── test_field_errors.py # Field-level error handling -│ ├── test_field_level_diagnostics.py -│ └── test_logging.py # General logging functionality +├── types/ # Type system tests +│ ├── test_company.py # Company identifier types +│ └── test_gatt_enums.py # GATT enum validation │ -└── docs/ # Documentation tests - ├── conftest.py # Shared fixtures for docs tests - ├── test_source_content.py # Markdown source validation - ├── test_generate_diagrams.py # Diagram generation tests - ├── test_sidebar_structure.py # Sidebar structure validation - ├── html/ # Static HTML validation (BeautifulSoup) - │ ├── test_accessibility_static.py - │ ├── test_structure_static.py - │ └── test_content_quality_static.py - └── playwright_tests/ # Interactive browser tests - ├── test_accessibility.py - ├── test_navigation.py - ├── test_diataxis_structure.py - ├── test_sidebar_content.py - └── test_documentation_quality.py +└── utils/ # Utility functions and helpers + └── test_profiling.py # Profiling utilities ``` ## Getting Started @@ -116,13 +224,19 @@ python -m pytest tests/docs/playwright_tests/ -v -n auto -m "built_docs and play ### 1. Clear Separation of Concerns -- **Core**: Framework-level functionality (translator) -- **GATT**: GATT-specific functionality split between characteristics and services -- **Device**: Device management and advertising functionality -- **Registry**: YAML/UUID registry functionality +- **Core**: Framework-level functionality (translator, async translator, encoding) +- **GATT**: GATT-specific functionality split between characteristics, services, and utilities +- **Advertising**: BLE advertising data parsing, EAD decryption, PDU parsing +- **Descriptors**: GATT descriptor implementations (CCCD, presentation format, valid range, etc.) +- **Device**: Device management, advertising, and peripheral device functionality +- **Registry**: YAML/UUID registry functionality and core registry types +- **Types**: Type system validation (company identifiers, GATT enums) +- **Stream**: Stream processing and pairing tests - **Utils**: Utility functions that don't fit elsewhere - **Integration**: End-to-end scenarios and comprehensive tests - **Diagnostics**: Logging and error handling tests +- **Static Analysis**: Registry completeness and YAML implementation coverage checks +- **Benchmarks**: Performance and comparison benchmarks (run with `--benchmark-only`) - **Docs**: Documentation validation (Markdown source, HTML parsing, and Playwright browser tests) ### 2. Hierarchical Organization diff --git a/tests/advertising/test_indoor_positioning.py b/tests/advertising/test_indoor_positioning.py index e18bc62d..99272300 100644 --- a/tests/advertising/test_indoor_positioning.py +++ b/tests/advertising/test_indoor_positioning.py @@ -297,4 +297,4 @@ def test_location_flags_mask_covers_all_coordinate_bits(self) -> None: def test_coordinate_system_local_is_bit_zero(self) -> None: """COORDINATE_SYSTEM_LOCAL is bit 0 per CSS Part A §1.14.""" - assert IndoorPositioningConfig.COORDINATE_SYSTEM_LOCAL == 0x01 + assert IndoorPositioningConfig.COORDINATE_SYSTEM_LOCAL.value == 0x01 diff --git a/tests/core/test_translator_encoding.py b/tests/core/test_translator_encoding.py index 63a1609a..595a85b3 100644 --- a/tests/core/test_translator_encoding.py +++ b/tests/core/test_translator_encoding.py @@ -5,7 +5,6 @@ import pytest from bluetooth_sig import BluetoothSIGTranslator -from bluetooth_sig.types.gatt_enums import ValueType class TestEncodeCharacteristic: @@ -84,21 +83,24 @@ def test_get_value_type_int(self) -> None: translator = BluetoothSIGTranslator() value_type = translator.get_value_type("2A19") # Battery Level - assert value_type == ValueType.INT + assert value_type is int def test_get_value_type_string(self) -> None: """Test getting value type for string characteristic.""" translator = BluetoothSIGTranslator() value_type = translator.get_value_type("2A00") # Device Name - assert value_type == ValueType.STRING + assert value_type is str def test_get_value_type_various(self) -> None: """Test getting value type for complex characteristic.""" translator = BluetoothSIGTranslator() value_type = translator.get_value_type("2A37") # Heart Rate Measurement - assert value_type == ValueType.VARIOUS + # HeartRateData is auto-resolved from the generic parameter BaseCharacteristic[HeartRateData] + from bluetooth_sig.gatt.characteristics.heart_rate_measurement import HeartRateData + + assert value_type is HeartRateData def test_get_value_type_invalid_uuid(self) -> None: """Test getting value type with invalid UUID.""" diff --git a/tests/descriptors/test_integration.py b/tests/descriptors/test_integration.py index 97f9df7b..024e6b26 100644 --- a/tests/descriptors/test_integration.py +++ b/tests/descriptors/test_integration.py @@ -6,7 +6,6 @@ from bluetooth_sig.gatt.context import CharacteristicContext from bluetooth_sig.gatt.descriptors import CCCDDescriptor from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -22,7 +21,7 @@ class MockCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789012"), name="Test Characteristic", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -56,7 +55,7 @@ class MockCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789013"), name="Test Characteristic 2", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( diff --git a/tests/diagnostics/test_field_errors.py b/tests/diagnostics/test_field_errors.py index a07c4b43..eb6ab8ca 100644 --- a/tests/diagnostics/test_field_errors.py +++ b/tests/diagnostics/test_field_errors.py @@ -13,7 +13,6 @@ from bluetooth_sig.gatt.context import CharacteristicContext from bluetooth_sig.gatt.exceptions import ParseFieldError as ParseFieldException from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -24,7 +23,7 @@ class LoggingTestCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("CCCCCCCC-1234-1234-1234-123456789012"), name="Logging Test Characteristic", unit="test", - value_type=ValueType.DICT, + python_type=dict, ) def _decode_value( @@ -168,7 +167,7 @@ class MultiErrorCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("DDDDDDDD-1234-1234-1234-123456789012"), name="Multi Error Test", unit="test", - value_type=ValueType.DICT, + python_type=dict, ) def _decode_value( diff --git a/tests/diagnostics/test_field_level_diagnostics.py b/tests/diagnostics/test_field_level_diagnostics.py index 3c5997b5..1823bf5e 100644 --- a/tests/diagnostics/test_field_level_diagnostics.py +++ b/tests/diagnostics/test_field_level_diagnostics.py @@ -15,7 +15,6 @@ from bluetooth_sig.gatt.context import CharacteristicContext from bluetooth_sig.gatt.exceptions import ParseFieldError as ParseFieldException from bluetooth_sig.types import CharacteristicInfo, ParseFieldError -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -26,7 +25,7 @@ class MultiFieldCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("AAAAAAAA-1234-1234-1234-123456789012"), name="Multi Field Test", unit="various", - value_type=ValueType.DICT, + python_type=dict, ) def _decode_value( @@ -291,7 +290,7 @@ class GenericErrorCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("BBBBBBBB-1234-1234-1234-123456789012"), name="Generic Error Test", unit="", - value_type=ValueType.INT, + python_type=int, ) expected_type: type | None = int @@ -382,7 +381,7 @@ class NoTraceCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("FFFFFFFF-1234-1234-1234-123456789012"), name="No Trace Test", unit="test", - value_type=ValueType.INT, + python_type=int, ) # Disable trace collection for performance @@ -423,7 +422,7 @@ class DefaultTraceCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("EEEEEEEE-1234-1234-1234-123456789012"), name="Default Trace Test", unit="test", - value_type=ValueType.INT, + python_type=int, ) # Don't set _enable_parse_trace - should default to True @@ -464,7 +463,7 @@ class EnvTraceCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("EEEEEEEE-1234-1234-1234-123456789012"), name="Environment Trace Test", unit="", - value_type=ValueType.INT, + python_type=int, ) min_length = 1 diff --git a/tests/gatt/characteristics/test_altitude.py b/tests/gatt/characteristics/test_altitude.py index 7329d822..b4a5ba7c 100644 --- a/tests/gatt/characteristics/test_altitude.py +++ b/tests/gatt/characteristics/test_altitude.py @@ -42,7 +42,7 @@ def test_altitude_parsing(self, characteristic: AltitudeCharacteristic) -> None: """Test Altitude characteristic parsing.""" # Test metadata assert characteristic.unit == "m" - assert characteristic.value_type.value == "float" + assert characteristic.python_type is float # Test normal parsing test_data = bytearray([0xED, 0x03]) # 1005 = 100.5m diff --git a/tests/gatt/characteristics/test_ammonia_concentration.py b/tests/gatt/characteristics/test_ammonia_concentration.py index facf5198..72c6a996 100644 --- a/tests/gatt/characteristics/test_ammonia_concentration.py +++ b/tests/gatt/characteristics/test_ammonia_concentration.py @@ -41,4 +41,4 @@ def test_ammonia_concentration_parsing(self, characteristic: AmmoniaConcentratio """Test ammonia concentration characteristic parsing.""" # Test metadata - Updated for SIG spec compliance (medfloat16, kg/m³) assert characteristic.unit == "kg/m³" - assert characteristic.value_type_resolved.value == "float" # YAML specifies medfloat16 format + assert characteristic.python_type is float # YAML specifies medfloat16 format diff --git a/tests/gatt/characteristics/test_barometric_pressure_trend.py b/tests/gatt/characteristics/test_barometric_pressure_trend.py index 14eb7f9e..2992a0f4 100644 --- a/tests/gatt/characteristics/test_barometric_pressure_trend.py +++ b/tests/gatt/characteristics/test_barometric_pressure_trend.py @@ -55,7 +55,7 @@ def test_barometric_pressure_trend_parsing(self, characteristic: BarometricPress """Test Barometric Pressure Trend characteristic parsing.""" # Test metadata assert characteristic.unit == "" # Enum, no units - assert characteristic._manual_value_type == "BarometricPressureTrend" + assert characteristic.python_type is BarometricPressureTrend # Test known trend values test_cases = [ diff --git a/tests/gatt/characteristics/test_base_characteristic.py b/tests/gatt/characteristics/test_base_characteristic.py index 7a8eeb22..aa75d617 100644 --- a/tests/gatt/characteristics/test_base_characteristic.py +++ b/tests/gatt/characteristics/test_base_characteristic.py @@ -8,7 +8,6 @@ from bluetooth_sig.gatt.context import CharacteristicContext from bluetooth_sig.gatt.exceptions import CharacteristicParseError from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -25,7 +24,7 @@ class ValidationHelperCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789012"), name="Test Validation", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: @@ -46,7 +45,7 @@ class NoValidationCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789013"), name="No Validation", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: @@ -106,7 +105,7 @@ class MinValueCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789014"), name="Min Value Test", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -135,7 +134,7 @@ class TypeValidationCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789015"), name="Type Test", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -173,7 +172,7 @@ class MinLengthCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789016"), name="Min Length Test", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -205,7 +204,7 @@ class MaxLengthCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789017"), name="Max Length Test", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -235,7 +234,7 @@ class ExceptionCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789018"), name="Exception Test", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -263,7 +262,7 @@ class StructErrorCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789019"), name="Struct Error Test", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -348,7 +347,7 @@ class TypeMismatchCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789020"), name="Type Mismatch", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( diff --git a/tests/gatt/characteristics/test_characteristic_role.py b/tests/gatt/characteristics/test_characteristic_role.py index 72889cf4..b0a5a650 100644 --- a/tests/gatt/characteristics/test_characteristic_role.py +++ b/tests/gatt/characteristics/test_characteristic_role.py @@ -11,7 +11,7 @@ from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic from bluetooth_sig.gatt.characteristics.registry import CharacteristicRegistry -from bluetooth_sig.types.gatt_enums import CharacteristicRole, ValueType +from bluetooth_sig.types.gatt_enums import CharacteristicRole # --------------------------------------------------------------------------- # Helper — instantiate a registered characteristic by its SIG name @@ -64,7 +64,8 @@ class TestMeasurementRole: def test_measurement_characteristics(self, sig_name: str) -> None: char = _get_char(sig_name) assert char.role == CharacteristicRole.MEASUREMENT, ( - f"{sig_name} expected MEASUREMENT, got {char.role.value} (vtype={char.value_type.name}, unit={char.unit!r})" + f"{sig_name} expected MEASUREMENT, got {char.role.value}" + f" (python_type={char.python_type}, unit={char.unit!r})" ) def test_measurement_interval_by_name(self) -> None: @@ -199,7 +200,7 @@ def test_feature_bitfield_type_beats_unknown(self) -> None: in the name. """ char = _get_char("Body Composition Feature") - assert char.value_type == ValueType.BITFIELD + assert char.is_bitfield assert char.role == CharacteristicRole.FEATURE diff --git a/tests/gatt/characteristics/test_characteristic_test_coverage.py b/tests/gatt/characteristics/test_characteristic_test_coverage.py index b02b16f5..c507568b 100644 --- a/tests/gatt/characteristics/test_characteristic_test_coverage.py +++ b/tests/gatt/characteristics/test_characteristic_test_coverage.py @@ -35,6 +35,7 @@ def test_characteristic_test_coverage(self) -> None: "test_characteristic_role.py", # Tests role classification, not a single characteristic "test_characteristic_test_coverage.py", # This coverage test "test_custom_characteristics.py", # Tests custom characteristic functionality + "test_python_type_auto_resolution.py", # Tests python_type auto-resolution mechanism "test_templates.py", # Tests template classes, not characteristics } for test_file in test_dir.glob("test_*.py"): diff --git a/tests/gatt/characteristics/test_chromatic_distance_from_planckian.py b/tests/gatt/characteristics/test_chromatic_distance_from_planckian.py new file mode 100644 index 00000000..56df819d --- /dev/null +++ b/tests/gatt/characteristics/test_chromatic_distance_from_planckian.py @@ -0,0 +1,46 @@ +"""Tests for ChromaticDistanceFromPlanckian characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ChromaticDistanceFromPlanckianCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestChromaticDistanceFromPlanckianCharacteristic(CommonCharacteristicTests): + """Test suite for ChromaticDistanceFromPlanckian characteristic.""" + + @pytest.fixture + def characteristic(self) -> ChromaticDistanceFromPlanckianCharacteristic: + """Provide ChromaticDistanceFromPlanckian characteristic.""" + return ChromaticDistanceFromPlanckianCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for ChromaticDistanceFromPlanckian.""" + return "2AE3" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for ChromaticDistanceFromPlanckian.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=0.0, + description="zero", + ), + CharacteristicTestData( + input_data=bytearray([0x64, 0x00]), + expected_value=0.001, + description="100 * 1e-5 = 0.001", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=1e-05, + description="1 * 1e-5", + ), + ] diff --git a/tests/gatt/characteristics/test_chromaticity_coordinates.py b/tests/gatt/characteristics/test_chromaticity_coordinates.py new file mode 100644 index 00000000..8f88b0dc --- /dev/null +++ b/tests/gatt/characteristics/test_chromaticity_coordinates.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ChromaticityCoordinatesCharacteristic +from bluetooth_sig.gatt.characteristics.chromaticity_coordinates import ( + ChromaticityCoordinatesData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + +# Chromaticity coordinate resolution per BLE spec +_RESOLUTION = 2**-16 + + +class TestChromaticityCoordinatesCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> ChromaticityCoordinatesCharacteristic: + return ChromaticityCoordinatesCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AE4" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00]), + expected_value=ChromaticityCoordinatesData(x=0.0, y=0.0), + description="Zero coordinates", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF, 0xFF, 0xFF]), + expected_value=ChromaticityCoordinatesData(x=0xFFFF * _RESOLUTION, y=0xFFFF * _RESOLUTION), + description="Maximum coordinates", + ), + CharacteristicTestData( + # x=0x8000 (32768), y=0x4000 (16384) + input_data=bytearray([0x00, 0x80, 0x00, 0x40]), + expected_value=ChromaticityCoordinatesData(x=0x8000 * _RESOLUTION, y=0x4000 * _RESOLUTION), + description="Mid-range coordinates", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = ChromaticityCoordinatesCharacteristic() + original = ChromaticityCoordinatesData(x=0.3127, y=0.3290) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert abs(decoded.x - original.x) < _RESOLUTION + assert abs(decoded.y - original.y) < _RESOLUTION + + def test_validation_rejects_negative(self) -> None: + """Negative coordinates are invalid.""" + with pytest.raises(ValueError, match="outside valid range"): + ChromaticityCoordinatesData(x=-0.1, y=0.5) + + def test_validation_rejects_overflow(self) -> None: + """Coordinates exceeding 1.0 are invalid (uint16 cannot represent them).""" + with pytest.raises(ValueError, match="outside valid range"): + ChromaticityCoordinatesData(x=1.1, y=0.5) diff --git a/tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py b/tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py new file mode 100644 index 00000000..1c370cc6 --- /dev/null +++ b/tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ChromaticityInCCTAndDuvValuesCharacteristic +from bluetooth_sig.gatt.characteristics.chromaticity_in_cct_and_duv_values import ( + ChromaticityInCCTAndDuvData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + +_DUV_RESOLUTION = 1e-5 + + +class TestChromaticityInCCTAndDuvValuesCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> ChromaticityInCCTAndDuvValuesCharacteristic: + return ChromaticityInCCTAndDuvValuesCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AE5" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00]), + expected_value=ChromaticityInCCTAndDuvData( + correlated_color_temperature=0, + chromaticity_distance_from_planckian=0.0, + ), + description="Zero CCT and zero Duv", + ), + CharacteristicTestData( + # CCT = 6500 K (0x1964), Duv = 0.003 → raw = 300 (0x012C) + input_data=bytearray([0x64, 0x19, 0x2C, 0x01]), + expected_value=ChromaticityInCCTAndDuvData( + correlated_color_temperature=6500, + chromaticity_distance_from_planckian=300 * _DUV_RESOLUTION, + ), + description="Typical daylight CCT with positive Duv", + ), + CharacteristicTestData( + # CCT = 2700 K (0x0A8C), Duv = -0.002 → raw = -200 (0xFF38 signed) + input_data=bytearray([0x8C, 0x0A, 0x38, 0xFF]), + expected_value=ChromaticityInCCTAndDuvData( + correlated_color_temperature=2700, + chromaticity_distance_from_planckian=-200 * _DUV_RESOLUTION, + ), + description="Warm white CCT with negative Duv", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = ChromaticityInCCTAndDuvValuesCharacteristic() + original = ChromaticityInCCTAndDuvData( + correlated_color_temperature=4000, + chromaticity_distance_from_planckian=0.001, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.correlated_color_temperature == original.correlated_color_temperature + assert ( + abs(decoded.chromaticity_distance_from_planckian - original.chromaticity_distance_from_planckian) + < _DUV_RESOLUTION + ) + + def test_validation_rejects_negative_cct(self) -> None: + """Negative CCT is invalid.""" + with pytest.raises(ValueError, match="CCT"): + ChromaticityInCCTAndDuvData( + correlated_color_temperature=-1, + chromaticity_distance_from_planckian=0.0, + ) diff --git a/tests/gatt/characteristics/test_chromaticity_tolerance.py b/tests/gatt/characteristics/test_chromaticity_tolerance.py new file mode 100644 index 00000000..7b64fd13 --- /dev/null +++ b/tests/gatt/characteristics/test_chromaticity_tolerance.py @@ -0,0 +1,51 @@ +"""Tests for ChromaticityTolerance characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ChromaticityToleranceCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestChromaticityToleranceCharacteristic(CommonCharacteristicTests): + """Test suite for ChromaticityTolerance characteristic.""" + + @pytest.fixture + def characteristic(self) -> ChromaticityToleranceCharacteristic: + """Provide ChromaticityTolerance characteristic.""" + return ChromaticityToleranceCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for ChromaticityTolerance.""" + return "2AE6" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for ChromaticityTolerance.""" + return [ + CharacteristicTestData( + input_data=bytearray([0]), + expected_value=0.0, + description="zero tolerance", + ), + CharacteristicTestData( + input_data=bytearray([100]), + expected_value=0.01, + description="100 * 1e-4 = 0.01", + ), + CharacteristicTestData( + input_data=bytearray([200]), + expected_value=0.02, + description="200 * 1e-4 = 0.02", + ), + CharacteristicTestData( + input_data=bytearray([254]), + expected_value=0.0254, + description="254 * 1e-4 = 0.0254", + ), + ] diff --git a/tests/gatt/characteristics/test_cie133_color_rendering_index.py b/tests/gatt/characteristics/test_cie133_color_rendering_index.py new file mode 100644 index 00000000..7ee86cde --- /dev/null +++ b/tests/gatt/characteristics/test_cie133_color_rendering_index.py @@ -0,0 +1,46 @@ +"""Tests for CIE133ColorRenderingIndex characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import CIE133ColorRenderingIndexCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestCIE133ColorRenderingIndexCharacteristic(CommonCharacteristicTests): + """Test suite for CIE133ColorRenderingIndex characteristic.""" + + @pytest.fixture + def characteristic(self) -> CIE133ColorRenderingIndexCharacteristic: + """Provide CIE133ColorRenderingIndex characteristic.""" + return CIE133ColorRenderingIndexCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for CIE133ColorRenderingIndex.""" + return "2AE7" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for CIE133ColorRenderingIndex.""" + return [ + CharacteristicTestData( + input_data=bytearray([50]), + expected_value=50, + description="CRI of 50", + ), + CharacteristicTestData( + input_data=bytearray([100]), + expected_value=100, + description="CRI of 100", + ), + CharacteristicTestData( + input_data=bytearray([0x80]), + expected_value=-128, + description="minimum CRI", + ), + ] diff --git a/tests/gatt/characteristics/test_contact_status_8.py b/tests/gatt/characteristics/test_contact_status_8.py new file mode 100644 index 00000000..6f0f7300 --- /dev/null +++ b/tests/gatt/characteristics/test_contact_status_8.py @@ -0,0 +1,54 @@ +"""Tests for ContactStatus8 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ContactStatus8Characteristic +from bluetooth_sig.gatt.characteristics.contact_status_8 import ContactStatus +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestContactStatus8Characteristic(CommonCharacteristicTests): + """Test suite for ContactStatus8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> ContactStatus8Characteristic: + """Provide ContactStatus8 characteristic.""" + return ContactStatus8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for ContactStatus8.""" + return "2C22" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for ContactStatus8.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00]), + expected_value=ContactStatus(0), + description="no contacts", + ), + CharacteristicTestData( + input_data=bytearray([0x03]), + expected_value=ContactStatus.CONTACT_0 | ContactStatus.CONTACT_1, + description="contacts 0+1", + ), + CharacteristicTestData( + input_data=bytearray([0xFF]), + expected_value=ContactStatus.CONTACT_0 + | ContactStatus.CONTACT_1 + | ContactStatus.CONTACT_2 + | ContactStatus.CONTACT_3 + | ContactStatus.CONTACT_4 + | ContactStatus.CONTACT_5 + | ContactStatus.CONTACT_6 + | ContactStatus.CONTACT_7, + description="all contacts", + ), + ] diff --git a/tests/gatt/characteristics/test_content_control_id.py b/tests/gatt/characteristics/test_content_control_id.py new file mode 100644 index 00000000..e33295a8 --- /dev/null +++ b/tests/gatt/characteristics/test_content_control_id.py @@ -0,0 +1,46 @@ +"""Tests for ContentControlId characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ContentControlIdCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestContentControlIdCharacteristic(CommonCharacteristicTests): + """Test suite for ContentControlId characteristic.""" + + @pytest.fixture + def characteristic(self) -> ContentControlIdCharacteristic: + """Provide ContentControlId characteristic.""" + return ContentControlIdCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for ContentControlId.""" + return "2BBA" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for ContentControlId.""" + return [ + CharacteristicTestData( + input_data=bytearray([0]), + expected_value=0, + description="zero ID", + ), + CharacteristicTestData( + input_data=bytearray([42]), + expected_value=42, + description="ID 42", + ), + CharacteristicTestData( + input_data=bytearray([255]), + expected_value=255, + description="max uint8", + ), + ] diff --git a/tests/gatt/characteristics/test_cosine_of_the_angle.py b/tests/gatt/characteristics/test_cosine_of_the_angle.py new file mode 100644 index 00000000..8533a612 --- /dev/null +++ b/tests/gatt/characteristics/test_cosine_of_the_angle.py @@ -0,0 +1,46 @@ +"""Tests for CosineOfTheAngle characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import CosineOfTheAngleCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestCosineOfTheAngleCharacteristic(CommonCharacteristicTests): + """Test suite for CosineOfTheAngle characteristic.""" + + @pytest.fixture + def characteristic(self) -> CosineOfTheAngleCharacteristic: + """Provide CosineOfTheAngle characteristic.""" + return CosineOfTheAngleCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for CosineOfTheAngle.""" + return "2B8D" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for CosineOfTheAngle.""" + return [ + CharacteristicTestData( + input_data=bytearray([100]), + expected_value=1.0, + description="100 * 0.01 = 1.0", + ), + CharacteristicTestData( + input_data=bytearray([50]), + expected_value=0.5, + description="50 * 0.01 = 0.5", + ), + CharacteristicTestData( + input_data=bytearray([0x9C]), + expected_value=-1.0, + description="-100 * 0.01 = -1.0", + ), + ] diff --git a/tests/gatt/characteristics/test_country_code.py b/tests/gatt/characteristics/test_country_code.py new file mode 100644 index 00000000..85050bef --- /dev/null +++ b/tests/gatt/characteristics/test_country_code.py @@ -0,0 +1,46 @@ +"""Tests for CountryCode characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import CountryCodeCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestCountryCodeCharacteristic(CommonCharacteristicTests): + """Test suite for CountryCode characteristic.""" + + @pytest.fixture + def characteristic(self) -> CountryCodeCharacteristic: + """Provide CountryCode characteristic.""" + return CountryCodeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for CountryCode.""" + return "2AEC" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for CountryCode.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x3A, 0x03]), + expected_value=826, + description="UK (ISO 3166-1)", + ), + CharacteristicTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=0, + description="zero", + ), + CharacteristicTestData( + input_data=bytearray([0x50, 0x03]), + expected_value=848, + description="USA (840 LE=0x48,0x03)", + ), + ] diff --git a/tests/gatt/characteristics/test_custom_characteristics.py b/tests/gatt/characteristics/test_custom_characteristics.py index 63c950aa..9cfbdc3e 100644 --- a/tests/gatt/characteristics/test_custom_characteristics.py +++ b/tests/gatt/characteristics/test_custom_characteristics.py @@ -24,7 +24,6 @@ from bluetooth_sig.gatt.context import CharacteristicContext from bluetooth_sig.gatt.exceptions import CharacteristicParseError from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID # ============================================================================== @@ -44,7 +43,7 @@ class SimpleTemperatureSensor(CustomBaseCharacteristic): uuid=BluetoothUUID("AA001234-0000-1000-8000-00805F9B34FB"), name="Simple Temperature Sensor", unit="°C", - value_type=ValueType.INT, + python_type=int, ) def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: @@ -72,7 +71,7 @@ class PrecisionHumiditySensor(CustomBaseCharacteristic): uuid=BluetoothUUID("BB001234-0000-1000-8000-00805F9B34FB"), name="Precision Humidity Sensor", unit="%", - value_type=ValueType.FLOAT, + python_type=float, ) @@ -99,7 +98,7 @@ class MultiSensorCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("CC001234-0000-1000-8000-00805F9B34FB"), name="Multi-Sensor Environmental", unit="various", - value_type=ValueType.BYTES, + python_type=bytes, ) def _decode_value( @@ -147,7 +146,7 @@ class DeviceSerialNumberCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("DD001234-0000-1000-8000-00805F9B34FB"), name="Device Serial Number", unit="", - value_type=ValueType.STRING, + python_type=str, ) def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: @@ -174,7 +173,7 @@ class DeviceStatusFlags(CustomBaseCharacteristic): uuid=BluetoothUUID("EE001234-0000-1000-8000-00805F9B34FB"), name="Device Status Flags", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -312,7 +311,7 @@ def test_device_serial_number(self) -> None: data = bytearray(b"SN123456789") result = char.parse_value(data) assert result == "SN123456789" - assert char.info.value_type == ValueType.STRING + assert char.info.python_type is str encoded = char.build_value("TEST12345") result = char.parse_value(encoded) @@ -370,7 +369,7 @@ class CustomBatteryLevel(CustomBaseCharacteristic, allow_sig_override=True): uuid=BluetoothUUID("2A19"), # Official SIG Battery Level UUID name="Custom Battery Level", unit="%", - value_type=ValueType.INT, + python_type=int, ) char = CustomBatteryLevel() @@ -399,7 +398,7 @@ class CustomBatteryLevel(CustomBaseCharacteristic, allow_sig_override=True): uuid=BluetoothUUID("2A19"), # Official SIG Battery Level UUID name="Custom Battery Level", unit="%", - value_type=ValueType.INT, + python_type=int, ) assert SimpleTemperatureSensor._is_custom is True @@ -427,7 +426,7 @@ def test_register_simple_characteristic(self) -> None: uuid=uuid, name="Simple Temperature Sensor", unit="°C", - value_type=ValueType.INT, + python_type=int, ), ) @@ -503,7 +502,7 @@ def _class_body(namespace: dict[str, Any]) -> None: # pragma: no cover uuid=BluetoothUUID("2A19"), # SIG UUID without override name="Unauthorized Battery", unit="%", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( # pylint: disable=duplicate-code @@ -611,7 +610,7 @@ class AutoInfoCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789ABC"), name="Auto Info Test", unit="units", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -629,7 +628,7 @@ def _encode_value(self, data: int) -> bytearray: assert char.info.uuid == BluetoothUUID("12345678-1234-1234-1234-123456789ABC") assert char.info.name == "Auto Info Test" assert char.info.unit == "units" - assert char.info.value_type == ValueType.INT + assert char.info.python_type is int # Should work for parsing test_data = bytearray([0x32, 0x00]) # 50 in little-endian @@ -647,7 +646,7 @@ class OverridableCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789ABC"), name="Original Name", unit="units", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( @@ -663,7 +662,7 @@ def _encode_value(self, data: int) -> bytearray: uuid=BluetoothUUID("ABCDEF12-3456-7890-ABCD-EF1234567890"), name="Override Name", unit="override_units", - value_type=ValueType.FLOAT, + python_type=float, ) char = OverridableCharacteristic(info=override_info) @@ -672,7 +671,7 @@ def _encode_value(self, data: int) -> bytearray: assert char.info.uuid == BluetoothUUID("ABCDEF12-3456-7890-ABCD-EF1234567890") assert char.info.name == "Override Name" assert char.info.unit == "override_units" - assert char.info.value_type == ValueType.FLOAT + assert char.info.python_type is float def test_sig_override_protection(self) -> None: """Test that __init_subclass__ prevents SIG UUID usage without permission.""" @@ -683,7 +682,7 @@ def _bad_body(namespace: dict[str, Any]) -> None: # pragma: no cover uuid=BluetoothUUID("2A19"), # SIG Battery Level UUID name="Bad Override", unit="%", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( # pylint: disable=duplicate-code @@ -721,7 +720,7 @@ class AllowedSIGOverride(CustomBaseCharacteristic, allow_sig_override=True): uuid=BluetoothUUID("2A19"), # SIG Battery Level UUID name="Allowed Override", unit="%", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( # pylint: disable=duplicate-code @@ -774,7 +773,7 @@ class CustomUUIDCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789ABC"), name="Custom Characteristic", unit="custom", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( diff --git a/tests/gatt/characteristics/test_date_utc.py b/tests/gatt/characteristics/test_date_utc.py new file mode 100644 index 00000000..c8244380 --- /dev/null +++ b/tests/gatt/characteristics/test_date_utc.py @@ -0,0 +1,43 @@ +"""Tests for DateUtc characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import DateUtcCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestDateUtcCharacteristic(CommonCharacteristicTests): + """Test suite for DateUtc characteristic.""" + + @pytest.fixture + def characteristic(self) -> DateUtcCharacteristic: + """Provide DateUtc characteristic.""" + return DateUtcCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for DateUtc.""" + return "2AED" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for DateUtc.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=datetime.date(1970, 1, 2), + description="day 1 = 1970-01-02", + ), + CharacteristicTestData( + input_data=bytearray([0xEB, 0x4D, 0x00]), + expected_value=datetime.date(2024, 8, 12), + description="day 19723", + ), + ] diff --git a/tests/gatt/characteristics/test_door_window_status.py b/tests/gatt/characteristics/test_door_window_status.py new file mode 100644 index 00000000..6a399386 --- /dev/null +++ b/tests/gatt/characteristics/test_door_window_status.py @@ -0,0 +1,47 @@ +"""Tests for DoorWindowStatus characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import DoorWindowStatusCharacteristic +from bluetooth_sig.gatt.characteristics.door_window_status import DoorWindowOpenStatus +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestDoorWindowStatusCharacteristic(CommonCharacteristicTests): + """Test suite for DoorWindowStatus characteristic.""" + + @pytest.fixture + def characteristic(self) -> DoorWindowStatusCharacteristic: + """Provide DoorWindowStatus characteristic.""" + return DoorWindowStatusCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for DoorWindowStatus.""" + return "2C20" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for DoorWindowStatus.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00]), + expected_value=DoorWindowOpenStatus.OPEN, + description="open", + ), + CharacteristicTestData( + input_data=bytearray([0x01]), + expected_value=DoorWindowOpenStatus.CLOSED, + description="closed", + ), + CharacteristicTestData( + input_data=bytearray([0x02]), + expected_value=DoorWindowOpenStatus.TILTED_AJAR, + description="tilted/ajar", + ), + ] diff --git a/tests/gatt/characteristics/test_elevation.py b/tests/gatt/characteristics/test_elevation.py index 0c51d2c5..516f5a48 100644 --- a/tests/gatt/characteristics/test_elevation.py +++ b/tests/gatt/characteristics/test_elevation.py @@ -39,7 +39,7 @@ def test_elevation_parsing(self, characteristic: ElevationCharacteristic) -> Non """Test Elevation characteristic parsing.""" # Test metadata assert characteristic.unit == "m" - assert characteristic.value_type.value == "float" + assert characteristic.python_type is float # Test normal parsing: 50000 (in 0.01 meters) = 500.00 meters test_data = bytearray([0x50, 0xC3, 0x00]) # 50000 in 24-bit little endian diff --git a/tests/gatt/characteristics/test_energy.py b/tests/gatt/characteristics/test_energy.py new file mode 100644 index 00000000..897e84ec --- /dev/null +++ b/tests/gatt/characteristics/test_energy.py @@ -0,0 +1,46 @@ +"""Tests for Energy characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import EnergyCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestEnergyCharacteristic(CommonCharacteristicTests): + """Test suite for Energy characteristic.""" + + @pytest.fixture + def characteristic(self) -> EnergyCharacteristic: + """Provide Energy characteristic.""" + return EnergyCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Energy.""" + return "2AF2" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Energy.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00, 0x00]), + expected_value=100, + description="100 kWh", + ), + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00]), + expected_value=0, + description="zero energy", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00]), + expected_value=1000, + description="1000 kWh", + ), + ] diff --git a/tests/gatt/characteristics/test_energy_32.py b/tests/gatt/characteristics/test_energy_32.py new file mode 100644 index 00000000..1b745e30 --- /dev/null +++ b/tests/gatt/characteristics/test_energy_32.py @@ -0,0 +1,46 @@ +"""Tests for Energy32 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import Energy32Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestEnergy32Characteristic(CommonCharacteristicTests): + """Test suite for Energy32 characteristic.""" + + @pytest.fixture + def characteristic(self) -> Energy32Characteristic: + """Provide Energy32 characteristic.""" + return Energy32Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Energy32.""" + return "2BA8" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Energy32.""" + return [ + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00, 0x00]), + expected_value=1000, + description="1000 kWh", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00, 0x00]), + expected_value=1, + description="1 kWh", + ), + CharacteristicTestData( + input_data=bytearray([0x40, 0x42, 0x0F, 0x00]), + expected_value=1000000, + description="1M kWh", + ), + ] diff --git a/tests/gatt/characteristics/test_energy_in_a_period_of_day.py b/tests/gatt/characteristics/test_energy_in_a_period_of_day.py new file mode 100644 index 00000000..e6576673 --- /dev/null +++ b/tests/gatt/characteristics/test_energy_in_a_period_of_day.py @@ -0,0 +1,64 @@ +"""Tests for Energy in a Period of Day characteristic (0x2AF3).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import EnergyInAPeriodOfDayCharacteristic +from bluetooth_sig.gatt.characteristics.energy_in_a_period_of_day import ( + EnergyInAPeriodOfDayData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestEnergyInAPeriodOfDayCharacteristic(CommonCharacteristicTests): + """Test suite for Energy in a Period of Day characteristic.""" + + @pytest.fixture + def characteristic(self) -> EnergyInAPeriodOfDayCharacteristic: + return EnergyInAPeriodOfDayCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AF3" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=EnergyInAPeriodOfDayData( + energy=0, + start_time=0.0, + end_time=0.0, + ), + description="Zero energy, midnight to midnight", + ), + CharacteristicTestData( + input_data=bytearray([0x0A, 0x00, 0x00, 0x3C, 0xB4]), + expected_value=EnergyInAPeriodOfDayData( + energy=10, + start_time=6.0, + end_time=18.0, + ), + description="10 kWh from 06:00 to 18:00", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = EnergyInAPeriodOfDayCharacteristic() + original = EnergyInAPeriodOfDayData( + energy=500, + start_time=8.0, + end_time=17.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_negative_energy(self) -> None: + """Negative energy is invalid for uint24.""" + with pytest.raises(ValueError, match="outside valid range"): + EnergyInAPeriodOfDayData(energy=-1, start_time=0.0, end_time=0.0) diff --git a/tests/gatt/characteristics/test_estimated_service_date.py b/tests/gatt/characteristics/test_estimated_service_date.py new file mode 100644 index 00000000..0a55965d --- /dev/null +++ b/tests/gatt/characteristics/test_estimated_service_date.py @@ -0,0 +1,43 @@ +"""Tests for EstimatedServiceDate characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import EstimatedServiceDateCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestEstimatedServiceDateCharacteristic(CommonCharacteristicTests): + """Test suite for EstimatedServiceDate characteristic.""" + + @pytest.fixture + def characteristic(self) -> EstimatedServiceDateCharacteristic: + """Provide EstimatedServiceDate characteristic.""" + return EstimatedServiceDateCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for EstimatedServiceDate.""" + return "2BEF" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for EstimatedServiceDate.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=datetime.date(1970, 1, 2), + description="day 1 = 1970-01-02", + ), + CharacteristicTestData( + input_data=bytearray([0xEB, 0x4D, 0x00]), + expected_value=datetime.date(2024, 8, 12), + description="day 19723", + ), + ] diff --git a/tests/gatt/characteristics/test_event_statistics.py b/tests/gatt/characteristics/test_event_statistics.py new file mode 100644 index 00000000..85aa0761 --- /dev/null +++ b/tests/gatt/characteristics/test_event_statistics.py @@ -0,0 +1,80 @@ +"""Tests for Event Statistics characteristic (0x2AF4).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import EventStatisticsCharacteristic +from bluetooth_sig.gatt.characteristics.event_statistics import EventStatisticsData + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestEventStatisticsCharacteristic(CommonCharacteristicTests): + """Test suite for Event Statistics characteristic.""" + + @pytest.fixture + def characteristic(self) -> EventStatisticsCharacteristic: + return EventStatisticsCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AF4" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=EventStatisticsData( + number_of_events=0, + average_event_duration=0, + time_elapsed_since_last_event=0.0, + sensing_duration=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0x0A, 0x00, 0x1E, 0x00, 0x40, 0x40]), + expected_value=EventStatisticsData( + number_of_events=10, + average_event_duration=30, + time_elapsed_since_last_event=1.0, + sensing_duration=1.0, + ), + description="10 events, 30s avg, 1s elapsed/sensing", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = EventStatisticsCharacteristic() + original = EventStatisticsData( + number_of_events=100, + average_event_duration=60, + time_elapsed_since_last_event=0.0, + sensing_duration=0.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_negative_count(self) -> None: + """Negative event count is invalid.""" + with pytest.raises(ValueError, match="outside valid range"): + EventStatisticsData( + number_of_events=-1, + average_event_duration=0, + time_elapsed_since_last_event=0.0, + sensing_duration=0.0, + ) + + def test_validation_rejects_negative_duration(self) -> None: + """Negative sensing duration is invalid.""" + with pytest.raises(ValueError, match="cannot be negative"): + EventStatisticsData( + number_of_events=0, + average_event_duration=0, + time_elapsed_since_last_event=0.0, + sensing_duration=-1.0, + ) diff --git a/tests/gatt/characteristics/test_fixed_string_16.py b/tests/gatt/characteristics/test_fixed_string_16.py new file mode 100644 index 00000000..77556608 --- /dev/null +++ b/tests/gatt/characteristics/test_fixed_string_16.py @@ -0,0 +1,41 @@ +"""Tests for FixedString16 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import FixedString16Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestFixedString16Characteristic(CommonCharacteristicTests): + """Test suite for FixedString16 characteristic.""" + + @pytest.fixture + def characteristic(self) -> FixedString16Characteristic: + """Provide FixedString16 characteristic.""" + return FixedString16Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for FixedString16.""" + return "2AF5" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for FixedString16.""" + return [ + CharacteristicTestData( + input_data=bytearray(b"1234567890123456"), + expected_value="1234567890123456", + description="full 16-char string", + ), + CharacteristicTestData( + input_data=bytearray(b"ABCDEFGHIJKLMNOP"), + expected_value="ABCDEFGHIJKLMNOP", + description="uppercase letters", + ), + ] diff --git a/tests/gatt/characteristics/test_fixed_string_24.py b/tests/gatt/characteristics/test_fixed_string_24.py new file mode 100644 index 00000000..4db9c239 --- /dev/null +++ b/tests/gatt/characteristics/test_fixed_string_24.py @@ -0,0 +1,41 @@ +"""Tests for FixedString24 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import FixedString24Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestFixedString24Characteristic(CommonCharacteristicTests): + """Test suite for FixedString24 characteristic.""" + + @pytest.fixture + def characteristic(self) -> FixedString24Characteristic: + """Provide FixedString24 characteristic.""" + return FixedString24Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for FixedString24.""" + return "2AF6" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for FixedString24.""" + return [ + CharacteristicTestData( + input_data=bytearray(b"123456789012345678901234"), + expected_value="123456789012345678901234", + description="full 24-char string", + ), + CharacteristicTestData( + input_data=bytearray(b"ABCDEFGHIJKLMNOPQRSTUVWX"), + expected_value="ABCDEFGHIJKLMNOPQRSTUVWX", + description="uppercase letters", + ), + ] diff --git a/tests/gatt/characteristics/test_fixed_string_36.py b/tests/gatt/characteristics/test_fixed_string_36.py new file mode 100644 index 00000000..47402e30 --- /dev/null +++ b/tests/gatt/characteristics/test_fixed_string_36.py @@ -0,0 +1,41 @@ +"""Tests for FixedString36 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import FixedString36Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestFixedString36Characteristic(CommonCharacteristicTests): + """Test suite for FixedString36 characteristic.""" + + @pytest.fixture + def characteristic(self) -> FixedString36Characteristic: + """Provide FixedString36 characteristic.""" + return FixedString36Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for FixedString36.""" + return "2AF7" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for FixedString36.""" + return [ + CharacteristicTestData( + input_data=bytearray(b"123456789012345678901234567890123456"), + expected_value="123456789012345678901234567890123456", + description="full 36-char string", + ), + CharacteristicTestData( + input_data=bytearray(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"), + expected_value="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + description="alphanumeric", + ), + ] diff --git a/tests/gatt/characteristics/test_fixed_string_64.py b/tests/gatt/characteristics/test_fixed_string_64.py new file mode 100644 index 00000000..eb84fad8 --- /dev/null +++ b/tests/gatt/characteristics/test_fixed_string_64.py @@ -0,0 +1,41 @@ +"""Tests for FixedString64 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import FixedString64Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestFixedString64Characteristic(CommonCharacteristicTests): + """Test suite for FixedString64 characteristic.""" + + @pytest.fixture + def characteristic(self) -> FixedString64Characteristic: + """Provide FixedString64 characteristic.""" + return FixedString64Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for FixedString64.""" + return "2BDE" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for FixedString64.""" + return [ + CharacteristicTestData( + input_data=bytearray(b"A" * 64), + expected_value="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + description="64 A's", + ), + CharacteristicTestData( + input_data=bytearray(b"1234567890" * 6 + b"1234"), + expected_value="1234567890" * 6 + "1234", + description="repeating digits", + ), + ] diff --git a/tests/gatt/characteristics/test_fixed_string_8.py b/tests/gatt/characteristics/test_fixed_string_8.py new file mode 100644 index 00000000..e5df502f --- /dev/null +++ b/tests/gatt/characteristics/test_fixed_string_8.py @@ -0,0 +1,41 @@ +"""Tests for FixedString8 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import FixedString8Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestFixedString8Characteristic(CommonCharacteristicTests): + """Test suite for FixedString8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> FixedString8Characteristic: + """Provide FixedString8 characteristic.""" + return FixedString8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for FixedString8.""" + return "2AF8" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for FixedString8.""" + return [ + CharacteristicTestData( + input_data=bytearray(b"12345678"), + expected_value="12345678", + description="full 8-char string", + ), + CharacteristicTestData( + input_data=bytearray(b"ABCDEFGH"), + expected_value="ABCDEFGH", + description="uppercase letters", + ), + ] diff --git a/tests/gatt/characteristics/test_generic_level.py b/tests/gatt/characteristics/test_generic_level.py new file mode 100644 index 00000000..4e8bc1a5 --- /dev/null +++ b/tests/gatt/characteristics/test_generic_level.py @@ -0,0 +1,46 @@ +"""Tests for GenericLevel characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import GenericLevelCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestGenericLevelCharacteristic(CommonCharacteristicTests): + """Test suite for GenericLevel characteristic.""" + + @pytest.fixture + def characteristic(self) -> GenericLevelCharacteristic: + """Provide GenericLevel characteristic.""" + return GenericLevelCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for GenericLevel.""" + return "2AF9" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for GenericLevel.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=0, + description="zero level", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=1000, + description="level 1000", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF]), + expected_value=65535, + description="max uint16", + ), + ] diff --git a/tests/gatt/characteristics/test_global_trade_item_number.py b/tests/gatt/characteristics/test_global_trade_item_number.py new file mode 100644 index 00000000..1f39a8f3 --- /dev/null +++ b/tests/gatt/characteristics/test_global_trade_item_number.py @@ -0,0 +1,41 @@ +"""Tests for GlobalTradeItemNumber characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import GlobalTradeItemNumberCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestGlobalTradeItemNumberCharacteristic(CommonCharacteristicTests): + """Test suite for GlobalTradeItemNumber characteristic.""" + + @pytest.fixture + def characteristic(self) -> GlobalTradeItemNumberCharacteristic: + """Provide GlobalTradeItemNumber characteristic.""" + return GlobalTradeItemNumberCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for GlobalTradeItemNumber.""" + return "2AFA" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for GlobalTradeItemNumber.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]), + expected_value=6618611909121, + description="GTIN value", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=1, + description="GTIN = 1", + ), + ] diff --git a/tests/gatt/characteristics/test_high_temperature.py b/tests/gatt/characteristics/test_high_temperature.py new file mode 100644 index 00000000..69246960 --- /dev/null +++ b/tests/gatt/characteristics/test_high_temperature.py @@ -0,0 +1,46 @@ +"""Tests for HighTemperature characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import HighTemperatureCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestHighTemperatureCharacteristic(CommonCharacteristicTests): + """Test suite for HighTemperature characteristic.""" + + @pytest.fixture + def characteristic(self) -> HighTemperatureCharacteristic: + """Provide HighTemperature characteristic.""" + return HighTemperatureCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for HighTemperature.""" + return "2BDF" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for HighTemperature.""" + return [ + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=500.0, + description="1000 * 0.5 = 500.0 degC", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=0.5, + description="1 * 0.5 = 0.5 degC", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF]), + expected_value=-0.5, + description="-1 * 0.5 = -0.5 degC", + ), + ] diff --git a/tests/gatt/characteristics/test_humidity_8.py b/tests/gatt/characteristics/test_humidity_8.py new file mode 100644 index 00000000..72c30448 --- /dev/null +++ b/tests/gatt/characteristics/test_humidity_8.py @@ -0,0 +1,46 @@ +"""Tests for Humidity8 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import Humidity8Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestHumidity8Characteristic(CommonCharacteristicTests): + """Test suite for Humidity8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> Humidity8Characteristic: + """Provide Humidity8 characteristic.""" + return Humidity8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Humidity8.""" + return "2C1B" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Humidity8.""" + return [ + CharacteristicTestData( + input_data=bytearray([0]), + expected_value=0.0, + description="0%", + ), + CharacteristicTestData( + input_data=bytearray([100]), + expected_value=50.0, + description="100 * 0.5 = 50%", + ), + CharacteristicTestData( + input_data=bytearray([200]), + expected_value=100.0, + description="200 * 0.5 = 100%", + ), + ] diff --git a/tests/gatt/characteristics/test_illuminance_16.py b/tests/gatt/characteristics/test_illuminance_16.py new file mode 100644 index 00000000..05e88c61 --- /dev/null +++ b/tests/gatt/characteristics/test_illuminance_16.py @@ -0,0 +1,46 @@ +"""Tests for Illuminance16 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import Illuminance16Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestIlluminance16Characteristic(CommonCharacteristicTests): + """Test suite for Illuminance16 characteristic.""" + + @pytest.fixture + def characteristic(self) -> Illuminance16Characteristic: + """Provide Illuminance16 characteristic.""" + return Illuminance16Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Illuminance16.""" + return "2C1C" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Illuminance16.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=0, + description="zero illuminance", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=1000, + description="1000 lux", + ), + CharacteristicTestData( + input_data=bytearray([0x64, 0x00]), + expected_value=100, + description="100 lux", + ), + ] diff --git a/tests/gatt/characteristics/test_light_distribution.py b/tests/gatt/characteristics/test_light_distribution.py new file mode 100644 index 00000000..f49d93a3 --- /dev/null +++ b/tests/gatt/characteristics/test_light_distribution.py @@ -0,0 +1,47 @@ +"""Tests for LightDistribution characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LightDistributionCharacteristic +from bluetooth_sig.gatt.characteristics.light_distribution import LightDistributionType +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLightDistributionCharacteristic(CommonCharacteristicTests): + """Test suite for LightDistribution characteristic.""" + + @pytest.fixture + def characteristic(self) -> LightDistributionCharacteristic: + """Provide LightDistribution characteristic.""" + return LightDistributionCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LightDistribution.""" + return "2BE1" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LightDistribution.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00]), + expected_value=LightDistributionType.NOT_SPECIFIED, + description="not specified", + ), + CharacteristicTestData( + input_data=bytearray([0x01]), + expected_value=LightDistributionType.TYPE_I, + description="type I", + ), + CharacteristicTestData( + input_data=bytearray([0x05]), + expected_value=LightDistributionType.TYPE_V, + description="type V", + ), + ] diff --git a/tests/gatt/characteristics/test_light_output.py b/tests/gatt/characteristics/test_light_output.py new file mode 100644 index 00000000..c732cc94 --- /dev/null +++ b/tests/gatt/characteristics/test_light_output.py @@ -0,0 +1,46 @@ +"""Tests for LightOutput characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LightOutputCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLightOutputCharacteristic(CommonCharacteristicTests): + """Test suite for LightOutput characteristic.""" + + @pytest.fixture + def characteristic(self) -> LightOutputCharacteristic: + """Provide LightOutput characteristic.""" + return LightOutputCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LightOutput.""" + return "2BE2" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LightOutput.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00, 0x00]), + expected_value=100, + description="100 lm", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00]), + expected_value=1000, + description="1000 lm", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=1, + description="1 lm", + ), + ] diff --git a/tests/gatt/characteristics/test_light_source_type.py b/tests/gatt/characteristics/test_light_source_type.py new file mode 100644 index 00000000..1cd336c8 --- /dev/null +++ b/tests/gatt/characteristics/test_light_source_type.py @@ -0,0 +1,47 @@ +"""Tests for LightSourceType characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LightSourceTypeCharacteristic +from bluetooth_sig.gatt.characteristics.light_source_type import LightSourceTypeValue +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLightSourceTypeCharacteristic(CommonCharacteristicTests): + """Test suite for LightSourceType characteristic.""" + + @pytest.fixture + def characteristic(self) -> LightSourceTypeCharacteristic: + """Provide LightSourceType characteristic.""" + return LightSourceTypeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LightSourceType.""" + return "2BE3" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LightSourceType.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00]), + expected_value=LightSourceTypeValue.NOT_SPECIFIED, + description="not specified", + ), + CharacteristicTestData( + input_data=bytearray([0x04]), + expected_value=LightSourceTypeValue.INCANDESCENT, + description="incandescent", + ), + CharacteristicTestData( + input_data=bytearray([0x05]), + expected_value=LightSourceTypeValue.LED, + description="LED", + ), + ] diff --git a/tests/gatt/characteristics/test_local_east_coordinate.py b/tests/gatt/characteristics/test_local_east_coordinate.py index 055d333e..8f980a6e 100644 --- a/tests/gatt/characteristics/test_local_east_coordinate.py +++ b/tests/gatt/characteristics/test_local_east_coordinate.py @@ -42,7 +42,7 @@ def test_local_east_coordinate_parsing(self, characteristic: LocalEastCoordinate """Test Local East Coordinate characteristic parsing.""" # Test metadata assert characteristic.unit == "m" - assert characteristic.value_type.value == "float" + assert characteristic.python_type is float # Test normal parsing test_data = bytearray([0xED, 0x03, 0x00]) # 1005 = 100.5m diff --git a/tests/gatt/characteristics/test_local_north_coordinate.py b/tests/gatt/characteristics/test_local_north_coordinate.py index d8ec07ee..64e8a5a0 100644 --- a/tests/gatt/characteristics/test_local_north_coordinate.py +++ b/tests/gatt/characteristics/test_local_north_coordinate.py @@ -42,7 +42,7 @@ def test_local_north_coordinate_parsing(self, characteristic: LocalNorthCoordina """Test Local North Coordinate characteristic parsing.""" # Test metadata assert characteristic.unit == "m" - assert characteristic.value_type.value == "float" + assert characteristic.python_type is float # Test normal parsing test_data = bytearray([0xED, 0x03, 0x00]) # 1005 = 100.5m diff --git a/tests/gatt/characteristics/test_luminous_efficacy.py b/tests/gatt/characteristics/test_luminous_efficacy.py new file mode 100644 index 00000000..95ce31d8 --- /dev/null +++ b/tests/gatt/characteristics/test_luminous_efficacy.py @@ -0,0 +1,46 @@ +"""Tests for LuminousEfficacy characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LuminousEfficacyCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLuminousEfficacyCharacteristic(CommonCharacteristicTests): + """Test suite for LuminousEfficacy characteristic.""" + + @pytest.fixture + def characteristic(self) -> LuminousEfficacyCharacteristic: + """Provide LuminousEfficacy characteristic.""" + return LuminousEfficacyCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LuminousEfficacy.""" + return "2AFC" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LuminousEfficacy.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00]), + expected_value=10.0, + description="100 * 0.1 = 10.0 lm/W", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=100.0, + description="1000 * 0.1 = 100.0 lm/W", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=0.1, + description="1 * 0.1 = 0.1 lm/W", + ), + ] diff --git a/tests/gatt/characteristics/test_luminous_energy.py b/tests/gatt/characteristics/test_luminous_energy.py new file mode 100644 index 00000000..95b7985b --- /dev/null +++ b/tests/gatt/characteristics/test_luminous_energy.py @@ -0,0 +1,46 @@ +"""Tests for LuminousEnergy characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LuminousEnergyCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLuminousEnergyCharacteristic(CommonCharacteristicTests): + """Test suite for LuminousEnergy characteristic.""" + + @pytest.fixture + def characteristic(self) -> LuminousEnergyCharacteristic: + """Provide LuminousEnergy characteristic.""" + return LuminousEnergyCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LuminousEnergy.""" + return "2AFD" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LuminousEnergy.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00, 0x00]), + expected_value=100, + description="100 lm*h", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00]), + expected_value=1000, + description="1000 lm*h", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=1, + description="1 lm*h", + ), + ] diff --git a/tests/gatt/characteristics/test_luminous_exposure.py b/tests/gatt/characteristics/test_luminous_exposure.py new file mode 100644 index 00000000..9c29ac7d --- /dev/null +++ b/tests/gatt/characteristics/test_luminous_exposure.py @@ -0,0 +1,46 @@ +"""Tests for LuminousExposure characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LuminousExposureCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLuminousExposureCharacteristic(CommonCharacteristicTests): + """Test suite for LuminousExposure characteristic.""" + + @pytest.fixture + def characteristic(self) -> LuminousExposureCharacteristic: + """Provide LuminousExposure characteristic.""" + return LuminousExposureCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LuminousExposure.""" + return "2AFE" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LuminousExposure.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00, 0x00]), + expected_value=100, + description="100 lux*h", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00]), + expected_value=1000, + description="1000 lux*h", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=1, + description="1 lux*h", + ), + ] diff --git a/tests/gatt/characteristics/test_luminous_flux.py b/tests/gatt/characteristics/test_luminous_flux.py new file mode 100644 index 00000000..be8ca3f0 --- /dev/null +++ b/tests/gatt/characteristics/test_luminous_flux.py @@ -0,0 +1,46 @@ +"""Tests for LuminousFlux characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LuminousFluxCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLuminousFluxCharacteristic(CommonCharacteristicTests): + """Test suite for LuminousFlux characteristic.""" + + @pytest.fixture + def characteristic(self) -> LuminousFluxCharacteristic: + """Provide LuminousFlux characteristic.""" + return LuminousFluxCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LuminousFlux.""" + return "2AFF" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LuminousFlux.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00]), + expected_value=100, + description="100 lm", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=1000, + description="1000 lm", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=1, + description="1 lm", + ), + ] diff --git a/tests/gatt/characteristics/test_luminous_flux_range.py b/tests/gatt/characteristics/test_luminous_flux_range.py new file mode 100644 index 00000000..98030c90 --- /dev/null +++ b/tests/gatt/characteristics/test_luminous_flux_range.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LuminousFluxRangeCharacteristic +from bluetooth_sig.gatt.characteristics.luminous_flux_range import LuminousFluxRangeData + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestLuminousFluxRangeCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> LuminousFluxRangeCharacteristic: + return LuminousFluxRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B00" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00]), + expected_value=LuminousFluxRangeData(minimum=0, maximum=0), + description="Zero flux range", + ), + CharacteristicTestData( + # min = 100 (0x0064), max = 800 (0x0320) + input_data=bytearray([0x64, 0x00, 0x20, 0x03]), + expected_value=LuminousFluxRangeData(minimum=100, maximum=800), + description="Typical LED bulb range (100-800 lm)", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF, 0xFF, 0xFF]), + expected_value=LuminousFluxRangeData(minimum=65535, maximum=65535), + description="Maximum flux range", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = LuminousFluxRangeCharacteristic() + original = LuminousFluxRangeData(minimum=200, maximum=1600) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum > maximum is invalid.""" + with pytest.raises(ValueError, match="cannot be greater"): + LuminousFluxRangeData(minimum=800, maximum=100) + + def test_validation_rejects_negative(self) -> None: + """Negative flux is invalid for uint16.""" + with pytest.raises(ValueError, match="outside valid range"): + LuminousFluxRangeData(minimum=-1, maximum=100) diff --git a/tests/gatt/characteristics/test_luminous_intensity.py b/tests/gatt/characteristics/test_luminous_intensity.py new file mode 100644 index 00000000..1749387d --- /dev/null +++ b/tests/gatt/characteristics/test_luminous_intensity.py @@ -0,0 +1,46 @@ +"""Tests for LuminousIntensity characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import LuminousIntensityCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestLuminousIntensityCharacteristic(CommonCharacteristicTests): + """Test suite for LuminousIntensity characteristic.""" + + @pytest.fixture + def characteristic(self) -> LuminousIntensityCharacteristic: + """Provide LuminousIntensity characteristic.""" + return LuminousIntensityCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for LuminousIntensity.""" + return "2B01" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for LuminousIntensity.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00]), + expected_value=100, + description="100 cd", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=1000, + description="1000 cd", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=1, + description="1 cd", + ), + ] diff --git a/tests/gatt/characteristics/test_magnetic_declination.py b/tests/gatt/characteristics/test_magnetic_declination.py index c423fd2b..328a9247 100644 --- a/tests/gatt/characteristics/test_magnetic_declination.py +++ b/tests/gatt/characteristics/test_magnetic_declination.py @@ -58,7 +58,7 @@ def test_magnetic_declination_parsing(self, characteristic: MagneticDeclinationC """Test Magnetic Declination characteristic parsing.""" # Test metadata assert characteristic.unit == "°" - assert characteristic.value_type.value == "float" + assert characteristic.python_type is float # Test normal parsing: 18000 (in 0.01 degrees) = 180.00 degrees test_data = bytearray([0x40, 0x46]) # 18000 in little endian uint16 diff --git a/tests/gatt/characteristics/test_magnetic_flux_density_2d.py b/tests/gatt/characteristics/test_magnetic_flux_density_2d.py index e4c1109b..3352ad2a 100644 --- a/tests/gatt/characteristics/test_magnetic_flux_density_2d.py +++ b/tests/gatt/characteristics/test_magnetic_flux_density_2d.py @@ -57,7 +57,7 @@ def test_magnetic_flux_density_2d_parsing(self, characteristic: BaseCharacterist """Test Magnetic Flux Density 2D characteristic parsing.""" # Test metadata assert characteristic.unit == "T" - assert characteristic.value_type.value == "string" + assert characteristic.python_type is str # Test normal parsing: X=1000, Y=-500 (in 10^-7 Tesla units) test_data = bytearray(struct.pack(" MassFlowCharacteristic: + """Provide MassFlow characteristic.""" + return MassFlowCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for MassFlow.""" + return "2B02" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for MassFlow.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=0, + description="zero flow", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=1000, + description="1000 g/s", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=1, + description="1 g/s", + ), + ] diff --git a/tests/gatt/characteristics/test_non_methane_voc_concentration.py b/tests/gatt/characteristics/test_non_methane_voc_concentration.py index 0217ecb6..f13f7148 100644 --- a/tests/gatt/characteristics/test_non_methane_voc_concentration.py +++ b/tests/gatt/characteristics/test_non_methane_voc_concentration.py @@ -43,7 +43,7 @@ def test_tvoc_concentration_parsing(self, characteristic: NonMethaneVOCConcentra """Test TVOC concentration characteristic parsing.""" # Test metadata - Updated for SIG spec compliance (medfloat16, kg/m³) assert characteristic.unit == "kg/m³" - assert characteristic.value_type_resolved.value == "float" # IEEE 11073 SFLOAT format + assert characteristic.python_type is float # IEEE 11073 SFLOAT format # Test normal parsing - IEEE 11073 SFLOAT format # Example: 0x1234 = exponent=1, mantissa=564 = 564 * 10^1 = 5640 diff --git a/tests/gatt/characteristics/test_object_first_created.py b/tests/gatt/characteristics/test_object_first_created.py new file mode 100644 index 00000000..7b3116ee --- /dev/null +++ b/tests/gatt/characteristics/test_object_first_created.py @@ -0,0 +1,46 @@ +"""Tests for Object First-Created characteristic (0x2AC1).""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import ObjectFirstCreatedCharacteristic + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestObjectFirstCreatedCharacteristic(CommonCharacteristicTests): + """Test suite for Object First-Created characteristic.""" + + @pytest.fixture + def characteristic(self) -> ObjectFirstCreatedCharacteristic: + return ObjectFirstCreatedCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AC1" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0xE3, 0x07, 12, 25, 10, 30, 45]), + expected_value=datetime(2019, 12, 25, 10, 30, 45), + description="Christmas 2019", + ), + CharacteristicTestData( + input_data=bytearray([0xE4, 0x07, 1, 1, 0, 0, 0]), + expected_value=datetime(2020, 1, 1, 0, 0, 0), + description="New Year 2020 midnight", + ), + ] + + def test_custom_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = ObjectFirstCreatedCharacteristic() + original = datetime(2025, 6, 15, 14, 30, 0) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_object_id.py b/tests/gatt/characteristics/test_object_id.py new file mode 100644 index 00000000..67a6b59b --- /dev/null +++ b/tests/gatt/characteristics/test_object_id.py @@ -0,0 +1,49 @@ +"""Tests for Object ID characteristic (0x2AC3).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ObjectIdCharacteristic + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestObjectIdCharacteristic(CommonCharacteristicTests): + """Test suite for Object ID characteristic.""" + + @pytest.fixture + def characteristic(self) -> ObjectIdCharacteristic: + return ObjectIdCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AC3" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=0, + description="Zero object ID", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=1, + description="Object ID = 1", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]), + expected_value=281474976710655, + description="Maximum uint48", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = ObjectIdCharacteristic() + original = 12345678 + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_object_last_modified.py b/tests/gatt/characteristics/test_object_last_modified.py new file mode 100644 index 00000000..78a70cd6 --- /dev/null +++ b/tests/gatt/characteristics/test_object_last_modified.py @@ -0,0 +1,46 @@ +"""Tests for Object Last-Modified characteristic (0x2AC2).""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import ObjectLastModifiedCharacteristic + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestObjectLastModifiedCharacteristic(CommonCharacteristicTests): + """Test suite for Object Last-Modified characteristic.""" + + @pytest.fixture + def characteristic(self) -> ObjectLastModifiedCharacteristic: + return ObjectLastModifiedCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AC2" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0xE3, 0x07, 12, 25, 10, 30, 45]), + expected_value=datetime(2019, 12, 25, 10, 30, 45), + description="Christmas 2019", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x07, 6, 15, 14, 0, 0]), + expected_value=datetime(2024, 6, 15, 14, 0, 0), + description="Summer 2024 afternoon", + ), + ] + + def test_custom_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = ObjectLastModifiedCharacteristic() + original = datetime(2025, 2, 23, 9, 15, 30) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_object_name.py b/tests/gatt/characteristics/test_object_name.py new file mode 100644 index 00000000..a4f47e42 --- /dev/null +++ b/tests/gatt/characteristics/test_object_name.py @@ -0,0 +1,52 @@ +"""Tests for Object Name characteristic (0x2ABE).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ObjectNameCharacteristic + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestObjectNameCharacteristic(CommonCharacteristicTests): + """Test suite for Object Name characteristic.""" + + @pytest.fixture + def characteristic(self) -> ObjectNameCharacteristic: + return ObjectNameCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2ABE" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray(b"hello"), + expected_value="hello", + description="Simple ASCII name", + ), + CharacteristicTestData( + input_data=bytearray(b""), + expected_value="", + description="Empty name", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = ObjectNameCharacteristic() + original = "My Photo.jpg" + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_utf8_round_trip(self) -> None: + """Verify UTF-8 encoding round-trip.""" + char = ObjectNameCharacteristic() + original = "caf\u00e9" + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_object_type.py b/tests/gatt/characteristics/test_object_type.py new file mode 100644 index 00000000..8324cb1e --- /dev/null +++ b/tests/gatt/characteristics/test_object_type.py @@ -0,0 +1,52 @@ +"""Tests for Object Type characteristic (0x2ABF).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ObjectTypeCharacteristic + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestObjectTypeCharacteristic(CommonCharacteristicTests): + """Test suite for Object Type characteristic.""" + + @pytest.fixture + def characteristic(self) -> ObjectTypeCharacteristic: + return ObjectTypeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2ABF" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0xC3, 0x2A]), + expected_value="2AC3", + description="16-bit UUID 0x2AC3 (Object ID)", + ), + CharacteristicTestData( + input_data=bytearray([0xBE, 0x2A]), + expected_value="2ABE", + description="16-bit UUID 0x2ABE (Object Name)", + ), + ] + + def test_encode_round_trip_16bit(self) -> None: + """Verify 16-bit UUID encode/decode round-trip.""" + char = ObjectTypeCharacteristic() + original = "2AC3" + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_128bit_uuid_round_trip(self) -> None: + """Verify 128-bit UUID encode/decode round-trip.""" + char = ObjectTypeCharacteristic() + original = "12345678-1234-5678-9ABC-DEF012345678" + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_perceived_lightness.py b/tests/gatt/characteristics/test_perceived_lightness.py new file mode 100644 index 00000000..e9a9f468 --- /dev/null +++ b/tests/gatt/characteristics/test_perceived_lightness.py @@ -0,0 +1,46 @@ +"""Tests for PerceivedLightness characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import PerceivedLightnessCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestPerceivedLightnessCharacteristic(CommonCharacteristicTests): + """Test suite for PerceivedLightness characteristic.""" + + @pytest.fixture + def characteristic(self) -> PerceivedLightnessCharacteristic: + """Provide PerceivedLightness characteristic.""" + return PerceivedLightnessCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for PerceivedLightness.""" + return "2B03" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for PerceivedLightness.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=0, + description="zero lightness", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=1000, + description="lightness 1000", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF]), + expected_value=65535, + description="max lightness", + ), + ] diff --git a/tests/gatt/characteristics/test_percentage_8.py b/tests/gatt/characteristics/test_percentage_8.py new file mode 100644 index 00000000..83edfb50 --- /dev/null +++ b/tests/gatt/characteristics/test_percentage_8.py @@ -0,0 +1,46 @@ +"""Tests for Percentage8 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import Percentage8Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestPercentage8Characteristic(CommonCharacteristicTests): + """Test suite for Percentage8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> Percentage8Characteristic: + """Provide Percentage8 characteristic.""" + return Percentage8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Percentage8.""" + return "2B04" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Percentage8.""" + return [ + CharacteristicTestData( + input_data=bytearray([0]), + expected_value=0.0, + description="0%", + ), + CharacteristicTestData( + input_data=bytearray([200]), + expected_value=100.0, + description="200 * 0.5 = 100%", + ), + CharacteristicTestData( + input_data=bytearray([100]), + expected_value=50.0, + description="100 * 0.5 = 50%", + ), + ] diff --git a/tests/gatt/characteristics/test_percentage_8_steps.py b/tests/gatt/characteristics/test_percentage_8_steps.py new file mode 100644 index 00000000..7b321f98 --- /dev/null +++ b/tests/gatt/characteristics/test_percentage_8_steps.py @@ -0,0 +1,46 @@ +"""Tests for Percentage8Steps characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import Percentage8StepsCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestPercentage8StepsCharacteristic(CommonCharacteristicTests): + """Test suite for Percentage8Steps characteristic.""" + + @pytest.fixture + def characteristic(self) -> Percentage8StepsCharacteristic: + """Provide Percentage8Steps characteristic.""" + return Percentage8StepsCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Percentage8Steps.""" + return "2C05" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Percentage8Steps.""" + return [ + CharacteristicTestData( + input_data=bytearray([1]), + expected_value=1, + description="1% (minimum valid)", + ), + CharacteristicTestData( + input_data=bytearray([50]), + expected_value=50, + description="50%", + ), + CharacteristicTestData( + input_data=bytearray([200]), + expected_value=200, + description="200 (maximum valid)", + ), + ] diff --git a/tests/gatt/characteristics/test_power.py b/tests/gatt/characteristics/test_power.py new file mode 100644 index 00000000..67f3d887 --- /dev/null +++ b/tests/gatt/characteristics/test_power.py @@ -0,0 +1,46 @@ +"""Tests for Power characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import PowerCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestPowerCharacteristic(CommonCharacteristicTests): + """Test suite for Power characteristic.""" + + @pytest.fixture + def characteristic(self) -> PowerCharacteristic: + """Provide Power characteristic.""" + return PowerCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Power.""" + return "2B05" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Power.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x00, 0x00]), + expected_value=10.0, + description="100 * 0.1 = 10.0 W", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00]), + expected_value=100.0, + description="1000 * 0.1 = 100.0 W", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=0.1, + description="1 * 0.1 = 0.1 W", + ), + ] diff --git a/tests/gatt/characteristics/test_precise_acceleration_3d.py b/tests/gatt/characteristics/test_precise_acceleration_3d.py new file mode 100644 index 00000000..7de45a7b --- /dev/null +++ b/tests/gatt/characteristics/test_precise_acceleration_3d.py @@ -0,0 +1,45 @@ +"""Tests for Precise Acceleration 3D characteristic (0x2C1E).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import PreciseAcceleration3DCharacteristic +from bluetooth_sig.gatt.characteristics.templates import VectorData + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestPreciseAcceleration3DCharacteristic(CommonCharacteristicTests): + """Test suite for Precise Acceleration 3D characteristic.""" + + @pytest.fixture + def characteristic(self) -> PreciseAcceleration3DCharacteristic: + return PreciseAcceleration3DCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2C1E" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=VectorData(x_axis=0.0, y_axis=0.0, z_axis=0.0), + description="Zero acceleration", + ), + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00, 0x00, 0x00, 0x00]), + expected_value=VectorData(x_axis=1.0, y_axis=0.0, z_axis=0.0), + description="1.0 gn on x-axis (raw 1000 * 0.001)", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = PreciseAcceleration3DCharacteristic() + original = VectorData(x_axis=0.5, y_axis=-0.25, z_axis=1.0) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_pushbutton_status_8.py b/tests/gatt/characteristics/test_pushbutton_status_8.py new file mode 100644 index 00000000..8935a7ba --- /dev/null +++ b/tests/gatt/characteristics/test_pushbutton_status_8.py @@ -0,0 +1,186 @@ +"""Tests for Pushbutton Status 8 characteristic (0x2C21).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.pushbutton_status_8 import ( + ButtonStatus, + PushbuttonStatus8Characteristic, + PushbuttonStatus8Data, +) +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestPushbuttonStatus8Characteristic(CommonCharacteristicTests): + """Test Pushbutton Status 8 characteristic implementation.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide Pushbutton Status 8 characteristic for testing.""" + return PushbuttonStatus8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Pushbutton Status 8.""" + return "2C21" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid pushbutton status test data covering various combinations.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.NOT_ACTUATED, + button_1=ButtonStatus.NOT_ACTUATED, + button_2=ButtonStatus.NOT_ACTUATED, + button_3=ButtonStatus.NOT_ACTUATED, + ), + description="All buttons not actuated", + ), + CharacteristicTestData( + input_data=bytearray([0x01]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.PRESSED, + button_1=ButtonStatus.NOT_ACTUATED, + button_2=ButtonStatus.NOT_ACTUATED, + button_3=ButtonStatus.NOT_ACTUATED, + ), + description="Button 0 pressed", + ), + CharacteristicTestData( + input_data=bytearray([0x04]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.NOT_ACTUATED, + button_1=ButtonStatus.PRESSED, + button_2=ButtonStatus.NOT_ACTUATED, + button_3=ButtonStatus.NOT_ACTUATED, + ), + description="Button 1 pressed", + ), + CharacteristicTestData( + input_data=bytearray([0x10]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.NOT_ACTUATED, + button_1=ButtonStatus.NOT_ACTUATED, + button_2=ButtonStatus.PRESSED, + button_3=ButtonStatus.NOT_ACTUATED, + ), + description="Button 2 pressed", + ), + CharacteristicTestData( + input_data=bytearray([0x40]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.NOT_ACTUATED, + button_1=ButtonStatus.NOT_ACTUATED, + button_2=ButtonStatus.NOT_ACTUATED, + button_3=ButtonStatus.PRESSED, + ), + description="Button 3 pressed", + ), + CharacteristicTestData( + input_data=bytearray([0x55]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.PRESSED, + button_1=ButtonStatus.PRESSED, + button_2=ButtonStatus.PRESSED, + button_3=ButtonStatus.PRESSED, + ), + description="All buttons pressed", + ), + CharacteristicTestData( + input_data=bytearray([0xAA]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.RELEASED, + button_1=ButtonStatus.RELEASED, + button_2=ButtonStatus.RELEASED, + button_3=ButtonStatus.RELEASED, + ), + description="All buttons released", + ), + CharacteristicTestData( + input_data=bytearray([0x09]), + expected_value=PushbuttonStatus8Data( + button_0=ButtonStatus.PRESSED, + button_1=ButtonStatus.RELEASED, + button_2=ButtonStatus.NOT_ACTUATED, + button_3=ButtonStatus.NOT_ACTUATED, + ), + description="Button 0 pressed, button 1 released", + ), + ] + + # === Pushbutton-Specific Tests === + + def test_individual_button_isolation(self, characteristic: BaseCharacteristic[Any]) -> None: + """Test that each 2-bit field is extracted independently.""" + # Button 0 at bits [1:0] + result = characteristic.parse_value(bytearray([0x02])) + assert result.button_0 == ButtonStatus.RELEASED + assert result.button_1 == ButtonStatus.NOT_ACTUATED + + # Button 1 at bits [3:2] + result = characteristic.parse_value(bytearray([0x08])) + assert result.button_1 == ButtonStatus.RELEASED + assert result.button_0 == ButtonStatus.NOT_ACTUATED + + # Button 2 at bits [5:4] + result = characteristic.parse_value(bytearray([0x20])) + assert result.button_2 == ButtonStatus.RELEASED + assert result.button_3 == ButtonStatus.NOT_ACTUATED + + # Button 3 at bits [7:6] + result = characteristic.parse_value(bytearray([0x80])) + assert result.button_3 == ButtonStatus.RELEASED + assert result.button_2 == ButtonStatus.NOT_ACTUATED + + def test_encoding(self, characteristic: PushbuttonStatus8Characteristic) -> None: + """Test encoding PushbuttonStatus8Data to bytes.""" + # All not actuated + data = PushbuttonStatus8Data( + button_0=ButtonStatus.NOT_ACTUATED, + button_1=ButtonStatus.NOT_ACTUATED, + button_2=ButtonStatus.NOT_ACTUATED, + button_3=ButtonStatus.NOT_ACTUATED, + ) + assert characteristic.build_value(data) == bytearray([0x00]) + + # All pressed + data = PushbuttonStatus8Data( + button_0=ButtonStatus.PRESSED, + button_1=ButtonStatus.PRESSED, + button_2=ButtonStatus.PRESSED, + button_3=ButtonStatus.PRESSED, + ) + assert characteristic.build_value(data) == bytearray([0x55]) + + # Mixed + data = PushbuttonStatus8Data( + button_0=ButtonStatus.RELEASED, + button_1=ButtonStatus.PRESSED, + button_2=ButtonStatus.NOT_ACTUATED, + button_3=ButtonStatus.RELEASED, + ) + # 0b10_00_01_10 = 0x86 + assert characteristic.build_value(data) == bytearray([0x86]) + + def test_round_trip(self, characteristic: PushbuttonStatus8Characteristic) -> None: # type: ignore[override] + """Test round-trip encoding/decoding preserves values.""" + test_bytes = [0x00, 0x01, 0x55, 0xAA, 0x09, 0x86, 0xFF] + + for raw in test_bytes: + decoded = characteristic.parse_value(bytearray([raw])) + encoded = characteristic.build_value(decoded) + assert encoded == bytearray([raw]), f"Round-trip failed for 0x{raw:02X}" + + def test_characteristic_metadata(self, characteristic: PushbuttonStatus8Characteristic) -> None: + """Test characteristic metadata.""" + assert characteristic.name == "Pushbutton Status 8" + assert characteristic.uuid == "2C21" diff --git a/tests/gatt/characteristics/test_python_type_auto_resolution.py b/tests/gatt/characteristics/test_python_type_auto_resolution.py new file mode 100644 index 00000000..76807d47 --- /dev/null +++ b/tests/gatt/characteristics/test_python_type_auto_resolution.py @@ -0,0 +1,249 @@ +"""Tests for automatic python_type resolution from generic parameters. + +Verifies the H.1 auto-resolution mechanism: + 1. CodingTemplate.resolve_python_type() — introspects CodingTemplate[T_co] + 2. BaseCharacteristic._resolve_generic_python_type() — introspects BaseCharacteristic[T] + 3. Resolution chain: YAML → template → generic param → manual _python_type +""" + +from __future__ import annotations + +from typing import Any + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.custom import CustomBaseCharacteristic +from bluetooth_sig.gatt.characteristics.templates import ( + EnumTemplate, + ScaledUint16Template, + Utf8StringTemplate, +) +from bluetooth_sig.gatt.characteristics.templates.base import CodingTemplate +from bluetooth_sig.gatt.characteristics.templates.numeric import Uint8Template +from bluetooth_sig.gatt.context import CharacteristicContext +from bluetooth_sig.types import CharacteristicInfo +from bluetooth_sig.types.uuid import BluetoothUUID + +# --------------------------------------------------------------------------- +# Helpers — unique UUIDs for throwaway test classes +# --------------------------------------------------------------------------- +_UUID_COUNTER = 0xAA000000 + + +def _next_uuid() -> BluetoothUUID: + global _UUID_COUNTER # noqa: PLW0603 + _UUID_COUNTER += 1 + return BluetoothUUID(f"{_UUID_COUNTER:08X}-0000-1000-8000-00805F9B34FB") + + +# =================================================================== +# Part 1: CodingTemplate.resolve_python_type() +# =================================================================== + + +class TestTemplateResolveType: + """CodingTemplate.resolve_python_type() extracts T from CodingTemplate[T_co].""" + + def test_uint8_template_resolves_int(self) -> None: + assert Uint8Template.resolve_python_type() is int + + def test_scaled_uint16_template_resolves_float(self) -> None: + assert ScaledUint16Template.resolve_python_type() is float + + def test_utf8_string_template_resolves_str(self) -> None: + assert Utf8StringTemplate.resolve_python_type() is str + + def test_enum_template_unbound_typevar_returns_none(self) -> None: + """EnumTemplate[T] has an unbound TypeVar — should not resolve.""" + assert EnumTemplate.resolve_python_type() is None + + def test_base_coding_template_returns_none(self) -> None: + """The abstract CodingTemplate itself has no concrete type arg.""" + assert CodingTemplate.resolve_python_type() is None + + def test_result_is_cached_per_class(self) -> None: + """Calling twice should return the same cached result.""" + first = Uint8Template.resolve_python_type() + second = Uint8Template.resolve_python_type() + assert first is second is int + + +# =================================================================== +# Part 2: BaseCharacteristic._resolve_generic_python_type() +# =================================================================== + + +class _FloatChar(CustomBaseCharacteristic): + """BaseCharacteristic[Any] (via Custom) then narrowed nowhere — should be None.""" + + _info = CharacteristicInfo(uuid=_next_uuid(), name="FloatCustom") + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + return 0.0 + + def _encode_value(self, data: float) -> bytearray: + return bytearray() + + +class _ConcreteGenericChar(BaseCharacteristic[float]): + """Directly parameterises BaseCharacteristic with float.""" + + _characteristic_name = "Temperature" + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + return 0.0 + + def _encode_value(self, data: float) -> bytearray: + return bytearray() + + +class _AnyGenericChar(BaseCharacteristic[Any]): + """BaseCharacteristic[Any] — should NOT resolve (Any is excluded).""" + + _characteristic_name = "Temperature" + + def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> Any: + return None + + def _encode_value(self, data: Any) -> bytearray: + return bytearray() + + +class TestGenericParamResolution: + """BaseCharacteristic._resolve_generic_python_type() introspects BaseCharacteristic[T].""" + + def test_concrete_type_resolves(self) -> None: + assert _ConcreteGenericChar._resolve_generic_python_type() is float + + def test_any_returns_none(self) -> None: + assert _AnyGenericChar._resolve_generic_python_type() is None + + def test_custom_base_returns_none(self) -> None: + """CustomBaseCharacteristic extends BaseCharacteristic[Any] — should not resolve.""" + assert CustomBaseCharacteristic._resolve_generic_python_type() is None + + def test_result_is_cached(self) -> None: + first = _ConcreteGenericChar._resolve_generic_python_type() + second = _ConcreteGenericChar._resolve_generic_python_type() + assert first is second is float + + +# =================================================================== +# Part 3: End-to-end resolution chain +# =================================================================== + + +class _TemplateOnlyChar(CustomBaseCharacteristic): + """Has a template (Uint8 → int) but no concrete generic param.""" + + _info = CharacteristicInfo(uuid=_next_uuid(), name="TemplateOnly") + _template = Uint8Template() + + def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: + return 0 + + def _encode_value(self, data: int) -> bytearray: + return bytearray() + + +class _GenericBeatsTemplate(BaseCharacteristic[int]): + """Generic param (int) should win over template (ScaledUint16 → float).""" + + _characteristic_name = "Battery Level" + _template = ScaledUint16Template(scale_factor=0.01) # type: ignore[assignment] + + def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: + return 0 + + def _encode_value(self, data: int) -> bytearray: + return bytearray() + + +class _ManualOverrideChar(BaseCharacteristic[float]): + """Manual _python_type (str) beats both generic (float) and template.""" + + _characteristic_name = "Temperature" + _python_type: type | str | None = str + + def _decode_value( + self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + return 0.0 + + def _encode_value(self, data: float) -> bytearray: + return bytearray() + + +class TestResolutionChain: + """Full end-to-end resolution: YAML → template → generic → manual override.""" + + def test_template_populates_python_type(self) -> None: + """Template type flows into info.python_type when no generic param exists.""" + char = _TemplateOnlyChar() + assert char.python_type is int + + def test_generic_param_overrides_template(self) -> None: + """Generic param (int) overrides template (float) because it is more authoritative.""" + char = _GenericBeatsTemplate() + assert char.python_type is int + + def test_manual_override_wins_over_all(self) -> None: + """Explicit _python_type wins over both template and generic param.""" + char = _ManualOverrideChar() + assert char.python_type is str + + +# =================================================================== +# Part 4: Spot-checks on real SIG characteristics +# =================================================================== + + +class TestRealCharacteristicResolution: + """Verify representative SIG characteristics resolve python_type correctly.""" + + def test_heart_rate_resolves_data_struct(self) -> None: + from bluetooth_sig.gatt.characteristics.heart_rate_measurement import ( + HeartRateData, + HeartRateMeasurementCharacteristic, + ) + + char = HeartRateMeasurementCharacteristic() + assert char.python_type is HeartRateData + + def test_pushbutton_status_8_resolves_data_struct(self) -> None: + from bluetooth_sig.gatt.characteristics.pushbutton_status_8 import ( + PushbuttonStatus8Characteristic, + PushbuttonStatus8Data, + ) + + char = PushbuttonStatus8Characteristic() + assert char.python_type is PushbuttonStatus8Data + + def test_fixed_string_8_resolves_str(self) -> None: + from bluetooth_sig.gatt.characteristics.fixed_string_8 import ( + FixedString8Characteristic, + ) + + char = FixedString8Characteristic() + assert char.python_type is str + + def test_temperature_resolves_float(self) -> None: + from bluetooth_sig.gatt.characteristics.temperature import ( + TemperatureCharacteristic, + ) + + char = TemperatureCharacteristic() + assert char.python_type is float + + def test_battery_level_resolves_int(self) -> None: + from bluetooth_sig.gatt.characteristics.battery_level import ( + BatteryLevelCharacteristic, + ) + + char = BatteryLevelCharacteristic() + # Generic param is int; template (PercentageTemplate) resolves float + # but generic param wins. + assert char.python_type is int diff --git a/tests/gatt/characteristics/test_relative_runtime_in_a_correlated_color_temperature_range.py b/tests/gatt/characteristics/test_relative_runtime_in_a_correlated_color_temperature_range.py new file mode 100644 index 00000000..666f8a9a --- /dev/null +++ b/tests/gatt/characteristics/test_relative_runtime_in_a_correlated_color_temperature_range.py @@ -0,0 +1,70 @@ +"""Tests for Relative Runtime in a CCT Range characteristic (0x2BE5).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + RelativeRuntimeInACorrelatedColorTemperatureRangeCharacteristic, +) +from bluetooth_sig.gatt.characteristics.relative_runtime_in_a_correlated_color_temperature_range import ( + RelativeRuntimeInACCTRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRelativeRuntimeInACCTRangeCharacteristic(CommonCharacteristicTests): + """Test suite for Relative Runtime in a CCT Range characteristic.""" + + @pytest.fixture + def characteristic(self) -> RelativeRuntimeInACorrelatedColorTemperatureRangeCharacteristic: + return RelativeRuntimeInACorrelatedColorTemperatureRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2BE5" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=RelativeRuntimeInACCTRangeData( + relative_runtime=0.0, + minimum_cct=0, + maximum_cct=0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0xC8, 0xBC, 0x0A, 0x3C, 0x13]), + expected_value=RelativeRuntimeInACCTRangeData( + relative_runtime=100.0, + minimum_cct=2748, + maximum_cct=4924, + ), + description="100% at warm-to-cool CCT", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = RelativeRuntimeInACorrelatedColorTemperatureRangeCharacteristic() + original = RelativeRuntimeInACCTRangeData( + relative_runtime=50.0, + minimum_cct=2700, + maximum_cct=6500, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum CCT must not exceed maximum.""" + with pytest.raises(ValueError, match="cannot exceed"): + RelativeRuntimeInACCTRangeData( + relative_runtime=50.0, + minimum_cct=6500, + maximum_cct=2700, + ) diff --git a/tests/gatt/characteristics/test_relative_runtime_in_a_current_range.py b/tests/gatt/characteristics/test_relative_runtime_in_a_current_range.py new file mode 100644 index 00000000..d2802680 --- /dev/null +++ b/tests/gatt/characteristics/test_relative_runtime_in_a_current_range.py @@ -0,0 +1,70 @@ +"""Tests for Relative Runtime in a Current Range characteristic (0x2B07).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + RelativeRuntimeInACurrentRangeCharacteristic, +) +from bluetooth_sig.gatt.characteristics.relative_runtime_in_a_current_range import ( + RelativeRuntimeInACurrentRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRelativeRuntimeInACurrentRangeCharacteristic(CommonCharacteristicTests): + """Test suite for Relative Runtime in a Current Range characteristic.""" + + @pytest.fixture + def characteristic(self) -> RelativeRuntimeInACurrentRangeCharacteristic: + return RelativeRuntimeInACurrentRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B07" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=RelativeRuntimeInACurrentRangeData( + relative_runtime=0.0, + minimum_current=0.0, + maximum_current=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0xC8, 0x32, 0x00, 0xC8, 0x00]), + expected_value=RelativeRuntimeInACurrentRangeData( + relative_runtime=100.0, + minimum_current=0.5, + maximum_current=2.0, + ), + description="100% at 0.5-2.0 A", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = RelativeRuntimeInACurrentRangeCharacteristic() + original = RelativeRuntimeInACurrentRangeData( + relative_runtime=50.0, + minimum_current=0.5, + maximum_current=2.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum current must not exceed maximum.""" + with pytest.raises(ValueError, match="cannot exceed"): + RelativeRuntimeInACurrentRangeData( + relative_runtime=50.0, + minimum_current=5.0, + maximum_current=1.0, + ) diff --git a/tests/gatt/characteristics/test_relative_runtime_in_a_generic_level_range.py b/tests/gatt/characteristics/test_relative_runtime_in_a_generic_level_range.py new file mode 100644 index 00000000..e77a4da8 --- /dev/null +++ b/tests/gatt/characteristics/test_relative_runtime_in_a_generic_level_range.py @@ -0,0 +1,70 @@ +"""Tests for Relative Runtime in a Generic Level Range characteristic (0x2B08).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + RelativeRuntimeInAGenericLevelRangeCharacteristic, +) +from bluetooth_sig.gatt.characteristics.relative_runtime_in_a_generic_level_range import ( + RelativeRuntimeInAGenericLevelRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRelativeRuntimeInAGenericLevelRangeCharacteristic(CommonCharacteristicTests): + """Test suite for Relative Runtime in a Generic Level Range characteristic.""" + + @pytest.fixture + def characteristic(self) -> RelativeRuntimeInAGenericLevelRangeCharacteristic: + return RelativeRuntimeInAGenericLevelRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B08" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=RelativeRuntimeInAGenericLevelRangeData( + relative_value=0.0, + minimum_generic_level=0, + maximum_generic_level=0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0xC8, 0x64, 0x00, 0xE8, 0x03]), + expected_value=RelativeRuntimeInAGenericLevelRangeData( + relative_value=100.0, + minimum_generic_level=100, + maximum_generic_level=1000, + ), + description="100% at level 100-1000", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = RelativeRuntimeInAGenericLevelRangeCharacteristic() + original = RelativeRuntimeInAGenericLevelRangeData( + relative_value=75.0, + minimum_generic_level=100, + maximum_generic_level=1000, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum level must not exceed maximum.""" + with pytest.raises(ValueError, match="cannot exceed"): + RelativeRuntimeInAGenericLevelRangeData( + relative_value=50.0, + minimum_generic_level=1000, + maximum_generic_level=100, + ) diff --git a/tests/gatt/characteristics/test_relative_value_in_a_period_of_day.py b/tests/gatt/characteristics/test_relative_value_in_a_period_of_day.py new file mode 100644 index 00000000..b0cc00d3 --- /dev/null +++ b/tests/gatt/characteristics/test_relative_value_in_a_period_of_day.py @@ -0,0 +1,61 @@ +"""Tests for Relative Value in a Period of Day characteristic (0x2B0B).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + RelativeValueInAPeriodOfDayCharacteristic, +) +from bluetooth_sig.gatt.characteristics.relative_value_in_a_period_of_day import ( + RelativeValueInAPeriodOfDayData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRelativeValueInAPeriodOfDayCharacteristic(CommonCharacteristicTests): + """Test suite for Relative Value in a Period of Day characteristic.""" + + @pytest.fixture + def characteristic(self) -> RelativeValueInAPeriodOfDayCharacteristic: + return RelativeValueInAPeriodOfDayCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B0B" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00]), + expected_value=RelativeValueInAPeriodOfDayData( + relative_value=0.0, + start_time=0.0, + end_time=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0xC8, 0x3C, 0xB4]), + expected_value=RelativeValueInAPeriodOfDayData( + relative_value=100.0, + start_time=6.0, + end_time=18.0, + ), + description="100% from 06:00 to 18:00", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = RelativeValueInAPeriodOfDayCharacteristic() + original = RelativeValueInAPeriodOfDayData( + relative_value=50.0, + start_time=8.0, + end_time=17.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_relative_value_in_a_temperature_range.py b/tests/gatt/characteristics/test_relative_value_in_a_temperature_range.py new file mode 100644 index 00000000..d9bce55e --- /dev/null +++ b/tests/gatt/characteristics/test_relative_value_in_a_temperature_range.py @@ -0,0 +1,70 @@ +"""Tests for Relative Value in a Temperature Range characteristic (0x2B0C).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + RelativeValueInATemperatureRangeCharacteristic, +) +from bluetooth_sig.gatt.characteristics.relative_value_in_a_temperature_range import ( + RelativeValueInATemperatureRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRelativeValueInATemperatureRangeCharacteristic(CommonCharacteristicTests): + """Test suite for Relative Value in a Temperature Range characteristic.""" + + @pytest.fixture + def characteristic(self) -> RelativeValueInATemperatureRangeCharacteristic: + return RelativeValueInATemperatureRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B0C" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=RelativeValueInATemperatureRangeData( + relative_value=0.0, + minimum_temperature=0.0, + maximum_temperature=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0xC8, 0xE8, 0x03, 0xD0, 0x07]), + expected_value=RelativeValueInATemperatureRangeData( + relative_value=100.0, + minimum_temperature=10.0, + maximum_temperature=20.0, + ), + description="100% at 10-20 C", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = RelativeValueInATemperatureRangeCharacteristic() + original = RelativeValueInATemperatureRangeData( + relative_value=50.0, + minimum_temperature=15.0, + maximum_temperature=25.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum temperature must not exceed maximum.""" + with pytest.raises(ValueError, match="cannot exceed"): + RelativeValueInATemperatureRangeData( + relative_value=50.0, + minimum_temperature=30.0, + maximum_temperature=10.0, + ) diff --git a/tests/gatt/characteristics/test_relative_value_in_a_voltage_range.py b/tests/gatt/characteristics/test_relative_value_in_a_voltage_range.py new file mode 100644 index 00000000..947106b1 --- /dev/null +++ b/tests/gatt/characteristics/test_relative_value_in_a_voltage_range.py @@ -0,0 +1,70 @@ +"""Tests for Relative Value in a Voltage Range characteristic (0x2B09).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + RelativeValueInAVoltageRangeCharacteristic, +) +from bluetooth_sig.gatt.characteristics.relative_value_in_a_voltage_range import ( + RelativeValueInAVoltageRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRelativeValueInAVoltageRangeCharacteristic(CommonCharacteristicTests): + """Test suite for Relative Value in a Voltage Range characteristic.""" + + @pytest.fixture + def characteristic(self) -> RelativeValueInAVoltageRangeCharacteristic: + return RelativeValueInAVoltageRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B09" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=RelativeValueInAVoltageRangeData( + relative_value=0.0, + minimum_voltage=0.0, + maximum_voltage=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0x64, 0x00, 0x04, 0x40, 0x06]), + expected_value=RelativeValueInAVoltageRangeData( + relative_value=50.0, + minimum_voltage=16.0, + maximum_voltage=25.0, + ), + description="50% at 16-25 V", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = RelativeValueInAVoltageRangeCharacteristic() + original = RelativeValueInAVoltageRangeData( + relative_value=50.0, + minimum_voltage=3.5, + maximum_voltage=5.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum voltage must not exceed maximum.""" + with pytest.raises(ValueError, match="cannot exceed"): + RelativeValueInAVoltageRangeData( + relative_value=50.0, + minimum_voltage=10.0, + maximum_voltage=5.0, + ) diff --git a/tests/gatt/characteristics/test_relative_value_in_an_illuminance_range.py b/tests/gatt/characteristics/test_relative_value_in_an_illuminance_range.py new file mode 100644 index 00000000..95a74728 --- /dev/null +++ b/tests/gatt/characteristics/test_relative_value_in_an_illuminance_range.py @@ -0,0 +1,70 @@ +"""Tests for Relative Value in an Illuminance Range characteristic (0x2B0A).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + RelativeValueInAnIlluminanceRangeCharacteristic, +) +from bluetooth_sig.gatt.characteristics.relative_value_in_an_illuminance_range import ( + RelativeValueInAnIlluminanceRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRelativeValueInAnIlluminanceRangeCharacteristic(CommonCharacteristicTests): + """Test suite for Relative Value in an Illuminance Range characteristic.""" + + @pytest.fixture + def characteristic(self) -> RelativeValueInAnIlluminanceRangeCharacteristic: + return RelativeValueInAnIlluminanceRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B0A" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=RelativeValueInAnIlluminanceRangeData( + relative_value=0.0, + minimum_illuminance=0.0, + maximum_illuminance=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0x64, 0x10, 0x27, 0x00, 0x50, 0xC3, 0x00]), + expected_value=RelativeValueInAnIlluminanceRangeData( + relative_value=50.0, + minimum_illuminance=100.0, + maximum_illuminance=500.0, + ), + description="50% at 100-500 lux", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = RelativeValueInAnIlluminanceRangeCharacteristic() + original = RelativeValueInAnIlluminanceRangeData( + relative_value=75.0, + minimum_illuminance=100.0, + maximum_illuminance=500.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum illuminance must not exceed maximum.""" + with pytest.raises(ValueError, match="cannot exceed"): + RelativeValueInAnIlluminanceRangeData( + relative_value=50.0, + minimum_illuminance=500.0, + maximum_illuminance=100.0, + ) diff --git a/tests/gatt/characteristics/test_sensor_location.py b/tests/gatt/characteristics/test_sensor_location.py new file mode 100644 index 00000000..da9412ba --- /dev/null +++ b/tests/gatt/characteristics/test_sensor_location.py @@ -0,0 +1,47 @@ +"""Tests for SensorLocation characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import SensorLocationCharacteristic +from bluetooth_sig.gatt.characteristics.sensor_location import SensorLocationValue +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestSensorLocationCharacteristic(CommonCharacteristicTests): + """Test suite for SensorLocation characteristic.""" + + @pytest.fixture + def characteristic(self) -> SensorLocationCharacteristic: + """Provide SensorLocation characteristic.""" + return SensorLocationCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for SensorLocation.""" + return "2A5D" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for SensorLocation.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x00]), + expected_value=SensorLocationValue.OTHER, + description="other", + ), + CharacteristicTestData( + input_data=bytearray([0x03]), + expected_value=SensorLocationValue.HIP, + description="hip", + ), + CharacteristicTestData( + input_data=bytearray([0x0E]), + expected_value=SensorLocationValue.CHEST, + description="chest", + ), + ] diff --git a/tests/gatt/characteristics/test_sulfur_hexafluoride_concentration.py b/tests/gatt/characteristics/test_sulfur_hexafluoride_concentration.py new file mode 100644 index 00000000..d4c28678 --- /dev/null +++ b/tests/gatt/characteristics/test_sulfur_hexafluoride_concentration.py @@ -0,0 +1,41 @@ +"""Tests for SulfurHexafluorideConcentration characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import SulfurHexafluorideConcentrationCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestSulfurHexafluorideConcentrationCharacteristic(CommonCharacteristicTests): + """Test suite for SulfurHexafluorideConcentration characteristic.""" + + @pytest.fixture + def characteristic(self) -> SulfurHexafluorideConcentrationCharacteristic: + """Provide SulfurHexafluorideConcentration characteristic.""" + return SulfurHexafluorideConcentrationCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for SulfurHexafluorideConcentration.""" + return "2BD9" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for SulfurHexafluorideConcentration.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x64, 0x80]), + expected_value=100.0, + description="100.0 ppm", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x80]), + expected_value=1.0, + description="1.0 ppm", + ), + ] diff --git a/tests/gatt/characteristics/test_supported_heart_rate_range.py b/tests/gatt/characteristics/test_supported_heart_rate_range.py new file mode 100644 index 00000000..74c219f1 --- /dev/null +++ b/tests/gatt/characteristics/test_supported_heart_rate_range.py @@ -0,0 +1,84 @@ +"""Tests for SupportedHeartRateRangeCharacteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import SupportedHeartRateRangeCharacteristic +from bluetooth_sig.gatt.characteristics.supported_heart_rate_range import ( + SupportedHeartRateRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestSupportedHeartRateRangeCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> SupportedHeartRateRangeCharacteristic: + return SupportedHeartRateRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AD7" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00]), + expected_value=SupportedHeartRateRangeData( + minimum=0, + maximum=0, + minimum_increment=0, + ), + description="Zero heart rate range", + ), + CharacteristicTestData( + # min=60 BPM, max=200 BPM, inc=1 BPM + input_data=bytearray([0x3C, 0xC8, 0x01]), + expected_value=SupportedHeartRateRangeData( + minimum=60, + maximum=200, + minimum_increment=1, + ), + description="Typical heart rate range (60-200 BPM, 1 BPM step)", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF, 0xFF]), + expected_value=SupportedHeartRateRangeData( + minimum=255, + maximum=255, + minimum_increment=255, + ), + description="Maximum heart rate range", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = SupportedHeartRateRangeCharacteristic() + original = SupportedHeartRateRangeData( + minimum=60, + maximum=180, + minimum_increment=1, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum > maximum is invalid.""" + with pytest.raises(ValueError, match="cannot be greater"): + SupportedHeartRateRangeData(minimum=200, maximum=60, minimum_increment=1) + + def test_validation_rejects_negative(self) -> None: + """Negative heart rate is invalid for uint8.""" + with pytest.raises(ValueError, match="outside valid range"): + SupportedHeartRateRangeData(minimum=-1, maximum=200, minimum_increment=1) + + def test_integer_values(self) -> None: + """Heart rate values are integers (no scaling).""" + data = SupportedHeartRateRangeData(minimum=60, maximum=200, minimum_increment=1) + assert isinstance(data.minimum, int) + assert isinstance(data.maximum, int) + assert isinstance(data.minimum_increment, int) diff --git a/tests/gatt/characteristics/test_supported_inclination_range.py b/tests/gatt/characteristics/test_supported_inclination_range.py new file mode 100644 index 00000000..57635e43 --- /dev/null +++ b/tests/gatt/characteristics/test_supported_inclination_range.py @@ -0,0 +1,82 @@ +"""Tests for SupportedInclinationRangeCharacteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import SupportedInclinationRangeCharacteristic +from bluetooth_sig.gatt.characteristics.supported_inclination_range import ( + SupportedInclinationRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestSupportedInclinationRangeCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> SupportedInclinationRangeCharacteristic: + return SupportedInclinationRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AD5" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=SupportedInclinationRangeData( + minimum=0.0, + maximum=0.0, + minimum_increment=0.0, + ), + description="Zero inclination range", + ), + CharacteristicTestData( + # min=-15.0% (raw -150=0xFF6A as sint16 LE: 0x6A, 0xFF) + # max=15.0% (raw 150=0x0096) + # inc=0.5% (raw 5=0x0005) + input_data=bytearray([0x6A, 0xFF, 0x96, 0x00, 0x05, 0x00]), + expected_value=SupportedInclinationRangeData( + minimum=-15.0, + maximum=15.0, + minimum_increment=0.5, + ), + description="Treadmill inclination range (-15% to +15%, 0.5% step)", + ), + CharacteristicTestData( + # max positive sint16: 32767 -> 3276.7% + # max uint16 increment: 65535 -> 6553.5% + input_data=bytearray([0x00, 0x80, 0xFF, 0x7F, 0xFF, 0xFF]), + expected_value=SupportedInclinationRangeData( + minimum=-3276.8, + maximum=3276.7, + minimum_increment=6553.5, + ), + description="Full range (sint16 min/max, uint16 max increment)", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = SupportedInclinationRangeCharacteristic() + original = SupportedInclinationRangeData( + minimum=-10.0, + maximum=15.0, + minimum_increment=0.5, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum > maximum is invalid.""" + with pytest.raises(ValueError, match="cannot be greater"): + SupportedInclinationRangeData(minimum=15.0, maximum=-5.0, minimum_increment=0.5) + + def test_negative_inclination_allowed(self) -> None: + """Negative inclination (decline) is valid for sint16.""" + data = SupportedInclinationRangeData(minimum=-20.0, maximum=-5.0, minimum_increment=1.0) + assert data.minimum == -20.0 + assert data.maximum == -5.0 diff --git a/tests/gatt/characteristics/test_supported_resistance_level_range.py b/tests/gatt/characteristics/test_supported_resistance_level_range.py new file mode 100644 index 00000000..5fecf5ea --- /dev/null +++ b/tests/gatt/characteristics/test_supported_resistance_level_range.py @@ -0,0 +1,77 @@ +"""Tests for SupportedResistanceLevelRangeCharacteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import SupportedResistanceLevelRangeCharacteristic +from bluetooth_sig.gatt.characteristics.supported_resistance_level_range import ( + SupportedResistanceLevelRangeData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestSupportedResistanceLevelRangeCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> SupportedResistanceLevelRangeCharacteristic: + return SupportedResistanceLevelRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AD6" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00]), + expected_value=SupportedResistanceLevelRangeData( + minimum=0.0, + maximum=0.0, + minimum_increment=0.0, + ), + description="Zero resistance range", + ), + CharacteristicTestData( + # min=10 (raw 1), max=200 (raw 20), inc=10 (raw 1) + input_data=bytearray([0x01, 0x14, 0x01]), + expected_value=SupportedResistanceLevelRangeData( + minimum=10.0, + maximum=200.0, + minimum_increment=10.0, + ), + description="Typical bike resistance range (10-200, step 10)", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF, 0xFF]), + expected_value=SupportedResistanceLevelRangeData( + minimum=2550.0, + maximum=2550.0, + minimum_increment=2550.0, + ), + description="Maximum resistance range", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = SupportedResistanceLevelRangeCharacteristic() + original = SupportedResistanceLevelRangeData( + minimum=20.0, + maximum=500.0, + minimum_increment=10.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum > maximum is invalid.""" + with pytest.raises(ValueError, match="cannot be greater"): + SupportedResistanceLevelRangeData(minimum=200.0, maximum=50.0, minimum_increment=10.0) + + def test_validation_rejects_negative(self) -> None: + """Negative resistance is invalid for uint8.""" + with pytest.raises(ValueError, match="outside valid range"): + SupportedResistanceLevelRangeData(minimum=-10.0, maximum=100.0, minimum_increment=10.0) diff --git a/tests/gatt/characteristics/test_supported_speed_range.py b/tests/gatt/characteristics/test_supported_speed_range.py new file mode 100644 index 00000000..b72644c7 --- /dev/null +++ b/tests/gatt/characteristics/test_supported_speed_range.py @@ -0,0 +1,76 @@ +"""Tests for SupportedSpeedRangeCharacteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import SupportedSpeedRangeCharacteristic +from bluetooth_sig.gatt.characteristics.supported_speed_range import SupportedSpeedRangeData + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestSupportedSpeedRangeCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> SupportedSpeedRangeCharacteristic: + return SupportedSpeedRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AD4" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=SupportedSpeedRangeData( + minimum=0.0, + maximum=0.0, + minimum_increment=0.0, + ), + description="Zero speed range", + ), + CharacteristicTestData( + # min=1.00 km/h (100=0x0064), max=20.00 km/h (2000=0x07D0), + # inc=0.10 km/h (10=0x000A) + input_data=bytearray([0x64, 0x00, 0xD0, 0x07, 0x0A, 0x00]), + expected_value=SupportedSpeedRangeData( + minimum=1.0, + maximum=20.0, + minimum_increment=0.1, + ), + description="Walking/running treadmill range (1-20 km/h, 0.1 step)", + ), + CharacteristicTestData( + input_data=bytearray([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]), + expected_value=SupportedSpeedRangeData( + minimum=655.35, + maximum=655.35, + minimum_increment=655.35, + ), + description="Maximum speed range", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = SupportedSpeedRangeCharacteristic() + original = SupportedSpeedRangeData( + minimum=5.0, + maximum=30.0, + minimum_increment=0.5, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum > maximum is invalid.""" + with pytest.raises(ValueError, match="cannot be greater"): + SupportedSpeedRangeData(minimum=20.0, maximum=5.0, minimum_increment=0.1) + + def test_validation_rejects_negative(self) -> None: + """Negative speed is invalid for uint16.""" + with pytest.raises(ValueError, match="outside valid range"): + SupportedSpeedRangeData(minimum=-1.0, maximum=10.0, minimum_increment=0.1) diff --git a/tests/gatt/characteristics/test_temperature_8.py b/tests/gatt/characteristics/test_temperature_8.py new file mode 100644 index 00000000..bffc5329 --- /dev/null +++ b/tests/gatt/characteristics/test_temperature_8.py @@ -0,0 +1,46 @@ +"""Tests for Temperature8 characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import Temperature8Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTemperature8Characteristic(CommonCharacteristicTests): + """Test suite for Temperature8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> Temperature8Characteristic: + """Provide Temperature8 characteristic.""" + return Temperature8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Temperature8.""" + return "2B0D" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Temperature8.""" + return [ + CharacteristicTestData( + input_data=bytearray([50]), + expected_value=25.0, + description="50 * 0.5 = 25.0 degC", + ), + CharacteristicTestData( + input_data=bytearray([0xEC]), + expected_value=-10.0, + description="-20 * 0.5 = -10.0 degC", + ), + CharacteristicTestData( + input_data=bytearray([0]), + expected_value=0.0, + description="0 degC", + ), + ] diff --git a/tests/gatt/characteristics/test_temperature_8_in_a_period_of_day.py b/tests/gatt/characteristics/test_temperature_8_in_a_period_of_day.py new file mode 100644 index 00000000..370cf579 --- /dev/null +++ b/tests/gatt/characteristics/test_temperature_8_in_a_period_of_day.py @@ -0,0 +1,68 @@ +"""Tests for Temperature 8 in a Period of Day characteristic (0x2B0E).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + Temperature8InAPeriodOfDayCharacteristic, +) +from bluetooth_sig.gatt.characteristics.temperature_8_in_a_period_of_day import ( + Temperature8InAPeriodOfDayData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestTemperature8InAPeriodOfDayCharacteristic(CommonCharacteristicTests): + """Test suite for Temperature 8 in a Period of Day characteristic.""" + + @pytest.fixture + def characteristic(self) -> Temperature8InAPeriodOfDayCharacteristic: + return Temperature8InAPeriodOfDayCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B0E" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00]), + expected_value=Temperature8InAPeriodOfDayData( + temperature=0.0, + start_time=0.0, + end_time=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0x2C, 0x3C, 0xB4]), + expected_value=Temperature8InAPeriodOfDayData( + temperature=22.0, + start_time=6.0, + end_time=18.0, + ), + description="22 C from 06:00 to 18:00", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = Temperature8InAPeriodOfDayCharacteristic() + original = Temperature8InAPeriodOfDayData( + temperature=10.0, + start_time=8.0, + end_time=20.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_negative_temperature(self) -> None: + """Verify negative temperature decodes correctly.""" + char = Temperature8InAPeriodOfDayCharacteristic() + # sint8 -20 = 0xEC, representing -10.0 C + result = char.parse_value(bytearray([0xEC, 0x00, 0x00])) + assert result.temperature == -10.0 diff --git a/tests/gatt/characteristics/test_temperature_8_statistics.py b/tests/gatt/characteristics/test_temperature_8_statistics.py new file mode 100644 index 00000000..d5ef09cd --- /dev/null +++ b/tests/gatt/characteristics/test_temperature_8_statistics.py @@ -0,0 +1,78 @@ +"""Tests for Temperature 8 Statistics characteristic (0x2B0F).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + Temperature8StatisticsCharacteristic, +) +from bluetooth_sig.gatt.characteristics.temperature_8_statistics import ( + Temperature8StatisticsData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestTemperature8StatisticsCharacteristic(CommonCharacteristicTests): + """Test suite for Temperature 8 Statistics characteristic.""" + + @pytest.fixture + def characteristic(self) -> Temperature8StatisticsCharacteristic: + return Temperature8StatisticsCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B0F" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=Temperature8StatisticsData( + average=0.0, + standard_deviation=0.0, + minimum=0.0, + maximum=0.0, + sensing_duration=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray([0x14, 0x04, 0x0A, 0x1E, 0x40]), + expected_value=Temperature8StatisticsData( + average=10.0, + standard_deviation=2.0, + minimum=5.0, + maximum=15.0, + sensing_duration=1.0, + ), + description="Typical temp stats (raw 64 = 1.1^0 = 1.0 s)", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = Temperature8StatisticsCharacteristic() + original = Temperature8StatisticsData( + average=10.0, + standard_deviation=2.0, + minimum=5.0, + maximum=15.0, + sensing_duration=0.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_negative_duration(self) -> None: + """Negative sensing duration is invalid.""" + with pytest.raises(ValueError, match="cannot be negative"): + Temperature8StatisticsData( + average=0.0, + standard_deviation=0.0, + minimum=0.0, + maximum=0.0, + sensing_duration=-1.0, + ) diff --git a/tests/gatt/characteristics/test_temperature_range.py b/tests/gatt/characteristics/test_temperature_range.py new file mode 100644 index 00000000..c87cfa9b --- /dev/null +++ b/tests/gatt/characteristics/test_temperature_range.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import TemperatureRangeCharacteristic +from bluetooth_sig.gatt.characteristics.temperature_range import TemperatureRangeData + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestTemperatureRangeCharacteristic(CommonCharacteristicTests): + @pytest.fixture + def characteristic(self) -> TemperatureRangeCharacteristic: + return TemperatureRangeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B10" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00]), + expected_value=TemperatureRangeData(minimum=0.0, maximum=0.0), + description="Zero temperature range", + ), + CharacteristicTestData( + # min = 20.00°C → raw 2000 (0x07D0), max = 25.00°C → raw 2500 (0x09C4) + input_data=bytearray([0xD0, 0x07, 0xC4, 0x09]), + expected_value=TemperatureRangeData(minimum=20.0, maximum=25.0), + description="Room temperature range (20-25°C)", + ), + CharacteristicTestData( + # min = -10.00°C → raw -1000 (0xFC18), max = 40.00°C → raw 4000 (0x0FA0) + input_data=bytearray([0x18, 0xFC, 0xA0, 0x0F]), + expected_value=TemperatureRangeData(minimum=-10.0, maximum=40.0), + description="Wide range with negative minimum", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = TemperatureRangeCharacteristic() + original = TemperatureRangeData(minimum=-5.5, maximum=35.75) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert abs(decoded.minimum - original.minimum) < 0.01 + assert abs(decoded.maximum - original.maximum) < 0.01 + + def test_validation_rejects_inverted_range(self) -> None: + """Minimum > maximum is invalid.""" + with pytest.raises(ValueError, match="cannot be greater"): + TemperatureRangeData(minimum=30.0, maximum=20.0) diff --git a/tests/gatt/characteristics/test_temperature_statistics.py b/tests/gatt/characteristics/test_temperature_statistics.py new file mode 100644 index 00000000..a95bc983 --- /dev/null +++ b/tests/gatt/characteristics/test_temperature_statistics.py @@ -0,0 +1,90 @@ +"""Tests for Temperature Statistics characteristic (0x2B11).""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import ( + TemperatureStatisticsCharacteristic, +) +from bluetooth_sig.gatt.characteristics.temperature_statistics import ( + TemperatureStatisticsData, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestTemperatureStatisticsCharacteristic(CommonCharacteristicTests): + """Test suite for Temperature Statistics characteristic.""" + + @pytest.fixture + def characteristic(self) -> TemperatureStatisticsCharacteristic: + return TemperatureStatisticsCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2B11" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + expected_value=TemperatureStatisticsData( + average=0.0, + standard_deviation=0.0, + minimum=0.0, + maximum=0.0, + sensing_duration=0.0, + ), + description="All zeros", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0xCA, + 0x08, # avg: 2250 -> 22.50 C + 0x96, + 0x00, # std: 150 -> 1.50 C + 0x08, + 0x07, # min: 1800 -> 18.00 C + 0x8C, + 0x0A, # max: 2700 -> 27.00 C + 0x40, # duration: raw 64 -> 1.0 s + ] + ), + expected_value=TemperatureStatisticsData( + average=22.5, + standard_deviation=1.5, + minimum=18.0, + maximum=27.0, + sensing_duration=1.0, + ), + description="Typical temperature stats", + ), + ] + + def test_encode_round_trip(self) -> None: + """Verify encode/decode round-trip.""" + char = TemperatureStatisticsCharacteristic() + original = TemperatureStatisticsData( + average=22.5, + standard_deviation=1.5, + minimum=18.0, + maximum=27.0, + sensing_duration=0.0, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded == original + + def test_validation_rejects_negative_duration(self) -> None: + """Negative sensing duration is invalid.""" + with pytest.raises(ValueError, match="cannot be negative"): + TemperatureStatisticsData( + average=0.0, + standard_deviation=0.0, + minimum=0.0, + maximum=0.0, + sensing_duration=-1.0, + ) diff --git a/tests/gatt/characteristics/test_time_decihour_8.py b/tests/gatt/characteristics/test_time_decihour_8.py new file mode 100644 index 00000000..ae0c6bac --- /dev/null +++ b/tests/gatt/characteristics/test_time_decihour_8.py @@ -0,0 +1,48 @@ +"""Tests for TimeDecihour8 characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import TimeDecihour8Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTimeDecihour8Characteristic(CommonCharacteristicTests): + """Test suite for TimeDecihour8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> TimeDecihour8Characteristic: + """Provide TimeDecihour8 characteristic.""" + return TimeDecihour8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for TimeDecihour8.""" + return "2B12" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for TimeDecihour8.""" + return [ + CharacteristicTestData( + input_data=bytearray([10]), + expected_value=datetime.timedelta(seconds=3600), + description="10 decihours = 1 hour", + ), + CharacteristicTestData( + input_data=bytearray([1]), + expected_value=datetime.timedelta(seconds=360), + description="1 decihour = 6 min", + ), + CharacteristicTestData( + input_data=bytearray([100]), + expected_value=datetime.timedelta(seconds=36000), + description="100 decihours = 10 hours", + ), + ] diff --git a/tests/gatt/characteristics/test_time_exponential_8.py b/tests/gatt/characteristics/test_time_exponential_8.py new file mode 100644 index 00000000..b2eb748c --- /dev/null +++ b/tests/gatt/characteristics/test_time_exponential_8.py @@ -0,0 +1,43 @@ +"""Tests for TimeExponential8 characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import TimeExponential8Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTimeExponential8Characteristic(CommonCharacteristicTests): + """Test suite for TimeExponential8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> TimeExponential8Characteristic: + """Provide TimeExponential8 characteristic.""" + return TimeExponential8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for TimeExponential8.""" + return "2B13" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for TimeExponential8.""" + return [ + CharacteristicTestData( + input_data=bytearray([64]), + expected_value=datetime.timedelta(seconds=1), + description="1.1^(64-64) = 1.0 second", + ), + CharacteristicTestData( + input_data=bytearray([74]), + expected_value=datetime.timedelta(seconds=1.1**10), + description="1.1^(74-64) = 1.1^10 seconds", + ), + ] diff --git a/tests/gatt/characteristics/test_time_hour_24.py b/tests/gatt/characteristics/test_time_hour_24.py new file mode 100644 index 00000000..bf114624 --- /dev/null +++ b/tests/gatt/characteristics/test_time_hour_24.py @@ -0,0 +1,48 @@ +"""Tests for TimeHour24 characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import TimeHour24Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTimeHour24Characteristic(CommonCharacteristicTests): + """Test suite for TimeHour24 characteristic.""" + + @pytest.fixture + def characteristic(self) -> TimeHour24Characteristic: + """Provide TimeHour24 characteristic.""" + return TimeHour24Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for TimeHour24.""" + return "2B14" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for TimeHour24.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=datetime.timedelta(hours=1), + description="1 hour", + ), + CharacteristicTestData( + input_data=bytearray([0x18, 0x00, 0x00]), + expected_value=datetime.timedelta(hours=24), + description="24 hours", + ), + CharacteristicTestData( + input_data=bytearray([0x02, 0x00, 0x00]), + expected_value=datetime.timedelta(hours=2), + description="2 hours", + ), + ] diff --git a/tests/gatt/characteristics/test_time_millisecond_24.py b/tests/gatt/characteristics/test_time_millisecond_24.py new file mode 100644 index 00000000..036d411b --- /dev/null +++ b/tests/gatt/characteristics/test_time_millisecond_24.py @@ -0,0 +1,48 @@ +"""Tests for TimeMillisecond24 characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import TimeMillisecond24Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTimeMillisecond24Characteristic(CommonCharacteristicTests): + """Test suite for TimeMillisecond24 characteristic.""" + + @pytest.fixture + def characteristic(self) -> TimeMillisecond24Characteristic: + """Provide TimeMillisecond24 characteristic.""" + return TimeMillisecond24Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for TimeMillisecond24.""" + return "2B15" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for TimeMillisecond24.""" + return [ + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00]), + expected_value=datetime.timedelta(seconds=1), + description="1000 ms = 1 sec", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=datetime.timedelta(milliseconds=1), + description="1 ms", + ), + CharacteristicTestData( + input_data=bytearray([0xD0, 0x07, 0x00]), + expected_value=datetime.timedelta(seconds=2), + description="2000 ms = 2 sec", + ), + ] diff --git a/tests/gatt/characteristics/test_time_second_16.py b/tests/gatt/characteristics/test_time_second_16.py new file mode 100644 index 00000000..a2e29d6f --- /dev/null +++ b/tests/gatt/characteristics/test_time_second_16.py @@ -0,0 +1,48 @@ +"""Tests for TimeSecond16 characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import TimeSecond16Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTimeSecond16Characteristic(CommonCharacteristicTests): + """Test suite for TimeSecond16 characteristic.""" + + @pytest.fixture + def characteristic(self) -> TimeSecond16Characteristic: + """Provide TimeSecond16 characteristic.""" + return TimeSecond16Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for TimeSecond16.""" + return "2B16" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for TimeSecond16.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x3C, 0x00]), + expected_value=datetime.timedelta(seconds=60), + description="60 seconds", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=datetime.timedelta(seconds=1), + description="1 second", + ), + CharacteristicTestData( + input_data=bytearray([0x10, 0x0E]), + expected_value=datetime.timedelta(seconds=3600), + description="3600 seconds", + ), + ] diff --git a/tests/gatt/characteristics/test_time_second_32.py b/tests/gatt/characteristics/test_time_second_32.py new file mode 100644 index 00000000..e2a54b16 --- /dev/null +++ b/tests/gatt/characteristics/test_time_second_32.py @@ -0,0 +1,48 @@ +"""Tests for TimeSecond32 characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import TimeSecond32Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTimeSecond32Characteristic(CommonCharacteristicTests): + """Test suite for TimeSecond32 characteristic.""" + + @pytest.fixture + def characteristic(self) -> TimeSecond32Characteristic: + """Provide TimeSecond32 characteristic.""" + return TimeSecond32Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for TimeSecond32.""" + return "2BE6" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for TimeSecond32.""" + return [ + CharacteristicTestData( + input_data=bytearray([0x10, 0x0E, 0x00, 0x00]), + expected_value=datetime.timedelta(seconds=3600), + description="3600 seconds", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00, 0x00]), + expected_value=datetime.timedelta(seconds=1), + description="1 second", + ), + CharacteristicTestData( + input_data=bytearray([0x80, 0x51, 0x01, 0x00]), + expected_value=datetime.timedelta(seconds=86400), + description="86400 sec = 1 day", + ), + ] diff --git a/tests/gatt/characteristics/test_time_second_8.py b/tests/gatt/characteristics/test_time_second_8.py new file mode 100644 index 00000000..568f86a4 --- /dev/null +++ b/tests/gatt/characteristics/test_time_second_8.py @@ -0,0 +1,48 @@ +"""Tests for TimeSecond8 characteristic.""" + +from __future__ import annotations + +import datetime + +import pytest + +from bluetooth_sig.gatt.characteristics import TimeSecond8Characteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTimeSecond8Characteristic(CommonCharacteristicTests): + """Test suite for TimeSecond8 characteristic.""" + + @pytest.fixture + def characteristic(self) -> TimeSecond8Characteristic: + """Provide TimeSecond8 characteristic.""" + return TimeSecond8Characteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for TimeSecond8.""" + return "2B17" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for TimeSecond8.""" + return [ + CharacteristicTestData( + input_data=bytearray([10]), + expected_value=datetime.timedelta(seconds=10), + description="10 seconds", + ), + CharacteristicTestData( + input_data=bytearray([1]), + expected_value=datetime.timedelta(seconds=1), + description="1 second", + ), + CharacteristicTestData( + input_data=bytearray([60]), + expected_value=datetime.timedelta(seconds=60), + description="60 seconds", + ), + ] diff --git a/tests/gatt/characteristics/test_torque.py b/tests/gatt/characteristics/test_torque.py new file mode 100644 index 00000000..a0f65d5d --- /dev/null +++ b/tests/gatt/characteristics/test_torque.py @@ -0,0 +1,46 @@ +"""Tests for Torque characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import TorqueCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestTorqueCharacteristic(CommonCharacteristicTests): + """Test suite for Torque characteristic.""" + + @pytest.fixture + def characteristic(self) -> TorqueCharacteristic: + """Provide Torque characteristic.""" + return TorqueCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Torque.""" + return "2C0B" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Torque.""" + return [ + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03, 0x00, 0x00]), + expected_value=10.0, + description="1000 * 0.01 = 10.0 Nm", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00, 0x00]), + expected_value=0.01, + description="1 * 0.01 = 0.01 Nm", + ), + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00, 0x00]), + expected_value=0.0, + description="zero torque", + ), + ] diff --git a/tests/gatt/characteristics/test_uncertainty.py b/tests/gatt/characteristics/test_uncertainty.py index 89efa92b..ca565b07 100644 --- a/tests/gatt/characteristics/test_uncertainty.py +++ b/tests/gatt/characteristics/test_uncertainty.py @@ -40,7 +40,7 @@ def test_uncertainty_parsing(self, characteristic: UncertaintyCharacteristic) -> """Test Uncertainty characteristic parsing.""" # Test metadata assert characteristic.unit == "m" - assert characteristic.value_type.value == "float" + assert characteristic.python_type is float # Test normal parsing test_data = bytearray([0x69]) # 105 = 10.5m diff --git a/tests/gatt/characteristics/test_voc_concentration.py b/tests/gatt/characteristics/test_voc_concentration.py index 3b41b92c..29a4a310 100644 --- a/tests/gatt/characteristics/test_voc_concentration.py +++ b/tests/gatt/characteristics/test_voc_concentration.py @@ -37,7 +37,7 @@ def test_voc_concentration_parsing(self, characteristic: VOCConcentrationCharact """Test VOC concentration characteristic parsing.""" # Test metadata - Updated for SIG spec compliance (uint16, ppb) assert characteristic.unit == "ppb" - assert characteristic.value_type_resolved.value == "int" # uint16 format + assert characteristic.python_type is int # uint16 format # Test normal value parsing test_data = bytearray([0x00, 0x04]) # 1024 ppb diff --git a/tests/gatt/characteristics/test_volume_flow.py b/tests/gatt/characteristics/test_volume_flow.py new file mode 100644 index 00000000..15f24e47 --- /dev/null +++ b/tests/gatt/characteristics/test_volume_flow.py @@ -0,0 +1,46 @@ +"""Tests for VolumeFlow characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics import VolumeFlowCharacteristic +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestVolumeFlowCharacteristic(CommonCharacteristicTests): + """Test suite for VolumeFlow characteristic.""" + + @pytest.fixture + def characteristic(self) -> VolumeFlowCharacteristic: + """Provide VolumeFlow characteristic.""" + return VolumeFlowCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for VolumeFlow.""" + return "2B1B" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for VolumeFlow.""" + return [ + CharacteristicTestData( + input_data=bytearray([0xE8, 0x03]), + expected_value=1.0, + description="1000 * 0.001 = 1.0 L/s", + ), + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=0.001, + description="1 * 0.001 = 0.001 L/s", + ), + CharacteristicTestData( + input_data=bytearray([0x00, 0x00]), + expected_value=0.0, + description="zero flow", + ), + ] diff --git a/tests/gatt/services/test_body_composition_service.py b/tests/gatt/services/test_body_composition_service.py index ade967fc..887679a9 100644 --- a/tests/gatt/services/test_body_composition_service.py +++ b/tests/gatt/services/test_body_composition_service.py @@ -13,7 +13,6 @@ from bluetooth_sig.gatt.characteristics.body_composition_measurement import BodyCompositionMeasurementCharacteristic from bluetooth_sig.gatt.exceptions import CharacteristicParseError from bluetooth_sig.gatt.services.body_composition import BodyCompositionService -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.units import MeasurementSystem, WeightUnit from .test_service_common import CommonServiceTests @@ -26,8 +25,10 @@ def test_characteristic_name(self) -> None: """Test characteristic name resolution.""" char = BodyCompositionMeasurementCharacteristic() assert char.name == "Body Composition Measurement" - # Value type for multi-field characteristic is VARIOUS - assert char.value_type == ValueType.VARIOUS + # Auto-resolved from BaseCharacteristic[BodyCompositionMeasurementData] + from bluetooth_sig.gatt.characteristics.body_composition_measurement import BodyCompositionMeasurementData + + assert char.python_type is BodyCompositionMeasurementData def test_parse_basic_body_fat_metric(self) -> None: """Test parsing basic body fat percentage in metric units.""" @@ -114,7 +115,9 @@ def test_characteristic_name(self) -> None: """Test characteristic name resolution.""" char = BodyCompositionFeatureCharacteristic() assert char.name == "Body Composition Feature" - assert char.value_type == ValueType.BITFIELD + from bluetooth_sig.gatt.characteristics.body_composition_feature import BodyCompositionFeatureData + + assert char.python_type is BodyCompositionFeatureData def test_parse_basic_features(self) -> None: """Test parsing basic feature flags.""" diff --git a/tests/gatt/services/test_custom_services.py b/tests/gatt/services/test_custom_services.py index af18049e..73a9fa71 100644 --- a/tests/gatt/services/test_custom_services.py +++ b/tests/gatt/services/test_custom_services.py @@ -26,7 +26,6 @@ from bluetooth_sig.gatt.services.registry import GattServiceRegistry from bluetooth_sig.gatt.services.unknown import UnknownService from bluetooth_sig.types import CharacteristicInfo, ServiceInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID # ============================================================================== @@ -552,7 +551,7 @@ def test_manual_characteristic_addition( uuid=BluetoothUUID("AAAA0100-0000-1000-8000-00805F9B34FB"), name="Test Char", unit="", - value_type=ValueType.BYTES, + python_type=bytes, ), ) @@ -573,7 +572,7 @@ def test_service_validation_with_characteristics( uuid=BluetoothUUID("AAAA0200-0000-1000-8000-00805F9B34FB"), name="Char 1", unit="", - value_type=ValueType.BYTES, + python_type=bytes, ), ) char2 = UnknownCharacteristic( @@ -581,7 +580,7 @@ def test_service_validation_with_characteristics( uuid=BluetoothUUID("AAAA0201-0000-1000-8000-00805F9B34FB"), name="Char 2", unit="", - value_type=ValueType.BYTES, + python_type=bytes, ), ) diff --git a/tests/gatt/services/test_weight_scale_service.py b/tests/gatt/services/test_weight_scale_service.py index 7aaa8a9d..6dfabc38 100644 --- a/tests/gatt/services/test_weight_scale_service.py +++ b/tests/gatt/services/test_weight_scale_service.py @@ -12,7 +12,6 @@ ) from bluetooth_sig.gatt.exceptions import CharacteristicParseError from bluetooth_sig.gatt.services.weight_scale import WeightScaleService -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.units import MeasurementSystem, WeightUnit @@ -23,8 +22,10 @@ def test_characteristic_name(self) -> None: """Test characteristic name resolution.""" char = WeightMeasurementCharacteristic() assert char._characteristic_name == "Weight Measurement" - # Value type for multi-field characteristic is VARIOUS - assert char.value_type == ValueType.VARIOUS + # Auto-resolved from BaseCharacteristic[WeightMeasurementData] + from bluetooth_sig.gatt.characteristics.weight_measurement import WeightMeasurementData + + assert char.python_type is WeightMeasurementData def test_parse_basic_weight_metric(self) -> None: """Test parsing basic weight in metric units.""" @@ -86,7 +87,9 @@ def test_characteristic_name(self) -> None: """Test characteristic name resolution.""" char = WeightScaleFeatureCharacteristic() assert char._characteristic_name == "Weight Scale Feature" - assert char.value_type == ValueType.BITFIELD + from bluetooth_sig.gatt.characteristics.weight_scale_feature import WeightScaleFeatureData + + assert char.python_type is WeightScaleFeatureData def test_parse_basic_features(self) -> None: """Test parsing basic feature flags.""" diff --git a/tests/gatt/test_context.py b/tests/gatt/test_context.py index 46b74351..5c161c85 100644 --- a/tests/gatt/test_context.py +++ b/tests/gatt/test_context.py @@ -5,7 +5,6 @@ from bluetooth_sig.gatt.characteristics.custom import CustomBaseCharacteristic from bluetooth_sig.gatt.context import CharacteristicContext, DeviceInfo from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -20,7 +19,7 @@ class CalibrationCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789001"), name="Test Calibration", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: @@ -38,7 +37,7 @@ class MeasurementCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("12345678-1234-1234-1234-123456789002"), name="Test Measurement", unit="", - value_type=ValueType.FLOAT, + python_type=float, ) def _decode_value( diff --git a/tests/gatt/test_resolver.py b/tests/gatt/test_resolver.py index 61e3c70d..939cc4c8 100644 --- a/tests/gatt/test_resolver.py +++ b/tests/gatt/test_resolver.py @@ -15,7 +15,6 @@ ServiceRegistrySearch, ) from bluetooth_sig.types import CharacteristicInfo, ServiceInfo -from bluetooth_sig.types.gatt_enums import ValueType class TestNameNormalizer: @@ -227,8 +226,8 @@ def test_search_returns_characteristic_info(self) -> None: assert hasattr(result, "uuid") assert hasattr(result, "name") assert hasattr(result, "unit") - assert hasattr(result, "value_type") - assert isinstance(result.value_type, ValueType) + assert hasattr(result, "python_type") + assert result.python_type is None or isinstance(result.python_type, type) class TestServiceRegistrySearch: diff --git a/tests/integration/test_custom_registration.py b/tests/integration/test_custom_registration.py index 7b699166..b229fde7 100644 --- a/tests/integration/test_custom_registration.py +++ b/tests/integration/test_custom_registration.py @@ -16,7 +16,7 @@ from bluetooth_sig.gatt.services.registry import GattServiceRegistry from bluetooth_sig.gatt.uuid_registry import uuid_registry from bluetooth_sig.types import CharacteristicInfo, ServiceInfo -from bluetooth_sig.types.gatt_enums import GattProperty, ValueType +from bluetooth_sig.types.gatt_enums import GattProperty from bluetooth_sig.types.uuid import BluetoothUUID @@ -28,7 +28,7 @@ class CustomCharacteristicImpl(CustomBaseCharacteristic): uuid=BluetoothUUID("abcd1234-0000-1000-8000-00805f9b34fb"), name="CustomCharacteristicImpl", unit="", - value_type=ValueType.INT, + python_type=int, ) def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> int: @@ -51,7 +51,7 @@ def from_uuid( uuid=uuid, name="CustomCharacteristicImpl", unit="", - value_type=ValueType.INT, + python_type=int, ) return cls(info=info) @@ -76,7 +76,7 @@ def test_register_custom_characteristic_metadata(self) -> None: uuid=BluetoothUUID("abcd1234-0000-1000-8000-00805f9b34fb"), name="Test Characteristic", unit="°C", - value_type=ValueType.FLOAT, + python_type=float, ) # Verify registration @@ -84,7 +84,7 @@ def test_register_custom_characteristic_metadata(self) -> None: assert info is not None assert info.name == "Test Characteristic" assert info.unit == "°C" - assert info.value_type == ValueType.FLOAT + assert info.python_type is float def test_register_custom_service_metadata(self) -> None: """Test registering custom service metadata.""" @@ -127,7 +127,7 @@ def test_translator_register_custom_characteristic(self) -> None: uuid=BluetoothUUID("abcd1234-0000-1000-8000-00805f9b34fb"), name="Test Characteristic", unit="°C", - value_type=ValueType.INT, + python_type=int, ), ) # Verify class registration cls = CharacteristicRegistry.get_characteristic_class_by_uuid("abcd1234-0000-1000-8000-00805f9b34fb") @@ -184,7 +184,7 @@ def test_conflict_detection(self) -> None: uuid=BluetoothUUID("2A19"), # Battery Level UUID name="Custom Battery", unit="%", - value_type=ValueType.INT, + python_type=int, ) def test_override_allowed(self) -> None: @@ -194,7 +194,7 @@ def test_override_allowed(self) -> None: uuid=BluetoothUUID("2A19"), # Battery Level UUID name="Custom Battery", unit="%", - value_type=ValueType.INT, + python_type=int, override=True, ) @@ -255,7 +255,7 @@ def _class_body(namespace: dict[str, Any]) -> None: # pragma: no cover uuid=BluetoothUUID("2A19"), # Battery Level UUID (SIG assigned) name="Unauthorized SIG Override", unit="%", - value_type=ValueType.INT, + python_type=int, ), } ) @@ -296,7 +296,7 @@ class SIGOverrideWithPermission(CustomBaseCharacteristic, allow_sig_override=Tru uuid=BluetoothUUID("2A19"), # Battery Level UUID (SIG assigned) name="Authorized SIG Override", unit="%", - value_type=ValueType.INT, + python_type=int, ) def _decode_value( # pylint: disable=duplicate-code diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py index 43f98c09..268cc874 100644 --- a/tests/integration/test_end_to_end.py +++ b/tests/integration/test_end_to_end.py @@ -15,7 +15,6 @@ from bluetooth_sig.gatt.characteristics.custom import CustomBaseCharacteristic from bluetooth_sig.gatt.context import CharacteristicContext from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.units import PressureUnit from bluetooth_sig.types.uuid import BluetoothUUID @@ -32,7 +31,7 @@ class CalibrationCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("CA11B001-0000-1000-8000-00805F9B34FB"), name="Calibration Factor", unit="unitless", - value_type=ValueType.FLOAT, + python_type=float, ) min_length = 4 @@ -71,7 +70,7 @@ class SensorReadingCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("5E450001-0000-1000-8000-00805F9B34FB"), name="Sensor Reading", unit="calibrated units", - value_type=ValueType.FLOAT, + python_type=float, ) # Reference calibration directly (no hardcoded UUIDs) @@ -126,7 +125,7 @@ class SequenceNumberCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("5E900001-0000-1000-8000-00805F9B34FB"), name="Sequence Number", unit="", - value_type=ValueType.INT, + python_type=int, ) min_length = 2 @@ -153,7 +152,7 @@ class SequencedDataCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("5E9DA7A1-0000-1000-8000-00805F9B34FB"), name="Sequenced Data", unit="various", - value_type=ValueType.BYTES, + python_type=bytes, ) # Declare dependency using direct class reference (following Django ForeignKey pattern) @@ -438,7 +437,7 @@ class CharA(CustomBaseCharacteristic): uuid=BluetoothUUID("C4A1AAAA-0000-1000-8000-00805F9B34FB"), name="Char A", unit="", - value_type=ValueType.INT, + python_type=int, ) # Forward reference will be resolved after CharB is defined _required_dependencies: ClassVar[list[type]] = [] @@ -456,7 +455,7 @@ class CharB(CustomBaseCharacteristic): uuid=BluetoothUUID("C4A1BBBB-0000-1000-8000-00805F9B34FB"), name="Char B", unit="", - value_type=ValueType.INT, + python_type=int, ) # Reference CharA directly (no hardcoding) _required_dependencies: ClassVar[list[type]] = [CharA] @@ -543,7 +542,7 @@ class MeasurementCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("0EA50001-0000-1000-8000-00805F9B34FB"), name="Measurement", unit="units", - value_type=ValueType.INT, + python_type=int, ) min_length = 2 @@ -564,7 +563,7 @@ class ContextCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("C0111E11-0000-1000-8000-00805F9B34FB"), name="Context", unit="various", - value_type=ValueType.DICT, + python_type=dict, ) _required_dependencies: ClassVar[list[type]] = [MeasurementCharacteristic] @@ -596,7 +595,7 @@ class EnrichmentCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("E111C401-0000-1000-8000-00805F9B34FB"), name="Enrichment", unit="factor", - value_type=ValueType.FLOAT, + python_type=float, ) min_length = 4 @@ -622,7 +621,7 @@ class DataCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("DA1A0001-0000-1000-8000-00805F9B34FB"), name="Data", unit="various", - value_type=ValueType.DICT, + python_type=dict, ) _optional_dependencies: ClassVar[list[type]] = [EnrichmentCharacteristic] @@ -654,7 +653,7 @@ class MultiDependencyCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("F0EADF11-0000-1000-8000-00805F9B34FB"), name="Multi Dependency", unit="composite", - value_type=ValueType.DICT, + python_type=dict, ) _required_dependencies: ClassVar[list[type]] = [ diff --git a/tests/integration/test_format_types_integration.py b/tests/integration/test_format_types_integration.py index 70e67b86..9d851ce8 100644 --- a/tests/integration/test_format_types_integration.py +++ b/tests/integration/test_format_types_integration.py @@ -3,7 +3,7 @@ from __future__ import annotations from bluetooth_sig.registry.core.formattypes import format_types_registry -from bluetooth_sig.types.gatt_enums import DataType +from bluetooth_sig.types.gatt_enums import WIRE_TYPE_MAP from bluetooth_sig.types.registry.formattypes import FormatTypeInfo @@ -41,16 +41,14 @@ def test_format_type_info_matches_hardcoded_enum(self) -> None: assert utf16s_info.value == 0x1A assert utf16s_info.short_name == "utf16s" - def test_datatype_enum_integration(self) -> None: - """Test that DataType enum integrates properly with format types.""" - # UTF16S should exist and work - assert DataType.UTF16S.value == "utf16s" - assert DataType.from_string("utf16s") == DataType.UTF16S - assert DataType.UTF16S.to_value_type().value == "string" - assert DataType.UTF16S.to_python_type() == "string" + def test_wire_type_map_integration(self) -> None: + """Test that WIRE_TYPE_MAP integrates properly with format types.""" + # utf16s and utf8s should both map to str + assert WIRE_TYPE_MAP["utf16s"] is str + assert WIRE_TYPE_MAP["utf8s"] is str - # Should be different from UTF8S - assert DataType.UTF16S != DataType.UTF8S # type: ignore[comparison-overlap] + # They should share the same mapping target + assert WIRE_TYPE_MAP["utf16s"] is WIRE_TYPE_MAP["utf8s"] def test_registry_singleton(self) -> None: """Test that format_types_registry is a proper singleton.""" diff --git a/tests/registry/test_registry_validation.py b/tests/registry/test_registry_validation.py index df54952c..b7ae95e1 100644 --- a/tests/registry/test_registry_validation.py +++ b/tests/registry/test_registry_validation.py @@ -17,7 +17,6 @@ from bluetooth_sig.gatt.services.base import BaseGattService from bluetooth_sig.gatt.uuid_registry import uuid_registry from bluetooth_sig.types import CharacteristicInfo -from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -168,27 +167,16 @@ def test_characteristic_properties(self, char_class: type[BaseCharacteristic[Any # Use Progressive API Level 1 - SIG characteristics with no parameters char = char_class() - # Verify value_type is set - assert hasattr(char, "value_type"), f"Characteristic {char_class.__name__} should have value_type attribute" - assert char.value_type, f"Characteristic {char_class.__name__} value_type should not be empty" - - # Verify value_type is one of the expected types - valid_types = { - "string", - "int", - "float", - "bool", - "boolean", - "bytes", - "dict", - "various", - "datetime", - "uuid", - "bitfield", - } - assert char.value_type.value in valid_types, ( - f"Characteristic {char_class.__name__} value_type '{char.value_type.value}' " - f"should be one of {valid_types}" + # Verify python_type is set + assert hasattr(char, "python_type"), ( + f"Characteristic {char_class.__name__} should have python_type attribute" + ) + assert char.python_type is not None, f"Characteristic {char_class.__name__} python_type should not be None" + + # Verify python_type is a sensible type or string template name + assert isinstance(char.python_type, (type, str)), ( + f"Characteristic {char_class.__name__} python_type '{char.python_type}' " + f"should be a type or string template name" ) # Verify decode_value method exists @@ -361,7 +349,7 @@ class TemperatureCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("AA112A6E-0000-1000-8000-00805F9B34FB"), name="Temperature", unit="°C", - value_type=ValueType.FLOAT, + python_type=float, ) def _decode_value( @@ -450,7 +438,7 @@ class ModelNumberStringCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("AA112A24-0000-1000-8000-00805F9B34FB"), name="Model Number String", unit="", - value_type=ValueType.STRING, + python_type=str, ) def _decode_value( @@ -490,7 +478,7 @@ class UnknownTestCharacteristic(CustomBaseCharacteristic): uuid=BluetoothUUID("1234"), name="Test Unknown Characteristic", unit="", - value_type=ValueType.STRING, + python_type=str, ) def _decode_value( diff --git a/tests/registry/test_yaml_cross_reference.py b/tests/registry/test_yaml_cross_reference.py index 9528c81c..9c9f5b66 100644 --- a/tests/registry/test_yaml_cross_reference.py +++ b/tests/registry/test_yaml_cross_reference.py @@ -252,5 +252,5 @@ def test_yaml_automation_integration_with_existing_functionality(self) -> None: assert humidity_char.unit in ["%", ""], "Humidity unit should work" # Test that value types are still accessible - assert hasattr(temp_char, "value_type"), "Should have value_type" - assert hasattr(humidity_char, "value_type"), "Should have value_type" + assert hasattr(temp_char, "python_type"), "Should have python_type" + assert hasattr(humidity_char, "python_type"), "Should have python_type" diff --git a/tests/types/test_gatt_enums.py b/tests/types/test_gatt_enums.py index 95dea4fb..34174951 100644 --- a/tests/types/test_gatt_enums.py +++ b/tests/types/test_gatt_enums.py @@ -1,65 +1,42 @@ -"""Tests for GATT enums functionality.""" +"""Tests for GATT enums and WIRE_TYPE_MAP functionality.""" from __future__ import annotations -from bluetooth_sig.types.gatt_enums import DataType, ValueType +from bluetooth_sig.types.gatt_enums import WIRE_TYPE_MAP -class TestDataType: - """Test the DataType enum.""" +class TestWireTypeMap: + """Test the WIRE_TYPE_MAP lookup table.""" - def test_utf16s_enum_member(self) -> None: - """Test that UTF16S enum member exists.""" - assert hasattr(DataType, "UTF16S") - assert DataType.UTF16S.value == "utf16s" + def test_utf16s_maps_to_str(self) -> None: + """Test that utf16s maps to str.""" + assert WIRE_TYPE_MAP["utf16s"] is str - def test_from_string_utf16s(self) -> None: - """Test that from_string("utf16s") returns UTF16S, not UTF8S.""" - result = DataType.from_string("utf16s") - assert result == DataType.UTF16S - assert result != DataType.UTF8S # type: ignore[comparison-overlap] + def test_utf8s_maps_to_str(self) -> None: + """Test that utf8s maps to str.""" + assert WIRE_TYPE_MAP["utf8s"] is str - def test_utf16s_to_value_type(self) -> None: - """Test that UTF16S.to_value_type() returns STRING.""" - assert DataType.UTF16S.to_value_type() == ValueType.STRING + def test_integer_types(self) -> None: + """Test that unsigned and signed integer wire types map to int.""" + for key in ("uint8", "uint16", "uint24", "uint32", "uint64", "sint8", "sint16", "sint24", "sint32", "sint64"): + assert WIRE_TYPE_MAP[key] is int, f"{key} should map to int" - def test_utf16s_to_python_type(self) -> None: - """Test that UTF16S.to_python_type() returns "string".""" - assert DataType.UTF16S.to_python_type() == "string" + def test_float_types(self) -> None: + """Test that float wire types map to float.""" + for key in ("float32", "float64", "medfloat16", "medfloat32", "sfloat", "float"): + assert WIRE_TYPE_MAP[key] is float, f"{key} should map to float" - def test_utf8s_still_works(self) -> None: - """Test that UTF8S still works as before.""" - assert DataType.UTF8S.to_value_type() == ValueType.STRING - assert DataType.UTF8S.to_python_type() == "string" + def test_boolean_type(self) -> None: + """Test that boolean maps to bool.""" + assert WIRE_TYPE_MAP["boolean"] is bool - def test_from_string_case_insensitive(self) -> None: - """Test that from_string is case insensitive.""" - assert DataType.from_string("UTF16S") == DataType.UTF16S - assert DataType.from_string("utf16s") == DataType.UTF16S - assert DataType.from_string("Utf16s") == DataType.UTF16S + def test_unknown_type_returns_none(self) -> None: + """Test that unknown wire type strings return None via .get().""" + assert WIRE_TYPE_MAP.get("unknown_type") is None + assert WIRE_TYPE_MAP.get("") is None + assert WIRE_TYPE_MAP.get("struct") is None - def test_from_string_aliases(self) -> None: - """Test that aliases still work.""" - assert DataType.from_string("sfloat") == DataType.MEDFLOAT16 - assert DataType.from_string("float") == DataType.FLOAT32 - assert DataType.from_string("variable") == DataType.STRUCT - - def test_from_string_unknown(self) -> None: - """Test that unknown strings return UNKNOWN.""" - assert DataType.from_string("unknown_type") == DataType.UNKNOWN - assert DataType.from_string("") == DataType.UNKNOWN - assert DataType.from_string(None) == DataType.UNKNOWN - - def test_all_members_have_value_type(self) -> None: - """Test that all DataType members have a valid to_value_type.""" - for member in DataType: - value_type = member.to_value_type() - assert isinstance(value_type, ValueType) - assert value_type != ValueType.UNKNOWN or member == DataType.UNKNOWN - - def test_all_members_have_python_type(self) -> None: - """Test that all DataType members have a valid to_python_type.""" - for member in DataType: - python_type = member.to_python_type() - assert isinstance(python_type, str) - assert len(python_type) > 0 + def test_all_values_are_types(self) -> None: + """Test that every value in the map is a Python type.""" + for key, val in WIRE_TYPE_MAP.items(): + assert isinstance(val, type), f"WIRE_TYPE_MAP[{key!r}] = {val!r} is not a type" diff --git a/tests/utils/test_performance_tracking.py b/tests/utils/test_performance_tracking.py deleted file mode 100644 index 28ccb3cd..00000000 --- a/tests/utils/test_performance_tracking.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Performance tracking tests using pytest-benchmark for historical comparison. - -These tests use pytest-benchmark to track parsing performance over time. -Run with: pytest tests/test_performance_tracking.py --benchmark-only --benchmark-save= -Compare with: pytest-benchmark compare - -Historical data is stored in .benchmarks/ directory for regression detection. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from bluetooth_sig import BluetoothSIGTranslator - -# Skip all tests in this file until proper performance tracking is implemented -pytestmark = pytest.mark.skip( - reason="Performance tracking disabled - needs proper benchmark infrastructure with historical data storage" -) - - -class TestPerformanceTracking: - """Track parsing performance to detect regressions using pytest-benchmark.""" - - @pytest.fixture - def translator(self) -> BluetoothSIGTranslator: - """Create a translator instance for testing.""" - return BluetoothSIGTranslator() - - @pytest.mark.benchmark - def test_battery_level_parse_performance( - self, translator: BluetoothSIGTranslator, benchmark: Any - ) -> None: # pytest-benchmark fixture - """Benchmark battery level parsing performance.""" - battery_data = bytes([0x64]) # 100% - - def parse_battery() -> None: - result = translator.parse_characteristic("2A19", battery_data) - assert result.parse_success - - benchmark(parse_battery) - - @pytest.mark.benchmark - def test_temperature_parse_performance( - self, translator: BluetoothSIGTranslator, benchmark: Any - ) -> None: # pytest-benchmark fixture - """Benchmark temperature parsing performance.""" - temp_data = bytes([0x64, 0x09]) # 24.20°C - - def parse_temperature() -> None: - result = translator.parse_characteristic("2A6E", temp_data) - assert result.parse_success - - benchmark(parse_temperature) - - @pytest.mark.benchmark - def test_batch_parse_performance( - self, translator: BluetoothSIGTranslator, benchmark: Any - ) -> None: # pytest-benchmark fixture - """Benchmark batch parsing performance for multiple characteristics.""" - sensor_data = { - "2A19": bytes([0x55]), # 85% battery - "2A6E": bytes([0x58, 0x07]), # 18.64°C temperature - "2A6F": bytes([0x38, 0x19]), # 65.12% humidity - "2A6D": bytes([0x70, 0x96, 0x00, 0x00]), # 996.8 hPa pressure - } - - def parse_batch() -> None: - results = translator.parse_characteristics(sensor_data) - assert len(results) == 4 - assert all(r.parse_success for r in results.values()) - - benchmark(parse_batch) - - @pytest.mark.benchmark - def test_uuid_resolution_performance( - self, translator: BluetoothSIGTranslator, benchmark: Any - ) -> None: # pytest-benchmark fixture - """Benchmark UUID resolution performance.""" - - def resolve_uuid() -> None: - info = translator.get_characteristic_info_by_uuid("2A19") - assert info is not None - - benchmark(resolve_uuid) From b362e6939266d5b3789a6480cc4ab28b518f2647 Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Mon, 23 Feb 2026 21:12:59 +0000 Subject: [PATCH 9/9] Even more chars --- pyproject.toml | 7 +- .../gatt/characteristics/__init__.py | 95 ++++ .../characteristics/battery_energy_status.py | 150 ++++++ .../battery_health_information.py | 171 +++++++ .../characteristics/battery_health_status.py | 179 +++++++ .../characteristics/battery_information.py | 275 ++++++++++ .../characteristics/battery_time_status.py | 195 +++++++ .../characteristics/blood_pressure_record.py | 141 ++++++ .../gatt/characteristics/cgm_feature.py | 164 ++++++ .../gatt/characteristics/cgm_measurement.py | 279 ++++++++++ .../characteristics/cgm_session_run_time.py | 89 ++++ .../characteristics/cgm_session_start_time.py | 120 +++++ .../gatt/characteristics/cgm_status.py | 144 ++++++ .../characteristics/characteristic_meta.py | 2 +- .../characteristics/cross_trainer_data.py | 479 ++++++++++++++++++ .../characteristics/current_elapsed_time.py | 152 ++++++ .../enhanced_blood_pressure_measurement.py | 216 ++++++++ .../enhanced_intermediate_cuff_pressure.py | 186 +++++++ .../characteristics/fitness_machine_common.py | 194 +++++++ ...0601_regulatory_certification_data_list.py | 86 ++++ .../gatt/characteristics/indoor_bike_data.py | 385 ++++++++++++++ .../characteristics/pm10_concentration.py | 14 +- .../gatt/characteristics/pm1_concentration.py | 18 +- .../characteristics/pm25_concentration.py | 13 +- .../gatt/characteristics/rower_data.py | 383 ++++++++++++++ .../characteristics/stair_climber_data.py | 302 +++++++++++ .../gatt/characteristics/step_climber_data.py | 294 +++++++++++ .../gatt/characteristics/treadmill_data.py | 429 ++++++++++++++++ src/bluetooth_sig/gatt/resolver.py | 4 +- .../registry/core/class_of_device.py | 2 +- src/bluetooth_sig/registry/gss.py | 2 +- .../test_battery_energy_status.py | 137 +++++ .../test_battery_health_information.py | 130 +++++ .../test_battery_health_status.py | 166 ++++++ .../test_battery_information.py | 180 +++++++ .../test_battery_time_status.py | 205 ++++++++ .../test_blood_pressure_record.py | 154 ++++++ .../gatt/characteristics/test_cgm_feature.py | 137 +++++ .../characteristics/test_cgm_measurement.py | 227 +++++++++ .../test_cgm_session_run_time.py | 93 ++++ .../test_cgm_session_start_time.py | 115 +++++ tests/gatt/characteristics/test_cgm_status.py | 133 +++++ .../test_cross_trainer_data.py | 261 ++++++++++ .../test_current_elapsed_time.py | 183 +++++++ .../test_custom_characteristics.py | 12 +- ...est_enhanced_blood_pressure_measurement.py | 218 ++++++++ ...est_enhanced_intermediate_cuff_pressure.py | 150 ++++++ .../test_ieee_11073_20601_regulatory.py | 79 +++ .../characteristics/test_indoor_bike_data.py | 180 +++++++ .../test_pm10_concentration.py | 40 +- .../characteristics/test_pm1_concentration.py | 65 +-- .../test_pm25_concentration.py | 38 +- tests/gatt/characteristics/test_rower_data.py | 194 +++++++ .../test_stair_climber_data.py | 143 ++++++ .../characteristics/test_step_climber_data.py | 157 ++++++ .../characteristics/test_treadmill_data.py | 263 ++++++++++ tests/integration/test_examples.py | 6 +- tests/registry/test_yaml_units.py | 2 +- tests/stream/test_pairing.py | 6 +- 59 files changed, 8749 insertions(+), 95 deletions(-) create mode 100644 src/bluetooth_sig/gatt/characteristics/battery_energy_status.py create mode 100644 src/bluetooth_sig/gatt/characteristics/battery_health_information.py create mode 100644 src/bluetooth_sig/gatt/characteristics/battery_health_status.py create mode 100644 src/bluetooth_sig/gatt/characteristics/battery_information.py create mode 100644 src/bluetooth_sig/gatt/characteristics/battery_time_status.py create mode 100644 src/bluetooth_sig/gatt/characteristics/blood_pressure_record.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cgm_feature.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cgm_measurement.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cgm_session_run_time.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cgm_session_start_time.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cgm_status.py create mode 100644 src/bluetooth_sig/gatt/characteristics/cross_trainer_data.py create mode 100644 src/bluetooth_sig/gatt/characteristics/current_elapsed_time.py create mode 100644 src/bluetooth_sig/gatt/characteristics/enhanced_blood_pressure_measurement.py create mode 100644 src/bluetooth_sig/gatt/characteristics/enhanced_intermediate_cuff_pressure.py create mode 100644 src/bluetooth_sig/gatt/characteristics/fitness_machine_common.py create mode 100644 src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py create mode 100644 src/bluetooth_sig/gatt/characteristics/indoor_bike_data.py create mode 100644 src/bluetooth_sig/gatt/characteristics/rower_data.py create mode 100644 src/bluetooth_sig/gatt/characteristics/stair_climber_data.py create mode 100644 src/bluetooth_sig/gatt/characteristics/step_climber_data.py create mode 100644 src/bluetooth_sig/gatt/characteristics/treadmill_data.py create mode 100644 tests/gatt/characteristics/test_battery_energy_status.py create mode 100644 tests/gatt/characteristics/test_battery_health_information.py create mode 100644 tests/gatt/characteristics/test_battery_health_status.py create mode 100644 tests/gatt/characteristics/test_battery_information.py create mode 100644 tests/gatt/characteristics/test_battery_time_status.py create mode 100644 tests/gatt/characteristics/test_blood_pressure_record.py create mode 100644 tests/gatt/characteristics/test_cgm_feature.py create mode 100644 tests/gatt/characteristics/test_cgm_measurement.py create mode 100644 tests/gatt/characteristics/test_cgm_session_run_time.py create mode 100644 tests/gatt/characteristics/test_cgm_session_start_time.py create mode 100644 tests/gatt/characteristics/test_cgm_status.py create mode 100644 tests/gatt/characteristics/test_cross_trainer_data.py create mode 100644 tests/gatt/characteristics/test_current_elapsed_time.py create mode 100644 tests/gatt/characteristics/test_enhanced_blood_pressure_measurement.py create mode 100644 tests/gatt/characteristics/test_enhanced_intermediate_cuff_pressure.py create mode 100644 tests/gatt/characteristics/test_ieee_11073_20601_regulatory.py create mode 100644 tests/gatt/characteristics/test_indoor_bike_data.py create mode 100644 tests/gatt/characteristics/test_rower_data.py create mode 100644 tests/gatt/characteristics/test_stair_climber_data.py create mode 100644 tests/gatt/characteristics/test_step_climber_data.py create mode 100644 tests/gatt/characteristics/test_treadmill_data.py diff --git a/pyproject.toml b/pyproject.toml index 615cc4cd..90de20c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,11 +49,11 @@ dev = [ "pytest-cov>=6.2,<8", "nest-asyncio>=1.5.0", "pytest-benchmark~=5.1", - "ruff~=0.13", + "ruff~=0.15", "pydocstyle~=6.3", "types-PyYAML~=6.0", "types-requests~=2.32", - "mypy>=1.13.0", + "mypy>=1.19.0", "ipdb~=0.13", "coverage~=7.0"] test = [ @@ -335,6 +335,9 @@ convention = "google" # Prevent inheriting a parent pydocstyle config which could change the # effective set of checked codes; keep the repository config deterministic. inherit = false +# D202: No blank lines allowed after function docstring - conflicts with Black +# formatter when there are nested functions (Black requires blank line) +add-ignore = ["D202"] [tool.git-cliff.changelog] # Auto-generated changelog from conventional commits diff --git a/src/bluetooth_sig/gatt/characteristics/__init__.py b/src/bluetooth_sig/gatt/characteristics/__init__.py index ca8eab8f..7a504145 100644 --- a/src/bluetooth_sig/gatt/characteristics/__init__.py +++ b/src/bluetooth_sig/gatt/characteristics/__init__.py @@ -37,10 +37,16 @@ from .barometric_pressure_trend import BarometricPressureTrendCharacteristic from .base import BaseCharacteristic from .battery_critical_status import BatteryCriticalStatusCharacteristic +from .battery_energy_status import BatteryEnergyStatusCharacteristic +from .battery_health_information import BatteryHealthInformationCharacteristic +from .battery_health_status import BatteryHealthStatusCharacteristic +from .battery_information import BatteryInformationCharacteristic from .battery_level import BatteryLevelCharacteristic from .battery_level_status import BatteryLevelStatusCharacteristic +from .battery_time_status import BatteryTimeStatusCharacteristic from .blood_pressure_feature import BloodPressureFeatureCharacteristic from .blood_pressure_measurement import BloodPressureMeasurementCharacteristic +from .blood_pressure_record import BloodPressureRecordCharacteristic, BloodPressureRecordData from .body_composition_feature import BodyCompositionFeatureCharacteristic from .body_composition_measurement import BodyCompositionMeasurementCharacteristic from .body_sensor_location import BodySensorLocation, BodySensorLocationCharacteristic @@ -56,6 +62,19 @@ from .boot_mouse_input_report import BootMouseInputReportCharacteristic, BootMouseInputReportData, MouseButtons from .caloric_intake import CaloricIntakeCharacteristic from .carbon_monoxide_concentration import CarbonMonoxideConcentrationCharacteristic +from .cgm_feature import CGMFeatureCharacteristic, CGMFeatureData, CGMFeatureFlags, CGMSampleLocation, CGMType +from .cgm_measurement import ( + CGMCalTempOctet, + CGMMeasurementCharacteristic, + CGMMeasurementData, + CGMMeasurementFlags, + CGMMeasurementRecord, + CGMSensorStatusOctet, + CGMWarningOctet, +) +from .cgm_session_run_time import CGMSessionRunTimeCharacteristic, CGMSessionRunTimeData +from .cgm_session_start_time import CGMSessionStartTimeCharacteristic, CGMSessionStartTimeData +from .cgm_status import CGMStatusCharacteristic, CGMStatusData, CGMStatusFlags from .chromatic_distance_from_planckian import ChromaticDistanceFromPlanckianCharacteristic from .chromaticity_coordinate import ChromaticityCoordinateCharacteristic from .chromaticity_coordinates import ChromaticityCoordinatesCharacteristic, ChromaticityCoordinatesData @@ -74,8 +93,15 @@ from .count_16 import Count16Characteristic from .count_24 import Count24Characteristic from .country_code import CountryCodeCharacteristic +from .cross_trainer_data import CrossTrainerData, CrossTrainerDataCharacteristic from .csc_feature import CSCFeatureCharacteristic from .csc_measurement import CSCMeasurementCharacteristic +from .current_elapsed_time import ( + CurrentElapsedTimeCharacteristic, + CurrentElapsedTimeData, + ElapsedTimeFlags, + TimeResolution, +) from .current_time import CurrentTimeCharacteristic from .cycling_power_control_point import CyclingPowerControlPointCharacteristic from .cycling_power_feature import CyclingPowerFeatureCharacteristic @@ -105,6 +131,16 @@ EnergyInAPeriodOfDayCharacteristic, EnergyInAPeriodOfDayData, ) +from .enhanced_blood_pressure_measurement import ( + EnhancedBloodPressureData, + EnhancedBloodPressureFlags, + EnhancedBloodPressureMeasurementCharacteristic, + EpochYear, +) +from .enhanced_intermediate_cuff_pressure import ( + EnhancedIntermediateCuffPressureCharacteristic, + EnhancedIntermediateCuffPressureData, +) from .estimated_service_date import EstimatedServiceDateCharacteristic from .event_statistics import EventStatisticsCharacteristic, EventStatisticsData from .exact_time_256 import ExactTime256Characteristic, ExactTime256Data @@ -141,8 +177,13 @@ from .hip_circumference import HipCircumferenceCharacteristic from .humidity import HumidityCharacteristic from .humidity_8 import Humidity8Characteristic +from .ieee_11073_20601_regulatory_certification_data_list import ( + IEEE11073RegulatoryData, + IEEE1107320601RegulatoryCharacteristic, +) from .illuminance import IlluminanceCharacteristic from .illuminance_16 import Illuminance16Characteristic +from .indoor_bike_data import IndoorBikeData, IndoorBikeDataCharacteristic from .indoor_positioning_configuration import IndoorPositioningConfigurationCharacteristic from .intermediate_temperature import IntermediateTemperatureCharacteristic from .irradiance import IrradianceCharacteristic @@ -248,6 +289,7 @@ ) from .resting_heart_rate import RestingHeartRateCharacteristic from .rotational_speed import RotationalSpeedCharacteristic +from .rower_data import RowerData, RowerDataCharacteristic from .rsc_feature import RSCFeatureCharacteristic from .rsc_measurement import RSCMeasurementCharacteristic from .scan_interval_window import ScanIntervalWindowCharacteristic @@ -261,6 +303,8 @@ SportType, SportTypeForAerobicAndAnaerobicThresholdsCharacteristic, ) +from .stair_climber_data import StairClimberData, StairClimberDataCharacteristic +from .step_climber_data import StepClimberData, StepClimberDataCharacteristic from .stride_length import StrideLengthCharacteristic from .sulfur_dioxide_concentration import SulfurDioxideConcentrationCharacteristic from .sulfur_hexafluoride_concentration import SulfurHexafluorideConcentrationCharacteristic @@ -313,6 +357,7 @@ from .time_with_dst import TimeWithDstCharacteristic from .time_zone import TimeZoneCharacteristic from .torque import TorqueCharacteristic +from .treadmill_data import TreadmillData, TreadmillDataCharacteristic from .true_wind_direction import TrueWindDirectionCharacteristic from .true_wind_speed import TrueWindSpeedCharacteristic from .two_zone_heart_rate_limits import TwoZoneHeartRateLimitsCharacteristic @@ -364,10 +409,17 @@ # Base characteristic "BaseCharacteristic", "BatteryCriticalStatusCharacteristic", + "BatteryEnergyStatusCharacteristic", + "BatteryHealthInformationCharacteristic", + "BatteryHealthStatusCharacteristic", + "BatteryInformationCharacteristic", "BatteryLevelCharacteristic", "BatteryLevelStatusCharacteristic", + "BatteryTimeStatusCharacteristic", "BloodPressureFeatureCharacteristic", "BloodPressureMeasurementCharacteristic", + "BloodPressureRecordCharacteristic", + "BloodPressureRecordData", "BodyCompositionFeatureCharacteristic", "BodyCompositionMeasurementCharacteristic", "BodySensorLocation", @@ -380,6 +432,25 @@ "BootKeyboardOutputReportCharacteristic", "BootMouseInputReportCharacteristic", "BootMouseInputReportData", + "CGMFeatureCharacteristic", + "CGMFeatureData", + "CGMFeatureFlags", + "CGMCalTempOctet", + "CGMMeasurementCharacteristic", + "CGMMeasurementData", + "CGMMeasurementFlags", + "CGMMeasurementRecord", + "CGMSampleLocation", + "CGMSensorStatusOctet", + "CGMSessionRunTimeCharacteristic", + "CGMSessionRunTimeData", + "CGMSessionStartTimeCharacteristic", + "CGMSessionStartTimeData", + "CGMStatusCharacteristic", + "CGMStatusData", + "CGMStatusFlags", + "CGMType", + "CGMWarningOctet", "CO2ConcentrationCharacteristic", "CSCFeatureCharacteristic", "CSCMeasurementCharacteristic", @@ -406,6 +477,10 @@ "Count16Characteristic", "Count24Characteristic", "CountryCodeCharacteristic", + "CrossTrainerData", + "CrossTrainerDataCharacteristic", + "CurrentElapsedTimeCharacteristic", + "CurrentElapsedTimeData", "CurrentTimeCharacteristic", "CyclingPowerControlPointCharacteristic", "CyclingPowerFeatureCharacteristic", @@ -435,6 +510,12 @@ "Energy32Characteristic", "EnergyInAPeriodOfDayCharacteristic", "EnergyInAPeriodOfDayData", + "EnhancedBloodPressureData", + "EnhancedBloodPressureFlags", + "EnhancedBloodPressureMeasurementCharacteristic", + "EnhancedIntermediateCuffPressureCharacteristic", + "EnhancedIntermediateCuffPressureData", + "EpochYear", "EstimatedServiceDateCharacteristic", "ExactTime256Characteristic", "ExactTime256Data", @@ -478,10 +559,16 @@ "HipCircumferenceCharacteristic", "HumidityCharacteristic", "Humidity8Characteristic", + "IEEE1107320601RegulatoryCharacteristic", + "IEEE11073RegulatoryData", "IlluminanceCharacteristic", "Illuminance16Characteristic", + "IndoorBikeData", + "IndoorBikeDataCharacteristic", "IndoorPositioningConfigurationCharacteristic", "IntermediateTemperatureCharacteristic", + "ElapsedTimeFlags", + "TimeResolution", "IrradianceCharacteristic", "LNControlPointCharacteristic", "LNFeatureCharacteristic", @@ -573,6 +660,8 @@ "RelativeValueInAnIlluminanceRangeData", "RestingHeartRateCharacteristic", "RotationalSpeedCharacteristic", + "RowerData", + "RowerDataCharacteristic", "ScanIntervalWindowCharacteristic", "ScanRefreshCharacteristic", "SedentaryIntervalNotificationCharacteristic", @@ -584,6 +673,10 @@ "SoftwareRevisionStringCharacteristic", "SportType", "SportTypeForAerobicAndAnaerobicThresholdsCharacteristic", + "StairClimberData", + "StairClimberDataCharacteristic", + "StepClimberData", + "StepClimberDataCharacteristic", "StrideLengthCharacteristic", "SulfurDioxideConcentrationCharacteristic", "SulfurHexafluorideConcentrationCharacteristic", @@ -630,6 +723,8 @@ "TimeWithDstCharacteristic", "TimeZoneCharacteristic", "TorqueCharacteristic", + "TreadmillData", + "TreadmillDataCharacteristic", "TrueWindDirectionCharacteristic", "TrueWindSpeedCharacteristic", "TwoZoneHeartRateLimitsCharacteristic", diff --git a/src/bluetooth_sig/gatt/characteristics/battery_energy_status.py b/src/bluetooth_sig/gatt/characteristics/battery_energy_status.py new file mode 100644 index 00000000..26b5219c --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/battery_energy_status.py @@ -0,0 +1,150 @@ +"""Battery Energy Status characteristic implementation. + +Implements the Battery Energy Status characteristic (0x2BF0) from the Battery +Service. An 8-bit flags field controls the presence of six optional medfloat16 +(IEEE 11073 SFLOAT) fields. + +All flag bits use normal logic (1 = present, 0 = absent). + +References: + Bluetooth SIG Battery Service 1.1 + org.bluetooth.characteristic.battery_energy_status (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser, IEEE11073Parser + + +class BatteryEnergyStatusFlags(IntFlag): + """Battery Energy Status flags as per Bluetooth SIG specification.""" + + EXTERNAL_SOURCE_POWER_PRESENT = 0x01 + PRESENT_VOLTAGE_PRESENT = 0x02 + AVAILABLE_ENERGY_PRESENT = 0x04 + AVAILABLE_BATTERY_CAPACITY_PRESENT = 0x08 + CHARGE_RATE_PRESENT = 0x10 + AVAILABLE_ENERGY_LAST_CHARGE_PRESENT = 0x20 + + +class BatteryEnergyStatus(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Parsed data from Battery Energy Status characteristic. + + Attributes: + flags: Raw 8-bit flags field. + external_source_power: Power consumed from external source (watts). + None if absent. + present_voltage: Terminal voltage of battery (volts). None if absent. + available_energy: Available energy (kWh). None if absent. + available_battery_capacity: Capacity at full charge (kWh). + None if absent. + charge_rate: Energy flow into battery (watts, negative = discharge). + None if absent. + available_energy_at_last_charge: Available energy at last charge (kWh). + None if absent. + + """ + + flags: BatteryEnergyStatusFlags + external_source_power: float | None = None + present_voltage: float | None = None + available_energy: float | None = None + available_battery_capacity: float | None = None + charge_rate: float | None = None + available_energy_at_last_charge: float | None = None + + +class BatteryEnergyStatusCharacteristic(BaseCharacteristic[BatteryEnergyStatus]): + """Battery Energy Status characteristic (0x2BF0). + + Reports battery energy information including voltage, energy capacity, + and charge/discharge rates. + + Flag-bit assignments (from GSS YAML): + Bit 0: External Source Power Present + Bit 1: Present Voltage Present + Bit 2: Available Energy Present + Bit 3: Available Battery Capacity Present + Bit 4: Charge Rate Present + Bit 5: Available Energy at Last Charge Present + Bits 6-7: Reserved for Future Use + + All value fields are medfloat16 (IEEE 11073 SFLOAT, 2 bytes each). + + """ + + expected_type = BatteryEnergyStatus + min_length: int = 1 # 1 byte flags only (all fields optional) + allow_variable_length: bool = True + + # Mapping: (flag_bit, attribute_name) in wire order + _FIELD_MAP: tuple[tuple[BatteryEnergyStatusFlags, str], ...] = ( + (BatteryEnergyStatusFlags.EXTERNAL_SOURCE_POWER_PRESENT, "external_source_power"), + (BatteryEnergyStatusFlags.PRESENT_VOLTAGE_PRESENT, "present_voltage"), + (BatteryEnergyStatusFlags.AVAILABLE_ENERGY_PRESENT, "available_energy"), + (BatteryEnergyStatusFlags.AVAILABLE_BATTERY_CAPACITY_PRESENT, "available_battery_capacity"), + (BatteryEnergyStatusFlags.CHARGE_RATE_PRESENT, "charge_rate"), + (BatteryEnergyStatusFlags.AVAILABLE_ENERGY_LAST_CHARGE_PRESENT, "available_energy_at_last_charge"), + ) + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> BatteryEnergyStatus: + """Parse Battery Energy Status from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + BatteryEnergyStatus with all present fields populated. + + """ + flags = BatteryEnergyStatusFlags(DataParser.parse_int8(data, 0, signed=False)) + offset = 1 + values: dict[str, float | None] = {} + + for flag_bit, attr_name in self._FIELD_MAP: + if flags & flag_bit: + values[attr_name] = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + else: + values[attr_name] = None + + return BatteryEnergyStatus(flags=flags, **values) + + def _encode_value(self, data: BatteryEnergyStatus) -> bytearray: + """Encode BatteryEnergyStatus back to BLE bytes. + + Args: + data: BatteryEnergyStatus instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = BatteryEnergyStatusFlags(0) + + for flag_bit, attr_name in self._FIELD_MAP: + if getattr(data, attr_name) is not None: + flags |= flag_bit + + result = DataParser.encode_int8(int(flags), signed=False) + + for _flag_bit, attr_name in self._FIELD_MAP: + value = getattr(data, attr_name) + if value is not None: + result.extend(IEEE11073Parser.encode_sfloat(value)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/battery_health_information.py b/src/bluetooth_sig/gatt/characteristics/battery_health_information.py new file mode 100644 index 00000000..d2d6399a --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/battery_health_information.py @@ -0,0 +1,171 @@ +"""Battery Health Information characteristic implementation. + +Implements the Battery Health Information characteristic (0x2BEB) from the +Battery Service. An 8-bit flags field controls the presence of optional fields. + +All flag bits use normal logic (1 = present, 0 = absent). + +Bit 1 gates two fields simultaneously: Min and Max Designed Operating +Temperature. + +References: + Bluetooth SIG Battery Service 1.1 + org.bluetooth.characteristic.battery_health_information (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import SINT8_MAX, SINT8_MIN, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class BatteryHealthInformationFlags(IntFlag): + """Battery Health Information flags as per Bluetooth SIG specification.""" + + CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT = 0x01 + TEMPERATURE_RANGE_PRESENT = 0x02 + + +class BatteryHealthInformation(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Parsed data from Battery Health Information characteristic. + + Attributes: + flags: Raw 8-bit flags field. + cycle_count_designed_lifetime: Designed number of charge cycles. + None if absent. + min_designed_operating_temperature: Min operating temperature (C). + 127 means ">126", -128 means "<-127". None if absent. + max_designed_operating_temperature: Max operating temperature (C). + 127 means ">126", -128 means "<-127". None if absent. + + """ + + flags: BatteryHealthInformationFlags + cycle_count_designed_lifetime: int | None = None + min_designed_operating_temperature: int | None = None + max_designed_operating_temperature: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if self.cycle_count_designed_lifetime is not None and not 0 <= self.cycle_count_designed_lifetime <= UINT16_MAX: + raise ValueError( + f"Cycle count designed lifetime must be 0-{UINT16_MAX}, got {self.cycle_count_designed_lifetime}" + ) + if ( + self.min_designed_operating_temperature is not None + and not SINT8_MIN <= self.min_designed_operating_temperature <= SINT8_MAX + ): + raise ValueError( + f"Min temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.min_designed_operating_temperature}" + ) + if ( + self.max_designed_operating_temperature is not None + and not SINT8_MIN <= self.max_designed_operating_temperature <= SINT8_MAX + ): + raise ValueError( + f"Max temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.max_designed_operating_temperature}" + ) + + +class BatteryHealthInformationCharacteristic( + BaseCharacteristic[BatteryHealthInformation], +): + """Battery Health Information characteristic (0x2BEB). + + Reports designed battery health parameters including designed cycle count + and designed operating temperature range. + + Flag-bit assignments (from GSS YAML): + Bit 0: Cycle Count Designed Lifetime Present + Bit 1: Min and Max Designed Operating Temperature Present + Bits 2-7: Reserved for Future Use + + Note: Bit 1 gates two fields (min + max temperature) simultaneously. + + """ + + expected_type = BatteryHealthInformation + min_length: int = 1 # 1 byte flags only (all fields optional) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> BatteryHealthInformation: + """Parse Battery Health Information from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + BatteryHealthInformation with all present fields populated. + + """ + flags = BatteryHealthInformationFlags(DataParser.parse_int8(data, 0, signed=False)) + offset = 1 + + # Bit 0 -- Cycle Count Designed Lifetime (uint16) + cycle_count_designed_lifetime = None + if flags & BatteryHealthInformationFlags.CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT: + cycle_count_designed_lifetime = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 1 -- Min AND Max Designed Operating Temperature (2 x sint8) + min_temp = None + max_temp = None + if flags & BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT: + min_temp = DataParser.parse_int8(data, offset, signed=True) + offset += 1 + max_temp = DataParser.parse_int8(data, offset, signed=True) + offset += 1 + + return BatteryHealthInformation( + flags=flags, + cycle_count_designed_lifetime=cycle_count_designed_lifetime, + min_designed_operating_temperature=min_temp, + max_designed_operating_temperature=max_temp, + ) + + def _encode_value(self, data: BatteryHealthInformation) -> bytearray: + """Encode BatteryHealthInformation back to BLE bytes. + + Args: + data: BatteryHealthInformation instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = BatteryHealthInformationFlags(0) + + if data.cycle_count_designed_lifetime is not None: + flags |= BatteryHealthInformationFlags.CYCLE_COUNT_DESIGNED_LIFETIME_PRESENT + if data.min_designed_operating_temperature is not None or data.max_designed_operating_temperature is not None: + flags |= BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT + + result = DataParser.encode_int8(int(flags), signed=False) + + if data.cycle_count_designed_lifetime is not None: + result.extend(DataParser.encode_int16(data.cycle_count_designed_lifetime, signed=False)) + if flags & BatteryHealthInformationFlags.TEMPERATURE_RANGE_PRESENT: + min_temp = ( + data.min_designed_operating_temperature if data.min_designed_operating_temperature is not None else 0 + ) + max_temp = ( + data.max_designed_operating_temperature if data.max_designed_operating_temperature is not None else 0 + ) + result.extend(DataParser.encode_int8(min_temp, signed=True)) + result.extend(DataParser.encode_int8(max_temp, signed=True)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/battery_health_status.py b/src/bluetooth_sig/gatt/characteristics/battery_health_status.py new file mode 100644 index 00000000..afede3fd --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/battery_health_status.py @@ -0,0 +1,179 @@ +"""Battery Health Status characteristic implementation. + +Implements the Battery Health Status characteristic (0x2BEA) from the Battery +Service. An 8-bit flags field controls the presence of four optional fields. + +All flag bits use normal logic (1 = present, 0 = absent). + +References: + Bluetooth SIG Battery Service 1.1 + org.bluetooth.characteristic.battery_health_status (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import SINT8_MAX, SINT8_MIN, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +# Maximum health summary percentage +_HEALTH_SUMMARY_MAX: int = 100 + +# Temperature sentinels (sint8) +_TEMP_GREATER_THAN_126: int = 0x7F # raw == 127 means ">126" +_TEMP_LESS_THAN_MINUS_127: int = -128 # raw == -128 (0x80) means "<-127" + + +class BatteryHealthStatusFlags(IntFlag): + """Battery Health Status flags as per Bluetooth SIG specification.""" + + HEALTH_SUMMARY_PRESENT = 0x01 + CYCLE_COUNT_PRESENT = 0x02 + CURRENT_TEMPERATURE_PRESENT = 0x04 + DEEP_DISCHARGE_COUNT_PRESENT = 0x08 + + +class BatteryHealthStatus(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Parsed data from Battery Health Status characteristic. + + Attributes: + flags: Raw 8-bit flags field. + battery_health_summary: Percentage 0-100 representing overall health. + None if absent. + cycle_count: Number of charge cycles. None if absent. + current_temperature: Current temperature in degrees Celsius. + 127 means ">126", -128 means "<-127". None if absent. + deep_discharge_count: Number of complete discharges. None if absent. + + """ + + flags: BatteryHealthStatusFlags + battery_health_summary: int | None = None + cycle_count: int | None = None + current_temperature: int | None = None + deep_discharge_count: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if self.battery_health_summary is not None and not 0 <= self.battery_health_summary <= _HEALTH_SUMMARY_MAX: + raise ValueError( + f"Battery health summary must be 0-{_HEALTH_SUMMARY_MAX}, got {self.battery_health_summary}" + ) + if self.cycle_count is not None and not 0 <= self.cycle_count <= UINT16_MAX: + raise ValueError(f"Cycle count must be 0-{UINT16_MAX}, got {self.cycle_count}") + if self.current_temperature is not None and not SINT8_MIN <= self.current_temperature <= SINT8_MAX: + raise ValueError(f"Temperature must be {SINT8_MIN}-{SINT8_MAX}, got {self.current_temperature}") + if self.deep_discharge_count is not None and not 0 <= self.deep_discharge_count <= UINT16_MAX: + raise ValueError(f"Deep discharge count must be 0-{UINT16_MAX}, got {self.deep_discharge_count}") + + +class BatteryHealthStatusCharacteristic(BaseCharacteristic[BatteryHealthStatus]): + """Battery Health Status characteristic (0x2BEA). + + Reports battery health information including summary percentage, cycle + count, temperature, and deep discharge count. + + Flag-bit assignments (from GSS YAML): + Bit 0: Battery Health Summary Present + Bit 1: Cycle Count Present + Bit 2: Current Temperature Present + Bit 3: Deep Discharge Count Present + Bits 4-7: Reserved for Future Use + + """ + + expected_type = BatteryHealthStatus + min_length: int = 1 # 1 byte flags only (all fields optional) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> BatteryHealthStatus: + """Parse Battery Health Status from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + BatteryHealthStatus with all present fields populated. + + """ + flags = BatteryHealthStatusFlags(DataParser.parse_int8(data, 0, signed=False)) + offset = 1 + + # Bit 0 -- Battery Health Summary (uint8, percentage) + battery_health_summary = None + if flags & BatteryHealthStatusFlags.HEALTH_SUMMARY_PRESENT: + battery_health_summary = DataParser.parse_int8(data, offset, signed=False) + offset += 1 + + # Bit 1 -- Cycle Count (uint16) + cycle_count = None + if flags & BatteryHealthStatusFlags.CYCLE_COUNT_PRESENT: + cycle_count = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 2 -- Current Temperature (sint8) + current_temperature = None + if flags & BatteryHealthStatusFlags.CURRENT_TEMPERATURE_PRESENT: + current_temperature = DataParser.parse_int8(data, offset, signed=True) + offset += 1 + + # Bit 3 -- Deep Discharge Count (uint16) + deep_discharge_count = None + if flags & BatteryHealthStatusFlags.DEEP_DISCHARGE_COUNT_PRESENT: + deep_discharge_count = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + return BatteryHealthStatus( + flags=flags, + battery_health_summary=battery_health_summary, + cycle_count=cycle_count, + current_temperature=current_temperature, + deep_discharge_count=deep_discharge_count, + ) + + def _encode_value(self, data: BatteryHealthStatus) -> bytearray: + """Encode BatteryHealthStatus back to BLE bytes. + + Args: + data: BatteryHealthStatus instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = BatteryHealthStatusFlags(0) + + if data.battery_health_summary is not None: + flags |= BatteryHealthStatusFlags.HEALTH_SUMMARY_PRESENT + if data.cycle_count is not None: + flags |= BatteryHealthStatusFlags.CYCLE_COUNT_PRESENT + if data.current_temperature is not None: + flags |= BatteryHealthStatusFlags.CURRENT_TEMPERATURE_PRESENT + if data.deep_discharge_count is not None: + flags |= BatteryHealthStatusFlags.DEEP_DISCHARGE_COUNT_PRESENT + + result = DataParser.encode_int8(int(flags), signed=False) + + if data.battery_health_summary is not None: + result.extend(DataParser.encode_int8(data.battery_health_summary, signed=False)) + if data.cycle_count is not None: + result.extend(DataParser.encode_int16(data.cycle_count, signed=False)) + if data.current_temperature is not None: + result.extend(DataParser.encode_int8(data.current_temperature, signed=True)) + if data.deep_discharge_count is not None: + result.extend(DataParser.encode_int16(data.deep_discharge_count, signed=False)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/battery_information.py b/src/bluetooth_sig/gatt/characteristics/battery_information.py new file mode 100644 index 00000000..88c07546 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/battery_information.py @@ -0,0 +1,275 @@ +"""Battery Information characteristic implementation. + +Implements the Battery Information characteristic (0x2BEC) from the Battery +Service. A 16-bit flags field controls the presence of optional fields. +A mandatory Battery Features byte is always present after the flags. + +All flag bits use normal logic (1 = present, 0 = absent). + +References: + Bluetooth SIG Battery Service 1.1 + org.bluetooth.characteristic.battery_information (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntEnum, IntFlag + +import msgspec + +from ..constants import UINT8_MAX, UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser, IEEE11073Parser + + +class BatteryInformationFlags(IntFlag): + """Battery Information flags as per Bluetooth SIG specification.""" + + MANUFACTURE_DATE_PRESENT = 0x0001 + EXPIRATION_DATE_PRESENT = 0x0002 + DESIGNED_CAPACITY_PRESENT = 0x0004 + LOW_ENERGY_PRESENT = 0x0008 + CRITICAL_ENERGY_PRESENT = 0x0010 + BATTERY_CHEMISTRY_PRESENT = 0x0020 + NOMINAL_VOLTAGE_PRESENT = 0x0040 + AGGREGATION_GROUP_PRESENT = 0x0080 + + +class BatteryFeatures(IntFlag): + """Battery Features bitfield as per Bluetooth SIG specification.""" + + REPLACEABLE = 0x01 + RECHARGEABLE = 0x02 + + +class BatteryChemistry(IntEnum): + """Battery Chemistry enumeration as per Bluetooth SIG specification.""" + + UNKNOWN = 0 + ALKALINE = 1 + LEAD_ACID = 2 + LITHIUM_IRON_DISULFIDE = 3 + LITHIUM_MANGANESE_DIOXIDE = 4 + LITHIUM_ION = 5 + LITHIUM_POLYMER = 6 + NICKEL_OXYHYDROXIDE = 7 + NICKEL_CADMIUM = 8 + NICKEL_METAL_HYDRIDE = 9 + SILVER_OXIDE = 10 + ZINC_CHLORIDE = 11 + ZINC_AIR = 12 + ZINC_CARBON = 13 + OTHER = 255 + + +class BatteryInformation(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Parsed data from Battery Information characteristic. + + Attributes: + flags: Raw 16-bit flags field. + battery_features: Mandatory features bitfield (replaceable, + rechargeable). + battery_manufacture_date: Days since epoch (1970-01-01). + None if absent. + battery_expiration_date: Days since epoch (1970-01-01). + None if absent. + battery_designed_capacity: Designed capacity in kWh (medfloat16). + None if absent. + battery_low_energy: Low energy threshold in kWh (medfloat16). + None if absent. + battery_critical_energy: Critical energy threshold in kWh + (medfloat16). None if absent. + battery_chemistry: Chemistry type. None if absent. + nominal_voltage: Nominal voltage in volts (medfloat16). + None if absent. + battery_aggregation_group: Aggregation group number (0=none, + 1-254=group). None if absent. + + """ + + flags: BatteryInformationFlags + battery_features: BatteryFeatures + battery_manufacture_date: int | None = None + battery_expiration_date: int | None = None + battery_designed_capacity: float | None = None + battery_low_energy: float | None = None + battery_critical_energy: float | None = None + battery_chemistry: BatteryChemistry | None = None + nominal_voltage: float | None = None + battery_aggregation_group: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if self.battery_manufacture_date is not None and not 0 <= self.battery_manufacture_date <= UINT24_MAX: + raise ValueError(f"Manufacture date must be 0-{UINT24_MAX}, got {self.battery_manufacture_date}") + if self.battery_expiration_date is not None and not 0 <= self.battery_expiration_date <= UINT24_MAX: + raise ValueError(f"Expiration date must be 0-{UINT24_MAX}, got {self.battery_expiration_date}") + if self.battery_aggregation_group is not None and not 0 <= self.battery_aggregation_group <= UINT8_MAX: + raise ValueError(f"Aggregation group must be 0-{UINT8_MAX}, got {self.battery_aggregation_group}") + + +class BatteryInformationCharacteristic( + BaseCharacteristic[BatteryInformation], +): + """Battery Information characteristic (0x2BEC). + + Reports physical battery characteristics including features, dates, + capacity, chemistry, voltage, and aggregation group. + + Flag-bit assignments (from GSS YAML, 16-bit flags): + Bit 0: Battery Manufacture Date Present + Bit 1: Battery Expiration Date Present + Bit 2: Battery Designed Capacity Present + Bit 3: Battery Low Energy Present + Bit 4: Battery Critical Energy Present + Bit 5: Battery Chemistry Present + Bit 6: Nominal Voltage Present + Bit 7: Battery Aggregation Group Present + Bits 8-15: Reserved for Future Use + + The mandatory Battery Features byte is always present after the flags. + + """ + + expected_type = BatteryInformation + min_length: int = 3 # 2 bytes flags + 1 byte mandatory features + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> BatteryInformation: + """Parse Battery Information from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + BatteryInformation with all present fields populated. + + """ + flags = BatteryInformationFlags(DataParser.parse_int16(data, 0, signed=False)) + battery_features = BatteryFeatures(DataParser.parse_int8(data, 2, signed=False)) + offset = 3 + + # Bit 0 -- Battery Manufacture Date (uint24, days since epoch) + manufacture_date = None + if flags & BatteryInformationFlags.MANUFACTURE_DATE_PRESENT: + manufacture_date = DataParser.parse_int24(data, offset, signed=False) + offset += 3 + + # Bit 1 -- Battery Expiration Date (uint24, days since epoch) + expiration_date = None + if flags & BatteryInformationFlags.EXPIRATION_DATE_PRESENT: + expiration_date = DataParser.parse_int24(data, offset, signed=False) + offset += 3 + + # Bit 2 -- Battery Designed Capacity (medfloat16, kWh) + designed_capacity = None + if flags & BatteryInformationFlags.DESIGNED_CAPACITY_PRESENT: + designed_capacity = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + # Bit 3 -- Battery Low Energy (medfloat16, kWh) + low_energy = None + if flags & BatteryInformationFlags.LOW_ENERGY_PRESENT: + low_energy = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + # Bit 4 -- Battery Critical Energy (medfloat16, kWh) + critical_energy = None + if flags & BatteryInformationFlags.CRITICAL_ENERGY_PRESENT: + critical_energy = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + # Bit 5 -- Battery Chemistry (uint8 enum) + chemistry = None + if flags & BatteryInformationFlags.BATTERY_CHEMISTRY_PRESENT: + raw_chem = DataParser.parse_int8(data, offset, signed=False) + try: + chemistry = BatteryChemistry(raw_chem) + except ValueError: + chemistry = BatteryChemistry.UNKNOWN + offset += 1 + + # Bit 6 -- Nominal Voltage (medfloat16, volts) + nominal_voltage = None + if flags & BatteryInformationFlags.NOMINAL_VOLTAGE_PRESENT: + nominal_voltage = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + # Bit 7 -- Battery Aggregation Group (uint8) + aggregation_group = None + if flags & BatteryInformationFlags.AGGREGATION_GROUP_PRESENT: + aggregation_group = DataParser.parse_int8(data, offset, signed=False) + offset += 1 + + return BatteryInformation( + flags=flags, + battery_features=battery_features, + battery_manufacture_date=manufacture_date, + battery_expiration_date=expiration_date, + battery_designed_capacity=designed_capacity, + battery_low_energy=low_energy, + battery_critical_energy=critical_energy, + battery_chemistry=chemistry, + nominal_voltage=nominal_voltage, + battery_aggregation_group=aggregation_group, + ) + + def _encode_value(self, data: BatteryInformation) -> bytearray: + """Encode BatteryInformation back to BLE bytes. + + Args: + data: BatteryInformation instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = BatteryInformationFlags(0) + + if data.battery_manufacture_date is not None: + flags |= BatteryInformationFlags.MANUFACTURE_DATE_PRESENT + if data.battery_expiration_date is not None: + flags |= BatteryInformationFlags.EXPIRATION_DATE_PRESENT + if data.battery_designed_capacity is not None: + flags |= BatteryInformationFlags.DESIGNED_CAPACITY_PRESENT + if data.battery_low_energy is not None: + flags |= BatteryInformationFlags.LOW_ENERGY_PRESENT + if data.battery_critical_energy is not None: + flags |= BatteryInformationFlags.CRITICAL_ENERGY_PRESENT + if data.battery_chemistry is not None: + flags |= BatteryInformationFlags.BATTERY_CHEMISTRY_PRESENT + if data.nominal_voltage is not None: + flags |= BatteryInformationFlags.NOMINAL_VOLTAGE_PRESENT + if data.battery_aggregation_group is not None: + flags |= BatteryInformationFlags.AGGREGATION_GROUP_PRESENT + + result = DataParser.encode_int16(int(flags), signed=False) + result.extend(DataParser.encode_int8(int(data.battery_features), signed=False)) + + if data.battery_manufacture_date is not None: + result.extend(DataParser.encode_int24(data.battery_manufacture_date, signed=False)) + if data.battery_expiration_date is not None: + result.extend(DataParser.encode_int24(data.battery_expiration_date, signed=False)) + if data.battery_designed_capacity is not None: + result.extend(IEEE11073Parser.encode_sfloat(data.battery_designed_capacity)) + if data.battery_low_energy is not None: + result.extend(IEEE11073Parser.encode_sfloat(data.battery_low_energy)) + if data.battery_critical_energy is not None: + result.extend(IEEE11073Parser.encode_sfloat(data.battery_critical_energy)) + if data.battery_chemistry is not None: + result.extend(DataParser.encode_int8(int(data.battery_chemistry), signed=False)) + if data.nominal_voltage is not None: + result.extend(IEEE11073Parser.encode_sfloat(data.nominal_voltage)) + if data.battery_aggregation_group is not None: + result.extend(DataParser.encode_int8(data.battery_aggregation_group, signed=False)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/battery_time_status.py b/src/bluetooth_sig/gatt/characteristics/battery_time_status.py new file mode 100644 index 00000000..26c92223 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/battery_time_status.py @@ -0,0 +1,195 @@ +"""Battery Time Status characteristic implementation. + +Implements the Battery Time Status characteristic (0x2BEE) from the Battery +Service. An 8-bit flags field controls the presence of optional time fields. + +All flag bits use **normal logic** (1 = present, 0 = absent). + +The mandatory "Time until Discharged" field and both optional time fields +use uint24 in **minutes**. Two sentinel values are defined: + - 0xFFFFFF: Unknown + - 0xFFFFFE: Greater than 0xFFFFFD + +References: + Bluetooth SIG Battery Service 1.1 + org.bluetooth.characteristic.battery_time_status (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +# Sentinel values for time fields (uint24 minutes) +_TIME_UNKNOWN: int = 0xFFFFFF +_TIME_OVERFLOW: int = 0xFFFFFE + + +class BatteryTimeStatusFlags(IntFlag): + """Battery Time Status flags as per Bluetooth SIG specification.""" + + DISCHARGED_ON_STANDBY_PRESENT = 0x01 + RECHARGED_PRESENT = 0x02 + + +class BatteryTimeStatus(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Parsed data from Battery Time Status characteristic. + + Attributes: + flags: Raw 8-bit flags field. + time_until_discharged: Estimated minutes until discharged. + None if raw == 0xFFFFFF (Unknown). + time_until_discharged_on_standby: Minutes until discharged on standby. + None if absent or raw == 0xFFFFFF (Unknown). + time_until_recharged: Minutes until recharged. + None if absent or raw == 0xFFFFFF (Unknown). + + """ + + flags: BatteryTimeStatusFlags + time_until_discharged: int | None = None + time_until_discharged_on_standby: int | None = None + time_until_recharged: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if self.time_until_discharged is not None and not 0 <= self.time_until_discharged <= UINT24_MAX: + raise ValueError(f"Time until discharged must be 0-{UINT24_MAX}, got {self.time_until_discharged}") + if ( + self.time_until_discharged_on_standby is not None + and not 0 <= self.time_until_discharged_on_standby <= UINT24_MAX + ): + raise ValueError( + f"Time until discharged on standby must be 0-{UINT24_MAX}, got {self.time_until_discharged_on_standby}" + ) + if self.time_until_recharged is not None and not 0 <= self.time_until_recharged <= UINT24_MAX: + raise ValueError(f"Time until recharged must be 0-{UINT24_MAX}, got {self.time_until_recharged}") + + +def _decode_time_minutes(data: bytearray, offset: int) -> tuple[int | None, int]: + """Decode a uint24 time field in minutes with sentinel handling. + + Args: + data: Raw BLE bytes. + offset: Current read position. + + Returns: + (value_or_none, new_offset). Returns None for the 0xFFFFFF sentinel. + + """ + if len(data) < offset + 3: + return None, offset + raw = DataParser.parse_int24(data, offset, signed=False) + if raw == _TIME_UNKNOWN: + return None, offset + 3 + return raw, offset + 3 + + +def _encode_time_minutes(value: int | None) -> bytearray: + """Encode a time-in-minutes field to uint24, using sentinel for None. + + Args: + value: Minutes value, or None for Unknown. + + Returns: + 3-byte encoded value. + + """ + if value is None: + return DataParser.encode_int24(_TIME_UNKNOWN, signed=False) + return DataParser.encode_int24(value, signed=False) + + +class BatteryTimeStatusCharacteristic(BaseCharacteristic[BatteryTimeStatus]): + """Battery Time Status characteristic (0x2BEE). + + Reports estimated times for battery discharge and recharge. + + Flag-bit assignments (from GSS YAML): + Bit 0: Time until Discharged on Standby present + Bit 1: Time until Recharged present + Bits 2-7: Reserved for Future Use + + The mandatory "Time until Discharged" field is always present after flags. + + """ + + expected_type = BatteryTimeStatus + min_length: int = 4 # 1 byte flags + 3 bytes mandatory time field + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> BatteryTimeStatus: + """Parse Battery Time Status from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + BatteryTimeStatus with all present fields populated. + + """ + flags = BatteryTimeStatusFlags(DataParser.parse_int8(data, 0, signed=False)) + offset = 1 + + # Mandatory: Time until Discharged (uint24, minutes) + time_until_discharged, offset = _decode_time_minutes(data, offset) + + # Bit 0 -- Time until Discharged on Standby + time_until_discharged_on_standby = None + if flags & BatteryTimeStatusFlags.DISCHARGED_ON_STANDBY_PRESENT: + time_until_discharged_on_standby, offset = _decode_time_minutes(data, offset) + + # Bit 1 -- Time until Recharged + time_until_recharged = None + if flags & BatteryTimeStatusFlags.RECHARGED_PRESENT: + time_until_recharged, offset = _decode_time_minutes(data, offset) + + return BatteryTimeStatus( + flags=flags, + time_until_discharged=time_until_discharged, + time_until_discharged_on_standby=time_until_discharged_on_standby, + time_until_recharged=time_until_recharged, + ) + + def _encode_value(self, data: BatteryTimeStatus) -> bytearray: + """Encode BatteryTimeStatus back to BLE bytes. + + Args: + data: BatteryTimeStatus instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = BatteryTimeStatusFlags(0) + + if data.time_until_discharged_on_standby is not None: + flags |= BatteryTimeStatusFlags.DISCHARGED_ON_STANDBY_PRESENT + if data.time_until_recharged is not None: + flags |= BatteryTimeStatusFlags.RECHARGED_PRESENT + + result = DataParser.encode_int8(int(flags), signed=False) + + # Mandatory: Time until Discharged + result.extend(_encode_time_minutes(data.time_until_discharged)) + + if data.time_until_discharged_on_standby is not None: + result.extend(_encode_time_minutes(data.time_until_discharged_on_standby)) + if data.time_until_recharged is not None: + result.extend(_encode_time_minutes(data.time_until_recharged)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/blood_pressure_record.py b/src/bluetooth_sig/gatt/characteristics/blood_pressure_record.py new file mode 100644 index 00000000..ea6d8a62 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/blood_pressure_record.py @@ -0,0 +1,141 @@ +"""Blood Pressure Record characteristic implementation. + +Implements the Blood Pressure Record characteristic (0x2B36). This is a +segmented record container that wraps another characteristic value identified +by a 16-bit UUID. + +Structure (from GSS YAML): + Segmentation Header (1 byte): + Bit 0: First Segment + Bit 1: Last Segment + Bits 2-7: Rolling Segment Counter (0-63) + Sequence Number (uint16) + UUID (uint16) -- identifies the recorded characteristic + Recorded Characteristic (variable) -- raw bytes of the inner characteristic + E2E-CRC (uint16, optional) -- presence defined by service + +References: + Bluetooth SIG Blood Pressure Service 1.1 + org.bluetooth.characteristic.blood_pressure_record (GSS YAML) +""" + +from __future__ import annotations + +import msgspec + +from bluetooth_sig.types.uuid import BluetoothUUID + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_SEGMENT_COUNTER_SHIFT = 2 +_SEGMENT_COUNTER_MASK = 0x3F # bits 2-7 = 6 bits + + +class BloodPressureRecordData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from Blood Pressure Record characteristic. + + Attributes: + first_segment: Whether this is the first segment of the record. + last_segment: Whether this is the last segment of the record. + segment_counter: Rolling segment counter (0-63). + sequence_number: Sequence number identifying this record. + uuid: 16-bit UUID of the recorded characteristic. + recorded_data: Raw bytes of the recorded characteristic value. + e2e_crc: End-to-end CRC value. None if absent. + + """ + + first_segment: bool + last_segment: bool + segment_counter: int + sequence_number: int + uuid: BluetoothUUID + recorded_data: bytes + e2e_crc: int | None = None + + +class BloodPressureRecordCharacteristic(BaseCharacteristic[BloodPressureRecordData]): + """Blood Pressure Record characteristic (0x2B36). + + A segmented record container that wraps another characteristic value. + The inner characteristic is identified by the UUID field and its raw + bytes are stored in ``recorded_data``. + """ + + expected_type = BloodPressureRecordData + min_length: int = 5 # header(1) + sequence(2) + uuid(2) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> BloodPressureRecordData: + """Parse Blood Pressure Record from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + BloodPressureRecordData with segmentation info and raw recorded data. + + """ + header = data[0] + first_segment = bool(header & 0x01) + last_segment = bool(header & 0x02) + segment_counter = (header >> _SEGMENT_COUNTER_SHIFT) & _SEGMENT_COUNTER_MASK + + sequence_number = DataParser.parse_int16(data, 1, signed=False) + uuid = BluetoothUUID(DataParser.parse_int16(data, 3, signed=False)) + offset = 5 + + # Determine if E2E-CRC is present. + # The recorded data occupies everything between offset and the end, + # except for the optional 2-byte CRC at the very end. + # We cannot deterministically know whether CRC is present without + # service-level context, so we expose all remaining bytes as recorded + # data. When the caller knows CRC is enabled, they can split the + # last 2 bytes themselves. + recorded_data = bytes(data[offset:]) + + return BloodPressureRecordData( + first_segment=first_segment, + last_segment=last_segment, + segment_counter=segment_counter, + sequence_number=sequence_number, + uuid=uuid, + recorded_data=recorded_data, + ) + + def _encode_value(self, data: BloodPressureRecordData) -> bytearray: + """Encode BloodPressureRecordData back to BLE bytes. + + Args: + data: BloodPressureRecordData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + header = 0 + if data.first_segment: + header |= 0x01 + if data.last_segment: + header |= 0x02 + header |= (data.segment_counter & _SEGMENT_COUNTER_MASK) << _SEGMENT_COUNTER_SHIFT + + result = bytearray([header]) + result.extend(DataParser.encode_int16(data.sequence_number, signed=False)) + result.extend(DataParser.encode_int16(int(data.uuid.short_form, 16), signed=False)) + result.extend(data.recorded_data) + + if data.e2e_crc is not None: + result.extend(DataParser.encode_int16(data.e2e_crc, signed=False)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/cgm_feature.py b/src/bluetooth_sig/gatt/characteristics/cgm_feature.py new file mode 100644 index 00000000..24248e80 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cgm_feature.py @@ -0,0 +1,164 @@ +"""CGM Feature characteristic implementation. + +Implements the CGM Feature characteristic (0x2AA8). Fixed-size structure of +6 bytes: 24-bit feature flags + packed nibble type/location + 16-bit E2E-CRC. + +Structure (from GSS YAML): + CGM Feature (3 bytes, boolean[24]) + CGM Type-Sample Location (1 byte, two 4-bit nibbles packed) + E2E-CRC (2 bytes, uint16) -- always present per spec + +References: + Bluetooth SIG Continuous Glucose Monitoring Service + org.bluetooth.characteristic.cgm_feature (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntEnum, IntFlag + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + +_NIBBLE_MASK = 0x0F +_NIBBLE_SHIFT = 4 + + +class CGMFeatureFlags(IntFlag): + """CGM Feature flags (24-bit).""" + + CALIBRATION_SUPPORTED = 0x000001 + PATIENT_HIGH_LOW_ALERTS = 0x000002 + HYPO_ALERTS = 0x000004 + HYPER_ALERTS = 0x000008 + RATE_ALERTS = 0x000010 + DEVICE_SPECIFIC_ALERT = 0x000020 + SENSOR_MALFUNCTION_DETECTION = 0x000040 + SENSOR_TEMP_HIGH_LOW_DETECTION = 0x000080 + SENSOR_RESULT_HIGH_LOW_DETECTION = 0x000100 + LOW_BATTERY_DETECTION = 0x000200 + SENSOR_TYPE_ERROR_DETECTION = 0x000400 + GENERAL_DEVICE_FAULT = 0x000800 + E2E_CRC_SUPPORTED = 0x001000 + MULTIPLE_BOND_SUPPORTED = 0x002000 + MULTIPLE_SESSIONS_SUPPORTED = 0x004000 + CGM_TREND_INFORMATION_SUPPORTED = 0x008000 + CGM_QUALITY_SUPPORTED = 0x010000 + + +class CGMType(IntEnum): + """CGM sample type (lower nibble).""" + + CAPILLARY_WHOLE_BLOOD = 0x1 + CAPILLARY_PLASMA = 0x2 + VENOUS_WHOLE_BLOOD = 0x3 + VENOUS_PLASMA = 0x4 + ARTERIAL_WHOLE_BLOOD = 0x5 + ARTERIAL_PLASMA = 0x6 + UNDETERMINED_WHOLE_BLOOD = 0x7 + UNDETERMINED_PLASMA = 0x8 + INTERSTITIAL_FLUID = 0x9 + CONTROL_SOLUTION = 0xA + + +class CGMSampleLocation(IntEnum): + """CGM sample location (upper nibble).""" + + FINGER = 0x1 + ALTERNATE_SITE_TEST = 0x2 + EARLOBE = 0x3 + CONTROL_SOLUTION = 0x4 + SUBCUTANEOUS_TISSUE = 0x5 + NOT_AVAILABLE = 0xF + + +class CGMFeatureData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from CGM Feature characteristic. + + Attributes: + features: 24-bit CGM feature flags. + cgm_type: CGM sample type. + sample_location: CGM sample location. + e2e_crc: E2E-CRC value. + + """ + + features: CGMFeatureFlags + cgm_type: CGMType + sample_location: CGMSampleLocation + e2e_crc: int + + +class CGMFeatureCharacteristic(BaseCharacteristic[CGMFeatureData]): + """CGM Feature characteristic (0x2AA8). + + Reports the supported features, sample type, sample location, and + E2E-CRC for a CGM sensor. Fixed 6-byte structure. + """ + + expected_type = CGMFeatureData + expected_length: int = 6 + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> CGMFeatureData: + """Parse CGM Feature from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic (6 bytes). + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + CGMFeatureData with parsed feature flags, type, and location. + + """ + # 24-bit feature flags (little-endian, 3 bytes) + features_raw = data[0] | (data[1] << 8) | (data[2] << 16) + features = CGMFeatureFlags(features_raw) + + # Type-Sample Location: lower nibble = type, upper nibble = location + type_location_byte = data[3] + cgm_type = CGMType(type_location_byte & _NIBBLE_MASK) + sample_location = CGMSampleLocation((type_location_byte >> _NIBBLE_SHIFT) & _NIBBLE_MASK) + + e2e_crc = DataParser.parse_int16(data, 4, signed=False) + + return CGMFeatureData( + features=features, + cgm_type=cgm_type, + sample_location=sample_location, + e2e_crc=e2e_crc, + ) + + def _encode_value(self, data: CGMFeatureData) -> bytearray: + """Encode CGMFeatureData back to BLE bytes. + + Args: + data: CGMFeatureData instance. + + Returns: + Encoded bytearray (6 bytes). + + """ + features_int = int(data.features) + result = bytearray( + [ + features_int & 0xFF, + (features_int >> 8) & 0xFF, + (features_int >> 16) & 0xFF, + ] + ) + + type_location = (data.cgm_type & _NIBBLE_MASK) | ((data.sample_location & _NIBBLE_MASK) << _NIBBLE_SHIFT) + result.append(type_location) + + result.extend(DataParser.encode_int16(data.e2e_crc, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/cgm_measurement.py b/src/bluetooth_sig/gatt/characteristics/cgm_measurement.py new file mode 100644 index 00000000..4fbf62c9 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cgm_measurement.py @@ -0,0 +1,279 @@ +"""CGM Measurement characteristic implementation. + +Implements the CGM Measurement characteristic (0x2AA7). The characteristic +value contains one or more CGM Measurement Records concatenated together. + +Each record contains: + Size (uint8) -- total size of the record including this field + Flags (uint8) -- controls presence of optional fields + CGM Glucose Concentration (medfloat16) + Time Offset (uint16) -- minutes since session start + Sensor Status Annunciation (0-3 octets, flag-gated) + CGM Trend Information (medfloat16, optional) + CGM Quality (medfloat16, optional) + +Flag-bit assignments: + Bit 0: CGM Trend Information present + Bit 1: CGM Quality present + Bits 2-4: Reserved + Bit 5: Warning-Octet present (Sensor Status Annunciation) + Bit 6: Cal/Temp-Octet present (Sensor Status Annunciation) + Bit 7: Status-Octet present (Sensor Status Annunciation) + +References: + Bluetooth SIG Continuous Glucose Monitoring Service + org.bluetooth.characteristic.cgm_measurement (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser, IEEE11073Parser + + +class CGMMeasurementFlags(IntFlag): + """CGM Measurement record flags.""" + + TREND_INFORMATION_PRESENT = 0x01 + QUALITY_PRESENT = 0x02 + WARNING_OCTET_PRESENT = 0x20 + CAL_TEMP_OCTET_PRESENT = 0x40 + STATUS_OCTET_PRESENT = 0x80 + + +class CGMSensorStatusOctet(IntFlag): + """CGM Sensor Status Annunciation — Status octet (bits 0-7).""" + + SESSION_STOPPED = 0x01 + DEVICE_BATTERY_LOW = 0x02 + SENSOR_TYPE_INCORRECT = 0x04 + SENSOR_MALFUNCTION = 0x08 + DEVICE_SPECIFIC_ALERT = 0x10 + GENERAL_DEVICE_FAULT = 0x20 + + +class CGMCalTempOctet(IntFlag): + """CGM Sensor Status Annunciation — Cal/Temp octet (bits 8-15).""" + + TIME_SYNC_REQUIRED = 0x01 + CALIBRATION_NOT_ALLOWED = 0x02 + CALIBRATION_RECOMMENDED = 0x04 + CALIBRATION_REQUIRED = 0x08 + SENSOR_TEMP_TOO_HIGH = 0x10 + SENSOR_TEMP_TOO_LOW = 0x20 + CALIBRATION_PENDING = 0x40 + + +class CGMWarningOctet(IntFlag): + """CGM Sensor Status Annunciation — Warning octet (bits 16-23).""" + + RESULT_LOWER_THAN_PATIENT_LOW = 0x01 + RESULT_HIGHER_THAN_PATIENT_HIGH = 0x02 + RESULT_LOWER_THAN_HYPO = 0x04 + RESULT_HIGHER_THAN_HYPER = 0x08 + RATE_OF_DECREASE_EXCEEDED = 0x10 + RATE_OF_INCREASE_EXCEEDED = 0x20 + RESULT_LOWER_THAN_DEVICE_CAN_PROCESS = 0x40 + RESULT_HIGHER_THAN_DEVICE_CAN_PROCESS = 0x80 + + +class CGMMeasurementRecord(msgspec.Struct, frozen=True, kw_only=True): + """A single CGM Measurement Record. + + Attributes: + size: Total size of this record in bytes (including the size field). + flags: Raw 8-bit flags field. + glucose_concentration: Glucose concentration in mg/dL. + time_offset: Minutes since session start. + status_octet: Sensor status octet (8 bits). None if absent. + cal_temp_octet: Calibration/temperature octet (8 bits). None if absent. + warning_octet: Warning octet (8 bits). None if absent. + trend_information: Glucose trend rate (mg/dL/min). None if absent. + quality: CGM quality percentage. None if absent. + + """ + + size: int + flags: CGMMeasurementFlags + glucose_concentration: float + time_offset: int + status_octet: CGMSensorStatusOctet | None = None + cal_temp_octet: CGMCalTempOctet | None = None + warning_octet: CGMWarningOctet | None = None + trend_information: float | None = None + quality: float | None = None + + +class CGMMeasurementData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from CGM Measurement characteristic. + + Attributes: + records: List of CGM Measurement Records. + + """ + + records: tuple[CGMMeasurementRecord, ...] + + +def _decode_single_record(data: bytearray, start: int) -> tuple[CGMMeasurementRecord, int]: + """Decode a single CGM Measurement Record from data at the given offset. + + Args: + data: Full characteristic data. + start: Byte offset where this record begins. + + Returns: + Tuple of (decoded record, next offset after this record). + + """ + record_size = data[start] + flags = CGMMeasurementFlags(data[start + 1]) + glucose_concentration = IEEE11073Parser.parse_sfloat(data, start + 2) + time_offset = DataParser.parse_int16(data, start + 4, signed=False) + offset = start + 6 + + # Sensor Status Annunciation: order is Status, Cal/Temp, Warning + # (per YAML spec structure order) + status_octet: CGMSensorStatusOctet | None = None + if flags & CGMMeasurementFlags.STATUS_OCTET_PRESENT: + status_octet = CGMSensorStatusOctet(data[offset]) + offset += 1 + + cal_temp_octet: CGMCalTempOctet | None = None + if flags & CGMMeasurementFlags.CAL_TEMP_OCTET_PRESENT: + cal_temp_octet = CGMCalTempOctet(data[offset]) + offset += 1 + + warning_octet: CGMWarningOctet | None = None + if flags & CGMMeasurementFlags.WARNING_OCTET_PRESENT: + warning_octet = CGMWarningOctet(data[offset]) + offset += 1 + + trend_information: float | None = None + if flags & CGMMeasurementFlags.TREND_INFORMATION_PRESENT: + trend_information = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + quality: float | None = None + if flags & CGMMeasurementFlags.QUALITY_PRESENT: + quality = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + # Skip any remaining bytes in this record (e.g. E2E-CRC) + record_end = start + record_size + return ( + CGMMeasurementRecord( + size=record_size, + flags=flags, + glucose_concentration=glucose_concentration, + time_offset=time_offset, + status_octet=status_octet, + cal_temp_octet=cal_temp_octet, + warning_octet=warning_octet, + trend_information=trend_information, + quality=quality, + ), + record_end, + ) + + +def _encode_single_record(record: CGMMeasurementRecord) -> bytearray: + """Encode a single CGM Measurement Record to bytes. + + Args: + record: CGMMeasurementRecord instance. + + Returns: + Encoded bytearray for this record. + + """ + flags = CGMMeasurementFlags(0) + if record.trend_information is not None: + flags |= CGMMeasurementFlags.TREND_INFORMATION_PRESENT + if record.quality is not None: + flags |= CGMMeasurementFlags.QUALITY_PRESENT + if record.status_octet is not None: + flags |= CGMMeasurementFlags.STATUS_OCTET_PRESENT + if record.cal_temp_octet is not None: + flags |= CGMMeasurementFlags.CAL_TEMP_OCTET_PRESENT + if record.warning_octet is not None: + flags |= CGMMeasurementFlags.WARNING_OCTET_PRESENT + + body = bytearray() + # Placeholder for size byte — filled in at the end + body.append(0) + body.append(int(flags)) + body.extend(IEEE11073Parser.encode_sfloat(record.glucose_concentration)) + body.extend(DataParser.encode_int16(record.time_offset, signed=False)) + + if record.status_octet is not None: + body.append(int(record.status_octet)) + if record.cal_temp_octet is not None: + body.append(int(record.cal_temp_octet)) + if record.warning_octet is not None: + body.append(int(record.warning_octet)) + if record.trend_information is not None: + body.extend(IEEE11073Parser.encode_sfloat(record.trend_information)) + if record.quality is not None: + body.extend(IEEE11073Parser.encode_sfloat(record.quality)) + + body[0] = len(body) + return body + + +class CGMMeasurementCharacteristic(BaseCharacteristic[CGMMeasurementData]): + """CGM Measurement characteristic (0x2AA7). + + Contains one or more CGM Measurement Records concatenated together. + Each record is self-sized via its leading Size byte. + """ + + expected_type = CGMMeasurementData + min_length: int = 6 # At least one record: size(1)+flags(1)+glucose(2)+time(2) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> CGMMeasurementData: + """Parse CGM Measurement records from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + CGMMeasurementData containing all parsed records. + + """ + records: list[CGMMeasurementRecord] = [] + offset = 0 + while offset < len(data): + record, offset = _decode_single_record(data, offset) + records.append(record) + + return CGMMeasurementData(records=tuple(records)) + + def _encode_value(self, data: CGMMeasurementData) -> bytearray: + """Encode CGMMeasurementData back to BLE bytes. + + Args: + data: CGMMeasurementData instance. + + Returns: + Encoded bytearray with all records concatenated. + + """ + result = bytearray() + for record in data.records: + result.extend(_encode_single_record(record)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/cgm_session_run_time.py b/src/bluetooth_sig/gatt/characteristics/cgm_session_run_time.py new file mode 100644 index 00000000..5fde8959 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cgm_session_run_time.py @@ -0,0 +1,89 @@ +"""CGM Session Run Time characteristic implementation. + +Implements the CGM Session Run Time characteristic (0x2AAB). + +Structure (from GSS YAML): + CGM Session Run Time (uint16) -- expected run time in hours + E2E-CRC (uint16, optional) -- present if E2E-CRC Supported + +References: + Bluetooth SIG Continuous Glucose Monitoring Service + org.bluetooth.characteristic.cgm_session_run_time (GSS YAML) +""" + +from __future__ import annotations + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class CGMSessionRunTimeData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from CGM Session Run Time characteristic. + + Attributes: + run_time_hours: Expected run time of the CGM session in hours. + e2e_crc: E2E-CRC value. None if absent. + + """ + + run_time_hours: int + e2e_crc: int | None = None + + +class CGMSessionRunTimeCharacteristic(BaseCharacteristic[CGMSessionRunTimeData]): + """CGM Session Run Time characteristic (0x2AAB). + + Reports the expected run time of the CGM session in hours. + """ + + expected_type = CGMSessionRunTimeData + min_length: int = 2 # run_time(2) + allow_variable_length: bool = True # optional E2E-CRC + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> CGMSessionRunTimeData: + """Parse CGM Session Run Time from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic (2 or 4 bytes). + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + CGMSessionRunTimeData with parsed run time. + + """ + run_time_hours = DataParser.parse_int16(data, 0, signed=False) + + _min_length_with_crc = 4 + e2e_crc: int | None = None + if len(data) >= _min_length_with_crc: + e2e_crc = DataParser.parse_int16(data, 2, signed=False) + + return CGMSessionRunTimeData( + run_time_hours=run_time_hours, + e2e_crc=e2e_crc, + ) + + def _encode_value(self, data: CGMSessionRunTimeData) -> bytearray: + """Encode CGMSessionRunTimeData back to BLE bytes. + + Args: + data: CGMSessionRunTimeData instance. + + Returns: + Encoded bytearray (2 or 4 bytes). + + """ + result = DataParser.encode_int16(data.run_time_hours, signed=False) + if data.e2e_crc is not None: + result.extend(DataParser.encode_int16(data.e2e_crc, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/cgm_session_start_time.py b/src/bluetooth_sig/gatt/characteristics/cgm_session_start_time.py new file mode 100644 index 00000000..66d64337 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cgm_session_start_time.py @@ -0,0 +1,120 @@ +"""CGM Session Start Time characteristic implementation. + +Implements the CGM Session Start Time characteristic (0x2AAA). + +Structure (from GSS YAML): + Session Start Time (7 bytes) -- DateTime struct (year+month+day+h+m+s) + Time Zone (1 byte, uint8) -- offset from UTC in 15-minute increments + DST Offset (1 byte, uint8) -- DST adjustment code + E2E-CRC (2 bytes, uint16, optional) + +References: + Bluetooth SIG Continuous Glucose Monitoring Service + org.bluetooth.characteristic.cgm_session_start_time (GSS YAML) +""" + +from __future__ import annotations + +from datetime import datetime + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .dst_offset import DSTOffset +from .utils import DataParser + + +class CGMSessionStartTimeData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from CGM Session Start Time characteristic. + + Attributes: + start_time: Session start date and time. + time_zone: Time zone offset from UTC in 15-minute increments. + dst_offset: DST adjustment code. + e2e_crc: E2E-CRC value. None if absent. + + """ + + start_time: datetime + time_zone: int + dst_offset: DSTOffset + e2e_crc: int | None = None + + +class CGMSessionStartTimeCharacteristic(BaseCharacteristic[CGMSessionStartTimeData]): + """CGM Session Start Time characteristic (0x2AAA). + + Reports the session start time, time zone, and DST offset + for a CGM session. + """ + + expected_type = CGMSessionStartTimeData + min_length: int = 9 # datetime(7) + timezone(1) + dst(1) + allow_variable_length: bool = True # optional E2E-CRC + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> CGMSessionStartTimeData: + """Parse CGM Session Start Time from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic (9 or 11 bytes). + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + CGMSessionStartTimeData with parsed date/time and timezone info. + + """ + year = DataParser.parse_int16(data, 0, signed=False) + month = data[2] + day = data[3] + hour = data[4] + minute = data[5] + second = data[6] + start_time = datetime(year=year, month=month, day=day, hour=hour, minute=minute, second=second) + + time_zone = data[7] + dst_offset = DSTOffset(data[8]) + + _min_length_with_crc = 11 + e2e_crc: int | None = None + if len(data) >= _min_length_with_crc: + e2e_crc = DataParser.parse_int16(data, 9, signed=False) + + return CGMSessionStartTimeData( + start_time=start_time, + time_zone=time_zone, + dst_offset=dst_offset, + e2e_crc=e2e_crc, + ) + + def _encode_value(self, data: CGMSessionStartTimeData) -> bytearray: + """Encode CGMSessionStartTimeData back to BLE bytes. + + Args: + data: CGMSessionStartTimeData instance. + + Returns: + Encoded bytearray (9 or 11 bytes). + + """ + result = bytearray() + result.extend(DataParser.encode_int16(data.start_time.year, signed=False)) + result.append(data.start_time.month) + result.append(data.start_time.day) + result.append(data.start_time.hour) + result.append(data.start_time.minute) + result.append(data.start_time.second) + result.append(data.time_zone) + result.append(int(data.dst_offset)) + + if data.e2e_crc is not None: + result.extend(DataParser.encode_int16(data.e2e_crc, signed=False)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/cgm_status.py b/src/bluetooth_sig/gatt/characteristics/cgm_status.py new file mode 100644 index 00000000..7fe7e847 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cgm_status.py @@ -0,0 +1,144 @@ +"""CGM Status characteristic implementation. + +Implements the CGM Status characteristic (0x2AA9). Reports the current +status of a CGM sensor. + +Structure (from GSS YAML): + Time Offset (uint16) -- minutes since session start + CGM Status (3 bytes, boolean[24]) -- always 3 octets + E2E-CRC (uint16, optional) -- present if E2E-CRC Supported + +The 24-bit status uses the same bit definitions as CGM Measurement's +Sensor Status Annunciation (Status + Cal/Temp + Warning). + +References: + Bluetooth SIG Continuous Glucose Monitoring Service + org.bluetooth.characteristic.cgm_status (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .utils import DataParser + + +class CGMStatusFlags(IntFlag): + """CGM Status flags (24-bit). + + Combined Status (bits 0-7), Cal/Temp (bits 8-15), and Warning (bits 16-23). + """ + + # Status octet (bits 0-7) + SESSION_STOPPED = 0x000001 + DEVICE_BATTERY_LOW = 0x000002 + SENSOR_TYPE_INCORRECT = 0x000004 + SENSOR_MALFUNCTION = 0x000008 + DEVICE_SPECIFIC_ALERT = 0x000010 + GENERAL_DEVICE_FAULT = 0x000020 + # Cal/Temp octet (bits 8-15) + TIME_SYNC_REQUIRED = 0x000100 + CALIBRATION_NOT_ALLOWED = 0x000200 + CALIBRATION_RECOMMENDED = 0x000400 + CALIBRATION_REQUIRED = 0x000800 + SENSOR_TEMP_TOO_HIGH = 0x001000 + SENSOR_TEMP_TOO_LOW = 0x002000 + CALIBRATION_PENDING = 0x004000 + # Warning octet (bits 16-23) + RESULT_LOWER_THAN_PATIENT_LOW = 0x010000 + RESULT_HIGHER_THAN_PATIENT_HIGH = 0x020000 + RESULT_LOWER_THAN_HYPO = 0x040000 + RESULT_HIGHER_THAN_HYPER = 0x080000 + RATE_OF_DECREASE_EXCEEDED = 0x100000 + RATE_OF_INCREASE_EXCEEDED = 0x200000 + RESULT_LOWER_THAN_DEVICE_CAN_PROCESS = 0x400000 + RESULT_HIGHER_THAN_DEVICE_CAN_PROCESS = 0x800000 + + +class CGMStatusData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from CGM Status characteristic. + + Attributes: + time_offset: Minutes since session start. + status: 24-bit combined status flags. + e2e_crc: E2E-CRC value. None if absent. + + """ + + time_offset: int + status: CGMStatusFlags + e2e_crc: int | None = None + + +class CGMStatusCharacteristic(BaseCharacteristic[CGMStatusData]): + """CGM Status characteristic (0x2AA9). + + Reports current CGM sensor status with 24-bit status flags + and optional E2E-CRC. + """ + + expected_type = CGMStatusData + min_length: int = 5 # time_offset(2) + status(3) + allow_variable_length: bool = True # optional E2E-CRC + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> CGMStatusData: + """Parse CGM Status from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic (5 or 7 bytes). + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + CGMStatusData with time offset and status flags. + + """ + time_offset = DataParser.parse_int16(data, 0, signed=False) + status_raw = data[2] | (data[3] << 8) | (data[4] << 16) + status = CGMStatusFlags(status_raw) + + _min_length_with_crc = 7 + e2e_crc: int | None = None + if len(data) >= _min_length_with_crc: + e2e_crc = DataParser.parse_int16(data, 5, signed=False) + + return CGMStatusData( + time_offset=time_offset, + status=status, + e2e_crc=e2e_crc, + ) + + def _encode_value(self, data: CGMStatusData) -> bytearray: + """Encode CGMStatusData back to BLE bytes. + + Args: + data: CGMStatusData instance. + + Returns: + Encoded bytearray (5 or 7 bytes). + + """ + result = DataParser.encode_int16(data.time_offset, signed=False) + status_int = int(data.status) + result.extend( + bytearray( + [ + status_int & 0xFF, + (status_int >> 8) & 0xFF, + (status_int >> 16) & 0xFF, + ] + ) + ) + if data.e2e_crc is not None: + result.extend(DataParser.encode_int16(data.e2e_crc, signed=False)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py b/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py index 71373509..7af09112 100644 --- a/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py +++ b/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py @@ -166,7 +166,7 @@ def __new__( module_name = namespace.get("__module__", "") is_in_templates = "templates" in module_name - if not is_in_templates and not namespace.get("_is_template_override", False): + if not is_in_templates and not namespace.get("_is_template_override"): has_template_parent = any(getattr(base, "_is_template", False) for base in bases) if has_template_parent and "_is_template" not in namespace: namespace["_is_template"] = False diff --git a/src/bluetooth_sig/gatt/characteristics/cross_trainer_data.py b/src/bluetooth_sig/gatt/characteristics/cross_trainer_data.py new file mode 100644 index 00000000..1b67d460 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/cross_trainer_data.py @@ -0,0 +1,479 @@ +"""Cross Trainer Data characteristic implementation. + +Implements the Cross Trainer Data characteristic (0x2ACE) from the Fitness +Machine Service. A 24-bit flags field (3 bytes) controls the presence of +optional data fields -- the widest flags field in the fitness machine set. + +Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the +Instantaneous Speed field IS present; when bit 0 is 1 it is absent. +All other presence bits use normal logic (1 = present). + +Bit 15 is a **semantic bit** (Movement Direction): 0 = Forward, 1 = Backward. +It does NOT gate any data fields. + +References: + Bluetooth SIG Fitness Machine Service 1.0 + org.bluetooth.characteristic.cross_trainer_data (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, UINT8_MAX, UINT16_MAX, UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .fitness_machine_common import ( + MET_RESOLUTION, + decode_elapsed_time, + decode_energy_triplet, + decode_heart_rate, + decode_metabolic_equivalent, + decode_remaining_time, + encode_elapsed_time, + encode_energy_triplet, + encode_heart_rate, + encode_metabolic_equivalent, + encode_remaining_time, +) +from .utils import DataParser + +# Speed: M=1, d=-2, b=0 -> actual = raw / 100 km/h +_SPEED_RESOLUTION = 100.0 + +# Stride Count: M=1, d=-1, b=0 -> actual = raw / 10 +_STRIDE_COUNT_RESOLUTION = 10.0 + +# Inclination: M=1, d=-1, b=0 -> actual = raw / 10 % +# Ramp Setting: M=1, d=-1, b=0 -> actual = raw / 10 degrees +_TENTH_RESOLUTION = 10.0 + +# Resistance Level: M=1, d=1, b=0 -> actual = raw * 10 +_RESISTANCE_RESOLUTION = 10.0 + + +class CrossTrainerDataFlags(IntFlag): + """Cross Trainer Data flags as per Bluetooth SIG specification. + + 24-bit flags field (3 bytes). Bit 0 uses inverted logic: + 0 = Instantaneous Speed present, 1 = absent. + Bit 15 is a semantic modifier (Movement Direction), not a presence flag. + """ + + MORE_DATA = 0x000001 # Inverted: 0 -> Speed present, 1 -> absent + AVERAGE_SPEED_PRESENT = 0x000002 + TOTAL_DISTANCE_PRESENT = 0x000004 + STEP_COUNT_PRESENT = 0x000008 + STRIDE_COUNT_PRESENT = 0x000010 + ELEVATION_GAIN_PRESENT = 0x000020 + INCLINATION_AND_RAMP_PRESENT = 0x000040 + RESISTANCE_LEVEL_PRESENT = 0x000080 + INSTANTANEOUS_POWER_PRESENT = 0x000100 + AVERAGE_POWER_PRESENT = 0x000200 + EXPENDED_ENERGY_PRESENT = 0x000400 + HEART_RATE_PRESENT = 0x000800 + METABOLIC_EQUIVALENT_PRESENT = 0x001000 + ELAPSED_TIME_PRESENT = 0x002000 + REMAINING_TIME_PRESENT = 0x004000 + MOVEMENT_DIRECTION_BACKWARD = 0x008000 # Semantic: 0=Forward, 1=Backward + + +class CrossTrainerData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes + """Parsed data from Cross Trainer Data characteristic. + + Attributes: + flags: Raw 24-bit flags field. + instantaneous_speed: Instantaneous speed in km/h (0.01 resolution). + average_speed: Average speed in km/h (0.01 resolution). + total_distance: Total distance in metres (uint24). + steps_per_minute: Steps per minute. + average_step_rate: Average step rate in steps/min. + stride_count: Stride count (0.1 resolution, a stride is a pair of steps). + positive_elevation_gain: Positive elevation gain in metres. + negative_elevation_gain: Negative elevation gain in metres. + inclination: Current inclination in % (0.1 resolution, signed). + ramp_setting: Current ramp angle in degrees (0.1 resolution, signed). + resistance_level: Resistance level (unitless, resolution 10). + instantaneous_power: Instantaneous power in watts (signed). + average_power: Average power in watts (signed). + total_energy: Total expended energy in kcal. + energy_per_hour: Expended energy per hour in kcal. + energy_per_minute: Expended energy per minute in kcal. + heart_rate: Heart rate in bpm. + metabolic_equivalent: MET value (0.1 resolution). + elapsed_time: Elapsed time in seconds. + remaining_time: Remaining time in seconds. + movement_direction_backward: True if movement is backward, False if forward. + + """ + + flags: CrossTrainerDataFlags + instantaneous_speed: float | None = None + average_speed: float | None = None + total_distance: int | None = None + steps_per_minute: int | None = None + average_step_rate: int | None = None + stride_count: float | None = None + positive_elevation_gain: int | None = None + negative_elevation_gain: int | None = None + inclination: float | None = None + ramp_setting: float | None = None + resistance_level: float | None = None + instantaneous_power: int | None = None + average_power: int | None = None + total_energy: int | None = None + energy_per_hour: int | None = None + energy_per_minute: int | None = None + heart_rate: int | None = None + metabolic_equivalent: float | None = None + elapsed_time: int | None = None + remaining_time: int | None = None + movement_direction_backward: bool = False + + def __post_init__(self) -> None: + """Validate field ranges.""" + if ( + self.instantaneous_speed is not None + and not 0.0 <= self.instantaneous_speed <= UINT16_MAX / _SPEED_RESOLUTION + ): + raise ValueError( + f"Instantaneous speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.instantaneous_speed}" + ) + if self.average_speed is not None and not 0.0 <= self.average_speed <= UINT16_MAX / _SPEED_RESOLUTION: + raise ValueError(f"Average speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.average_speed}") + if self.total_distance is not None and not 0 <= self.total_distance <= UINT24_MAX: + raise ValueError(f"Total distance must be 0-{UINT24_MAX}, got {self.total_distance}") + if self.steps_per_minute is not None and not 0 <= self.steps_per_minute <= UINT16_MAX: + raise ValueError(f"Steps per minute must be 0-{UINT16_MAX}, got {self.steps_per_minute}") + if self.average_step_rate is not None and not 0 <= self.average_step_rate <= UINT16_MAX: + raise ValueError(f"Average step rate must be 0-{UINT16_MAX}, got {self.average_step_rate}") + if self.stride_count is not None and not 0.0 <= self.stride_count <= UINT16_MAX / _STRIDE_COUNT_RESOLUTION: + raise ValueError( + f"Stride count must be 0.0-{UINT16_MAX / _STRIDE_COUNT_RESOLUTION}, got {self.stride_count}" + ) + if self.positive_elevation_gain is not None and not 0 <= self.positive_elevation_gain <= UINT16_MAX: + raise ValueError(f"Positive elevation must be 0-{UINT16_MAX}, got {self.positive_elevation_gain}") + if self.negative_elevation_gain is not None and not 0 <= self.negative_elevation_gain <= UINT16_MAX: + raise ValueError(f"Negative elevation must be 0-{UINT16_MAX}, got {self.negative_elevation_gain}") + if ( + self.inclination is not None + and not SINT16_MIN / _TENTH_RESOLUTION <= self.inclination <= SINT16_MAX / _TENTH_RESOLUTION + ): + raise ValueError( + f"Inclination must be {SINT16_MIN / _TENTH_RESOLUTION}-{SINT16_MAX / _TENTH_RESOLUTION}, " + f"got {self.inclination}" + ) + if ( + self.ramp_setting is not None + and not SINT16_MIN / _TENTH_RESOLUTION <= self.ramp_setting <= SINT16_MAX / _TENTH_RESOLUTION + ): + raise ValueError( + f"Ramp setting must be {SINT16_MIN / _TENTH_RESOLUTION}-{SINT16_MAX / _TENTH_RESOLUTION}, " + f"got {self.ramp_setting}" + ) + if self.resistance_level is not None and not 0.0 <= self.resistance_level <= UINT8_MAX * _RESISTANCE_RESOLUTION: + raise ValueError( + f"Resistance level must be 0.0-{UINT8_MAX * _RESISTANCE_RESOLUTION}, got {self.resistance_level}" + ) + if self.instantaneous_power is not None and not SINT16_MIN <= self.instantaneous_power <= SINT16_MAX: + raise ValueError(f"Instantaneous power must be {SINT16_MIN}-{SINT16_MAX}, got {self.instantaneous_power}") + if self.average_power is not None and not SINT16_MIN <= self.average_power <= SINT16_MAX: + raise ValueError(f"Average power must be {SINT16_MIN}-{SINT16_MAX}, got {self.average_power}") + if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX: + raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}") + if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX: + raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}") + if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX: + raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}") + if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX: + raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}") + if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION: + raise ValueError( + f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}" + ) + if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX: + raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}") + if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX: + raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}") + + +class CrossTrainerDataCharacteristic(BaseCharacteristic[CrossTrainerData]): + """Cross Trainer Data characteristic (0x2ACE). + + Used in the Fitness Machine Service to transmit cross trainer workout + data. A 24-bit flags field (3 bytes) controls which optional fields + are present -- the widest flags field in the fitness machine set. + + Flag-bit assignments (from GSS YAML): + Bit 0: More Data -- **inverted**: 0 -> Inst. Speed present, 1 -> absent + Bit 1: Average Speed present + Bit 2: Total Distance present + Bit 3: Step Count present (gates Steps/Min + Avg Step Rate) + Bit 4: Stride Count present + Bit 5: Elevation Gain present (gates Pos + Neg) + Bit 6: Inclination and Ramp Angle Setting present (gates 2 fields) + Bit 7: Resistance Level present + Bit 8: Instantaneous Power present + Bit 9: Average Power present + Bit 10: Expended Energy present (gates triplet: total + /hr + /min) + Bit 11: Heart Rate present + Bit 12: Metabolic Equivalent present + Bit 13: Elapsed Time present + Bit 14: Remaining Time present + Bit 15: Movement Direction (0=Forward, 1=Backward) -- semantic, not presence + Bits 16-23: Reserved for Future Use + + """ + + expected_type = CrossTrainerData + min_length: int = 3 # Flags only (24-bit = 3 bytes) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> CrossTrainerData: + """Parse Cross Trainer Data from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + CrossTrainerData with all present fields populated. + + """ + flags = CrossTrainerDataFlags(DataParser.parse_int24(data, 0, signed=False)) + offset = 3 + + # Bit 0 -- inverted: Instantaneous Speed present when bit is NOT set + instantaneous_speed = None + if not (flags & CrossTrainerDataFlags.MORE_DATA) and len(data) >= offset + 2: + raw_speed = DataParser.parse_int16(data, offset, signed=False) + instantaneous_speed = raw_speed / _SPEED_RESOLUTION + offset += 2 + + # Bit 1 -- Average Speed + average_speed = None + if (flags & CrossTrainerDataFlags.AVERAGE_SPEED_PRESENT) and len(data) >= offset + 2: + raw_avg_speed = DataParser.parse_int16(data, offset, signed=False) + average_speed = raw_avg_speed / _SPEED_RESOLUTION + offset += 2 + + # Bit 2 -- Total Distance (uint24) + total_distance = None + if (flags & CrossTrainerDataFlags.TOTAL_DISTANCE_PRESENT) and len(data) >= offset + 3: + total_distance = DataParser.parse_int24(data, offset, signed=False) + offset += 3 + + # Bit 3 -- Steps Per Minute (uint16) + Average Step Rate (uint16) + steps_per_minute = None + average_step_rate = None + if (flags & CrossTrainerDataFlags.STEP_COUNT_PRESENT) and len(data) >= offset + 4: + steps_per_minute = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + average_step_rate = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 4 -- Stride Count (uint16, d=-1 -> raw/10) + stride_count = None + if (flags & CrossTrainerDataFlags.STRIDE_COUNT_PRESENT) and len(data) >= offset + 2: + raw_stride = DataParser.parse_int16(data, offset, signed=False) + stride_count = raw_stride / _STRIDE_COUNT_RESOLUTION + offset += 2 + + # Bit 5 -- Positive Elevation Gain (uint16) + Negative Elevation Gain (uint16) + positive_elevation_gain = None + negative_elevation_gain = None + if (flags & CrossTrainerDataFlags.ELEVATION_GAIN_PRESENT) and len(data) >= offset + 4: + positive_elevation_gain = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + negative_elevation_gain = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 6 -- Inclination (sint16, d=-1) + Ramp Setting (sint16, d=-1) + inclination = None + ramp_setting = None + if (flags & CrossTrainerDataFlags.INCLINATION_AND_RAMP_PRESENT) and len(data) >= offset + 4: + raw_incl = DataParser.parse_int16(data, offset, signed=True) + inclination = raw_incl / _TENTH_RESOLUTION + offset += 2 + raw_ramp = DataParser.parse_int16(data, offset, signed=True) + ramp_setting = raw_ramp / _TENTH_RESOLUTION + offset += 2 + + # Bit 7 -- Resistance Level (uint8, d=1 -> raw * 10) + resistance_level = None + if (flags & CrossTrainerDataFlags.RESISTANCE_LEVEL_PRESENT) and len(data) >= offset + 1: + raw_resistance = DataParser.parse_int8(data, offset, signed=False) + resistance_level = raw_resistance * _RESISTANCE_RESOLUTION + offset += 1 + + # Bit 8 -- Instantaneous Power (sint16) + instantaneous_power = None + if (flags & CrossTrainerDataFlags.INSTANTANEOUS_POWER_PRESENT) and len(data) >= offset + 2: + instantaneous_power = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + # Bit 9 -- Average Power (sint16) + average_power = None + if (flags & CrossTrainerDataFlags.AVERAGE_POWER_PRESENT) and len(data) >= offset + 2: + average_power = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + # Bit 10 -- Energy triplet (Total + Per Hour + Per Minute) + total_energy = None + energy_per_hour = None + energy_per_minute = None + if flags & CrossTrainerDataFlags.EXPENDED_ENERGY_PRESENT: + total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset) + + # Bit 11 -- Heart Rate + heart_rate = None + if flags & CrossTrainerDataFlags.HEART_RATE_PRESENT: + heart_rate, offset = decode_heart_rate(data, offset) + + # Bit 12 -- Metabolic Equivalent + metabolic_equivalent = None + if flags & CrossTrainerDataFlags.METABOLIC_EQUIVALENT_PRESENT: + metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset) + + # Bit 13 -- Elapsed Time + elapsed_time = None + if flags & CrossTrainerDataFlags.ELAPSED_TIME_PRESENT: + elapsed_time, offset = decode_elapsed_time(data, offset) + + # Bit 14 -- Remaining Time + remaining_time = None + if flags & CrossTrainerDataFlags.REMAINING_TIME_PRESENT: + remaining_time, offset = decode_remaining_time(data, offset) + + # Bit 15 -- Movement Direction (semantic, no data fields) + movement_direction_backward = bool(flags & CrossTrainerDataFlags.MOVEMENT_DIRECTION_BACKWARD) + + return CrossTrainerData( + flags=flags, + instantaneous_speed=instantaneous_speed, + average_speed=average_speed, + total_distance=total_distance, + steps_per_minute=steps_per_minute, + average_step_rate=average_step_rate, + stride_count=stride_count, + positive_elevation_gain=positive_elevation_gain, + negative_elevation_gain=negative_elevation_gain, + inclination=inclination, + ramp_setting=ramp_setting, + resistance_level=resistance_level, + instantaneous_power=instantaneous_power, + average_power=average_power, + total_energy=total_energy, + energy_per_hour=energy_per_hour, + energy_per_minute=energy_per_minute, + heart_rate=heart_rate, + metabolic_equivalent=metabolic_equivalent, + elapsed_time=elapsed_time, + remaining_time=remaining_time, + movement_direction_backward=movement_direction_backward, + ) + + def _encode_value(self, data: CrossTrainerData) -> bytearray: # noqa: PLR0912 + """Encode CrossTrainerData back to BLE bytes. + + Reconstructs 24-bit flags from present fields so round-trip encoding + preserves the original wire format. + + Args: + data: CrossTrainerData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = CrossTrainerDataFlags(0) + + # Bit 0 -- inverted: set MORE_DATA when Speed is absent + if data.instantaneous_speed is None: + flags |= CrossTrainerDataFlags.MORE_DATA + if data.average_speed is not None: + flags |= CrossTrainerDataFlags.AVERAGE_SPEED_PRESENT + if data.total_distance is not None: + flags |= CrossTrainerDataFlags.TOTAL_DISTANCE_PRESENT + if data.steps_per_minute is not None: + flags |= CrossTrainerDataFlags.STEP_COUNT_PRESENT + if data.stride_count is not None: + flags |= CrossTrainerDataFlags.STRIDE_COUNT_PRESENT + if data.positive_elevation_gain is not None: + flags |= CrossTrainerDataFlags.ELEVATION_GAIN_PRESENT + if data.inclination is not None: + flags |= CrossTrainerDataFlags.INCLINATION_AND_RAMP_PRESENT + if data.resistance_level is not None: + flags |= CrossTrainerDataFlags.RESISTANCE_LEVEL_PRESENT + if data.instantaneous_power is not None: + flags |= CrossTrainerDataFlags.INSTANTANEOUS_POWER_PRESENT + if data.average_power is not None: + flags |= CrossTrainerDataFlags.AVERAGE_POWER_PRESENT + if data.total_energy is not None: + flags |= CrossTrainerDataFlags.EXPENDED_ENERGY_PRESENT + if data.heart_rate is not None: + flags |= CrossTrainerDataFlags.HEART_RATE_PRESENT + if data.metabolic_equivalent is not None: + flags |= CrossTrainerDataFlags.METABOLIC_EQUIVALENT_PRESENT + if data.elapsed_time is not None: + flags |= CrossTrainerDataFlags.ELAPSED_TIME_PRESENT + if data.remaining_time is not None: + flags |= CrossTrainerDataFlags.REMAINING_TIME_PRESENT + if data.movement_direction_backward: + flags |= CrossTrainerDataFlags.MOVEMENT_DIRECTION_BACKWARD + + result = DataParser.encode_int24(int(flags), signed=False) + + if data.instantaneous_speed is not None: + raw_speed = round(data.instantaneous_speed * _SPEED_RESOLUTION) + result.extend(DataParser.encode_int16(raw_speed, signed=False)) + if data.average_speed is not None: + raw_avg_speed = round(data.average_speed * _SPEED_RESOLUTION) + result.extend(DataParser.encode_int16(raw_avg_speed, signed=False)) + if data.total_distance is not None: + result.extend(DataParser.encode_int24(data.total_distance, signed=False)) + if data.steps_per_minute is not None: + result.extend(DataParser.encode_int16(data.steps_per_minute, signed=False)) + if data.average_step_rate is not None: + result.extend(DataParser.encode_int16(data.average_step_rate, signed=False)) + if data.stride_count is not None: + raw_stride = round(data.stride_count * _STRIDE_COUNT_RESOLUTION) + result.extend(DataParser.encode_int16(raw_stride, signed=False)) + if data.positive_elevation_gain is not None: + result.extend(DataParser.encode_int16(data.positive_elevation_gain, signed=False)) + if data.negative_elevation_gain is not None: + result.extend(DataParser.encode_int16(data.negative_elevation_gain, signed=False)) + if data.inclination is not None: + raw_incl = round(data.inclination * _TENTH_RESOLUTION) + result.extend(DataParser.encode_int16(raw_incl, signed=True)) + if data.ramp_setting is not None: + raw_ramp = round(data.ramp_setting * _TENTH_RESOLUTION) + result.extend(DataParser.encode_int16(raw_ramp, signed=True)) + if data.resistance_level is not None: + raw_resistance = round(data.resistance_level / _RESISTANCE_RESOLUTION) + result.extend(DataParser.encode_int8(raw_resistance, signed=False)) + if data.instantaneous_power is not None: + result.extend(DataParser.encode_int16(data.instantaneous_power, signed=True)) + if data.average_power is not None: + result.extend(DataParser.encode_int16(data.average_power, signed=True)) + if data.total_energy is not None: + result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute)) + if data.heart_rate is not None: + result.extend(encode_heart_rate(data.heart_rate)) + if data.metabolic_equivalent is not None: + result.extend(encode_metabolic_equivalent(data.metabolic_equivalent)) + if data.elapsed_time is not None: + result.extend(encode_elapsed_time(data.elapsed_time)) + if data.remaining_time is not None: + result.extend(encode_remaining_time(data.remaining_time)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/current_elapsed_time.py b/src/bluetooth_sig/gatt/characteristics/current_elapsed_time.py new file mode 100644 index 00000000..c9ce57e7 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/current_elapsed_time.py @@ -0,0 +1,152 @@ +"""Current Elapsed Time characteristic implementation. + +Implements the Current Elapsed Time characteristic (0x2BF2). + +Structure (from GSS YAML - org.bluetooth.characteristic.elapsed_time): + Flags (uint8, 1 byte) -- interpretation flags + Time Value (uint48, 6 bytes) -- counter in time-resolution units + Time Sync Source Type (uint8, 1 byte) -- sync source enum + TZ/DST Offset (sint8, 1 byte) -- combined offset in 15-minute units + +Note: The GSS YAML identifier is ``elapsed_time`` but the UUID registry +identifier is ``current_elapsed_time`` (0x2BF2). File is named to match +the UUID registry for auto-discovery. + +Flag bits: + 0: Tick counter (0=time of day, 1=relative counter) + 1: UTC (0=local time, 1=UTC) — meaningless for tick counter + 2-3: Time resolution (00=1s, 01=100ms, 10=1ms, 11=100µs) + 4: TZ/DST offset used (0=not used, 1=used) + 5: Current timeline (0=not current, 1=current) + 6-7: Reserved + +References: + Bluetooth SIG Generic Sensor Service + org.bluetooth.characteristic.elapsed_time (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntEnum, IntFlag + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .reference_time_information import TimeSource +from .utils import DataParser + +_TIME_RESOLUTION_MASK = 0x0C +_TIME_RESOLUTION_SHIFT = 2 + + +class ElapsedTimeFlags(IntFlag): + """Flags for the Elapsed Time characteristic.""" + + TICK_COUNTER = 1 << 0 + UTC = 1 << 1 + TZ_DST_USED = 1 << 4 + CURRENT_TIMELINE = 1 << 5 + + +class TimeResolution(IntEnum): + """Time resolution values (bits 2-3 of flags).""" + + ONE_SECOND = 0 + HUNDRED_MILLISECONDS = 1 + ONE_MILLISECOND = 2 + HUNDRED_MICROSECONDS = 3 + + +class CurrentElapsedTimeData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from Current Elapsed Time characteristic. + + Attributes: + flags: Interpretation flags. + time_value: Counter value in the resolution defined by flags. + time_resolution: Resolution of the time value. + is_tick_counter: True if time_value is a relative counter. + is_utc: True if time_value reports UTC (only meaningful if not tick counter). + tz_dst_used: True if tz_dst_offset is meaningful. + is_current_timeline: True if time stamp is from the current timeline. + sync_source_type: Time synchronisation source type. + tz_dst_offset: Combined TZ/DST offset from UTC in 15-minute units. + + """ + + flags: ElapsedTimeFlags + time_value: int + time_resolution: TimeResolution + is_tick_counter: bool + is_utc: bool + tz_dst_used: bool + is_current_timeline: bool + sync_source_type: TimeSource + tz_dst_offset: int + + +class CurrentElapsedTimeCharacteristic(BaseCharacteristic[CurrentElapsedTimeData]): + """Current Elapsed Time characteristic (0x2BF2). + + Reports the current time of a clock or tick counter. + Fixed 9-byte structure. + """ + + expected_type = CurrentElapsedTimeData + min_length: int = 9 + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> CurrentElapsedTimeData: + """Parse Current Elapsed Time from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic (9 bytes). + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + CurrentElapsedTimeData with parsed time information. + + """ + flags_raw = data[0] + flags = ElapsedTimeFlags(flags_raw & 0x33) # Mask out resolution bits + reserved + + time_resolution = TimeResolution((flags_raw & _TIME_RESOLUTION_MASK) >> _TIME_RESOLUTION_SHIFT) + + time_value = DataParser.parse_int48(data, 1, signed=False) + sync_source_type = TimeSource(data[7]) + tz_dst_offset = DataParser.parse_int8(data, 8, signed=True) + + return CurrentElapsedTimeData( + flags=flags, + time_value=time_value, + time_resolution=time_resolution, + is_tick_counter=bool(flags & ElapsedTimeFlags.TICK_COUNTER), + is_utc=bool(flags & ElapsedTimeFlags.UTC), + tz_dst_used=bool(flags & ElapsedTimeFlags.TZ_DST_USED), + is_current_timeline=bool(flags & ElapsedTimeFlags.CURRENT_TIMELINE), + sync_source_type=sync_source_type, + tz_dst_offset=tz_dst_offset, + ) + + def _encode_value(self, data: CurrentElapsedTimeData) -> bytearray: + """Encode CurrentElapsedTimeData back to BLE bytes. + + Args: + data: CurrentElapsedTimeData instance. + + Returns: + Encoded bytearray (9 bytes). + + """ + flags_raw = int(data.flags) | (data.time_resolution << _TIME_RESOLUTION_SHIFT) + result = bytearray([flags_raw]) + result.extend(DataParser.encode_int48(data.time_value, signed=False)) + result.append(int(data.sync_source_type)) + result.extend(DataParser.encode_int8(data.tz_dst_offset, signed=True)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/enhanced_blood_pressure_measurement.py b/src/bluetooth_sig/gatt/characteristics/enhanced_blood_pressure_measurement.py new file mode 100644 index 00000000..cc0b624b --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/enhanced_blood_pressure_measurement.py @@ -0,0 +1,216 @@ +"""Enhanced Blood Pressure Measurement characteristic implementation. + +Implements the Enhanced Blood Pressure Measurement characteristic (0x2B34). +Extends the regular Blood Pressure Measurement with uint32 timestamps +(seconds since epoch), a User Facing Time field, and an Epoch Start flag. + +Flag-bit assignments (from GSS YAML): + Bit 0: Units (0=mmHg, 1=kPa) + Bit 1: Time Stamp present (uint32 seconds since epoch) + Bit 2: Pulse Rate present (medfloat16) + Bit 3: User ID present (uint8) + Bit 4: Measurement Status present (boolean[16]) + Bit 5: User Facing Time present (uint32 seconds since epoch) + Bit 6: Epoch Start 2000 (0=1900, 1=2000) + Bit 7: Reserved + +References: + Bluetooth SIG Blood Pressure Service 1.1 + org.bluetooth.characteristic.enhanced_blood_pressure_measurement (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntEnum, IntFlag + +import msgspec + +from bluetooth_sig.types.units import PressureUnit + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .blood_pressure_measurement import BloodPressureMeasurementStatus +from .utils import DataParser, IEEE11073Parser + + +class EpochYear(IntEnum): + """Epoch start year for Enhanced Blood Pressure timestamps.""" + + EPOCH_1900 = 1900 + EPOCH_2000 = 2000 + + +class EnhancedBloodPressureFlags(IntFlag): + """Enhanced Blood Pressure Measurement flags.""" + + UNITS_KPA = 0x01 + TIMESTAMP_PRESENT = 0x02 + PULSE_RATE_PRESENT = 0x04 + USER_ID_PRESENT = 0x08 + MEASUREMENT_STATUS_PRESENT = 0x10 + USER_FACING_TIME_PRESENT = 0x20 + EPOCH_START_2000 = 0x40 + + +class EnhancedBloodPressureData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from Enhanced Blood Pressure Measurement characteristic. + + Attributes: + flags: Raw 8-bit flags field. + systolic: Systolic pressure value. + diastolic: Diastolic pressure value. + mean_arterial_pressure: Mean arterial pressure value. + unit: Pressure unit (mmHg or kPa). + timestamp: Seconds since epoch start. None if absent. + pulse_rate: Pulse rate in BPM. None if absent. + user_id: User ID (0-255). None if absent. + measurement_status: 16-bit measurement status flags. None if absent. + user_facing_time: User-facing time in seconds since epoch. None if absent. + epoch_year: Epoch start year (1900 or 2000). + + """ + + flags: EnhancedBloodPressureFlags + systolic: float + diastolic: float + mean_arterial_pressure: float + unit: PressureUnit + timestamp: int | None = None + pulse_rate: float | None = None + user_id: int | None = None + measurement_status: BloodPressureMeasurementStatus | None = None + user_facing_time: int | None = None + epoch_year: EpochYear = EpochYear.EPOCH_1900 + + +class EnhancedBloodPressureMeasurementCharacteristic( + BaseCharacteristic[EnhancedBloodPressureData], +): + """Enhanced Blood Pressure Measurement characteristic (0x2B34). + + Enhanced variant of Blood Pressure Measurement with uint32 timestamps + (seconds since epoch) instead of 7-byte DateTime, plus a new User Facing + Time field and Epoch Start 2000 flag. + """ + + expected_type = EnhancedBloodPressureData + min_length: int = 7 # flags(1) + compound value(6) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> EnhancedBloodPressureData: + """Parse Enhanced Blood Pressure Measurement from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + EnhancedBloodPressureData with all present fields populated. + + """ + flags = EnhancedBloodPressureFlags(data[0]) + unit = PressureUnit.KPA if flags & EnhancedBloodPressureFlags.UNITS_KPA else PressureUnit.MMHG + + # Compound pressure value: 3 x medfloat16 (6 bytes) + systolic = IEEE11073Parser.parse_sfloat(data, 1) + diastolic = IEEE11073Parser.parse_sfloat(data, 3) + mean_arterial_pressure = IEEE11073Parser.parse_sfloat(data, 5) + offset = 7 + + epoch_year = ( + EpochYear.EPOCH_2000 if flags & EnhancedBloodPressureFlags.EPOCH_START_2000 else EpochYear.EPOCH_1900 + ) + + timestamp: int | None = None + if flags & EnhancedBloodPressureFlags.TIMESTAMP_PRESENT: + timestamp = DataParser.parse_int32(data, offset, signed=False) + offset += 4 + + pulse_rate: float | None = None + if flags & EnhancedBloodPressureFlags.PULSE_RATE_PRESENT: + pulse_rate = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + user_id: int | None = None + if flags & EnhancedBloodPressureFlags.USER_ID_PRESENT: + user_id = data[offset] + offset += 1 + + measurement_status: BloodPressureMeasurementStatus | None = None + if flags & EnhancedBloodPressureFlags.MEASUREMENT_STATUS_PRESENT: + measurement_status = BloodPressureMeasurementStatus(DataParser.parse_int16(data, offset, signed=False)) + offset += 2 + + user_facing_time: int | None = None + if flags & EnhancedBloodPressureFlags.USER_FACING_TIME_PRESENT: + user_facing_time = DataParser.parse_int32(data, offset, signed=False) + offset += 4 + + return EnhancedBloodPressureData( + flags=flags, + systolic=systolic, + diastolic=diastolic, + mean_arterial_pressure=mean_arterial_pressure, + unit=unit, + timestamp=timestamp, + pulse_rate=pulse_rate, + user_id=user_id, + measurement_status=measurement_status, + user_facing_time=user_facing_time, + epoch_year=epoch_year, + ) + + def _encode_value(self, data: EnhancedBloodPressureData) -> bytearray: + """Encode EnhancedBloodPressureData back to BLE bytes. + + Args: + data: EnhancedBloodPressureData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = EnhancedBloodPressureFlags(0) + if data.unit == PressureUnit.KPA: + flags |= EnhancedBloodPressureFlags.UNITS_KPA + if data.timestamp is not None: + flags |= EnhancedBloodPressureFlags.TIMESTAMP_PRESENT + if data.pulse_rate is not None: + flags |= EnhancedBloodPressureFlags.PULSE_RATE_PRESENT + if data.user_id is not None: + flags |= EnhancedBloodPressureFlags.USER_ID_PRESENT + if data.measurement_status is not None: + flags |= EnhancedBloodPressureFlags.MEASUREMENT_STATUS_PRESENT + if data.user_facing_time is not None: + flags |= EnhancedBloodPressureFlags.USER_FACING_TIME_PRESENT + if data.epoch_year == EpochYear.EPOCH_2000: + flags |= EnhancedBloodPressureFlags.EPOCH_START_2000 + + result = bytearray([int(flags)]) + result.extend(IEEE11073Parser.encode_sfloat(data.systolic)) + result.extend(IEEE11073Parser.encode_sfloat(data.diastolic)) + result.extend(IEEE11073Parser.encode_sfloat(data.mean_arterial_pressure)) + + if data.timestamp is not None: + result.extend(DataParser.encode_int32(data.timestamp, signed=False)) + + if data.pulse_rate is not None: + result.extend(IEEE11073Parser.encode_sfloat(data.pulse_rate)) + + if data.user_id is not None: + result.append(data.user_id) + + if data.measurement_status is not None: + result.extend(DataParser.encode_int16(int(data.measurement_status), signed=False)) + + if data.user_facing_time is not None: + result.extend(DataParser.encode_int32(data.user_facing_time, signed=False)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/enhanced_intermediate_cuff_pressure.py b/src/bluetooth_sig/gatt/characteristics/enhanced_intermediate_cuff_pressure.py new file mode 100644 index 00000000..8334a0ce --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/enhanced_intermediate_cuff_pressure.py @@ -0,0 +1,186 @@ +"""Enhanced Intermediate Cuff Pressure characteristic implementation. + +Implements the Enhanced Intermediate Cuff Pressure characteristic (0x2B35). +Reports a single intermediate cuff pressure reading (medfloat16) during +an ongoing measurement, with enhanced optional fields matching the Enhanced +Blood Pressure Measurement pattern. + +Flag-bit assignments (from GSS YAML): + Bit 0: Units (0=mmHg, 1=kPa) + Bit 1: Time Stamp present (uint32 seconds since epoch) + Bit 2: Pulse Rate present (medfloat16) + Bit 3: User ID present (uint8) + Bit 4: Measurement Status present (boolean[16]) + Bit 5: User Facing Time present (uint32 seconds since epoch) + Bit 6: Epoch Start 2000 (0=1900, 1=2000) + Bit 7: Reserved + +References: + Bluetooth SIG Blood Pressure Service 1.1 + org.bluetooth.characteristic.enhanced_intermediate_cuff_pressure (GSS YAML) +""" + +from __future__ import annotations + +import msgspec + +from bluetooth_sig.types.units import PressureUnit + +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .blood_pressure_measurement import BloodPressureMeasurementStatus +from .enhanced_blood_pressure_measurement import EnhancedBloodPressureFlags, EpochYear +from .utils import DataParser, IEEE11073Parser + + +class EnhancedIntermediateCuffPressureData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from Enhanced Intermediate Cuff Pressure characteristic. + + Attributes: + flags: Raw 8-bit flags field. + cuff_pressure: Current intermediate cuff pressure value. + unit: Pressure unit (mmHg or kPa). + timestamp: Seconds since epoch start. None if absent. + pulse_rate: Pulse rate in BPM. None if absent. + user_id: User ID (0-255). None if absent. + measurement_status: 16-bit measurement status flags. None if absent. + user_facing_time: User-facing time in seconds since epoch. None if absent. + epoch_year: Epoch start year (1900 or 2000). + + """ + + flags: EnhancedBloodPressureFlags + cuff_pressure: float + unit: PressureUnit + timestamp: int | None = None + pulse_rate: float | None = None + user_id: int | None = None + measurement_status: BloodPressureMeasurementStatus | None = None + user_facing_time: int | None = None + epoch_year: EpochYear = EpochYear.EPOCH_1900 + + +class EnhancedIntermediateCuffPressureCharacteristic( + BaseCharacteristic[EnhancedIntermediateCuffPressureData], +): + """Enhanced Intermediate Cuff Pressure characteristic (0x2B35). + + Reports a single intermediate cuff pressure reading during an ongoing + blood pressure measurement, with enhanced timestamps and epoch flag. + """ + + expected_type = EnhancedIntermediateCuffPressureData + min_length: int = 3 # flags(1) + cuff_pressure(2) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> EnhancedIntermediateCuffPressureData: + """Parse Enhanced Intermediate Cuff Pressure from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + EnhancedIntermediateCuffPressureData with all present fields. + + """ + flags = EnhancedBloodPressureFlags(data[0]) + unit = PressureUnit.KPA if flags & EnhancedBloodPressureFlags.UNITS_KPA else PressureUnit.MMHG + + # Mandatory single cuff pressure value (medfloat16) + cuff_pressure = IEEE11073Parser.parse_sfloat(data, 1) + offset = 3 + + epoch_year = ( + EpochYear.EPOCH_2000 if flags & EnhancedBloodPressureFlags.EPOCH_START_2000 else EpochYear.EPOCH_1900 + ) + + timestamp: int | None = None + if flags & EnhancedBloodPressureFlags.TIMESTAMP_PRESENT: + timestamp = DataParser.parse_int32(data, offset, signed=False) + offset += 4 + + pulse_rate: float | None = None + if flags & EnhancedBloodPressureFlags.PULSE_RATE_PRESENT: + pulse_rate = IEEE11073Parser.parse_sfloat(data, offset) + offset += 2 + + user_id: int | None = None + if flags & EnhancedBloodPressureFlags.USER_ID_PRESENT: + user_id = data[offset] + offset += 1 + + measurement_status: BloodPressureMeasurementStatus | None = None + if flags & EnhancedBloodPressureFlags.MEASUREMENT_STATUS_PRESENT: + measurement_status = BloodPressureMeasurementStatus(DataParser.parse_int16(data, offset, signed=False)) + offset += 2 + + user_facing_time: int | None = None + if flags & EnhancedBloodPressureFlags.USER_FACING_TIME_PRESENT: + user_facing_time = DataParser.parse_int32(data, offset, signed=False) + offset += 4 + + return EnhancedIntermediateCuffPressureData( + flags=flags, + cuff_pressure=cuff_pressure, + unit=unit, + timestamp=timestamp, + pulse_rate=pulse_rate, + user_id=user_id, + measurement_status=measurement_status, + user_facing_time=user_facing_time, + epoch_year=epoch_year, + ) + + def _encode_value(self, data: EnhancedIntermediateCuffPressureData) -> bytearray: + """Encode EnhancedIntermediateCuffPressureData back to BLE bytes. + + Args: + data: EnhancedIntermediateCuffPressureData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = EnhancedBloodPressureFlags(0) + if data.unit == PressureUnit.KPA: + flags |= EnhancedBloodPressureFlags.UNITS_KPA + if data.timestamp is not None: + flags |= EnhancedBloodPressureFlags.TIMESTAMP_PRESENT + if data.pulse_rate is not None: + flags |= EnhancedBloodPressureFlags.PULSE_RATE_PRESENT + if data.user_id is not None: + flags |= EnhancedBloodPressureFlags.USER_ID_PRESENT + if data.measurement_status is not None: + flags |= EnhancedBloodPressureFlags.MEASUREMENT_STATUS_PRESENT + if data.user_facing_time is not None: + flags |= EnhancedBloodPressureFlags.USER_FACING_TIME_PRESENT + if data.epoch_year == EpochYear.EPOCH_2000: + flags |= EnhancedBloodPressureFlags.EPOCH_START_2000 + + result = bytearray([int(flags)]) + result.extend(IEEE11073Parser.encode_sfloat(data.cuff_pressure)) + + if data.timestamp is not None: + result.extend(DataParser.encode_int32(data.timestamp, signed=False)) + + if data.pulse_rate is not None: + result.extend(IEEE11073Parser.encode_sfloat(data.pulse_rate)) + + if data.user_id is not None: + result.append(data.user_id) + + if data.measurement_status is not None: + result.extend(DataParser.encode_int16(int(data.measurement_status), signed=False)) + + if data.user_facing_time is not None: + result.extend(DataParser.encode_int32(data.user_facing_time, signed=False)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/fitness_machine_common.py b/src/bluetooth_sig/gatt/characteristics/fitness_machine_common.py new file mode 100644 index 00000000..7addc0d8 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/fitness_machine_common.py @@ -0,0 +1,194 @@ +"""Shared parsing/encoding utilities for Fitness Machine Service characteristics. + +All six Fitness Machine data characteristics (treadmill, indoor bike, cross +trainer, rower, stair climber, step climber) share the same trailing optional +field blocks: energy triplet, heart rate, metabolic equivalent, elapsed time, +and remaining time. This module provides reusable helpers so each +characteristic only has to implement its own unique fields. + +References: + Bluetooth SIG Fitness Machine Service 1.0 + org.bluetooth.characteristic.{treadmill,indoor_bike,cross_trainer, + rower,stair_climber,step_climber}_data YAML specs in GSS submodule +""" + +from __future__ import annotations + +from .utils import DataParser + +# --------------------------------------------------------------------------- +# Scaling constants (from YAML M/d/b parameters) +# --------------------------------------------------------------------------- +MET_RESOLUTION = 10.0 # M=1, d=-1, b=0 -> raw / 10 + +# --------------------------------------------------------------------------- +# Decode helpers +# --------------------------------------------------------------------------- + + +def decode_energy_triplet(data: bytearray, offset: int) -> tuple[int | None, int | None, int | None, int]: + """Decode the shared Energy triplet (Total + Per Hour + Per Minute). + + Wire format: uint16 + uint16 + uint8 = 5 bytes, all gated by a single + flag bit. + + Args: + data: Raw BLE bytes. + offset: Current read position. + + Returns: + (total_energy, energy_per_hour, energy_per_minute, new_offset) + + """ + if len(data) < offset + 5: + return None, None, None, offset + total_energy = DataParser.parse_int16(data, offset, signed=False) + energy_per_hour = DataParser.parse_int16(data, offset + 2, signed=False) + energy_per_minute = DataParser.parse_int8(data, offset + 4, signed=False) + return total_energy, energy_per_hour, energy_per_minute, offset + 5 + + +def decode_heart_rate(data: bytearray, offset: int) -> tuple[int | None, int]: + """Decode the shared Heart Rate field (uint8, bpm). + + Args: + data: Raw BLE bytes. + offset: Current read position. + + Returns: + (heart_rate, new_offset) + + """ + if len(data) < offset + 1: + return None, offset + return DataParser.parse_int8(data, offset, signed=False), offset + 1 + + +def decode_metabolic_equivalent(data: bytearray, offset: int) -> tuple[float | None, int]: + """Decode the shared Metabolic Equivalent field (uint8, M=1 d=-1 b=0). + + Args: + data: Raw BLE bytes. + offset: Current read position. + + Returns: + (metabolic_equivalent, new_offset) + + """ + if len(data) < offset + 1: + return None, offset + raw = DataParser.parse_int8(data, offset, signed=False) + return raw / MET_RESOLUTION, offset + 1 + + +def decode_elapsed_time(data: bytearray, offset: int) -> tuple[int | None, int]: + """Decode the shared Elapsed Time field (uint16, seconds). + + Args: + data: Raw BLE bytes. + offset: Current read position. + + Returns: + (elapsed_time, new_offset) + + """ + if len(data) < offset + 2: + return None, offset + return DataParser.parse_int16(data, offset, signed=False), offset + 2 + + +def decode_remaining_time(data: bytearray, offset: int) -> tuple[int | None, int]: + """Decode the shared Remaining Time field (uint16, seconds). + + Args: + data: Raw BLE bytes. + offset: Current read position. + + Returns: + (remaining_time, new_offset) + + """ + if len(data) < offset + 2: + return None, offset + return DataParser.parse_int16(data, offset, signed=False), offset + 2 + + +# --------------------------------------------------------------------------- +# Encode helpers +# --------------------------------------------------------------------------- + + +def encode_energy_triplet( + total_energy: int | None, + energy_per_hour: int | None, + energy_per_minute: int | None, +) -> bytearray: + """Encode the shared Energy triplet (Total + Per Hour + Per Minute). + + Args: + total_energy: Total energy in kcal (uint16). + energy_per_hour: Energy per hour in kcal (uint16). + energy_per_minute: Energy per minute in kcal (uint8). + + Returns: + 5-byte bytearray (uint16 + uint16 + uint8). + + """ + result = bytearray() + result.extend(DataParser.encode_int16(total_energy if total_energy is not None else 0, signed=False)) + result.extend(DataParser.encode_int16(energy_per_hour if energy_per_hour is not None else 0, signed=False)) + result.extend(DataParser.encode_int8(energy_per_minute if energy_per_minute is not None else 0, signed=False)) + return result + + +def encode_heart_rate(heart_rate: int) -> bytearray: + """Encode the shared Heart Rate field (uint8, bpm). + + Args: + heart_rate: Heart rate in bpm (uint8). + + Returns: + 1-byte bytearray. + + """ + return DataParser.encode_int8(heart_rate, signed=False) + + +def encode_metabolic_equivalent(metabolic_equivalent: float) -> bytearray: + """Encode the shared Metabolic Equivalent field (uint8, M=1 d=-1 b=0). + + Args: + metabolic_equivalent: Metabolic equivalent value (real). + + Returns: + 1-byte bytearray. + + """ + raw = round(metabolic_equivalent * MET_RESOLUTION) + return DataParser.encode_int8(raw, signed=False) + + +def encode_elapsed_time(elapsed_time: int) -> bytearray: + """Encode the shared Elapsed Time field (uint16, seconds). + + Args: + elapsed_time: Elapsed time in seconds (uint16). + + Returns: + 2-byte bytearray. + + """ + return DataParser.encode_int16(elapsed_time, signed=False) + + +def encode_remaining_time(remaining_time: int) -> bytearray: + """Encode the shared Remaining Time field (uint16, seconds). + + Args: + remaining_time: Remaining time in seconds (uint16). + + Returns: + 2-byte bytearray. + + """ + return DataParser.encode_int16(remaining_time, signed=False) diff --git a/src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py b/src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py new file mode 100644 index 00000000..0be324bf --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py @@ -0,0 +1,86 @@ +"""IEEE 11073-20601 Regulatory Certification Data List characteristic. + +Implements the IEEE 11073-20601 Regulatory Certification Data List +characteristic (0x2A2A). + +Structure (from GSS YAML): + IEEE 11073-20601 Regulatory Certification Data List (struct, variable) + +The content of this characteristic is determined by the authorizing +organisation that provides certifications. The internal structure +is defined by IEEE 11073-20601 and is opaque to the Bluetooth GATT +layer. This implementation stores and returns the raw certification +data as bytes. + +References: + Bluetooth SIG Generic Attribute Profile + org.bluetooth.characteristic.ieee_11073-20601_regulatory_certification_data_list + IEEE 11073-20601 +""" + +from __future__ import annotations + +import msgspec + +from ..context import CharacteristicContext +from .base import BaseCharacteristic + + +class IEEE11073RegulatoryData(msgspec.Struct, frozen=True, kw_only=True): + """Parsed data from IEEE 11073-20601 Regulatory Certification Data List. + + Attributes: + certification_data: Raw certification data bytes as defined + by the authorizing organisation per IEEE 11073-20601. + + """ + + certification_data: bytes + + +class IEEE1107320601RegulatoryCharacteristic( + BaseCharacteristic[IEEE11073RegulatoryData], +): + """IEEE 11073-20601 Regulatory Certification Data List (0x2A2A). + + Contains regulatory and certification information in a format + defined by IEEE 11073-20601. The data is treated as opaque + bytes by this library. + """ + + _characteristic_name = "IEEE 11073-20601 Regulatory Certification Data List" + expected_type = IEEE11073RegulatoryData + min_length: int = 1 + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> IEEE11073RegulatoryData: + """Parse IEEE 11073-20601 regulatory data from raw BLE bytes. + + Args: + data: Raw bytearray containing certification data. + ctx: Optional context (unused). + validate: Whether to validate (unused for opaque data). + + Returns: + IEEE11073RegulatoryData wrapping the raw bytes. + + """ + return IEEE11073RegulatoryData(certification_data=bytes(data)) + + def _encode_value(self, data: IEEE11073RegulatoryData) -> bytearray: + """Encode IEEE11073RegulatoryData back to BLE bytes. + + Args: + data: IEEE11073RegulatoryData instance. + + Returns: + Encoded bytearray containing the raw certification data. + + """ + return bytearray(data.certification_data) diff --git a/src/bluetooth_sig/gatt/characteristics/indoor_bike_data.py b/src/bluetooth_sig/gatt/characteristics/indoor_bike_data.py new file mode 100644 index 00000000..c4350208 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/indoor_bike_data.py @@ -0,0 +1,385 @@ +"""Indoor Bike Data characteristic implementation. + +Implements the Indoor Bike Data characteristic (0x2AD2) from the Fitness +Machine Service. A 16-bit flags field controls the presence of optional +data fields. + +Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the +Instantaneous Speed field IS present; when bit 0 is 1 it is absent. +All other bits use normal logic (1 = present). + +References: + Bluetooth SIG Fitness Machine Service 1.0 + org.bluetooth.characteristic.indoor_bike_data (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, UINT8_MAX, UINT16_MAX, UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .fitness_machine_common import ( + MET_RESOLUTION, + decode_elapsed_time, + decode_energy_triplet, + decode_heart_rate, + decode_metabolic_equivalent, + decode_remaining_time, + encode_elapsed_time, + encode_energy_triplet, + encode_heart_rate, + encode_metabolic_equivalent, + encode_remaining_time, +) +from .utils import DataParser + +# Speed: M=1, d=-2, b=0 -> actual = raw / 100 km/h +_SPEED_RESOLUTION = 100.0 + +# Cadence: M=1, d=0, b=-1 -> actual = raw / 2 rpm +_CADENCE_DIVISOR = 2.0 + +# Resistance level: M=1, d=1, b=0 -> actual = raw * 10 +_RESISTANCE_RESOLUTION = 10.0 + + +class IndoorBikeDataFlags(IntFlag): + """Indoor Bike Data flags as per Bluetooth SIG specification. + + Bit 0 uses inverted logic: 0 = Instantaneous Speed present, + 1 = absent. + """ + + MORE_DATA = 0x0001 # Inverted: 0 -> Speed present, 1 -> absent + AVERAGE_SPEED_PRESENT = 0x0002 + INSTANTANEOUS_CADENCE_PRESENT = 0x0004 + AVERAGE_CADENCE_PRESENT = 0x0008 + TOTAL_DISTANCE_PRESENT = 0x0010 + RESISTANCE_LEVEL_PRESENT = 0x0020 + INSTANTANEOUS_POWER_PRESENT = 0x0040 + AVERAGE_POWER_PRESENT = 0x0080 + EXPENDED_ENERGY_PRESENT = 0x0100 + HEART_RATE_PRESENT = 0x0200 + METABOLIC_EQUIVALENT_PRESENT = 0x0400 + ELAPSED_TIME_PRESENT = 0x0800 + REMAINING_TIME_PRESENT = 0x1000 + + +class IndoorBikeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes + """Parsed data from Indoor Bike Data characteristic. + + Attributes: + flags: Raw 16-bit flags field. + instantaneous_speed: Instantaneous speed in km/h (0.01 resolution). + average_speed: Average speed in km/h (0.01 resolution). + instantaneous_cadence: Instantaneous cadence in rpm (0.5 resolution). + average_cadence: Average cadence in rpm (0.5 resolution). + total_distance: Total distance in metres (uint24). + resistance_level: Resistance level (unitless, resolution 10). + instantaneous_power: Instantaneous power in watts (signed). + average_power: Average power in watts (signed). + total_energy: Total expended energy in kcal. + energy_per_hour: Expended energy per hour in kcal. + energy_per_minute: Expended energy per minute in kcal. + heart_rate: Heart rate in bpm. + metabolic_equivalent: MET value (0.1 resolution). + elapsed_time: Elapsed time in seconds. + remaining_time: Remaining time in seconds. + + """ + + flags: IndoorBikeDataFlags + instantaneous_speed: float | None = None + average_speed: float | None = None + instantaneous_cadence: float | None = None + average_cadence: float | None = None + total_distance: int | None = None + resistance_level: float | None = None + instantaneous_power: int | None = None + average_power: int | None = None + total_energy: int | None = None + energy_per_hour: int | None = None + energy_per_minute: int | None = None + heart_rate: int | None = None + metabolic_equivalent: float | None = None + elapsed_time: int | None = None + remaining_time: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if ( + self.instantaneous_speed is not None + and not 0.0 <= self.instantaneous_speed <= UINT16_MAX / _SPEED_RESOLUTION + ): + raise ValueError( + f"Instantaneous speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.instantaneous_speed}" + ) + if self.average_speed is not None and not 0.0 <= self.average_speed <= UINT16_MAX / _SPEED_RESOLUTION: + raise ValueError(f"Average speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.average_speed}") + if ( + self.instantaneous_cadence is not None + and not 0.0 <= self.instantaneous_cadence <= UINT16_MAX / _CADENCE_DIVISOR + ): + raise ValueError( + f"Instantaneous cadence must be 0.0-{UINT16_MAX / _CADENCE_DIVISOR}, got {self.instantaneous_cadence}" + ) + if self.average_cadence is not None and not 0.0 <= self.average_cadence <= UINT16_MAX / _CADENCE_DIVISOR: + raise ValueError(f"Average cadence must be 0.0-{UINT16_MAX / _CADENCE_DIVISOR}, got {self.average_cadence}") + if self.total_distance is not None and not 0 <= self.total_distance <= UINT24_MAX: + raise ValueError(f"Total distance must be 0-{UINT24_MAX}, got {self.total_distance}") + if self.resistance_level is not None and not 0.0 <= self.resistance_level <= UINT8_MAX * _RESISTANCE_RESOLUTION: + raise ValueError( + f"Resistance level must be 0.0-{UINT8_MAX * _RESISTANCE_RESOLUTION}, got {self.resistance_level}" + ) + if self.instantaneous_power is not None and not SINT16_MIN <= self.instantaneous_power <= SINT16_MAX: + raise ValueError(f"Instantaneous power must be {SINT16_MIN}-{SINT16_MAX}, got {self.instantaneous_power}") + if self.average_power is not None and not SINT16_MIN <= self.average_power <= SINT16_MAX: + raise ValueError(f"Average power must be {SINT16_MIN}-{SINT16_MAX}, got {self.average_power}") + if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX: + raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}") + if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX: + raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}") + if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX: + raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}") + if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX: + raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}") + if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION: + raise ValueError( + f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}" + ) + if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX: + raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}") + if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX: + raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}") + + +class IndoorBikeDataCharacteristic(BaseCharacteristic[IndoorBikeData]): + """Indoor Bike Data characteristic (0x2AD2). + + Used in the Fitness Machine Service to transmit indoor bike workout + data. A 16-bit flags field controls which optional fields are present. + + Flag-bit assignments (from GSS YAML): + Bit 0: More Data -- **inverted**: 0 -> Inst. Speed present, 1 -> absent + Bit 1: Average Speed present + Bit 2: Instantaneous Cadence present + Bit 3: Average Cadence present + Bit 4: Total Distance present + Bit 5: Resistance Level present + Bit 6: Instantaneous Power present + Bit 7: Average Power present + Bit 8: Expended Energy present (gates triplet: total + /hr + /min) + Bit 9: Heart Rate present + Bit 10: Metabolic Equivalent present + Bit 11: Elapsed Time present + Bit 12: Remaining Time present + Bits 13-15: Reserved for Future Use + + """ + + expected_type = IndoorBikeData + min_length: int = 2 # Flags only (all optional fields absent) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> IndoorBikeData: + """Parse Indoor Bike Data from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + IndoorBikeData with all present fields populated. + + """ + flags = IndoorBikeDataFlags(DataParser.parse_int16(data, 0, signed=False)) + offset = 2 + + # Bit 0 -- inverted: Instantaneous Speed present when bit is NOT set + instantaneous_speed = None + if not (flags & IndoorBikeDataFlags.MORE_DATA) and len(data) >= offset + 2: + raw_speed = DataParser.parse_int16(data, offset, signed=False) + instantaneous_speed = raw_speed / _SPEED_RESOLUTION + offset += 2 + + # Bit 1 -- Average Speed + average_speed = None + if (flags & IndoorBikeDataFlags.AVERAGE_SPEED_PRESENT) and len(data) >= offset + 2: + raw_avg_speed = DataParser.parse_int16(data, offset, signed=False) + average_speed = raw_avg_speed / _SPEED_RESOLUTION + offset += 2 + + # Bit 2 -- Instantaneous Cadence + instantaneous_cadence = None + if (flags & IndoorBikeDataFlags.INSTANTANEOUS_CADENCE_PRESENT) and len(data) >= offset + 2: + raw_cadence = DataParser.parse_int16(data, offset, signed=False) + instantaneous_cadence = raw_cadence / _CADENCE_DIVISOR + offset += 2 + + # Bit 3 -- Average Cadence + average_cadence = None + if (flags & IndoorBikeDataFlags.AVERAGE_CADENCE_PRESENT) and len(data) >= offset + 2: + raw_avg_cadence = DataParser.parse_int16(data, offset, signed=False) + average_cadence = raw_avg_cadence / _CADENCE_DIVISOR + offset += 2 + + # Bit 4 -- Total Distance (uint24) + total_distance = None + if (flags & IndoorBikeDataFlags.TOTAL_DISTANCE_PRESENT) and len(data) >= offset + 3: + total_distance = DataParser.parse_int24(data, offset, signed=False) + offset += 3 + + # Bit 5 -- Resistance Level + resistance_level = None + if (flags & IndoorBikeDataFlags.RESISTANCE_LEVEL_PRESENT) and len(data) >= offset + 1: + raw_resistance = DataParser.parse_int8(data, offset, signed=False) + resistance_level = raw_resistance * _RESISTANCE_RESOLUTION + offset += 1 + + # Bit 6 -- Instantaneous Power (sint16) + instantaneous_power = None + if (flags & IndoorBikeDataFlags.INSTANTANEOUS_POWER_PRESENT) and len(data) >= offset + 2: + instantaneous_power = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + # Bit 7 -- Average Power (sint16) + average_power = None + if (flags & IndoorBikeDataFlags.AVERAGE_POWER_PRESENT) and len(data) >= offset + 2: + average_power = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + # Bit 8 -- Energy triplet (Total + Per Hour + Per Minute) + total_energy = None + energy_per_hour = None + energy_per_minute = None + if flags & IndoorBikeDataFlags.EXPENDED_ENERGY_PRESENT: + total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset) + + # Bit 9 -- Heart Rate + heart_rate = None + if flags & IndoorBikeDataFlags.HEART_RATE_PRESENT: + heart_rate, offset = decode_heart_rate(data, offset) + + # Bit 10 -- Metabolic Equivalent + metabolic_equivalent = None + if flags & IndoorBikeDataFlags.METABOLIC_EQUIVALENT_PRESENT: + metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset) + + # Bit 11 -- Elapsed Time + elapsed_time = None + if flags & IndoorBikeDataFlags.ELAPSED_TIME_PRESENT: + elapsed_time, offset = decode_elapsed_time(data, offset) + + # Bit 12 -- Remaining Time + remaining_time = None + if flags & IndoorBikeDataFlags.REMAINING_TIME_PRESENT: + remaining_time, offset = decode_remaining_time(data, offset) + + return IndoorBikeData( + flags=flags, + instantaneous_speed=instantaneous_speed, + average_speed=average_speed, + instantaneous_cadence=instantaneous_cadence, + average_cadence=average_cadence, + total_distance=total_distance, + resistance_level=resistance_level, + instantaneous_power=instantaneous_power, + average_power=average_power, + total_energy=total_energy, + energy_per_hour=energy_per_hour, + energy_per_minute=energy_per_minute, + heart_rate=heart_rate, + metabolic_equivalent=metabolic_equivalent, + elapsed_time=elapsed_time, + remaining_time=remaining_time, + ) + + def _encode_value(self, data: IndoorBikeData) -> bytearray: # noqa: PLR0912 + """Encode IndoorBikeData back to BLE bytes. + + Reconstructs flags from present fields so round-trip encoding + preserves the original wire format. + + Args: + data: IndoorBikeData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = IndoorBikeDataFlags(0) + + # Bit 0 -- inverted: set MORE_DATA when Speed is absent + if data.instantaneous_speed is None: + flags |= IndoorBikeDataFlags.MORE_DATA + if data.average_speed is not None: + flags |= IndoorBikeDataFlags.AVERAGE_SPEED_PRESENT + if data.instantaneous_cadence is not None: + flags |= IndoorBikeDataFlags.INSTANTANEOUS_CADENCE_PRESENT + if data.average_cadence is not None: + flags |= IndoorBikeDataFlags.AVERAGE_CADENCE_PRESENT + if data.total_distance is not None: + flags |= IndoorBikeDataFlags.TOTAL_DISTANCE_PRESENT + if data.resistance_level is not None: + flags |= IndoorBikeDataFlags.RESISTANCE_LEVEL_PRESENT + if data.instantaneous_power is not None: + flags |= IndoorBikeDataFlags.INSTANTANEOUS_POWER_PRESENT + if data.average_power is not None: + flags |= IndoorBikeDataFlags.AVERAGE_POWER_PRESENT + if data.total_energy is not None: + flags |= IndoorBikeDataFlags.EXPENDED_ENERGY_PRESENT + if data.heart_rate is not None: + flags |= IndoorBikeDataFlags.HEART_RATE_PRESENT + if data.metabolic_equivalent is not None: + flags |= IndoorBikeDataFlags.METABOLIC_EQUIVALENT_PRESENT + if data.elapsed_time is not None: + flags |= IndoorBikeDataFlags.ELAPSED_TIME_PRESENT + if data.remaining_time is not None: + flags |= IndoorBikeDataFlags.REMAINING_TIME_PRESENT + + result = DataParser.encode_int16(int(flags), signed=False) + + if data.instantaneous_speed is not None: + raw_speed = round(data.instantaneous_speed * _SPEED_RESOLUTION) + result.extend(DataParser.encode_int16(raw_speed, signed=False)) + if data.average_speed is not None: + raw_avg_speed = round(data.average_speed * _SPEED_RESOLUTION) + result.extend(DataParser.encode_int16(raw_avg_speed, signed=False)) + if data.instantaneous_cadence is not None: + raw_cadence = round(data.instantaneous_cadence * _CADENCE_DIVISOR) + result.extend(DataParser.encode_int16(raw_cadence, signed=False)) + if data.average_cadence is not None: + raw_avg_cadence = round(data.average_cadence * _CADENCE_DIVISOR) + result.extend(DataParser.encode_int16(raw_avg_cadence, signed=False)) + if data.total_distance is not None: + result.extend(DataParser.encode_int24(data.total_distance, signed=False)) + if data.resistance_level is not None: + raw_resistance = round(data.resistance_level / _RESISTANCE_RESOLUTION) + result.extend(DataParser.encode_int8(raw_resistance, signed=False)) + if data.instantaneous_power is not None: + result.extend(DataParser.encode_int16(data.instantaneous_power, signed=True)) + if data.average_power is not None: + result.extend(DataParser.encode_int16(data.average_power, signed=True)) + if data.total_energy is not None: + result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute)) + if data.heart_rate is not None: + result.extend(encode_heart_rate(data.heart_rate)) + if data.metabolic_equivalent is not None: + result.extend(encode_metabolic_equivalent(data.metabolic_equivalent)) + if data.elapsed_time is not None: + result.extend(encode_elapsed_time(data.elapsed_time)) + if data.remaining_time is not None: + result.extend(encode_remaining_time(data.remaining_time)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py b/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py index 76f5134d..bb0b7525 100644 --- a/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/pm10_concentration.py @@ -3,21 +3,17 @@ from __future__ import annotations from .base import BaseCharacteristic -from .templates import ConcentrationTemplate +from .templates import IEEE11073FloatTemplate class PM10ConcentrationCharacteristic(BaseCharacteristic[float]): """PM10 particulate matter concentration characteristic (0x2BD7). - Represents particulate matter PM10 concentration in micrograms per - cubic meter with a resolution of 1 μg/m³. + Uses IEEE 11073 SFLOAT format (medfloat16) as per SIG specification. + Unit: kg/m³ (kilogram per cubic meter) """ - _template = ConcentrationTemplate() + _template = IEEE11073FloatTemplate() _characteristic_name: str = "Particulate Matter - PM10 Concentration" - _python_type: type | str | None = int - _manual_unit: str = "µg/m³" # Override template's "ppm" default - - # Template configuration - resolution: float = 1.0 + _manual_unit: str = "kg/m³" diff --git a/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py b/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py index d1941bb2..b1754ec7 100644 --- a/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/pm1_concentration.py @@ -3,25 +3,17 @@ from __future__ import annotations from .base import BaseCharacteristic -from .templates import ConcentrationTemplate +from .templates import IEEE11073FloatTemplate class PM1ConcentrationCharacteristic(BaseCharacteristic[float]): """Particulate Matter - PM1 Concentration characteristic (0x2BD5). - org.bluetooth.characteristic.particulate_matter_pm1_concentration - - PM1 particulate matter concentration characteristic (0x2BD7). - - Represents particulate matter PM1 concentration in micrograms per - cubic meter with a resolution of 1 μg/m³. + Uses IEEE 11073 SFLOAT format (medfloat16) as per SIG specification. + Unit: kg/m³ (kilogram per cubic meter) """ - _template = ConcentrationTemplate() + _template = IEEE11073FloatTemplate() _characteristic_name: str = "Particulate Matter - PM1 Concentration" - _python_type: type | str | None = int - _manual_unit: str = "µg/m³" # Override template's "ppm" default - - # Template configuration - resolution: float = 1.0 + _manual_unit: str = "kg/m³" diff --git a/src/bluetooth_sig/gatt/characteristics/pm25_concentration.py b/src/bluetooth_sig/gatt/characteristics/pm25_concentration.py index b9711fcb..c6a2d7c8 100644 --- a/src/bluetooth_sig/gatt/characteristics/pm25_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/pm25_concentration.py @@ -3,18 +3,17 @@ from __future__ import annotations from .base import BaseCharacteristic -from .templates import ConcentrationTemplate +from .templates import IEEE11073FloatTemplate class PM25ConcentrationCharacteristic(BaseCharacteristic[float]): - """PM2.5 particulate matter concentration characteristic (0x2BD6). + """Particulate Matter - PM2.5 Concentration characteristic (0x2BD6). - Represents particulate matter PM2.5 concentration in micrograms per - cubic meter with a resolution of 1 μg/m³. + Uses IEEE 11073 SFLOAT format (medfloat16) as per SIG specification. + Unit: kg/m³ (kilogram per cubic meter) """ - _template = ConcentrationTemplate() + _template = IEEE11073FloatTemplate() _characteristic_name: str = "Particulate Matter - PM2.5 Concentration" - resolution: float = 1.0 - _manual_unit: str = "µg/m³" # Override template's "ppm" default + _manual_unit: str = "kg/m³" diff --git a/src/bluetooth_sig/gatt/characteristics/rower_data.py b/src/bluetooth_sig/gatt/characteristics/rower_data.py new file mode 100644 index 00000000..d9449d2e --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/rower_data.py @@ -0,0 +1,383 @@ +"""Rower Data characteristic implementation. + +Implements the Rower Data characteristic (0x2AD1) from the Fitness Machine +Service. A 16-bit flags field controls the presence of optional data +fields. + +Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the Stroke +Rate and Stroke Count fields ARE present; when bit 0 is 1 they are absent. +All other bits use normal logic (1 = present). + +References: + Bluetooth SIG Fitness Machine Service 1.0 + org.bluetooth.characteristic.rower_data (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, UINT8_MAX, UINT16_MAX, UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .fitness_machine_common import ( + MET_RESOLUTION, + decode_elapsed_time, + decode_energy_triplet, + decode_heart_rate, + decode_metabolic_equivalent, + decode_remaining_time, + encode_elapsed_time, + encode_energy_triplet, + encode_heart_rate, + encode_metabolic_equivalent, + encode_remaining_time, +) +from .utils import DataParser + +# Stroke rate: M=1, d=0, b=-1 -> actual = raw / 2 +_STROKE_RATE_DIVISOR = 2.0 + +# Resistance level: M=1, d=1, b=0 -> actual = raw * 10 +_RESISTANCE_RESOLUTION = 10.0 + + +class RowerDataFlags(IntFlag): + """Rower Data flags as per Bluetooth SIG specification. + + Bit 0 uses inverted logic: 0 = Stroke Rate + Stroke Count present, + 1 = absent. + """ + + MORE_DATA = 0x0001 # Inverted: 0 -> fields present, 1 -> absent + AVERAGE_STROKE_RATE_PRESENT = 0x0002 + TOTAL_DISTANCE_PRESENT = 0x0004 + INSTANTANEOUS_PACE_PRESENT = 0x0008 + AVERAGE_PACE_PRESENT = 0x0010 + INSTANTANEOUS_POWER_PRESENT = 0x0020 + AVERAGE_POWER_PRESENT = 0x0040 + RESISTANCE_LEVEL_PRESENT = 0x0080 + EXPENDED_ENERGY_PRESENT = 0x0100 + HEART_RATE_PRESENT = 0x0200 + METABOLIC_EQUIVALENT_PRESENT = 0x0400 + ELAPSED_TIME_PRESENT = 0x0800 + REMAINING_TIME_PRESENT = 0x1000 + + +class RowerData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes + """Parsed data from Rower Data characteristic. + + Attributes: + flags: Raw 16-bit flags field. + stroke_rate: Instantaneous stroke rate in strokes/min (0.5 resolution). + stroke_count: Total strokes since session start. + average_stroke_rate: Average stroke rate in strokes/min (0.5 resolution). + total_distance: Total distance in metres (uint24). + instantaneous_pace: Instantaneous pace in seconds per 500 m. + average_pace: Average pace in seconds per 500 m. + instantaneous_power: Instantaneous power in watts (signed). + average_power: Average power in watts (signed). + resistance_level: Resistance level (unitless, resolution 10). + total_energy: Total expended energy in kcal. + energy_per_hour: Expended energy per hour in kcal. + energy_per_minute: Expended energy per minute in kcal. + heart_rate: Heart rate in bpm. + metabolic_equivalent: MET value (0.1 resolution). + elapsed_time: Elapsed time in seconds. + remaining_time: Remaining time in seconds. + + """ + + flags: RowerDataFlags + stroke_rate: float | None = None + stroke_count: int | None = None + average_stroke_rate: float | None = None + total_distance: int | None = None + instantaneous_pace: int | None = None + average_pace: int | None = None + instantaneous_power: int | None = None + average_power: int | None = None + resistance_level: float | None = None + total_energy: int | None = None + energy_per_hour: int | None = None + energy_per_minute: int | None = None + heart_rate: int | None = None + metabolic_equivalent: float | None = None + elapsed_time: int | None = None + remaining_time: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if self.stroke_rate is not None and not 0.0 <= self.stroke_rate <= UINT8_MAX / _STROKE_RATE_DIVISOR: + raise ValueError(f"Stroke rate must be 0.0-{UINT8_MAX / _STROKE_RATE_DIVISOR}, got {self.stroke_rate}") + if self.stroke_count is not None and not 0 <= self.stroke_count <= UINT16_MAX: + raise ValueError(f"Stroke count must be 0-{UINT16_MAX}, got {self.stroke_count}") + if ( + self.average_stroke_rate is not None + and not 0.0 <= self.average_stroke_rate <= UINT8_MAX / _STROKE_RATE_DIVISOR + ): + raise ValueError( + f"Average stroke rate must be 0.0-{UINT8_MAX / _STROKE_RATE_DIVISOR}, got {self.average_stroke_rate}" + ) + if self.total_distance is not None and not 0 <= self.total_distance <= UINT24_MAX: + raise ValueError(f"Total distance must be 0-{UINT24_MAX}, got {self.total_distance}") + if self.instantaneous_pace is not None and not 0 <= self.instantaneous_pace <= UINT16_MAX: + raise ValueError(f"Instantaneous pace must be 0-{UINT16_MAX}, got {self.instantaneous_pace}") + if self.average_pace is not None and not 0 <= self.average_pace <= UINT16_MAX: + raise ValueError(f"Average pace must be 0-{UINT16_MAX}, got {self.average_pace}") + if self.instantaneous_power is not None and not SINT16_MIN <= self.instantaneous_power <= SINT16_MAX: + raise ValueError(f"Instantaneous power must be {SINT16_MIN}-{SINT16_MAX}, got {self.instantaneous_power}") + if self.average_power is not None and not SINT16_MIN <= self.average_power <= SINT16_MAX: + raise ValueError(f"Average power must be {SINT16_MIN}-{SINT16_MAX}, got {self.average_power}") + if self.resistance_level is not None and not 0.0 <= self.resistance_level <= UINT8_MAX * _RESISTANCE_RESOLUTION: + raise ValueError( + f"Resistance level must be 0.0-{UINT8_MAX * _RESISTANCE_RESOLUTION}, got {self.resistance_level}" + ) + if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX: + raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}") + if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX: + raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}") + if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX: + raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}") + if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX: + raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}") + if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION: + raise ValueError( + f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}" + ) + if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX: + raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}") + if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX: + raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}") + + +class RowerDataCharacteristic(BaseCharacteristic[RowerData]): + """Rower Data characteristic (0x2AD1). + + Used in the Fitness Machine Service to transmit rowing workout data. + A 16-bit flags field controls which optional fields are present. + + Flag-bit assignments (from GSS YAML): + Bit 0: More Data -- **inverted**: 0 -> Stroke Rate + Count present + Bit 1: Average Stroke Rate present + Bit 2: Total Distance present + Bit 3: Instantaneous Pace present + Bit 4: Average Pace present + Bit 5: Instantaneous Power present + Bit 6: Average Power present + Bit 7: Resistance Level present + Bit 8: Expended Energy present (gates triplet: total + /hr + /min) + Bit 9: Heart Rate present + Bit 10: Metabolic Equivalent present + Bit 11: Elapsed Time present + Bit 12: Remaining Time present + Bits 13-15: Reserved for Future Use + + """ + + expected_type = RowerData + min_length: int = 2 # Flags only (all optional fields absent) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> RowerData: + """Parse Rower Data from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + RowerData with all present fields populated. + + """ + flags = RowerDataFlags(DataParser.parse_int16(data, 0, signed=False)) + offset = 2 + + # Bit 0 -- inverted: Stroke Rate + Stroke Count present when bit is NOT set + stroke_rate = None + stroke_count = None + if not (flags & RowerDataFlags.MORE_DATA) and len(data) >= offset + 3: + raw_stroke_rate = DataParser.parse_int8(data, offset, signed=False) + stroke_rate = raw_stroke_rate / _STROKE_RATE_DIVISOR + offset += 1 + stroke_count = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 1 -- Average Stroke Rate + average_stroke_rate = None + if (flags & RowerDataFlags.AVERAGE_STROKE_RATE_PRESENT) and len(data) >= offset + 1: + raw_avg = DataParser.parse_int8(data, offset, signed=False) + average_stroke_rate = raw_avg / _STROKE_RATE_DIVISOR + offset += 1 + + # Bit 2 -- Total Distance (uint24) + total_distance = None + if (flags & RowerDataFlags.TOTAL_DISTANCE_PRESENT) and len(data) >= offset + 3: + total_distance = DataParser.parse_int24(data, offset, signed=False) + offset += 3 + + # Bit 3 -- Instantaneous Pace + instantaneous_pace = None + if (flags & RowerDataFlags.INSTANTANEOUS_PACE_PRESENT) and len(data) >= offset + 2: + instantaneous_pace = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 4 -- Average Pace + average_pace = None + if (flags & RowerDataFlags.AVERAGE_PACE_PRESENT) and len(data) >= offset + 2: + average_pace = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 5 -- Instantaneous Power (sint16) + instantaneous_power = None + if (flags & RowerDataFlags.INSTANTANEOUS_POWER_PRESENT) and len(data) >= offset + 2: + instantaneous_power = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + # Bit 6 -- Average Power (sint16) + average_power = None + if (flags & RowerDataFlags.AVERAGE_POWER_PRESENT) and len(data) >= offset + 2: + average_power = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + # Bit 7 -- Resistance Level + resistance_level = None + if (flags & RowerDataFlags.RESISTANCE_LEVEL_PRESENT) and len(data) >= offset + 1: + raw_resistance = DataParser.parse_int8(data, offset, signed=False) + resistance_level = raw_resistance * _RESISTANCE_RESOLUTION + offset += 1 + + # Bit 8 -- Energy triplet (Total + Per Hour + Per Minute) + total_energy = None + energy_per_hour = None + energy_per_minute = None + if flags & RowerDataFlags.EXPENDED_ENERGY_PRESENT: + total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset) + + # Bit 9 -- Heart Rate + heart_rate = None + if flags & RowerDataFlags.HEART_RATE_PRESENT: + heart_rate, offset = decode_heart_rate(data, offset) + + # Bit 10 -- Metabolic Equivalent + metabolic_equivalent = None + if flags & RowerDataFlags.METABOLIC_EQUIVALENT_PRESENT: + metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset) + + # Bit 11 -- Elapsed Time + elapsed_time = None + if flags & RowerDataFlags.ELAPSED_TIME_PRESENT: + elapsed_time, offset = decode_elapsed_time(data, offset) + + # Bit 12 -- Remaining Time + remaining_time = None + if flags & RowerDataFlags.REMAINING_TIME_PRESENT: + remaining_time, offset = decode_remaining_time(data, offset) + + return RowerData( + flags=flags, + stroke_rate=stroke_rate, + stroke_count=stroke_count, + average_stroke_rate=average_stroke_rate, + total_distance=total_distance, + instantaneous_pace=instantaneous_pace, + average_pace=average_pace, + instantaneous_power=instantaneous_power, + average_power=average_power, + resistance_level=resistance_level, + total_energy=total_energy, + energy_per_hour=energy_per_hour, + energy_per_minute=energy_per_minute, + heart_rate=heart_rate, + metabolic_equivalent=metabolic_equivalent, + elapsed_time=elapsed_time, + remaining_time=remaining_time, + ) + + def _encode_value(self, data: RowerData) -> bytearray: # noqa: PLR0912 + """Encode RowerData back to BLE bytes. + + Reconstructs flags from present fields so round-trip encoding + preserves the original wire format. + + Args: + data: RowerData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = RowerDataFlags(0) + + # Bit 0 -- inverted: set MORE_DATA when Stroke Rate/Count absent + if data.stroke_rate is None: + flags |= RowerDataFlags.MORE_DATA + if data.average_stroke_rate is not None: + flags |= RowerDataFlags.AVERAGE_STROKE_RATE_PRESENT + if data.total_distance is not None: + flags |= RowerDataFlags.TOTAL_DISTANCE_PRESENT + if data.instantaneous_pace is not None: + flags |= RowerDataFlags.INSTANTANEOUS_PACE_PRESENT + if data.average_pace is not None: + flags |= RowerDataFlags.AVERAGE_PACE_PRESENT + if data.instantaneous_power is not None: + flags |= RowerDataFlags.INSTANTANEOUS_POWER_PRESENT + if data.average_power is not None: + flags |= RowerDataFlags.AVERAGE_POWER_PRESENT + if data.resistance_level is not None: + flags |= RowerDataFlags.RESISTANCE_LEVEL_PRESENT + if data.total_energy is not None: + flags |= RowerDataFlags.EXPENDED_ENERGY_PRESENT + if data.heart_rate is not None: + flags |= RowerDataFlags.HEART_RATE_PRESENT + if data.metabolic_equivalent is not None: + flags |= RowerDataFlags.METABOLIC_EQUIVALENT_PRESENT + if data.elapsed_time is not None: + flags |= RowerDataFlags.ELAPSED_TIME_PRESENT + if data.remaining_time is not None: + flags |= RowerDataFlags.REMAINING_TIME_PRESENT + + result = DataParser.encode_int16(int(flags), signed=False) + + if data.stroke_rate is not None: + raw_stroke = round(data.stroke_rate * _STROKE_RATE_DIVISOR) + result.extend(DataParser.encode_int8(raw_stroke, signed=False)) + if data.stroke_count is not None: + result.extend(DataParser.encode_int16(data.stroke_count, signed=False)) + if data.average_stroke_rate is not None: + raw_avg = round(data.average_stroke_rate * _STROKE_RATE_DIVISOR) + result.extend(DataParser.encode_int8(raw_avg, signed=False)) + if data.total_distance is not None: + result.extend(DataParser.encode_int24(data.total_distance, signed=False)) + if data.instantaneous_pace is not None: + result.extend(DataParser.encode_int16(data.instantaneous_pace, signed=False)) + if data.average_pace is not None: + result.extend(DataParser.encode_int16(data.average_pace, signed=False)) + if data.instantaneous_power is not None: + result.extend(DataParser.encode_int16(data.instantaneous_power, signed=True)) + if data.average_power is not None: + result.extend(DataParser.encode_int16(data.average_power, signed=True)) + if data.resistance_level is not None: + raw_resistance = round(data.resistance_level / _RESISTANCE_RESOLUTION) + result.extend(DataParser.encode_int8(raw_resistance, signed=False)) + if data.total_energy is not None: + result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute)) + if data.heart_rate is not None: + result.extend(encode_heart_rate(data.heart_rate)) + if data.metabolic_equivalent is not None: + result.extend(encode_metabolic_equivalent(data.metabolic_equivalent)) + if data.elapsed_time is not None: + result.extend(encode_elapsed_time(data.elapsed_time)) + if data.remaining_time is not None: + result.extend(encode_remaining_time(data.remaining_time)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/stair_climber_data.py b/src/bluetooth_sig/gatt/characteristics/stair_climber_data.py new file mode 100644 index 00000000..a57f15df --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/stair_climber_data.py @@ -0,0 +1,302 @@ +"""Stair Climber Data characteristic implementation. + +Implements the Stair Climber Data characteristic (0x2AD0) from the Fitness +Machine Service. A 16-bit flags field controls the presence of optional +data fields. + +Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the Floors +field IS present; when bit 0 is 1 it is absent. All other bits use normal +logic (1 = present). + +References: + Bluetooth SIG Fitness Machine Service 1.0 + org.bluetooth.characteristic.stair_climber_data (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import UINT8_MAX, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .fitness_machine_common import ( + MET_RESOLUTION, + decode_elapsed_time, + decode_energy_triplet, + decode_heart_rate, + decode_metabolic_equivalent, + decode_remaining_time, + encode_elapsed_time, + encode_energy_triplet, + encode_heart_rate, + encode_metabolic_equivalent, + encode_remaining_time, +) +from .utils import DataParser + + +class StairClimberDataFlags(IntFlag): + """Stair Climber Data flags as per Bluetooth SIG specification. + + Bit 0 uses inverted logic: 0 = Floors present, 1 = Floors absent. + """ + + MORE_DATA = 0x0001 # Inverted: 0 -> Floors present, 1 -> absent + STEPS_PER_MINUTE_PRESENT = 0x0002 + AVERAGE_STEP_RATE_PRESENT = 0x0004 + POSITIVE_ELEVATION_GAIN_PRESENT = 0x0008 + STRIDE_COUNT_PRESENT = 0x0010 + EXPENDED_ENERGY_PRESENT = 0x0020 + HEART_RATE_PRESENT = 0x0040 + METABOLIC_EQUIVALENT_PRESENT = 0x0080 + ELAPSED_TIME_PRESENT = 0x0100 + REMAINING_TIME_PRESENT = 0x0200 + + +class StairClimberData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes + """Parsed data from Stair Climber Data characteristic. + + Attributes: + flags: Raw 16-bit flags field. + floors: Total floors counted (present when bit 0 is 0). + steps_per_minute: Step rate in steps/min. + average_step_rate: Average step rate in steps/min. + positive_elevation_gain: Positive elevation gain in metres. + stride_count: Total strides since session start. + total_energy: Total expended energy in kcal. + energy_per_hour: Expended energy per hour in kcal. + energy_per_minute: Expended energy per minute in kcal. + heart_rate: Heart rate in bpm. + metabolic_equivalent: MET value (0.1 resolution). + elapsed_time: Elapsed time in seconds. + remaining_time: Remaining time in seconds. + + """ + + flags: StairClimberDataFlags + floors: int | None = None + steps_per_minute: int | None = None + average_step_rate: int | None = None + positive_elevation_gain: int | None = None + stride_count: int | None = None + total_energy: int | None = None + energy_per_hour: int | None = None + energy_per_minute: int | None = None + heart_rate: int | None = None + metabolic_equivalent: float | None = None + elapsed_time: int | None = None + remaining_time: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if self.floors is not None and not 0 <= self.floors <= UINT16_MAX: + raise ValueError(f"Floors must be 0-{UINT16_MAX}, got {self.floors}") + if self.steps_per_minute is not None and not 0 <= self.steps_per_minute <= UINT16_MAX: + raise ValueError(f"Steps per minute must be 0-{UINT16_MAX}, got {self.steps_per_minute}") + if self.average_step_rate is not None and not 0 <= self.average_step_rate <= UINT16_MAX: + raise ValueError(f"Average step rate must be 0-{UINT16_MAX}, got {self.average_step_rate}") + if self.positive_elevation_gain is not None and not 0 <= self.positive_elevation_gain <= UINT16_MAX: + raise ValueError(f"Positive elevation gain must be 0-{UINT16_MAX}, got {self.positive_elevation_gain}") + if self.stride_count is not None and not 0 <= self.stride_count <= UINT16_MAX: + raise ValueError(f"Stride count must be 0-{UINT16_MAX}, got {self.stride_count}") + if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX: + raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}") + if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX: + raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}") + if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX: + raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}") + if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX: + raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}") + if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION: + raise ValueError( + f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}" + ) + if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX: + raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}") + if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX: + raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}") + + +class StairClimberDataCharacteristic(BaseCharacteristic[StairClimberData]): + """Stair Climber Data characteristic (0x2AD0). + + Used in the Fitness Machine Service to transmit stair climber workout + data. A 16-bit flags field controls which optional fields are present. + + Flag-bit assignments (from GSS YAML): + Bit 0: More Data -- **inverted**: 0 -> Floors present, 1 -> absent + Bit 1: Steps Per Minute present + Bit 2: Average Step Rate present + Bit 3: Positive Elevation Gain present + Bit 4: Stride Count present + Bit 5: Expended Energy present (gates triplet: total + /hr + /min) + Bit 6: Heart Rate present + Bit 7: Metabolic Equivalent present + Bit 8: Elapsed Time present + Bit 9: Remaining Time present + Bits 10-15: Reserved for Future Use + + """ + + expected_type = StairClimberData + min_length: int = 2 # Flags only (all optional fields absent) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> StairClimberData: + """Parse Stair Climber Data from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + StairClimberData with all present fields populated. + + """ + flags = StairClimberDataFlags(DataParser.parse_int16(data, 0, signed=False)) + offset = 2 + + # Bit 0 -- inverted: Floors present when bit is NOT set + floors = None + if not (flags & StairClimberDataFlags.MORE_DATA) and len(data) >= offset + 2: + floors = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 1 -- Steps Per Minute + steps_per_minute = None + if (flags & StairClimberDataFlags.STEPS_PER_MINUTE_PRESENT) and len(data) >= offset + 2: + steps_per_minute = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 2 -- Average Step Rate + average_step_rate = None + if (flags & StairClimberDataFlags.AVERAGE_STEP_RATE_PRESENT) and len(data) >= offset + 2: + average_step_rate = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 3 -- Positive Elevation Gain + positive_elevation_gain = None + if (flags & StairClimberDataFlags.POSITIVE_ELEVATION_GAIN_PRESENT) and len(data) >= offset + 2: + positive_elevation_gain = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 4 -- Stride Count + stride_count = None + if (flags & StairClimberDataFlags.STRIDE_COUNT_PRESENT) and len(data) >= offset + 2: + stride_count = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 5 -- Energy triplet (Total + Per Hour + Per Minute) + total_energy = None + energy_per_hour = None + energy_per_minute = None + if flags & StairClimberDataFlags.EXPENDED_ENERGY_PRESENT: + total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset) + + # Bit 6 -- Heart Rate + heart_rate = None + if flags & StairClimberDataFlags.HEART_RATE_PRESENT: + heart_rate, offset = decode_heart_rate(data, offset) + + # Bit 7 -- Metabolic Equivalent + metabolic_equivalent = None + if flags & StairClimberDataFlags.METABOLIC_EQUIVALENT_PRESENT: + metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset) + + # Bit 8 -- Elapsed Time + elapsed_time = None + if flags & StairClimberDataFlags.ELAPSED_TIME_PRESENT: + elapsed_time, offset = decode_elapsed_time(data, offset) + + # Bit 9 -- Remaining Time + remaining_time = None + if flags & StairClimberDataFlags.REMAINING_TIME_PRESENT: + remaining_time, offset = decode_remaining_time(data, offset) + + return StairClimberData( + flags=flags, + floors=floors, + steps_per_minute=steps_per_minute, + average_step_rate=average_step_rate, + positive_elevation_gain=positive_elevation_gain, + stride_count=stride_count, + total_energy=total_energy, + energy_per_hour=energy_per_hour, + energy_per_minute=energy_per_minute, + heart_rate=heart_rate, + metabolic_equivalent=metabolic_equivalent, + elapsed_time=elapsed_time, + remaining_time=remaining_time, + ) + + def _encode_value(self, data: StairClimberData) -> bytearray: + """Encode StairClimberData back to BLE bytes. + + Reconstructs flags from present fields so round-trip encoding + preserves the original wire format. + + Args: + data: StairClimberData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = StairClimberDataFlags(0) + + # Bit 0 -- inverted: set MORE_DATA when Floors is absent + if data.floors is None: + flags |= StairClimberDataFlags.MORE_DATA + if data.steps_per_minute is not None: + flags |= StairClimberDataFlags.STEPS_PER_MINUTE_PRESENT + if data.average_step_rate is not None: + flags |= StairClimberDataFlags.AVERAGE_STEP_RATE_PRESENT + if data.positive_elevation_gain is not None: + flags |= StairClimberDataFlags.POSITIVE_ELEVATION_GAIN_PRESENT + if data.stride_count is not None: + flags |= StairClimberDataFlags.STRIDE_COUNT_PRESENT + if data.total_energy is not None: + flags |= StairClimberDataFlags.EXPENDED_ENERGY_PRESENT + if data.heart_rate is not None: + flags |= StairClimberDataFlags.HEART_RATE_PRESENT + if data.metabolic_equivalent is not None: + flags |= StairClimberDataFlags.METABOLIC_EQUIVALENT_PRESENT + if data.elapsed_time is not None: + flags |= StairClimberDataFlags.ELAPSED_TIME_PRESENT + if data.remaining_time is not None: + flags |= StairClimberDataFlags.REMAINING_TIME_PRESENT + + result = DataParser.encode_int16(int(flags), signed=False) + + if data.floors is not None: + result.extend(DataParser.encode_int16(data.floors, signed=False)) + if data.steps_per_minute is not None: + result.extend(DataParser.encode_int16(data.steps_per_minute, signed=False)) + if data.average_step_rate is not None: + result.extend(DataParser.encode_int16(data.average_step_rate, signed=False)) + if data.positive_elevation_gain is not None: + result.extend(DataParser.encode_int16(data.positive_elevation_gain, signed=False)) + if data.stride_count is not None: + result.extend(DataParser.encode_int16(data.stride_count, signed=False)) + if data.total_energy is not None: + result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute)) + if data.heart_rate is not None: + result.extend(encode_heart_rate(data.heart_rate)) + if data.metabolic_equivalent is not None: + result.extend(encode_metabolic_equivalent(data.metabolic_equivalent)) + if data.elapsed_time is not None: + result.extend(encode_elapsed_time(data.elapsed_time)) + if data.remaining_time is not None: + result.extend(encode_remaining_time(data.remaining_time)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/step_climber_data.py b/src/bluetooth_sig/gatt/characteristics/step_climber_data.py new file mode 100644 index 00000000..055f1412 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/step_climber_data.py @@ -0,0 +1,294 @@ +"""Step Climber Data characteristic implementation. + +Implements the Step Climber Data characteristic (0x2ACF) from the Fitness +Machine Service. A 16-bit flags field controls the presence of optional +data fields. + +Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 **both** +Floors and Step Count are present; when bit 0 is 1 they are absent. +All other bits use normal logic (1 = present). + +References: + Bluetooth SIG Fitness Machine Service 1.0 + org.bluetooth.characteristic.step_climber_data (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import UINT8_MAX, UINT16_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .fitness_machine_common import ( + MET_RESOLUTION, + decode_elapsed_time, + decode_energy_triplet, + decode_heart_rate, + decode_metabolic_equivalent, + decode_remaining_time, + encode_elapsed_time, + encode_energy_triplet, + encode_heart_rate, + encode_metabolic_equivalent, + encode_remaining_time, +) +from .utils import DataParser + + +class StepClimberDataFlags(IntFlag): + """Step Climber Data flags as per Bluetooth SIG specification. + + Bit 0 uses inverted logic: 0 -> Floors + Step Count present, 1 -> absent. + """ + + MORE_DATA = 0x0001 # Inverted: 0 -> Floors + Step Count present + STEPS_PER_MINUTE_PRESENT = 0x0002 + AVERAGE_STEP_RATE_PRESENT = 0x0004 + POSITIVE_ELEVATION_GAIN_PRESENT = 0x0008 + EXPENDED_ENERGY_PRESENT = 0x0010 + HEART_RATE_PRESENT = 0x0020 + METABOLIC_EQUIVALENT_PRESENT = 0x0040 + ELAPSED_TIME_PRESENT = 0x0080 + REMAINING_TIME_PRESENT = 0x0100 + + +class StepClimberData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes + """Parsed data from Step Climber Data characteristic. + + Attributes: + flags: Raw 16-bit flags field. + floors: Total floors counted (present when bit 0 is 0). + step_count: Total steps counted (present when bit 0 is 0). + steps_per_minute: Step rate in steps/min. + average_step_rate: Average step rate in steps/min. + positive_elevation_gain: Positive elevation gain in metres. + total_energy: Total expended energy in kcal. + energy_per_hour: Expended energy per hour in kcal. + energy_per_minute: Expended energy per minute in kcal. + heart_rate: Heart rate in bpm. + metabolic_equivalent: MET value (0.1 resolution). + elapsed_time: Elapsed time in seconds. + remaining_time: Remaining time in seconds. + + """ + + flags: StepClimberDataFlags + floors: int | None = None + step_count: int | None = None + steps_per_minute: int | None = None + average_step_rate: int | None = None + positive_elevation_gain: int | None = None + total_energy: int | None = None + energy_per_hour: int | None = None + energy_per_minute: int | None = None + heart_rate: int | None = None + metabolic_equivalent: float | None = None + elapsed_time: int | None = None + remaining_time: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if self.floors is not None and not 0 <= self.floors <= UINT16_MAX: + raise ValueError(f"Floors must be 0-{UINT16_MAX}, got {self.floors}") + if self.step_count is not None and not 0 <= self.step_count <= UINT16_MAX: + raise ValueError(f"Step count must be 0-{UINT16_MAX}, got {self.step_count}") + if self.steps_per_minute is not None and not 0 <= self.steps_per_minute <= UINT16_MAX: + raise ValueError(f"Steps per minute must be 0-{UINT16_MAX}, got {self.steps_per_minute}") + if self.average_step_rate is not None and not 0 <= self.average_step_rate <= UINT16_MAX: + raise ValueError(f"Average step rate must be 0-{UINT16_MAX}, got {self.average_step_rate}") + if self.positive_elevation_gain is not None and not 0 <= self.positive_elevation_gain <= UINT16_MAX: + raise ValueError(f"Positive elevation gain must be 0-{UINT16_MAX}, got {self.positive_elevation_gain}") + if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX: + raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}") + if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX: + raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}") + if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX: + raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}") + if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX: + raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}") + if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION: + raise ValueError( + f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}" + ) + if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX: + raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}") + if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX: + raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}") + + +class StepClimberDataCharacteristic(BaseCharacteristic[StepClimberData]): + """Step Climber Data characteristic (0x2ACF). + + Used in the Fitness Machine Service to transmit step climber workout + data. A 16-bit flags field controls which optional fields are present. + + Flag-bit assignments (from GSS YAML): + Bit 0: More Data -- **inverted**: 0 -> Floors + Step Count present + Bit 1: Steps Per Minute present + Bit 2: Average Step Rate present + Bit 3: Positive Elevation Gain present + Bit 4: Expended Energy present (gates triplet: total + /hr + /min) + Bit 5: Heart Rate present + Bit 6: Metabolic Equivalent present + Bit 7: Elapsed Time present + Bit 8: Remaining Time present + Bits 9-15: Reserved for Future Use + + """ + + expected_type = StepClimberData + min_length: int = 2 # Flags only (all optional fields absent) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> StepClimberData: + """Parse Step Climber Data from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + StepClimberData with all present fields populated. + + """ + flags = StepClimberDataFlags(DataParser.parse_int16(data, 0, signed=False)) + offset = 2 + + # Bit 0 -- inverted: Floors + Step Count present when bit is NOT set + floors = None + step_count = None + if not (flags & StepClimberDataFlags.MORE_DATA) and len(data) >= offset + 4: + floors = DataParser.parse_int16(data, offset, signed=False) + step_count = DataParser.parse_int16(data, offset + 2, signed=False) + offset += 4 + + # Bit 1 -- Steps Per Minute + steps_per_minute = None + if (flags & StepClimberDataFlags.STEPS_PER_MINUTE_PRESENT) and len(data) >= offset + 2: + steps_per_minute = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 2 -- Average Step Rate + average_step_rate = None + if (flags & StepClimberDataFlags.AVERAGE_STEP_RATE_PRESENT) and len(data) >= offset + 2: + average_step_rate = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 3 -- Positive Elevation Gain + positive_elevation_gain = None + if (flags & StepClimberDataFlags.POSITIVE_ELEVATION_GAIN_PRESENT) and len(data) >= offset + 2: + positive_elevation_gain = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 4 -- Energy triplet (Total + Per Hour + Per Minute) + total_energy = None + energy_per_hour = None + energy_per_minute = None + if flags & StepClimberDataFlags.EXPENDED_ENERGY_PRESENT: + total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset) + + # Bit 5 -- Heart Rate + heart_rate = None + if flags & StepClimberDataFlags.HEART_RATE_PRESENT: + heart_rate, offset = decode_heart_rate(data, offset) + + # Bit 6 -- Metabolic Equivalent + metabolic_equivalent = None + if flags & StepClimberDataFlags.METABOLIC_EQUIVALENT_PRESENT: + metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset) + + # Bit 7 -- Elapsed Time + elapsed_time = None + if flags & StepClimberDataFlags.ELAPSED_TIME_PRESENT: + elapsed_time, offset = decode_elapsed_time(data, offset) + + # Bit 8 -- Remaining Time + remaining_time = None + if flags & StepClimberDataFlags.REMAINING_TIME_PRESENT: + remaining_time, offset = decode_remaining_time(data, offset) + + return StepClimberData( + flags=flags, + floors=floors, + step_count=step_count, + steps_per_minute=steps_per_minute, + average_step_rate=average_step_rate, + positive_elevation_gain=positive_elevation_gain, + total_energy=total_energy, + energy_per_hour=energy_per_hour, + energy_per_minute=energy_per_minute, + heart_rate=heart_rate, + metabolic_equivalent=metabolic_equivalent, + elapsed_time=elapsed_time, + remaining_time=remaining_time, + ) + + def _encode_value(self, data: StepClimberData) -> bytearray: + """Encode StepClimberData back to BLE bytes. + + Reconstructs flags from present fields so round-trip encoding + preserves the original wire format. + + Args: + data: StepClimberData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = StepClimberDataFlags(0) + + # Bit 0 -- inverted: set MORE_DATA when Floors/Step Count absent + if data.floors is None: + flags |= StepClimberDataFlags.MORE_DATA + if data.steps_per_minute is not None: + flags |= StepClimberDataFlags.STEPS_PER_MINUTE_PRESENT + if data.average_step_rate is not None: + flags |= StepClimberDataFlags.AVERAGE_STEP_RATE_PRESENT + if data.positive_elevation_gain is not None: + flags |= StepClimberDataFlags.POSITIVE_ELEVATION_GAIN_PRESENT + if data.total_energy is not None: + flags |= StepClimberDataFlags.EXPENDED_ENERGY_PRESENT + if data.heart_rate is not None: + flags |= StepClimberDataFlags.HEART_RATE_PRESENT + if data.metabolic_equivalent is not None: + flags |= StepClimberDataFlags.METABOLIC_EQUIVALENT_PRESENT + if data.elapsed_time is not None: + flags |= StepClimberDataFlags.ELAPSED_TIME_PRESENT + if data.remaining_time is not None: + flags |= StepClimberDataFlags.REMAINING_TIME_PRESENT + + result = DataParser.encode_int16(int(flags), signed=False) + + if data.floors is not None: + result.extend(DataParser.encode_int16(data.floors, signed=False)) + if data.step_count is not None: + result.extend(DataParser.encode_int16(data.step_count, signed=False)) + if data.steps_per_minute is not None: + result.extend(DataParser.encode_int16(data.steps_per_minute, signed=False)) + if data.average_step_rate is not None: + result.extend(DataParser.encode_int16(data.average_step_rate, signed=False)) + if data.positive_elevation_gain is not None: + result.extend(DataParser.encode_int16(data.positive_elevation_gain, signed=False)) + if data.total_energy is not None: + result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute)) + if data.heart_rate is not None: + result.extend(encode_heart_rate(data.heart_rate)) + if data.metabolic_equivalent is not None: + result.extend(encode_metabolic_equivalent(data.metabolic_equivalent)) + if data.elapsed_time is not None: + result.extend(encode_elapsed_time(data.elapsed_time)) + if data.remaining_time is not None: + result.extend(encode_remaining_time(data.remaining_time)) + + return result diff --git a/src/bluetooth_sig/gatt/characteristics/treadmill_data.py b/src/bluetooth_sig/gatt/characteristics/treadmill_data.py new file mode 100644 index 00000000..7cd41ca7 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/treadmill_data.py @@ -0,0 +1,429 @@ +"""Treadmill Data characteristic implementation. + +Implements the Treadmill Data characteristic (0x2ACD) from the Fitness Machine +Service. A 16-bit flags field controls the presence of optional data fields. + +Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the +Instantaneous Speed field IS present; when bit 0 is 1 it is absent. +All other bits use normal logic (1 = present). + +References: + Bluetooth SIG Fitness Machine Service 1.0 + org.bluetooth.characteristic.treadmill_data (GSS YAML) +""" + +from __future__ import annotations + +from enum import IntFlag + +import msgspec + +from ..constants import SINT16_MAX, SINT16_MIN, UINT8_MAX, UINT16_MAX, UINT24_MAX +from ..context import CharacteristicContext +from .base import BaseCharacteristic +from .fitness_machine_common import ( + MET_RESOLUTION, + decode_elapsed_time, + decode_energy_triplet, + decode_heart_rate, + decode_metabolic_equivalent, + decode_remaining_time, + encode_elapsed_time, + encode_energy_triplet, + encode_heart_rate, + encode_metabolic_equivalent, + encode_remaining_time, +) +from .utils import DataParser + +# Speed: M=1, d=-2, b=0 -> actual = raw / 100 km/h +_SPEED_RESOLUTION = 100.0 + +# Inclination: M=1, d=-1, b=0 -> actual = raw / 10 % +# Ramp Angle: M=1, d=-1, b=0 -> actual = raw / 10 degrees +# Elevation: M=1, d=-1, b=0 -> actual = raw / 10 metres +_TENTH_RESOLUTION = 10.0 + + +class TreadmillDataFlags(IntFlag): + """Treadmill Data flags as per Bluetooth SIG specification. + + Bit 0 uses inverted logic: 0 = Instantaneous Speed present, 1 = absent. + """ + + MORE_DATA = 0x0001 # Inverted: 0 -> Speed present, 1 -> absent + AVERAGE_SPEED_PRESENT = 0x0002 + TOTAL_DISTANCE_PRESENT = 0x0004 + INCLINATION_AND_RAMP_PRESENT = 0x0008 + ELEVATION_GAIN_PRESENT = 0x0010 + INSTANTANEOUS_PACE_PRESENT = 0x0020 + AVERAGE_PACE_PRESENT = 0x0040 + EXPENDED_ENERGY_PRESENT = 0x0080 + HEART_RATE_PRESENT = 0x0100 + METABOLIC_EQUIVALENT_PRESENT = 0x0200 + ELAPSED_TIME_PRESENT = 0x0400 + REMAINING_TIME_PRESENT = 0x0800 + FORCE_AND_POWER_PRESENT = 0x1000 + + +class TreadmillData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes + """Parsed data from Treadmill Data characteristic. + + Attributes: + flags: Raw 16-bit flags field. + instantaneous_speed: Instantaneous belt speed in km/h (0.01 resolution). + average_speed: Average speed in km/h (0.01 resolution). + total_distance: Total distance in metres (uint24). + inclination: Current inclination in % (0.1 resolution, signed). + ramp_angle_setting: Current ramp angle in degrees (0.1 resolution, signed). + positive_elevation_gain: Positive elevation gain in metres (0.1 resolution). + negative_elevation_gain: Negative elevation gain in metres (0.1 resolution). + instantaneous_pace: Instantaneous pace in seconds per 500 m. + average_pace: Average pace in seconds per 500 m. + total_energy: Total expended energy in kcal. + energy_per_hour: Expended energy per hour in kcal. + energy_per_minute: Expended energy per minute in kcal. + heart_rate: Heart rate in bpm. + metabolic_equivalent: MET value (0.1 resolution). + elapsed_time: Elapsed time in seconds. + remaining_time: Remaining time in seconds. + force_on_belt: Force on belt in newtons (signed). + power_output: Power output in watts (signed). + + """ + + flags: TreadmillDataFlags + instantaneous_speed: float | None = None + average_speed: float | None = None + total_distance: int | None = None + inclination: float | None = None + ramp_angle_setting: float | None = None + positive_elevation_gain: float | None = None + negative_elevation_gain: float | None = None + instantaneous_pace: int | None = None + average_pace: int | None = None + total_energy: int | None = None + energy_per_hour: int | None = None + energy_per_minute: int | None = None + heart_rate: int | None = None + metabolic_equivalent: float | None = None + elapsed_time: int | None = None + remaining_time: int | None = None + force_on_belt: int | None = None + power_output: int | None = None + + def __post_init__(self) -> None: + """Validate field ranges.""" + if ( + self.instantaneous_speed is not None + and not 0.0 <= self.instantaneous_speed <= UINT16_MAX / _SPEED_RESOLUTION + ): + raise ValueError( + f"Instantaneous speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.instantaneous_speed}" + ) + if self.average_speed is not None and not 0.0 <= self.average_speed <= UINT16_MAX / _SPEED_RESOLUTION: + raise ValueError(f"Average speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.average_speed}") + if self.total_distance is not None and not 0 <= self.total_distance <= UINT24_MAX: + raise ValueError(f"Total distance must be 0-{UINT24_MAX}, got {self.total_distance}") + if ( + self.inclination is not None + and not SINT16_MIN / _TENTH_RESOLUTION <= self.inclination <= SINT16_MAX / _TENTH_RESOLUTION + ): + raise ValueError( + f"Inclination must be {SINT16_MIN / _TENTH_RESOLUTION}-{SINT16_MAX / _TENTH_RESOLUTION}, " + f"got {self.inclination}" + ) + if ( + self.ramp_angle_setting is not None + and not SINT16_MIN / _TENTH_RESOLUTION <= self.ramp_angle_setting <= SINT16_MAX / _TENTH_RESOLUTION + ): + raise ValueError( + f"Ramp angle must be {SINT16_MIN / _TENTH_RESOLUTION}-{SINT16_MAX / _TENTH_RESOLUTION}, " + f"got {self.ramp_angle_setting}" + ) + if ( + self.positive_elevation_gain is not None + and not 0.0 <= self.positive_elevation_gain <= UINT16_MAX / _TENTH_RESOLUTION + ): + raise ValueError( + f"Positive elevation must be 0.0-{UINT16_MAX / _TENTH_RESOLUTION}, got {self.positive_elevation_gain}" + ) + if ( + self.negative_elevation_gain is not None + and not 0.0 <= self.negative_elevation_gain <= UINT16_MAX / _TENTH_RESOLUTION + ): + raise ValueError( + f"Negative elevation must be 0.0-{UINT16_MAX / _TENTH_RESOLUTION}, got {self.negative_elevation_gain}" + ) + if self.instantaneous_pace is not None and not 0 <= self.instantaneous_pace <= UINT16_MAX: + raise ValueError(f"Instantaneous pace must be 0-{UINT16_MAX}, got {self.instantaneous_pace}") + if self.average_pace is not None and not 0 <= self.average_pace <= UINT16_MAX: + raise ValueError(f"Average pace must be 0-{UINT16_MAX}, got {self.average_pace}") + if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX: + raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}") + if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX: + raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}") + if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX: + raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}") + if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX: + raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}") + if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION: + raise ValueError( + f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}" + ) + if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX: + raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}") + if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX: + raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}") + if self.force_on_belt is not None and not SINT16_MIN <= self.force_on_belt <= SINT16_MAX: + raise ValueError(f"Force on belt must be {SINT16_MIN}-{SINT16_MAX}, got {self.force_on_belt}") + if self.power_output is not None and not SINT16_MIN <= self.power_output <= SINT16_MAX: + raise ValueError(f"Power output must be {SINT16_MIN}-{SINT16_MAX}, got {self.power_output}") + + +class TreadmillDataCharacteristic(BaseCharacteristic[TreadmillData]): + """Treadmill Data characteristic (0x2ACD). + + Used in the Fitness Machine Service to transmit treadmill workout data. + A 16-bit flags field controls which optional fields are present. + + Flag-bit assignments (from GSS YAML): + Bit 0: More Data -- **inverted**: 0 -> Inst. Speed present, 1 -> absent + Bit 1: Average Speed present + Bit 2: Total Distance present + Bit 3: Inclination and Ramp Angle Setting present (gates 2 fields) + Bit 4: Elevation Gain present (gates 2 fields: pos + neg) + Bit 5: Instantaneous Pace present + Bit 6: Average Pace present + Bit 7: Expended Energy present (gates triplet: total + /hr + /min) + Bit 8: Heart Rate present + Bit 9: Metabolic Equivalent present + Bit 10: Elapsed Time present + Bit 11: Remaining Time present + Bit 12: Force On Belt and Power Output present (gates 2 fields) + Bits 13-15: Reserved for Future Use + + """ + + expected_type = TreadmillData + min_length: int = 2 # Flags only (all optional fields absent) + allow_variable_length: bool = True + + def _decode_value( + self, + data: bytearray, + ctx: CharacteristicContext | None = None, + *, + validate: bool = True, + ) -> TreadmillData: + """Parse Treadmill Data from raw BLE bytes. + + Args: + data: Raw bytearray from BLE characteristic. + ctx: Optional context (unused). + validate: Whether to validate ranges. + + Returns: + TreadmillData with all present fields populated. + + """ + flags = TreadmillDataFlags(DataParser.parse_int16(data, 0, signed=False)) + offset = 2 + + # Bit 0 -- inverted: Instantaneous Speed present when bit is NOT set + instantaneous_speed = None + if not (flags & TreadmillDataFlags.MORE_DATA) and len(data) >= offset + 2: + raw_speed = DataParser.parse_int16(data, offset, signed=False) + instantaneous_speed = raw_speed / _SPEED_RESOLUTION + offset += 2 + + # Bit 1 -- Average Speed + average_speed = None + if (flags & TreadmillDataFlags.AVERAGE_SPEED_PRESENT) and len(data) >= offset + 2: + raw_avg_speed = DataParser.parse_int16(data, offset, signed=False) + average_speed = raw_avg_speed / _SPEED_RESOLUTION + offset += 2 + + # Bit 2 -- Total Distance (uint24) + total_distance = None + if (flags & TreadmillDataFlags.TOTAL_DISTANCE_PRESENT) and len(data) >= offset + 3: + total_distance = DataParser.parse_int24(data, offset, signed=False) + offset += 3 + + # Bit 3 -- Inclination (sint16) + Ramp Angle Setting (sint16) + inclination = None + ramp_angle_setting = None + if (flags & TreadmillDataFlags.INCLINATION_AND_RAMP_PRESENT) and len(data) >= offset + 4: + raw_incl = DataParser.parse_int16(data, offset, signed=True) + inclination = raw_incl / _TENTH_RESOLUTION + offset += 2 + raw_ramp = DataParser.parse_int16(data, offset, signed=True) + ramp_angle_setting = raw_ramp / _TENTH_RESOLUTION + offset += 2 + + # Bit 4 -- Positive Elevation Gain (uint16) + Negative Elevation Gain (uint16) + positive_elevation_gain = None + negative_elevation_gain = None + if (flags & TreadmillDataFlags.ELEVATION_GAIN_PRESENT) and len(data) >= offset + 4: + raw_pos = DataParser.parse_int16(data, offset, signed=False) + positive_elevation_gain = raw_pos / _TENTH_RESOLUTION + offset += 2 + raw_neg = DataParser.parse_int16(data, offset, signed=False) + negative_elevation_gain = raw_neg / _TENTH_RESOLUTION + offset += 2 + + # Bit 5 -- Instantaneous Pace + instantaneous_pace = None + if (flags & TreadmillDataFlags.INSTANTANEOUS_PACE_PRESENT) and len(data) >= offset + 2: + instantaneous_pace = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 6 -- Average Pace + average_pace = None + if (flags & TreadmillDataFlags.AVERAGE_PACE_PRESENT) and len(data) >= offset + 2: + average_pace = DataParser.parse_int16(data, offset, signed=False) + offset += 2 + + # Bit 7 -- Energy triplet (Total + Per Hour + Per Minute) + total_energy = None + energy_per_hour = None + energy_per_minute = None + if flags & TreadmillDataFlags.EXPENDED_ENERGY_PRESENT: + total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset) + + # Bit 8 -- Heart Rate + heart_rate = None + if flags & TreadmillDataFlags.HEART_RATE_PRESENT: + heart_rate, offset = decode_heart_rate(data, offset) + + # Bit 9 -- Metabolic Equivalent + metabolic_equivalent = None + if flags & TreadmillDataFlags.METABOLIC_EQUIVALENT_PRESENT: + metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset) + + # Bit 10 -- Elapsed Time + elapsed_time = None + if flags & TreadmillDataFlags.ELAPSED_TIME_PRESENT: + elapsed_time, offset = decode_elapsed_time(data, offset) + + # Bit 11 -- Remaining Time + remaining_time = None + if flags & TreadmillDataFlags.REMAINING_TIME_PRESENT: + remaining_time, offset = decode_remaining_time(data, offset) + + # Bit 12 -- Force On Belt (sint16) + Power Output (sint16) + force_on_belt = None + power_output = None + if (flags & TreadmillDataFlags.FORCE_AND_POWER_PRESENT) and len(data) >= offset + 4: + force_on_belt = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + power_output = DataParser.parse_int16(data, offset, signed=True) + offset += 2 + + return TreadmillData( + flags=flags, + instantaneous_speed=instantaneous_speed, + average_speed=average_speed, + total_distance=total_distance, + inclination=inclination, + ramp_angle_setting=ramp_angle_setting, + positive_elevation_gain=positive_elevation_gain, + negative_elevation_gain=negative_elevation_gain, + instantaneous_pace=instantaneous_pace, + average_pace=average_pace, + total_energy=total_energy, + energy_per_hour=energy_per_hour, + energy_per_minute=energy_per_minute, + heart_rate=heart_rate, + metabolic_equivalent=metabolic_equivalent, + elapsed_time=elapsed_time, + remaining_time=remaining_time, + force_on_belt=force_on_belt, + power_output=power_output, + ) + + def _encode_value(self, data: TreadmillData) -> bytearray: # noqa: PLR0912 + """Encode TreadmillData back to BLE bytes. + + Reconstructs flags from present fields so round-trip encoding + preserves the original wire format. + + Args: + data: TreadmillData instance. + + Returns: + Encoded bytearray matching the BLE wire format. + + """ + flags = TreadmillDataFlags(0) + + # Bit 0 -- inverted: set MORE_DATA when Speed is absent + if data.instantaneous_speed is None: + flags |= TreadmillDataFlags.MORE_DATA + if data.average_speed is not None: + flags |= TreadmillDataFlags.AVERAGE_SPEED_PRESENT + if data.total_distance is not None: + flags |= TreadmillDataFlags.TOTAL_DISTANCE_PRESENT + if data.inclination is not None: + flags |= TreadmillDataFlags.INCLINATION_AND_RAMP_PRESENT + if data.positive_elevation_gain is not None: + flags |= TreadmillDataFlags.ELEVATION_GAIN_PRESENT + if data.instantaneous_pace is not None: + flags |= TreadmillDataFlags.INSTANTANEOUS_PACE_PRESENT + if data.average_pace is not None: + flags |= TreadmillDataFlags.AVERAGE_PACE_PRESENT + if data.total_energy is not None: + flags |= TreadmillDataFlags.EXPENDED_ENERGY_PRESENT + if data.heart_rate is not None: + flags |= TreadmillDataFlags.HEART_RATE_PRESENT + if data.metabolic_equivalent is not None: + flags |= TreadmillDataFlags.METABOLIC_EQUIVALENT_PRESENT + if data.elapsed_time is not None: + flags |= TreadmillDataFlags.ELAPSED_TIME_PRESENT + if data.remaining_time is not None: + flags |= TreadmillDataFlags.REMAINING_TIME_PRESENT + if data.force_on_belt is not None: + flags |= TreadmillDataFlags.FORCE_AND_POWER_PRESENT + + result = DataParser.encode_int16(int(flags), signed=False) + + if data.instantaneous_speed is not None: + raw_speed = round(data.instantaneous_speed * _SPEED_RESOLUTION) + result.extend(DataParser.encode_int16(raw_speed, signed=False)) + if data.average_speed is not None: + raw_avg_speed = round(data.average_speed * _SPEED_RESOLUTION) + result.extend(DataParser.encode_int16(raw_avg_speed, signed=False)) + if data.total_distance is not None: + result.extend(DataParser.encode_int24(data.total_distance, signed=False)) + if data.inclination is not None: + raw_incl = round(data.inclination * _TENTH_RESOLUTION) + result.extend(DataParser.encode_int16(raw_incl, signed=True)) + if data.ramp_angle_setting is not None: + raw_ramp = round(data.ramp_angle_setting * _TENTH_RESOLUTION) + result.extend(DataParser.encode_int16(raw_ramp, signed=True)) + if data.positive_elevation_gain is not None: + raw_pos = round(data.positive_elevation_gain * _TENTH_RESOLUTION) + result.extend(DataParser.encode_int16(raw_pos, signed=False)) + if data.negative_elevation_gain is not None: + raw_neg = round(data.negative_elevation_gain * _TENTH_RESOLUTION) + result.extend(DataParser.encode_int16(raw_neg, signed=False)) + if data.instantaneous_pace is not None: + result.extend(DataParser.encode_int16(data.instantaneous_pace, signed=False)) + if data.average_pace is not None: + result.extend(DataParser.encode_int16(data.average_pace, signed=False)) + if data.total_energy is not None: + result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute)) + if data.heart_rate is not None: + result.extend(encode_heart_rate(data.heart_rate)) + if data.metabolic_equivalent is not None: + result.extend(encode_metabolic_equivalent(data.metabolic_equivalent)) + if data.elapsed_time is not None: + result.extend(encode_elapsed_time(data.elapsed_time)) + if data.remaining_time is not None: + result.extend(encode_remaining_time(data.remaining_time)) + if data.force_on_belt is not None: + result.extend(DataParser.encode_int16(data.force_on_belt, signed=True)) + if data.power_output is not None: + result.extend(DataParser.encode_int16(data.power_output, signed=True)) + + return result diff --git a/src/bluetooth_sig/gatt/resolver.py b/src/bluetooth_sig/gatt/resolver.py index fc2f5de7..12e117f5 100644 --- a/src/bluetooth_sig/gatt/resolver.py +++ b/src/bluetooth_sig/gatt/resolver.py @@ -89,7 +89,9 @@ def to_org_format(words: list[str], entity_type: str) -> str: def snake_case_to_camel_case(s: str) -> str: """Convert snake_case to CamelCase with acronym handling (for test file mapping).""" acronyms = { + "cgm", "co2", + "ieee", "pm1", "pm10", "pm25", @@ -207,7 +209,7 @@ def generate_service_variants(class_name: str, explicit_name: str | None = None) base_name = NameNormalizer.remove_suffix(class_name, "Service") # Split on camelCase and convert to space-separated - words = re.findall("[A-Z][^A-Z]*", base_name) + words = re.findall(r"[A-Z][^A-Z]*", base_name) display_name = " ".join(words) # Generate org format diff --git a/src/bluetooth_sig/registry/core/class_of_device.py b/src/bluetooth_sig/registry/core/class_of_device.py index 597546ce..c7508e62 100644 --- a/src/bluetooth_sig/registry/core/class_of_device.py +++ b/src/bluetooth_sig/registry/core/class_of_device.py @@ -172,7 +172,7 @@ def _load_minor_classes(self, major_val: int, major_item: dict[str, Any]) -> Non minor_val = minor_item.get("value") minor_name = minor_item.get("name") if minor_val is not None and minor_name: - self._minor_classes[(major_val, minor_val)] = MinorDeviceClassInfo( + self._minor_classes[major_val, minor_val] = MinorDeviceClassInfo( value=minor_val, name=minor_name, major_class=major_val, diff --git a/src/bluetooth_sig/registry/gss.py b/src/bluetooth_sig/registry/gss.py index 2e2849e6..70034548 100644 --- a/src/bluetooth_sig/registry/gss.py +++ b/src/bluetooth_sig/registry/gss.py @@ -240,7 +240,7 @@ def _extract_unit_id_and_line(self, description: str) -> tuple[str | None, str | unit_line = parts[1].strip() elif "Unit:" in description: # Inline format: "Unit: org.bluetooth.unit.xxx" - unit_line = description.split("Unit:")[1].split("\n")[0].strip() + unit_line = description.split("Unit:")[1].split("\n", maxsplit=1)[0].strip() if unit_line and "org.bluetooth.unit." in unit_line: # Remove all spaces (handles YAML formatting issues) diff --git a/tests/gatt/characteristics/test_battery_energy_status.py b/tests/gatt/characteristics/test_battery_energy_status.py new file mode 100644 index 00000000..d871ac06 --- /dev/null +++ b/tests/gatt/characteristics/test_battery_energy_status.py @@ -0,0 +1,137 @@ +"""Tests for Battery Energy Status characteristic (0x2BF0).""" + +from __future__ import annotations + +import math +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.battery_energy_status import ( + BatteryEnergyStatus, + BatteryEnergyStatusCharacteristic, + BatteryEnergyStatusFlags, +) +from bluetooth_sig.gatt.characteristics.utils import IEEE11073Parser +from bluetooth_sig.gatt.exceptions import CharacteristicParseError +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +def _sfloat_bytes(value: float) -> list[int]: + """Encode a float to SFLOAT and return as list of ints.""" + return list(IEEE11073Parser.encode_sfloat(value)) + + +class TestBatteryEnergyStatusCharacteristic(CommonCharacteristicTests): + """Test Battery Energy Status characteristic implementation.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide Battery Energy Status characteristic for testing.""" + return BatteryEnergyStatusCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Battery Energy Status characteristic.""" + return "2BF0" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + """Valid battery energy status test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x3F, # flags: all 6 fields present + *_sfloat_bytes(5.0), # external_source_power: 5W + *_sfloat_bytes(4.0), # present_voltage: 4V + *_sfloat_bytes(10.0), # available_energy: 10kWh + *_sfloat_bytes(20.0), # available_battery_capacity: 20kWh + *_sfloat_bytes(3.0), # charge_rate: 3W + *_sfloat_bytes(8.0), # available_energy_at_last_charge: 8kWh + ] + ), + expected_value=BatteryEnergyStatus( + flags=BatteryEnergyStatusFlags(0x3F), + external_source_power=5.0, + present_voltage=4.0, + available_energy=10.0, + available_battery_capacity=20.0, + charge_rate=3.0, + available_energy_at_last_charge=8.0, + ), + description="All fields present", + ), + CharacteristicTestData( + input_data=bytearray([0x00]), # flags only, no fields + expected_value=BatteryEnergyStatus( + flags=BatteryEnergyStatusFlags(0x00), + ), + description="Flags only, no optional fields", + ), + CharacteristicTestData( + input_data=bytearray( + [0x02, *_sfloat_bytes(4.0)] # flags + present_voltage: 4V + ), + expected_value=BatteryEnergyStatus( + flags=BatteryEnergyStatusFlags(0x02), + present_voltage=4.0, + ), + description="Voltage only", + ), + ] + + def test_single_field_external_power(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify external source power only.""" + data = bytearray([0x01, *_sfloat_bytes(100.0)]) + result = characteristic.parse_value(data) + assert result.external_source_power == 100.0 + assert result.present_voltage is None + assert result.available_energy is None + + def test_charge_rate_negative(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify negative charge rate (discharging).""" + data = bytearray([0x10, *_sfloat_bytes(-2.0)]) + result = characteristic.parse_value(data) + assert result.charge_rate == -2.0 + + def test_roundtrip_all_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with all fields.""" + original = BatteryEnergyStatus( + flags=BatteryEnergyStatusFlags(0x3F), + external_source_power=5.0, + present_voltage=4.0, + available_energy=10.0, + available_battery_capacity=20.0, + charge_rate=3.0, + available_energy_at_last_charge=8.0, + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original + + def test_roundtrip_partial_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with subset of fields.""" + original = BatteryEnergyStatus( + flags=BatteryEnergyStatusFlags(0x0A), + present_voltage=4.0, + available_battery_capacity=20.0, + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original + + def test_nan_sfloat(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify NaN SFLOAT value is handled.""" + data = bytearray([0x02, *IEEE11073Parser.encode_sfloat(float("nan"))]) + result = characteristic.parse_value(data) + assert math.isnan(result.present_voltage) + + def test_too_short_data(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify that empty data raises error.""" + with pytest.raises(CharacteristicParseError): + characteristic.parse_value(bytearray([])) diff --git a/tests/gatt/characteristics/test_battery_health_information.py b/tests/gatt/characteristics/test_battery_health_information.py new file mode 100644 index 00000000..fef6f7c3 --- /dev/null +++ b/tests/gatt/characteristics/test_battery_health_information.py @@ -0,0 +1,130 @@ +"""Tests for Battery Health Information characteristic (0x2BEB).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.battery_health_information import ( + BatteryHealthInformation, + BatteryHealthInformationCharacteristic, + BatteryHealthInformationFlags, +) +from bluetooth_sig.gatt.exceptions import CharacteristicParseError +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestBatteryHealthInformationCharacteristic(CommonCharacteristicTests): + """Test Battery Health Information characteristic implementation.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide Battery Health Information characteristic for testing.""" + return BatteryHealthInformationCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Battery Health Information characteristic.""" + return "2BEB" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + """Valid battery health information test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x03, # flags: both fields present + 0xE8, + 0x03, # cycle_count_designed_lifetime: 1000 + 0xEC, # min_temp: -20 C + 0x3C, # max_temp: 60 C + ] + ), + expected_value=BatteryHealthInformation( + flags=BatteryHealthInformationFlags(0x03), + cycle_count_designed_lifetime=1000, + min_designed_operating_temperature=-20, + max_designed_operating_temperature=60, + ), + description="All fields present", + ), + CharacteristicTestData( + input_data=bytearray([0x00]), # flags only + expected_value=BatteryHealthInformation( + flags=BatteryHealthInformationFlags(0x00), + ), + description="Flags only, no optional fields", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x01, # flags: cycle count only + 0xD0, + 0x07, # cycle_count_designed_lifetime: 2000 + ] + ), + expected_value=BatteryHealthInformation( + flags=BatteryHealthInformationFlags(0x01), + cycle_count_designed_lifetime=2000, + ), + description="Cycle count only", + ), + ] + + def test_bit1_gates_two_temperature_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify bit 1 gates both min and max temperature simultaneously.""" + data = bytearray( + [ + 0x02, # flags: temperature range present + 0xF6, # min_temp: -10 C + 0x37, # max_temp: 55 C + ] + ) + result = characteristic.parse_value(data) + assert result.cycle_count_designed_lifetime is None + assert result.min_designed_operating_temperature == -10 + assert result.max_designed_operating_temperature == 55 + + def test_temperature_sentinels(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify temperature sentinel values.""" + data = bytearray( + [ + 0x02, # flags: temperature range present + 0x80, # min_temp: -128 (means "<-127") + 0x7F, # max_temp: 127 (means ">126") + ] + ) + result = characteristic.parse_value(data) + assert result.min_designed_operating_temperature == -128 + assert result.max_designed_operating_temperature == 127 + + def test_roundtrip_all_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with all fields.""" + original = BatteryHealthInformation( + flags=BatteryHealthInformationFlags(0x03), + cycle_count_designed_lifetime=500, + min_designed_operating_temperature=-10, + max_designed_operating_temperature=45, + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original + + def test_validation_cycle_count(self) -> None: + """Verify cycle count range validation.""" + with pytest.raises(ValueError, match="Cycle count designed lifetime"): + BatteryHealthInformation( + flags=BatteryHealthInformationFlags(0x01), + cycle_count_designed_lifetime=-1, + ) + + def test_too_short_data(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify that empty data raises error.""" + with pytest.raises(CharacteristicParseError): + characteristic.parse_value(bytearray([])) diff --git a/tests/gatt/characteristics/test_battery_health_status.py b/tests/gatt/characteristics/test_battery_health_status.py new file mode 100644 index 00000000..17929abb --- /dev/null +++ b/tests/gatt/characteristics/test_battery_health_status.py @@ -0,0 +1,166 @@ +"""Tests for Battery Health Status characteristic (0x2BEA).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.battery_health_status import ( + BatteryHealthStatus, + BatteryHealthStatusCharacteristic, + BatteryHealthStatusFlags, +) +from bluetooth_sig.gatt.exceptions import CharacteristicParseError +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestBatteryHealthStatusCharacteristic(CommonCharacteristicTests): + """Test Battery Health Status characteristic implementation.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide Battery Health Status characteristic for testing.""" + return BatteryHealthStatusCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Battery Health Status characteristic.""" + return "2BEA" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + """Valid battery health status test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x0F, # flags: all 4 fields present + 0x50, # health_summary: 80% + 0xE8, + 0x03, # cycle_count: 1000 + 0x19, # current_temperature: 25 C + 0x05, + 0x00, # deep_discharge_count: 5 + ] + ), + expected_value=BatteryHealthStatus( + flags=BatteryHealthStatusFlags(0x0F), + battery_health_summary=80, + cycle_count=1000, + current_temperature=25, + deep_discharge_count=5, + ), + description="All fields present", + ), + CharacteristicTestData( + input_data=bytearray([0x00]), # flags only, no optional fields + expected_value=BatteryHealthStatus( + flags=BatteryHealthStatusFlags(0x00), + ), + description="Flags only, no optional fields", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x01, # flags: only health summary + 0x64, # health_summary: 100% + ] + ), + expected_value=BatteryHealthStatus( + flags=BatteryHealthStatusFlags(0x01), + battery_health_summary=100, + ), + description="Only health summary present", + ), + ] + + def test_temperature_sentinel_greater_than_126(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify sint8 0x7F (127) decodes as >126 sentinel.""" + data = bytearray( + [ + 0x04, # flags: temperature present + 0x7F, # temperature: 127 (means ">126") + ] + ) + result = characteristic.parse_value(data) + assert result.current_temperature == 127 + + def test_temperature_sentinel_less_than_minus_127(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify sint8 0x80 (-128) decodes as <-127 sentinel.""" + data = bytearray( + [ + 0x04, # flags: temperature present + 0x80, # temperature: -128 (means "<-127") + ] + ) + result = characteristic.parse_value(data) + assert result.current_temperature == -128 + + def test_negative_temperature(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify negative temperature values decode correctly.""" + data = bytearray( + [ + 0x04, # flags: temperature present + 0xF6, # temperature: -10 C (signed: 0xF6 = 246 unsigned = -10 signed) + ] + ) + result = characteristic.parse_value(data) + assert result.current_temperature == -10 + + def test_cycle_count_only(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify cycle count only field present.""" + data = bytearray( + [ + 0x02, # flags: cycle count present + 0xFF, + 0xFF, # cycle_count: 65535 (max uint16) + ] + ) + result = characteristic.parse_value(data) + assert result.battery_health_summary is None + assert result.cycle_count == 65535 + assert result.current_temperature is None + assert result.deep_discharge_count is None + + def test_deep_discharge_count_only(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify deep discharge count only field present.""" + data = bytearray( + [ + 0x08, # flags: deep discharge present + 0x0A, + 0x00, # deep_discharge_count: 10 + ] + ) + result = characteristic.parse_value(data) + assert result.deep_discharge_count == 10 + + def test_health_summary_validation(self) -> None: + """Verify health summary must be 0-100.""" + with pytest.raises(ValueError, match="Battery health summary must be 0-100"): + BatteryHealthStatus( + flags=BatteryHealthStatusFlags(0x01), + battery_health_summary=101, + ) + + def test_roundtrip_all_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with all fields.""" + original = BatteryHealthStatus( + flags=BatteryHealthStatusFlags(0x0F), + battery_health_summary=75, + cycle_count=500, + current_temperature=-5, + deep_discharge_count=3, + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original + + def test_too_short_data(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify that empty data raises error.""" + with pytest.raises(CharacteristicParseError): + characteristic.parse_value(bytearray([])) diff --git a/tests/gatt/characteristics/test_battery_information.py b/tests/gatt/characteristics/test_battery_information.py new file mode 100644 index 00000000..83b29759 --- /dev/null +++ b/tests/gatt/characteristics/test_battery_information.py @@ -0,0 +1,180 @@ +"""Tests for Battery Information characteristic (0x2BEC).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.battery_information import ( + BatteryChemistry, + BatteryFeatures, + BatteryInformation, + BatteryInformationCharacteristic, + BatteryInformationFlags, +) +from bluetooth_sig.gatt.characteristics.utils import IEEE11073Parser +from bluetooth_sig.gatt.exceptions import CharacteristicParseError +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +def _sfloat_bytes(value: float) -> list[int]: + """Encode a float to SFLOAT and return as list of ints.""" + return list(IEEE11073Parser.encode_sfloat(value)) + + +def _uint24_le(value: int) -> list[int]: + """Encode uint24 as 3-byte little-endian list.""" + return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF] + + +class TestBatteryInformationCharacteristic(CommonCharacteristicTests): + """Test Battery Information characteristic implementation.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide Battery Information characteristic for testing.""" + return BatteryInformationCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Battery Information characteristic.""" + return "2BEC" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + """Valid battery information test data.""" + # 19724 days since epoch = 2024-01-01 (approx) + # 21550 days since epoch = 2029-01-01 (approx) + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0xFF, + 0x00, # flags: bits 0-7 all set (16-bit LE) + 0x03, # features: replaceable + rechargeable + *_uint24_le(19724), # manufacture_date + *_uint24_le(21550), # expiration_date + *_sfloat_bytes(10.0), # designed_capacity: 10kWh + *_sfloat_bytes(2.0), # low_energy: 2kWh + *_sfloat_bytes(1.0), # critical_energy: 1kWh + 0x05, # chemistry: Lithium Ion + *_sfloat_bytes(4.0), # nominal_voltage: 4V + 0x01, # aggregation_group: 1 + ] + ), + expected_value=BatteryInformation( + flags=BatteryInformationFlags(0x00FF), + battery_features=BatteryFeatures(0x03), + battery_manufacture_date=19724, + battery_expiration_date=21550, + battery_designed_capacity=10.0, + battery_low_energy=2.0, + battery_critical_energy=1.0, + battery_chemistry=BatteryChemistry.LITHIUM_ION, + nominal_voltage=4.0, + battery_aggregation_group=1, + ), + description="All fields present", + ), + CharacteristicTestData( + input_data=bytearray([0x00, 0x00, 0x00]), + expected_value=BatteryInformation( + flags=BatteryInformationFlags(0x0000), + battery_features=BatteryFeatures(0x00), + ), + description="Mandatory fields only", + ), + CharacteristicTestData( + input_data=bytearray([0x20, 0x00, 0x02, 0x06]), + expected_value=BatteryInformation( + flags=BatteryInformationFlags(0x0020), + battery_features=BatteryFeatures(0x02), + battery_chemistry=BatteryChemistry.LITHIUM_POLYMER, + ), + description="Chemistry only", + ), + ] + + def test_features_replaceable(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify replaceable feature bit.""" + data = bytearray([0x00, 0x00, 0x01]) # features: replaceable only + result = characteristic.parse_value(data) + assert result.battery_features & BatteryFeatures.REPLACEABLE + assert not result.battery_features & BatteryFeatures.RECHARGEABLE + + def test_features_rechargeable(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify rechargeable feature bit.""" + data = bytearray([0x00, 0x00, 0x02]) # features: rechargeable only + result = characteristic.parse_value(data) + assert result.battery_features & BatteryFeatures.RECHARGEABLE + assert not result.battery_features & BatteryFeatures.REPLACEABLE + + def test_chemistry_enum_values(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify chemistry enum maps correctly.""" + for chem_val, expected in [ + (0, BatteryChemistry.UNKNOWN), + (5, BatteryChemistry.LITHIUM_ION), + (8, BatteryChemistry.NICKEL_CADMIUM), + (13, BatteryChemistry.ZINC_CARBON), + (255, BatteryChemistry.OTHER), + ]: + data = bytearray([0x20, 0x00, 0x00, chem_val]) + result = characteristic.parse_value(data) + assert result.battery_chemistry == expected + + def test_chemistry_unknown_rfu_value(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify RFU chemistry value falls back to UNKNOWN.""" + data = bytearray([0x20, 0x00, 0x00, 200]) + result = characteristic.parse_value(data) + assert result.battery_chemistry == BatteryChemistry.UNKNOWN + + def test_dates_decode(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify date fields decode correctly.""" + data = bytearray([0x03, 0x00, 0x00, *_uint24_le(0), *_uint24_le(36524)]) + result = characteristic.parse_value(data) + assert result.battery_manufacture_date == 0 + assert result.battery_expiration_date == 36524 + + def test_aggregation_group(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify aggregation group field.""" + data = bytearray([0x80, 0x00, 0x00, 0x05]) + result = characteristic.parse_value(data) + assert result.battery_aggregation_group == 5 + + def test_roundtrip_all_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with all fields.""" + original = BatteryInformation( + flags=BatteryInformationFlags(0x00FF), + battery_features=BatteryFeatures(0x03), + battery_manufacture_date=19724, + battery_expiration_date=21550, + battery_designed_capacity=10.0, + battery_low_energy=2.0, + battery_critical_energy=1.0, + battery_chemistry=BatteryChemistry.LITHIUM_ION, + nominal_voltage=4.0, + battery_aggregation_group=1, + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original + + def test_roundtrip_minimal(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with minimal fields.""" + original = BatteryInformation( + flags=BatteryInformationFlags(0x0000), + battery_features=BatteryFeatures(0x01), + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original + + def test_too_short_data(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify that data shorter than min_length raises error.""" + with pytest.raises(CharacteristicParseError): + characteristic.parse_value(bytearray([0x00, 0x00])) diff --git a/tests/gatt/characteristics/test_battery_time_status.py b/tests/gatt/characteristics/test_battery_time_status.py new file mode 100644 index 00000000..247fd8ae --- /dev/null +++ b/tests/gatt/characteristics/test_battery_time_status.py @@ -0,0 +1,205 @@ +"""Tests for Battery Time Status characteristic (0x2BEF).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.battery_time_status import ( + BatteryTimeStatus, + BatteryTimeStatusCharacteristic, + BatteryTimeStatusFlags, +) +from bluetooth_sig.gatt.exceptions import CharacteristicParseError +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestBatteryTimeStatusCharacteristic(CommonCharacteristicTests): + """Test Battery Time Status characteristic implementation.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide Battery Time Status characteristic for testing.""" + return BatteryTimeStatusCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID for Battery Time Status characteristic.""" + return "2BEE" + + @pytest.fixture + def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + """Valid battery time status test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x03, # flags: both optional fields present + 0x3C, + 0x00, + 0x00, # time_until_discharged: 60 minutes + 0x1E, + 0x00, + 0x00, # time_until_discharged_on_standby: 30 minutes + 0x5A, + 0x00, + 0x00, # time_until_recharged: 90 minutes + ] + ), + expected_value=BatteryTimeStatus( + flags=BatteryTimeStatusFlags(0x03), + time_until_discharged=60, + time_until_discharged_on_standby=30, + time_until_recharged=90, + ), + description="All fields present", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x00, # flags: no optional fields + 0x78, + 0x00, + 0x00, # time_until_discharged: 120 minutes + ] + ), + expected_value=BatteryTimeStatus( + flags=BatteryTimeStatusFlags(0x00), + time_until_discharged=120, + ), + description="Mandatory field only", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x00, # flags: no optional fields + 0xFF, + 0xFF, + 0xFF, # time_until_discharged: Unknown sentinel + ] + ), + expected_value=BatteryTimeStatus( + flags=BatteryTimeStatusFlags(0x00), + time_until_discharged=None, + ), + description="Unknown sentinel for mandatory field", + ), + ] + + def test_sentinel_unknown_returns_none(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify that 0xFFFFFF sentinel decodes to None.""" + data = bytearray( + [ + 0x03, # both optional fields present + 0xFF, + 0xFF, + 0xFF, # discharged: Unknown + 0xFF, + 0xFF, + 0xFF, # on standby: Unknown + 0xFF, + 0xFF, + 0xFF, # recharged: Unknown + ] + ) + result = characteristic.parse_value(data) + assert result.time_until_discharged is None + assert result.time_until_discharged_on_standby is None + assert result.time_until_recharged is None + + def test_large_time_value(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify large time values (just under sentinel) decode correctly.""" + # 0xFFFFFD = 16777213 minutes + data = bytearray( + [ + 0x00, # flags: mandatory only + 0xFD, + 0xFF, + 0xFF, # time_until_discharged: 16777213 minutes + ] + ) + result = characteristic.parse_value(data) + assert result.time_until_discharged == 0xFFFFFD + + def test_standby_only(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify only standby optional field present.""" + data = bytearray( + [ + 0x01, # flags: bit 0 = standby present + 0x0A, + 0x00, + 0x00, # discharged: 10 minutes + 0x14, + 0x00, + 0x00, # on standby: 20 minutes + ] + ) + result = characteristic.parse_value(data) + assert result.time_until_discharged == 10 + assert result.time_until_discharged_on_standby == 20 + assert result.time_until_recharged is None + + def test_recharged_only(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify only recharged optional field present.""" + data = bytearray( + [ + 0x02, # flags: bit 1 = recharged present + 0x05, + 0x00, + 0x00, # discharged: 5 minutes + 0x2D, + 0x00, + 0x00, # recharged: 45 minutes + ] + ) + result = characteristic.parse_value(data) + assert result.time_until_discharged == 5 + assert result.time_until_discharged_on_standby is None + assert result.time_until_recharged == 45 + + def test_roundtrip_all_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with all fields.""" + original = BatteryTimeStatus( + flags=BatteryTimeStatusFlags(0x03), + time_until_discharged=120, + time_until_discharged_on_standby=480, + time_until_recharged=60, + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original + + def test_roundtrip_unknown_sentinel(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify encode/decode roundtrip with None (unknown sentinel).""" + original = BatteryTimeStatus( + flags=BatteryTimeStatusFlags(0x00), + time_until_discharged=None, + ) + encoded = characteristic.build_value(original) + # Unknown sentinel should produce 0xFF 0xFF 0xFF + assert encoded[1:4] == bytearray([0xFF, 0xFF, 0xFF]) + decoded = characteristic.parse_value(encoded) + assert decoded.time_until_discharged is None + + def test_zero_time(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify zero minutes value is valid.""" + data = bytearray( + [ + 0x00, + 0x00, + 0x00, + 0x00, # discharged: 0 minutes + ] + ) + result = characteristic.parse_value(data) + assert result.time_until_discharged == 0 + + def test_too_short_data(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify that data shorter than min_length raises error.""" + with pytest.raises(CharacteristicParseError): + characteristic.parse_value(bytearray([0x00, 0x01, 0x02])) diff --git a/tests/gatt/characteristics/test_blood_pressure_record.py b/tests/gatt/characteristics/test_blood_pressure_record.py new file mode 100644 index 00000000..a3ee0d05 --- /dev/null +++ b/tests/gatt/characteristics/test_blood_pressure_record.py @@ -0,0 +1,154 @@ +"""Tests for Blood Pressure Record characteristic (0x2B36).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.blood_pressure_record import ( + BloodPressureRecordCharacteristic, + BloodPressureRecordData, +) +from bluetooth_sig.types.uuid import BluetoothUUID +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestBloodPressureRecordCharacteristic(CommonCharacteristicTests): + """Test Blood Pressure Record characteristic.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide characteristic instance.""" + return BloodPressureRecordCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID.""" + return "2B36" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x03, # header: first + last segment, counter=0 + 0x01, + 0x00, # sequence_number: 1 + 0x34, + 0x2B, # uuid: 0x2B34 (enhanced BP measurement) + 0xAA, + 0xBB, + 0xCC, # recorded_data: 3 bytes + ] + ), + expected_value=BloodPressureRecordData( + first_segment=True, + last_segment=True, + segment_counter=0, + sequence_number=1, + uuid=BluetoothUUID(0x2B34), + recorded_data=b"\xaa\xbb\xcc", + ), + description="Single-segment record with 3 bytes payload", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x05, # header: first segment, counter=1 + 0x00, + 0x00, # sequence_number: 0 + 0x35, + 0x2B, # uuid: 0x2B35 + ] + ), + expected_value=BloodPressureRecordData( + first_segment=True, + last_segment=False, + segment_counter=1, + sequence_number=0, + uuid=BluetoothUUID(0x2B35), + recorded_data=b"", + ), + description="First segment, no recorded data", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0xFA, # header: last segment, counter=62 (0x3E << 2 | 0x02 = 0xFA) + 0xFF, + 0xFF, # sequence_number: 65535 + 0x34, + 0x2B, # uuid: 0x2B34 + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, # recorded_data: 5 bytes + ] + ), + expected_value=BloodPressureRecordData( + first_segment=False, + last_segment=True, + segment_counter=62, + sequence_number=65535, + uuid=BluetoothUUID(0x2B34), + recorded_data=b"\x01\x02\x03\x04\x05", + ), + description="Last segment with high counter and sequence", + ), + ] + + def test_segment_counter_max(self) -> None: + """Test maximum segment counter value (63).""" + char = BloodPressureRecordCharacteristic() + # counter=63 -> bits 2-7 = 0x3F << 2 = 0xFC + data = bytearray([0xFC, 0x00, 0x00, 0x34, 0x2B]) + result = char.parse_value(data) + assert result.segment_counter == 63 + assert result.first_segment is False + assert result.last_segment is False + + def test_round_trip_record(self) -> None: + """Test encode/decode round-trip.""" + char = BloodPressureRecordCharacteristic() + original = BloodPressureRecordData( + first_segment=True, + last_segment=True, + segment_counter=10, + sequence_number=42, + uuid=BluetoothUUID(0x2B34), + recorded_data=b"\xde\xad\xbe\xef", + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.first_segment == original.first_segment + assert decoded.last_segment == original.last_segment + assert decoded.segment_counter == original.segment_counter + assert decoded.sequence_number == original.sequence_number + assert decoded.uuid == original.uuid + assert decoded.recorded_data == original.recorded_data + + def test_encode_with_e2e_crc(self) -> None: + """Test encoding with E2E-CRC appended.""" + char = BloodPressureRecordCharacteristic() + original = BloodPressureRecordData( + first_segment=True, + last_segment=True, + segment_counter=0, + sequence_number=1, + uuid=BluetoothUUID(0x2B34), + recorded_data=b"\xaa", + e2e_crc=0x1234, + ) + encoded = char.build_value(original) + # header(1) + seq(2) + uuid(2) + data(1) + crc(2) = 8 + assert len(encoded) == 8 + # CRC should be at the end + assert encoded[-2:] == bytearray([0x34, 0x12]) diff --git a/tests/gatt/characteristics/test_cgm_feature.py b/tests/gatt/characteristics/test_cgm_feature.py new file mode 100644 index 00000000..89eca5fd --- /dev/null +++ b/tests/gatt/characteristics/test_cgm_feature.py @@ -0,0 +1,137 @@ +"""Tests for CGM Feature characteristic (0x2AA8).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.cgm_feature import ( + CGMFeatureCharacteristic, + CGMFeatureData, + CGMFeatureFlags, + CGMSampleLocation, + CGMType, +) +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestCGMFeatureCharacteristic(CommonCharacteristicTests): + """Test CGM Feature characteristic.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide characteristic instance.""" + return CGMFeatureCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID.""" + return "2AA8" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x01, + 0x00, + 0x00, # features: calibration supported + 0x59, # type=ISF (0x9), location=subcutaneous (0x5) + 0xFF, + 0xFF, # e2e_crc + ] + ), + expected_value=CGMFeatureData( + features=CGMFeatureFlags.CALIBRATION_SUPPORTED, + cgm_type=CGMType.INTERSTITIAL_FLUID, + sample_location=CGMSampleLocation.SUBCUTANEOUS_TISSUE, + e2e_crc=0xFFFF, + ), + description="Calibration supported, ISF, subcutaneous", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0xFF, + 0xFF, + 0x01, # features: many bits set + 0x11, # type=capillary whole blood (0x1), location=finger (0x1) + 0x34, + 0x12, # e2e_crc: 0x1234 + ] + ), + expected_value=CGMFeatureData( + features=CGMFeatureFlags(0x01FFFF), + cgm_type=CGMType.CAPILLARY_WHOLE_BLOOD, + sample_location=CGMSampleLocation.FINGER, + e2e_crc=0x1234, + ), + description="Many features, finger, capillary whole blood", + ), + ] + + def test_all_features_enabled(self) -> None: + """Test with all 17 feature bits set.""" + char = CGMFeatureCharacteristic() + all_features = 0x01FFFF + data = bytearray( + [ + all_features & 0xFF, + (all_features >> 8) & 0xFF, + (all_features >> 16) & 0xFF, + 0x59, # ISF, subcutaneous + 0x00, + 0x00, # CRC + ] + ) + result = char.parse_value(data) + assert result.features & CGMFeatureFlags.CALIBRATION_SUPPORTED + assert result.features & CGMFeatureFlags.E2E_CRC_SUPPORTED + assert result.features & CGMFeatureFlags.CGM_QUALITY_SUPPORTED + + def test_no_features(self) -> None: + """Test with no features enabled.""" + char = CGMFeatureCharacteristic() + data = bytearray([0x00, 0x00, 0x00, 0x11, 0x00, 0x00]) + result = char.parse_value(data) + assert int(result.features) == 0 + assert result.cgm_type == CGMType.CAPILLARY_WHOLE_BLOOD + assert result.sample_location == CGMSampleLocation.FINGER + + def test_round_trip_feature(self) -> None: + """Test encode/decode round-trip.""" + char = CGMFeatureCharacteristic() + original = CGMFeatureData( + features=CGMFeatureFlags.CALIBRATION_SUPPORTED + | CGMFeatureFlags.E2E_CRC_SUPPORTED + | CGMFeatureFlags.CGM_TREND_INFORMATION_SUPPORTED, + cgm_type=CGMType.INTERSTITIAL_FLUID, + sample_location=CGMSampleLocation.SUBCUTANEOUS_TISSUE, + e2e_crc=0xABCD, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.features == original.features + assert decoded.cgm_type == original.cgm_type + assert decoded.sample_location == original.sample_location + assert decoded.e2e_crc == original.e2e_crc + + def test_nibble_packing(self) -> None: + """Test that type and sample location nibbles are packed correctly.""" + char = CGMFeatureCharacteristic() + original = CGMFeatureData( + features=CGMFeatureFlags(0), + cgm_type=CGMType.CONTROL_SOLUTION, # 0xA + sample_location=CGMSampleLocation.NOT_AVAILABLE, # 0xF + e2e_crc=0, + ) + encoded = char.build_value(original) + # byte 3 should be (0xF << 4) | 0xA = 0xFA + assert encoded[3] == 0xFA diff --git a/tests/gatt/characteristics/test_cgm_measurement.py b/tests/gatt/characteristics/test_cgm_measurement.py new file mode 100644 index 00000000..2ca57f62 --- /dev/null +++ b/tests/gatt/characteristics/test_cgm_measurement.py @@ -0,0 +1,227 @@ +"""Tests for CGM Measurement characteristic (0x2AA7).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.cgm_measurement import ( + CGMCalTempOctet, + CGMMeasurementCharacteristic, + CGMMeasurementData, + CGMMeasurementFlags, + CGMMeasurementRecord, + CGMSensorStatusOctet, + CGMWarningOctet, +) +from bluetooth_sig.gatt.characteristics.utils import IEEE11073Parser +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +def _sfloat_bytes(value: float) -> list[int]: + """Encode a float to SFLOAT and return as list of ints.""" + return list(IEEE11073Parser.encode_sfloat(value)) + + +class TestCGMMeasurementCharacteristic(CommonCharacteristicTests): + """Test CGM Measurement characteristic.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide characteristic instance.""" + return CGMMeasurementCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID.""" + return "2AA7" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x06, # size: 6 bytes + 0x00, # flags: nothing optional + *_sfloat_bytes(100.0), # glucose concentration + 0x0A, + 0x00, # time_offset: 10 minutes + ] + ), + expected_value=CGMMeasurementData( + records=( + CGMMeasurementRecord( + size=6, + flags=CGMMeasurementFlags(0x00), + glucose_concentration=100.0, + time_offset=10, + ), + ), + ), + description="Single record, no optional fields", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x0A, # size: 10 bytes + 0x03, # flags: trend + quality present + *_sfloat_bytes(120.0), # glucose + 0x1E, + 0x00, # time_offset: 30 + *_sfloat_bytes(2.0), # trend: 2 mg/dL/min + *_sfloat_bytes(95.0), # quality: 95% + ] + ), + expected_value=CGMMeasurementData( + records=( + CGMMeasurementRecord( + size=10, + flags=CGMMeasurementFlags(0x03), + glucose_concentration=120.0, + time_offset=30, + trend_information=2.0, + quality=95.0, + ), + ), + ), + description="Single record with trend and quality", + ), + ] + + def test_annunciation_octets(self) -> None: + """Test parsing with all three annunciation octets.""" + char = CGMMeasurementCharacteristic() + data = bytearray( + [ + 0x09, # size: 9 bytes + 0xE0, # flags: status + cal_temp + warning present + *_sfloat_bytes(90.0), # glucose + 0x05, + 0x00, # time_offset: 5 + 0x01, # status_octet + 0x02, # cal_temp_octet + 0x04, # warning_octet + ] + ) + result = char.parse_value(data) + assert len(result.records) == 1 + rec = result.records[0] + assert rec.status_octet == 0x01 + assert rec.cal_temp_octet == 0x02 + assert rec.warning_octet == 0x04 + assert rec.trend_information is None + assert rec.quality is None + + def test_multiple_records(self) -> None: + """Test parsing multiple concatenated records.""" + char = CGMMeasurementCharacteristic() + data = bytearray( + [ + # Record 1: minimal + 0x06, + 0x00, + *_sfloat_bytes(100.0), + 0x0A, + 0x00, + # Record 2: minimal + 0x06, + 0x00, + *_sfloat_bytes(110.0), + 0x14, + 0x00, + ] + ) + result = char.parse_value(data) + assert len(result.records) == 2 + assert result.records[0].glucose_concentration == 100.0 + assert result.records[0].time_offset == 10 + assert result.records[1].glucose_concentration == 110.0 + assert result.records[1].time_offset == 20 + + def test_round_trip_single_record(self) -> None: + """Test encode/decode round-trip for a single record.""" + char = CGMMeasurementCharacteristic() + original = CGMMeasurementData( + records=( + CGMMeasurementRecord( + size=10, + flags=CGMMeasurementFlags(0x03), + glucose_concentration=120.0, + time_offset=30, + trend_information=2.0, + quality=95.0, + ), + ), + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert len(decoded.records) == 1 + rec = decoded.records[0] + assert rec.glucose_concentration == 120.0 + assert rec.time_offset == 30 + assert rec.trend_information == 2.0 + assert rec.quality == 95.0 + + def test_round_trip_multiple_records(self) -> None: + """Test encode/decode round-trip with multiple records.""" + char = CGMMeasurementCharacteristic() + original = CGMMeasurementData( + records=( + CGMMeasurementRecord( + size=6, + flags=CGMMeasurementFlags(0x00), + glucose_concentration=100.0, + time_offset=10, + ), + CGMMeasurementRecord( + size=9, + flags=CGMMeasurementFlags(0xE0), + glucose_concentration=90.0, + time_offset=5, + status_octet=CGMSensorStatusOctet(0x01), + cal_temp_octet=CGMCalTempOctet(0x02), + warning_octet=CGMWarningOctet(0x04), + ), + ), + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert len(decoded.records) == 2 + assert decoded.records[0].glucose_concentration == 100.0 + assert decoded.records[1].status_octet == CGMSensorStatusOctet(0x01) + assert decoded.records[1].warning_octet == CGMWarningOctet(0x04) + + def test_round_trip_with_all_fields(self) -> None: + """Test round-trip with all optional fields present.""" + char = CGMMeasurementCharacteristic() + original = CGMMeasurementData( + records=( + CGMMeasurementRecord( + size=13, + flags=CGMMeasurementFlags(0xE3), + glucose_concentration=150.0, + time_offset=60, + status_octet=CGMSensorStatusOctet(0x0F), + cal_temp_octet=CGMCalTempOctet(0x03), + warning_octet=CGMWarningOctet(0x01), + trend_information=1.0, + quality=98.0, + ), + ), + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + rec = decoded.records[0] + assert rec.glucose_concentration == 150.0 + assert rec.status_octet == CGMSensorStatusOctet(0x0F) + assert rec.cal_temp_octet == CGMCalTempOctet(0x03) + assert rec.warning_octet == CGMWarningOctet(0x01) + assert rec.trend_information == 1.0 + assert rec.quality == 98.0 diff --git a/tests/gatt/characteristics/test_cgm_session_run_time.py b/tests/gatt/characteristics/test_cgm_session_run_time.py new file mode 100644 index 00000000..e437bc0d --- /dev/null +++ b/tests/gatt/characteristics/test_cgm_session_run_time.py @@ -0,0 +1,93 @@ +"""Tests for CGM Session Run Time characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics.cgm_session_run_time import ( + CGMSessionRunTimeCharacteristic, + CGMSessionRunTimeData, +) + + +@pytest.fixture +def characteristic() -> CGMSessionRunTimeCharacteristic: + """Create a CGMSessionRunTimeCharacteristic instance.""" + return CGMSessionRunTimeCharacteristic() + + +class TestCGMSessionRunTimeDecode: + """Tests for CGM Session Run Time decoding.""" + + def test_basic_run_time(self, characteristic: CGMSessionRunTimeCharacteristic) -> None: + """Test decoding a basic run time without CRC.""" + # 168 hours = 0x00A8 + data = bytearray(b"\xa8\x00") + result = characteristic.parse_value(data) + assert isinstance(result, CGMSessionRunTimeData) + assert result.run_time_hours == 168 + assert result.e2e_crc is None + + def test_run_time_with_crc(self, characteristic: CGMSessionRunTimeCharacteristic) -> None: + """Test decoding run time with E2E-CRC.""" + # 336 hours = 0x0150, CRC = 0xABCD + data = bytearray(b"\x50\x01\xcd\xab") + result = characteristic.parse_value(data) + assert isinstance(result, CGMSessionRunTimeData) + assert result.run_time_hours == 336 + assert result.e2e_crc == 0xABCD + + def test_zero_run_time(self, characteristic: CGMSessionRunTimeCharacteristic) -> None: + """Test decoding zero run time.""" + data = bytearray(b"\x00\x00") + result = characteristic.parse_value(data) + assert result.run_time_hours == 0 + assert result.e2e_crc is None + + def test_max_run_time(self, characteristic: CGMSessionRunTimeCharacteristic) -> None: + """Test decoding maximum run time (65535 hours).""" + data = bytearray(b"\xff\xff") + result = characteristic.parse_value(data) + assert result.run_time_hours == 65535 + assert result.e2e_crc is None + + +class TestCGMSessionRunTimeEncode: + """Tests for CGM Session Run Time encoding.""" + + def test_encode_basic(self, characteristic: CGMSessionRunTimeCharacteristic) -> None: + """Test encoding run time without CRC.""" + data = CGMSessionRunTimeData(run_time_hours=168) + result = characteristic.build_value(data) + assert result == bytearray(b"\xa8\x00") + + def test_encode_with_crc(self, characteristic: CGMSessionRunTimeCharacteristic) -> None: + """Test encoding run time with E2E-CRC.""" + data = CGMSessionRunTimeData(run_time_hours=336, e2e_crc=0xABCD) + result = characteristic.build_value(data) + assert result == bytearray(b"\x50\x01\xcd\xab") + + +class TestCGMSessionRunTimeRoundTrip: + """Round-trip tests for CGM Session Run Time.""" + + @pytest.mark.parametrize( + ("run_time", "crc"), + [ + (0, None), + (168, None), + (336, 0x1234), + (65535, 0xFFFF), + ], + ) + def test_round_trip( + self, + characteristic: CGMSessionRunTimeCharacteristic, + run_time: int, + crc: int | None, + ) -> None: + """Test encode → decode round-trip.""" + original = CGMSessionRunTimeData(run_time_hours=run_time, e2e_crc=crc) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_cgm_session_start_time.py b/tests/gatt/characteristics/test_cgm_session_start_time.py new file mode 100644 index 00000000..98cf6629 --- /dev/null +++ b/tests/gatt/characteristics/test_cgm_session_start_time.py @@ -0,0 +1,115 @@ +"""Tests for CGM Session Start Time characteristic (0x2AAA).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.cgm_session_start_time import ( + CGMSessionStartTimeCharacteristic, + CGMSessionStartTimeData, +) +from bluetooth_sig.gatt.characteristics.dst_offset import DSTOffset +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestCGMSessionStartTimeCharacteristic(CommonCharacteristicTests): + """Test CGM Session Start Time characteristic.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide characteristic instance.""" + return CGMSessionStartTimeCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID.""" + return "2AAA" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0xE0, + 0x07, # year: 2016 + 0x06, # month: June + 0x0F, # day: 15 + 0x0A, # hour: 10 + 0x1E, # minute: 30 + 0x00, # second: 0 + 0x04, # timezone: UTC+1 (4 * 15min = 60min) + 0x04, # dst_offset: 4 (daylight time +1h) + ] + ), + expected_value=CGMSessionStartTimeData( + start_time=datetime(2016, 6, 15, 10, 30, 0), + time_zone=4, + dst_offset=DSTOffset.DAYLIGHT_TIME, + ), + description="Typical session start, no CRC", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0xEA, + 0x07, # year: 2026 + 0x02, # month: February + 0x17, # day: 23 + 0x08, # hour: 8 + 0x00, # minute: 0 + 0x00, # second: 0 + 0x00, # timezone: UTC + 0x00, # dst_offset: 0 (standard time) + 0xAB, + 0xCD, # e2e_crc: 0xCDAB + ] + ), + expected_value=CGMSessionStartTimeData( + start_time=datetime(2026, 2, 23, 8, 0, 0), + time_zone=0, + dst_offset=DSTOffset.STANDARD_TIME, + e2e_crc=0xCDAB, + ), + description="Session start with CRC", + ), + ] + + def test_round_trip_without_crc(self) -> None: + """Test encode/decode round-trip without CRC.""" + char = CGMSessionStartTimeCharacteristic() + original = CGMSessionStartTimeData( + start_time=datetime(2025, 12, 25, 14, 30, 45), + time_zone=8, + dst_offset=DSTOffset.STANDARD_TIME, + ) + encoded = char.build_value(original) + assert len(encoded) == 9 + decoded = char.parse_value(encoded) + assert decoded.start_time == original.start_time + assert decoded.time_zone == original.time_zone + assert decoded.dst_offset == original.dst_offset + assert decoded.e2e_crc is None + + def test_round_trip_with_crc(self) -> None: + """Test encode/decode round-trip with CRC.""" + char = CGMSessionStartTimeCharacteristic() + original = CGMSessionStartTimeData( + start_time=datetime(2020, 1, 1, 0, 0, 0), + time_zone=0, + dst_offset=DSTOffset.STANDARD_TIME, + e2e_crc=0x1234, + ) + encoded = char.build_value(original) + assert len(encoded) == 11 + decoded = char.parse_value(encoded) + assert decoded.start_time == original.start_time + assert decoded.e2e_crc == 0x1234 diff --git a/tests/gatt/characteristics/test_cgm_status.py b/tests/gatt/characteristics/test_cgm_status.py new file mode 100644 index 00000000..0d0cbf94 --- /dev/null +++ b/tests/gatt/characteristics/test_cgm_status.py @@ -0,0 +1,133 @@ +"""Tests for CGM Status characteristic (0x2AA9).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.cgm_status import ( + CGMStatusCharacteristic, + CGMStatusData, + CGMStatusFlags, +) +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +class TestCGMStatusCharacteristic(CommonCharacteristicTests): + """Test CGM Status characteristic.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide characteristic instance.""" + return CGMStatusCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID.""" + return "2AA9" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x0A, + 0x00, # time_offset: 10 minutes + 0x00, + 0x00, + 0x00, # status: no flags set + ] + ), + expected_value=CGMStatusData( + time_offset=10, + status=CGMStatusFlags(0), + ), + description="No status flags, no CRC", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x1E, + 0x00, # time_offset: 30 minutes + 0x03, + 0x00, + 0x00, # status: session stopped + battery low + 0x34, + 0x12, # e2e_crc: 0x1234 + ] + ), + expected_value=CGMStatusData( + time_offset=30, + status=CGMStatusFlags.SESSION_STOPPED | CGMStatusFlags.DEVICE_BATTERY_LOW, + e2e_crc=0x1234, + ), + description="Status flags with CRC", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x3C, + 0x00, # time_offset: 60 minutes + 0x00, + 0x01, + 0x01, # status: time sync req + patient low + ] + ), + expected_value=CGMStatusData( + time_offset=60, + status=CGMStatusFlags.TIME_SYNC_REQUIRED | CGMStatusFlags.RESULT_LOWER_THAN_PATIENT_LOW, + ), + description="Cal/temp and warning flags set", + ), + ] + + def test_all_octets_set(self) -> None: + """Test with flags from all three octets.""" + char = CGMStatusCharacteristic() + data = bytearray( + [ + 0x05, + 0x00, # time_offset: 5 + 0x01, # status octet: session stopped + 0x04, # cal/temp octet: calibration recommended + 0x10, # warning octet: rate of decrease exceeded + ] + ) + result = char.parse_value(data) + assert result.status & CGMStatusFlags.SESSION_STOPPED + assert result.status & CGMStatusFlags.CALIBRATION_RECOMMENDED + assert result.status & CGMStatusFlags.RATE_OF_DECREASE_EXCEEDED + + def test_round_trip_with_crc(self) -> None: + """Test encode/decode round-trip with CRC.""" + char = CGMStatusCharacteristic() + original = CGMStatusData( + time_offset=45, + status=CGMStatusFlags.SENSOR_MALFUNCTION | CGMStatusFlags.CALIBRATION_REQUIRED, + e2e_crc=0xBEEF, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.time_offset == original.time_offset + assert decoded.status == original.status + assert decoded.e2e_crc == original.e2e_crc + + def test_round_trip_without_crc(self) -> None: + """Test encode/decode round-trip without CRC.""" + char = CGMStatusCharacteristic() + original = CGMStatusData( + time_offset=10, + status=CGMStatusFlags(0), + ) + encoded = char.build_value(original) + assert len(encoded) == 5 + decoded = char.parse_value(encoded) + assert decoded.time_offset == 10 + assert decoded.e2e_crc is None diff --git a/tests/gatt/characteristics/test_cross_trainer_data.py b/tests/gatt/characteristics/test_cross_trainer_data.py new file mode 100644 index 00000000..3f7b454d --- /dev/null +++ b/tests/gatt/characteristics/test_cross_trainer_data.py @@ -0,0 +1,261 @@ +"""Tests for Cross Trainer Data characteristic.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.cross_trainer_data import ( + CrossTrainerData, + CrossTrainerDataCharacteristic, + CrossTrainerDataFlags, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestCrossTrainerDataCharacteristic(CommonCharacteristicTests): + """Tests for CrossTrainerDataCharacteristic.""" + + characteristic_cls = CrossTrainerDataCharacteristic + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + return CrossTrainerDataCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2ACE" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + # Case 1: Flags only -- bit 0 set (MORE_DATA), all fields absent + # 24-bit flags = 0x000001 (3 bytes) + CharacteristicTestData( + input_data=bytearray([0x01, 0x00, 0x00]), + expected_value=CrossTrainerData( + flags=CrossTrainerDataFlags.MORE_DATA, + ), + description="Flags only, no optional fields (24-bit)", + ), + # Case 2: Speed (bit 0=0) + HR (bit 11) + backward direction (bit 15) + # Flags = 0x008800: bit 11=1 (HR), bit 15=1 (backward) + # Speed raw=1500 -> 15.00 km/h, HR=160 + CharacteristicTestData( + input_data=bytearray( + [ + 0x00, + 0x88, + 0x00, # Flags = 0x008800 + 0xDC, + 0x05, # Speed raw = 1500 -> 15.00 + 0xA0, # HR = 160 + ] + ), + expected_value=CrossTrainerData( + flags=CrossTrainerDataFlags.HEART_RATE_PRESENT | CrossTrainerDataFlags.MOVEMENT_DIRECTION_BACKWARD, + instantaneous_speed=15.00, + heart_rate=160, + movement_direction_backward=True, + ), + description="Speed + HR + backward direction", + ), + # Case 3: Multiple fields - speed, step count, stride, power, energy + # Flags = 0x00051C: bit 0=0 (speed), bit 2 (distance), bit 3 (steps), + # bit 4 (stride), bit 8 (inst power), bit 10 (energy) + # Speed raw=1000 -> 10.00, Distance=3000, + # Steps/min=120, Avg step rate=115, + # Stride raw=500 -> 50.0, Power=200, + # Energy: total=300, /hr=400, /min=7 + CharacteristicTestData( + input_data=bytearray( + [ + 0x1C, + 0x05, + 0x00, # Flags = 0x00051C + 0xE8, + 0x03, # Speed raw = 1000 -> 10.00 + 0xB8, + 0x0B, + 0x00, # Distance = 3000 + 0x78, + 0x00, # Steps/min = 120 + 0x73, + 0x00, # Avg step rate = 115 + 0xF4, + 0x01, # Stride raw = 500 -> 50.0 + 0xC8, + 0x00, # Inst power = 200 + 0x2C, + 0x01, # Total energy = 300 + 0x90, + 0x01, # Energy/hr = 400 + 0x07, # Energy/min = 7 + ] + ), + expected_value=CrossTrainerData( + flags=CrossTrainerDataFlags(0x00051C), + instantaneous_speed=10.00, + total_distance=3000, + steps_per_minute=120, + average_step_rate=115, + stride_count=50.0, + instantaneous_power=200, + total_energy=300, + energy_per_hour=400, + energy_per_minute=7, + ), + description="Speed + distance + steps + stride + power + energy", + ), + ] + + def test_24bit_flags_parsed_correctly( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """24-bit flags (3 bytes) are parsed correctly.""" + # Flags = 0x000001 (MORE_DATA), 3 bytes + data = bytearray([0x01, 0x00, 0x00]) + result = characteristic.parse_value(data) + assert result.flags == CrossTrainerDataFlags.MORE_DATA + assert result.instantaneous_speed is None + + def test_inverted_bit0_speed_present_when_bit_clear( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 0 = 0 means Instantaneous Speed IS present (inverted).""" + # Flags = 0x000000: bit 0 clear -> speed present + data = bytearray([0x00, 0x00, 0x00, 0xE8, 0x03]) # Speed raw=1000 -> 10.00 + result = characteristic.parse_value(data) + assert result.instantaneous_speed == 10.00 + + def test_inverted_bit0_speed_absent_when_bit_set( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 0 = 1 means Instantaneous Speed IS absent (inverted).""" + data = bytearray([0x01, 0x00, 0x00]) + result = characteristic.parse_value(data) + assert result.instantaneous_speed is None + + def test_movement_direction_forward( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 15 = 0 means forward movement.""" + data = bytearray([0x01, 0x00, 0x00]) # No bit 15 + result = characteristic.parse_value(data) + assert result.movement_direction_backward is False + + def test_movement_direction_backward( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 15 = 1 means backward movement (semantic flag).""" + # Flags = 0x008001: bit 0=1 (no speed), bit 15=1 (backward) + data = bytearray([0x01, 0x80, 0x00]) + result = characteristic.parse_value(data) + assert result.movement_direction_backward is True + assert result.instantaneous_speed is None + + def test_step_count_dual_field_gating( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 3 gates both Steps Per Minute and Average Step Rate (4 bytes).""" + # Flags = 0x000009: bit 0=1 (no speed), bit 3=1 (step count) + data = bytearray( + [ + 0x09, + 0x00, + 0x00, # Flags + 0x64, + 0x00, # Steps/min = 100 + 0x5A, + 0x00, # Avg step rate = 90 + ] + ) + result = characteristic.parse_value(data) + assert result.steps_per_minute == 100 + assert result.average_step_rate == 90 + + def test_stride_count_tenth_resolution( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Stride count uses d=-1 scaling (raw/10).""" + # Flags = 0x000011: bit 0=1 (no speed), bit 4=1 (stride count) + data = bytearray( + [ + 0x11, + 0x00, + 0x00, # Flags + 0xFB, + 0x00, # Stride raw = 251 -> 25.1 + ] + ) + result = characteristic.parse_value(data) + assert result.stride_count == pytest.approx(25.1) + + def test_elevation_gain_raw_metres( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Cross trainer elevation gain is raw metres (no d=-1 scaling).""" + # Flags = 0x000021: bit 0=1 (no speed), bit 5=1 (elevation gain) + data = bytearray( + [ + 0x21, + 0x00, + 0x00, # Flags + 0xC8, + 0x00, # Pos elev = 200 m + 0x64, + 0x00, # Neg elev = 100 m + ] + ) + result = characteristic.parse_value(data) + assert result.positive_elevation_gain == 200 + assert result.negative_elevation_gain == 100 + + def test_inclination_and_ramp_tenth_resolution( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Inclination and ramp use d=-1 scaling (raw/10).""" + # Flags = 0x000041: bit 0=1 (no speed), bit 6=1 (incl+ramp) + data = bytearray( + [ + 0x41, + 0x00, + 0x00, # Flags + 0xEC, + 0xFF, # Incl raw = -20 -> -2.0% + 0x1E, + 0x00, # Ramp raw = 30 -> 3.0 deg + ] + ) + result = characteristic.parse_value(data) + assert result.inclination == -2.0 + assert result.ramp_setting == 3.0 + + def test_resistance_level_times_ten_scaling( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Resistance level uses d=1 scaling (raw * 10).""" + # Flags = 0x000081: bit 0=1 (no speed), bit 7=1 (resistance) + data = bytearray( + [ + 0x81, + 0x00, + 0x00, # Flags + 0x05, # Resistance raw = 5 -> 50.0 + ] + ) + result = characteristic.parse_value(data) + assert result.resistance_level == 50.0 diff --git a/tests/gatt/characteristics/test_current_elapsed_time.py b/tests/gatt/characteristics/test_current_elapsed_time.py new file mode 100644 index 00000000..251ed8c2 --- /dev/null +++ b/tests/gatt/characteristics/test_current_elapsed_time.py @@ -0,0 +1,183 @@ +"""Tests for Current Elapsed Time characteristic.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics.current_elapsed_time import ( + CurrentElapsedTimeCharacteristic, + CurrentElapsedTimeData, + ElapsedTimeFlags, + TimeResolution, +) +from bluetooth_sig.gatt.characteristics.reference_time_information import TimeSource + + +@pytest.fixture +def characteristic() -> CurrentElapsedTimeCharacteristic: + """Create a CurrentElapsedTimeCharacteristic instance.""" + return CurrentElapsedTimeCharacteristic() + + +class TestCurrentElapsedTimeDecode: + """Tests for Current Elapsed Time decoding.""" + + def test_basic_utc_time(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test decoding a UTC time-of-day value (1 second resolution).""" + # Flags: UTC (bit 1) = 0x02, resolution 1s (bits 2-3 = 00) + # Time: 1000000 = 0x0F4240 (as uint48 little-endian: 40 42 0F 00 00 00) + # Sync source: 0x01 + # TZ/DST offset: 0 (sint8) + data = bytearray(b"\x02\x40\x42\x0f\x00\x00\x00\x01\x00") + result = characteristic.parse_value(data) + assert isinstance(result, CurrentElapsedTimeData) + assert result.is_utc is True + assert result.is_tick_counter is False + assert result.time_resolution == TimeResolution.ONE_SECOND + assert result.time_value == 1000000 + assert result.sync_source_type == 1 + assert result.tz_dst_offset == 0 + + def test_tick_counter_100ms(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test decoding a tick counter with 100ms resolution.""" + # Flags: tick_counter (bit 0) + resolution 100ms (bits 2-3 = 01) + # = 0x01 | (0x01 << 2) = 0x01 | 0x04 = 0x05 + # Time: 500 = 0x01F4 (as uint48 LE: F4 01 00 00 00 00) + # Sync source: 0x00 + # TZ/DST offset: 0 + data = bytearray(b"\x05\xf4\x01\x00\x00\x00\x00\x00\x00") + result = characteristic.parse_value(data) + assert result.is_tick_counter is True + assert result.time_resolution == TimeResolution.HUNDRED_MILLISECONDS + assert result.time_value == 500 + assert result.tz_dst_used is False + + def test_local_time_with_tz(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test decoding local time with TZ/DST offset.""" + # Flags: tz_dst_used (bit 4) + current_timeline (bit 5) = 0x30 + # Time: 86400 = 0x015180 (one day in seconds) + # LE: 80 51 01 00 00 00 + # Sync source: 0x04 + # TZ/DST offset: 8 (= 2 hours ahead, sint8) + data = bytearray(b"\x30\x80\x51\x01\x00\x00\x00\x04\x08") + result = characteristic.parse_value(data) + assert result.is_utc is False + assert result.tz_dst_used is True + assert result.is_current_timeline is True + assert result.time_value == 86400 + assert result.tz_dst_offset == 8 + + def test_negative_tz_offset(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test decoding with negative TZ/DST offset.""" + # Flags: UTC + tz_dst_used = 0x02 | 0x10 = 0x12 + # Time value: zero, Sync source: 0x00 + # TZ/DST offset: -20 (= -5 hours, sint8 = 0xEC) + data = bytearray(b"\x12\x00\x00\x00\x00\x00\x00\x00\xec") + result = characteristic.parse_value(data) + assert result.is_utc is True + assert result.tz_dst_used is True + assert result.tz_dst_offset == -20 + + def test_1ms_resolution(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test decoding with 1 millisecond resolution.""" + # Flags: resolution 1ms (bits 2-3 = 10) = 0x08 + # Time: 3600000 = 0x36EE80 (one hour in ms) + # LE: 80 EE 36 00 00 00 + data = bytearray(b"\x08\x80\xee\x36\x00\x00\x00\x00\x00") + result = characteristic.parse_value(data) + assert result.time_resolution == TimeResolution.ONE_MILLISECOND + assert result.time_value == 3600000 + + def test_100us_resolution(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test decoding with 100 microsecond resolution.""" + # Flags: resolution 100µs (bits 2-3 = 11) = 0x0C + data = bytearray(b"\x0c\x01\x00\x00\x00\x00\x00\x00\x00") + result = characteristic.parse_value(data) + assert result.time_resolution == TimeResolution.HUNDRED_MICROSECONDS + assert result.time_value == 1 + + +class TestCurrentElapsedTimeEncode: + """Tests for Current Elapsed Time encoding.""" + + def test_encode_utc_time(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test encoding a UTC time value.""" + data = CurrentElapsedTimeData( + flags=ElapsedTimeFlags.UTC, + time_value=1000000, + time_resolution=TimeResolution.ONE_SECOND, + is_tick_counter=False, + is_utc=True, + tz_dst_used=False, + is_current_timeline=False, + sync_source_type=TimeSource.NETWORK_TIME_PROTOCOL, + tz_dst_offset=0, + ) + result = characteristic.build_value(data) + assert result == bytearray(b"\x02\x40\x42\x0f\x00\x00\x00\x01\x00") + + def test_encode_tick_counter(self, characteristic: CurrentElapsedTimeCharacteristic) -> None: + """Test encoding a tick counter with 100ms resolution.""" + data = CurrentElapsedTimeData( + flags=ElapsedTimeFlags.TICK_COUNTER, + time_value=500, + time_resolution=TimeResolution.HUNDRED_MILLISECONDS, + is_tick_counter=True, + is_utc=False, + tz_dst_used=False, + is_current_timeline=False, + sync_source_type=TimeSource.UNKNOWN, + tz_dst_offset=0, + ) + result = characteristic.build_value(data) + assert result == bytearray(b"\x05\xf4\x01\x00\x00\x00\x00\x00\x00") + + +class TestCurrentElapsedTimeRoundTrip: + """Round-trip tests for Current Elapsed Time.""" + + @pytest.mark.parametrize( + ("flags", "resolution", "time_val", "sync", "tz_dst"), + [ + (ElapsedTimeFlags.UTC, TimeResolution.ONE_SECOND, 0, TimeSource.UNKNOWN, 0), + (ElapsedTimeFlags.TICK_COUNTER, TimeResolution.HUNDRED_MILLISECONDS, 500, TimeSource.UNKNOWN, 0), + ( + ElapsedTimeFlags.UTC | ElapsedTimeFlags.TZ_DST_USED | ElapsedTimeFlags.CURRENT_TIMELINE, + TimeResolution.ONE_SECOND, + 86400, + TimeSource.MANUAL, + 8, + ), + ( + ElapsedTimeFlags.UTC | ElapsedTimeFlags.TZ_DST_USED, + TimeResolution.ONE_SECOND, + 0, + TimeSource.UNKNOWN, + -20, + ), + ], + ) + def test_round_trip( + self, + characteristic: CurrentElapsedTimeCharacteristic, + flags: ElapsedTimeFlags, + resolution: TimeResolution, + time_val: int, + sync: TimeSource, + tz_dst: int, + ) -> None: + """Test encode -> decode round-trip.""" + original = CurrentElapsedTimeData( + flags=flags, + time_value=time_val, + time_resolution=resolution, + is_tick_counter=bool(flags & ElapsedTimeFlags.TICK_COUNTER), + is_utc=bool(flags & ElapsedTimeFlags.UTC), + tz_dst_used=bool(flags & ElapsedTimeFlags.TZ_DST_USED), + is_current_timeline=bool(flags & ElapsedTimeFlags.CURRENT_TIMELINE), + sync_source_type=sync, + tz_dst_offset=tz_dst, + ) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_custom_characteristics.py b/tests/gatt/characteristics/test_custom_characteristics.py index 9cfbdc3e..2ef2d274 100644 --- a/tests/gatt/characteristics/test_custom_characteristics.py +++ b/tests/gatt/characteristics/test_custom_characteristics.py @@ -193,17 +193,17 @@ def _decode_value( def _encode_value(self, data: dict[str, bool]) -> bytearray: """Encode status flags dict to byte.""" byte = 0 - if data.get("powered_on", False): + if data.get("powered_on"): byte |= 0x01 - if data.get("charging", False): + if data.get("charging"): byte |= 0x02 - if data.get("low_battery", False): + if data.get("low_battery"): byte |= 0x04 - if data.get("error", False): + if data.get("error"): byte |= 0x08 - if data.get("bluetooth_connected", False): + if data.get("bluetooth_connected"): byte |= 0x10 - if data.get("wifi_connected", False): + if data.get("wifi_connected"): byte |= 0x20 return bytearray([byte]) diff --git a/tests/gatt/characteristics/test_enhanced_blood_pressure_measurement.py b/tests/gatt/characteristics/test_enhanced_blood_pressure_measurement.py new file mode 100644 index 00000000..9f3701f8 --- /dev/null +++ b/tests/gatt/characteristics/test_enhanced_blood_pressure_measurement.py @@ -0,0 +1,218 @@ +"""Tests for Enhanced Blood Pressure Measurement characteristic (0x2B34).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.blood_pressure_measurement import ( + BloodPressureMeasurementStatus, +) +from bluetooth_sig.gatt.characteristics.enhanced_blood_pressure_measurement import ( + EnhancedBloodPressureData, + EnhancedBloodPressureFlags, + EnhancedBloodPressureMeasurementCharacteristic, + EpochYear, +) +from bluetooth_sig.gatt.characteristics.utils import IEEE11073Parser +from bluetooth_sig.types.units import PressureUnit +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +def _sfloat_bytes(value: float) -> list[int]: + """Encode a float to SFLOAT and return as list of ints.""" + return list(IEEE11073Parser.encode_sfloat(value)) + + +class TestEnhancedBloodPressureMeasurementCharacteristic(CommonCharacteristicTests): + """Test Enhanced Blood Pressure Measurement characteristic.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide characteristic instance.""" + return EnhancedBloodPressureMeasurementCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID.""" + return "2B34" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data for Enhanced Blood Pressure Measurement.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x00, # flags: mmHg, no optional fields, epoch 1900 + *_sfloat_bytes(120.0), # systolic + *_sfloat_bytes(80.0), # diastolic + *_sfloat_bytes(100.0), # MAP + ] + ), + expected_value=EnhancedBloodPressureData( + flags=EnhancedBloodPressureFlags(0x00), + systolic=120.0, + diastolic=80.0, + mean_arterial_pressure=100.0, + unit=PressureUnit.MMHG, + epoch_year=EpochYear.EPOCH_1900, + ), + description="Minimal: mmHg, no optional fields", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x01, # flags: kPa + *_sfloat_bytes(16.0), # systolic kPa + *_sfloat_bytes(11.0), # diastolic kPa + *_sfloat_bytes(13.0), # MAP kPa + ] + ), + expected_value=EnhancedBloodPressureData( + flags=EnhancedBloodPressureFlags(0x01), + systolic=16.0, + diastolic=11.0, + mean_arterial_pressure=13.0, + unit=PressureUnit.KPA, + epoch_year=EpochYear.EPOCH_1900, + ), + description="kPa units, no optional fields", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x7E, # flags: all optional present + epoch 2000 + *_sfloat_bytes(120.0), + *_sfloat_bytes(80.0), + *_sfloat_bytes(100.0), + 0x80, + 0x51, + 0x01, + 0x00, # timestamp: 86400 seconds + *_sfloat_bytes(72.0), # pulse rate + 0x05, # user_id + 0x03, + 0x00, # measurement_status: 0x0003 + 0x00, + 0xA3, + 0x02, + 0x00, # user_facing_time: 172800 + ] + ), + expected_value=EnhancedBloodPressureData( + flags=EnhancedBloodPressureFlags(0x7E), + systolic=120.0, + diastolic=80.0, + mean_arterial_pressure=100.0, + unit=PressureUnit.MMHG, + timestamp=86400, + pulse_rate=72.0, + user_id=5, + measurement_status=BloodPressureMeasurementStatus(0x0003), + user_facing_time=172800, + epoch_year=EpochYear.EPOCH_2000, + ), + description="All optional fields present, epoch 2000", + ), + ] + + def test_timestamp_only(self) -> None: + """Test with only timestamp present.""" + char = EnhancedBloodPressureMeasurementCharacteristic() + data = bytearray( + [ + 0x02, # flags: timestamp present + *_sfloat_bytes(120.0), + *_sfloat_bytes(80.0), + *_sfloat_bytes(100.0), + 0xE8, + 0x03, + 0x00, + 0x00, # timestamp: 1000 + ] + ) + result = char.parse_value(data) + assert result.timestamp == 1000 + assert result.pulse_rate is None + assert result.user_facing_time is None + + def test_user_facing_time_only(self) -> None: + """Test with only user facing time present.""" + char = EnhancedBloodPressureMeasurementCharacteristic() + data = bytearray( + [ + 0x20, # flags: user facing time present + *_sfloat_bytes(120.0), + *_sfloat_bytes(80.0), + *_sfloat_bytes(100.0), + 0xD0, + 0x07, + 0x00, + 0x00, # user_facing_time: 2000 + ] + ) + result = char.parse_value(data) + assert result.timestamp is None + assert result.user_facing_time == 2000 + + def test_epoch_flag(self) -> None: + """Test epoch start flag interpretation.""" + char = EnhancedBloodPressureMeasurementCharacteristic() + # Epoch 1900 + data_1900 = bytearray([0x00, *_sfloat_bytes(120.0), *_sfloat_bytes(80.0), *_sfloat_bytes(100.0)]) + assert char.parse_value(data_1900).epoch_year == EpochYear.EPOCH_1900 + + # Epoch 2000 + data_2000 = bytearray([0x40, *_sfloat_bytes(120.0), *_sfloat_bytes(80.0), *_sfloat_bytes(100.0)]) + assert char.parse_value(data_2000).epoch_year == EpochYear.EPOCH_2000 + + def test_round_trip_all_fields(self) -> None: + """Test encode/decode round-trip with all fields.""" + char = EnhancedBloodPressureMeasurementCharacteristic() + original = EnhancedBloodPressureData( + flags=EnhancedBloodPressureFlags(0x7E), + systolic=120.0, + diastolic=80.0, + mean_arterial_pressure=100.0, + unit=PressureUnit.MMHG, + timestamp=86400, + pulse_rate=72.0, + user_id=5, + measurement_status=BloodPressureMeasurementStatus(0x0003), + user_facing_time=172800, + epoch_year=EpochYear.EPOCH_2000, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.systolic == original.systolic + assert decoded.diastolic == original.diastolic + assert decoded.mean_arterial_pressure == original.mean_arterial_pressure + assert decoded.unit == original.unit + assert decoded.timestamp == original.timestamp + assert decoded.pulse_rate == original.pulse_rate + assert decoded.user_id == original.user_id + assert decoded.measurement_status == original.measurement_status + assert decoded.user_facing_time == original.user_facing_time + assert decoded.epoch_year == original.epoch_year + + def test_round_trip_minimal(self) -> None: + """Test encode/decode round-trip with minimal fields.""" + char = EnhancedBloodPressureMeasurementCharacteristic() + original = EnhancedBloodPressureData( + flags=EnhancedBloodPressureFlags(0x00), + systolic=120.0, + diastolic=80.0, + mean_arterial_pressure=100.0, + unit=PressureUnit.MMHG, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.systolic == original.systolic + assert decoded.unit == original.unit + assert decoded.timestamp is None diff --git a/tests/gatt/characteristics/test_enhanced_intermediate_cuff_pressure.py b/tests/gatt/characteristics/test_enhanced_intermediate_cuff_pressure.py new file mode 100644 index 00000000..333e2c33 --- /dev/null +++ b/tests/gatt/characteristics/test_enhanced_intermediate_cuff_pressure.py @@ -0,0 +1,150 @@ +"""Tests for Enhanced Intermediate Cuff Pressure characteristic (0x2B35).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.blood_pressure_measurement import ( + BloodPressureMeasurementStatus, +) +from bluetooth_sig.gatt.characteristics.enhanced_blood_pressure_measurement import ( + EnhancedBloodPressureFlags, + EpochYear, +) +from bluetooth_sig.gatt.characteristics.enhanced_intermediate_cuff_pressure import ( + EnhancedIntermediateCuffPressureCharacteristic, + EnhancedIntermediateCuffPressureData, +) +from bluetooth_sig.gatt.characteristics.utils import IEEE11073Parser +from bluetooth_sig.types.units import PressureUnit +from tests.gatt.characteristics.test_characteristic_common import ( + CharacteristicTestData, + CommonCharacteristicTests, +) + + +def _sfloat_bytes(value: float) -> list[int]: + """Encode a float to SFLOAT and return as list of ints.""" + return list(IEEE11073Parser.encode_sfloat(value)) + + +class TestEnhancedIntermediateCuffPressureCharacteristic(CommonCharacteristicTests): + """Test Enhanced Intermediate Cuff Pressure characteristic.""" + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + """Provide characteristic instance.""" + return EnhancedIntermediateCuffPressureCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + """Expected UUID.""" + return "2B35" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + """Valid test data.""" + return [ + CharacteristicTestData( + input_data=bytearray( + [ + 0x00, # flags: mmHg, no optional fields + *_sfloat_bytes(150.0), # cuff_pressure + ] + ), + expected_value=EnhancedIntermediateCuffPressureData( + flags=EnhancedBloodPressureFlags(0x00), + cuff_pressure=150.0, + unit=PressureUnit.MMHG, + epoch_year=EpochYear.EPOCH_1900, + ), + description="Minimal: cuff pressure only in mmHg", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x01, # flags: kPa + *_sfloat_bytes(20.0), # cuff_pressure in kPa + ] + ), + expected_value=EnhancedIntermediateCuffPressureData( + flags=EnhancedBloodPressureFlags(0x01), + cuff_pressure=20.0, + unit=PressureUnit.KPA, + epoch_year=EpochYear.EPOCH_1900, + ), + description="kPa units", + ), + CharacteristicTestData( + input_data=bytearray( + [ + 0x7E, # flags: all optional present + epoch 2000 + *_sfloat_bytes(130.0), # cuff_pressure + 0xE8, + 0x03, + 0x00, + 0x00, # timestamp: 1000 + *_sfloat_bytes(72.0), # pulse rate + 0x01, # user_id + 0x05, + 0x00, # measurement_status + 0xD0, + 0x07, + 0x00, + 0x00, # user_facing_time: 2000 + ] + ), + expected_value=EnhancedIntermediateCuffPressureData( + flags=EnhancedBloodPressureFlags(0x7E), + cuff_pressure=130.0, + unit=PressureUnit.MMHG, + timestamp=1000, + pulse_rate=72.0, + user_id=1, + measurement_status=BloodPressureMeasurementStatus(0x0005), + user_facing_time=2000, + epoch_year=EpochYear.EPOCH_2000, + ), + description="All optional fields present, epoch 2000", + ), + ] + + def test_round_trip_all_fields(self) -> None: + """Test encode/decode round-trip with all fields.""" + char = EnhancedIntermediateCuffPressureCharacteristic() + original = EnhancedIntermediateCuffPressureData( + flags=EnhancedBloodPressureFlags(0x7E), + cuff_pressure=130.0, + unit=PressureUnit.MMHG, + timestamp=1000, + pulse_rate=72.0, + user_id=1, + measurement_status=BloodPressureMeasurementStatus(0x0005), + user_facing_time=2000, + epoch_year=EpochYear.EPOCH_2000, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.cuff_pressure == original.cuff_pressure + assert decoded.timestamp == original.timestamp + assert decoded.pulse_rate == original.pulse_rate + assert decoded.user_id == original.user_id + assert decoded.measurement_status == original.measurement_status + assert decoded.user_facing_time == original.user_facing_time + assert decoded.epoch_year == original.epoch_year + + def test_round_trip_minimal(self) -> None: + """Test encode/decode round-trip with minimal fields.""" + char = EnhancedIntermediateCuffPressureCharacteristic() + original = EnhancedIntermediateCuffPressureData( + flags=EnhancedBloodPressureFlags(0x00), + cuff_pressure=150.0, + unit=PressureUnit.MMHG, + ) + encoded = char.build_value(original) + decoded = char.parse_value(encoded) + assert decoded.cuff_pressure == original.cuff_pressure + assert decoded.timestamp is None diff --git a/tests/gatt/characteristics/test_ieee_11073_20601_regulatory.py b/tests/gatt/characteristics/test_ieee_11073_20601_regulatory.py new file mode 100644 index 00000000..232792c2 --- /dev/null +++ b/tests/gatt/characteristics/test_ieee_11073_20601_regulatory.py @@ -0,0 +1,79 @@ +"""Tests for IEEE 11073-20601 Regulatory Certification Data List.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics.ieee_11073_20601_regulatory_certification_data_list import ( + IEEE11073RegulatoryData, + IEEE1107320601RegulatoryCharacteristic, +) + + +@pytest.fixture +def characteristic() -> IEEE1107320601RegulatoryCharacteristic: + """Create an IEEE1107320601RegulatoryCharacteristic instance.""" + return IEEE1107320601RegulatoryCharacteristic() + + +class TestIEEE11073Decode: + """Tests for IEEE 11073-20601 regulatory data decoding.""" + + def test_single_byte(self, characteristic: IEEE1107320601RegulatoryCharacteristic) -> None: + """Test decoding a single byte of certification data.""" + data = bytearray(b"\x42") + result = characteristic.parse_value(data) + assert isinstance(result, IEEE11073RegulatoryData) + assert result.certification_data == b"\x42" + + def test_multi_byte(self, characteristic: IEEE1107320601RegulatoryCharacteristic) -> None: + """Test decoding multi-byte certification data.""" + raw = bytearray(b"\x01\x02\x03\x04\x05\x06\x07\x08") + result = characteristic.parse_value(raw) + assert result.certification_data == b"\x01\x02\x03\x04\x05\x06\x07\x08" + + def test_preserves_all_bytes(self, characteristic: IEEE1107320601RegulatoryCharacteristic) -> None: + """Test that all bytes are preserved including embedded nulls.""" + raw = bytearray(b"\x00\xff\x00\xff\x00") + result = characteristic.parse_value(raw) + assert result.certification_data == b"\x00\xff\x00\xff\x00" + + +class TestIEEE11073Encode: + """Tests for IEEE 11073-20601 regulatory data encoding.""" + + def test_encode_single_byte(self, characteristic: IEEE1107320601RegulatoryCharacteristic) -> None: + """Test encoding a single byte.""" + data = IEEE11073RegulatoryData(certification_data=b"\x42") + result = characteristic.build_value(data) + assert result == bytearray(b"\x42") + + def test_encode_multi_byte(self, characteristic: IEEE1107320601RegulatoryCharacteristic) -> None: + """Test encoding multi-byte data.""" + data = IEEE11073RegulatoryData(certification_data=b"\x01\x02\x03\x04") + result = characteristic.build_value(data) + assert result == bytearray(b"\x01\x02\x03\x04") + + +class TestIEEE11073RoundTrip: + """Round-trip tests for IEEE 11073-20601 regulatory data.""" + + @pytest.mark.parametrize( + "cert_data", + [ + b"\x01", + b"\x00\xff\x00\xff", + b"\xde\xad\xbe\xef\xca\xfe\xba\xbe", + bytes(range(256)), + ], + ) + def test_round_trip( + self, + characteristic: IEEE1107320601RegulatoryCharacteristic, + cert_data: bytes, + ) -> None: + """Test encode -> decode round-trip.""" + original = IEEE11073RegulatoryData(certification_data=cert_data) + encoded = characteristic.build_value(original) + decoded = characteristic.parse_value(encoded) + assert decoded == original diff --git a/tests/gatt/characteristics/test_indoor_bike_data.py b/tests/gatt/characteristics/test_indoor_bike_data.py new file mode 100644 index 00000000..4d91ffd8 --- /dev/null +++ b/tests/gatt/characteristics/test_indoor_bike_data.py @@ -0,0 +1,180 @@ +"""Tests for Indoor Bike Data characteristic.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.indoor_bike_data import ( + IndoorBikeData, + IndoorBikeDataCharacteristic, + IndoorBikeDataFlags, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestIndoorBikeDataCharacteristic(CommonCharacteristicTests): + """Tests for IndoorBikeDataCharacteristic.""" + + characteristic_cls = IndoorBikeDataCharacteristic + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + return IndoorBikeDataCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AD2" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + # Case 1: Flags only -- bit 0 set (MORE_DATA), all fields absent + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=IndoorBikeData( + flags=IndoorBikeDataFlags.MORE_DATA, + ), + description="Flags only, no optional fields", + ), + # Case 2: Speed present (bit 0 = 0) + Cadence (bit 2) + HR (bit 9) + # Flags = 0x0204, Speed raw=2500 -> 25.00 km/h, + # Cadence raw=180 -> 90.0 rpm, HR=155 + CharacteristicTestData( + input_data=bytearray( + [ + 0x04, + 0x02, # Flags = 0x0204 + 0xC4, + 0x09, # Speed raw = 2500 -> 25.00 + 0xB4, + 0x00, # Cadence raw = 180 -> 90.0 + 0x9B, # HR = 155 + ] + ), + expected_value=IndoorBikeData( + flags=IndoorBikeDataFlags(0x0204), + instantaneous_speed=25.0, + instantaneous_cadence=90.0, + heart_rate=155, + ), + description="Speed, cadence, and heart rate", + ), + # Case 3: All fields present + # Flags = 0x1FFE (bits 1-12 set, bit 0 clear -> speed present) + # Speed raw=3000 -> 30.00, Avg Speed raw=2800 -> 28.00 + # Cadence raw=160 -> 80.0, Avg Cadence raw=150 -> 75.0 + # Distance=10000 (uint24), Resistance raw=7 -> 70.0 + # Inst Power=200 (sint16), Avg Power=185 (sint16) + # Energy: total=800, /hr=400, /min=7 + # HR=145, MET raw=80 -> 8.0, Elapsed=5400, Remaining=600 + CharacteristicTestData( + input_data=bytearray( + [ + 0xFE, + 0x1F, # Flags = 0x1FFE + 0xB8, + 0x0B, # Speed raw = 3000 -> 30.00 + 0xF0, + 0x0A, # Avg speed raw = 2800 -> 28.00 + 0xA0, + 0x00, # Cadence raw = 160 -> 80.0 + 0x96, + 0x00, # Avg cadence raw = 150 -> 75.0 + 0x10, + 0x27, + 0x00, # Distance = 10000 (uint24) + 0x07, # Resistance raw = 7 -> 70.0 + 0xC8, + 0x00, # Inst power = 200 (sint16) + 0xB9, + 0x00, # Avg power = 185 (sint16) + 0x20, + 0x03, # Total energy = 800 + 0x90, + 0x01, # Energy/hr = 400 + 0x07, # Energy/min = 7 + 0x91, # HR = 145 + 0x50, # MET raw = 80 -> 8.0 + 0x18, + 0x15, # Elapsed = 5400 + 0x58, + 0x02, # Remaining = 600 + ] + ), + expected_value=IndoorBikeData( + flags=IndoorBikeDataFlags(0x1FFE), + instantaneous_speed=30.0, + average_speed=28.0, + instantaneous_cadence=80.0, + average_cadence=75.0, + total_distance=10000, + resistance_level=70.0, + instantaneous_power=200, + average_power=185, + total_energy=800, + energy_per_hour=400, + energy_per_minute=7, + heart_rate=145, + metabolic_equivalent=8.0, + elapsed_time=5400, + remaining_time=600, + ), + description="All fields present", + ), + ] + + def test_more_data_inverted_logic(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify bit 0 inversion: 0 -> Speed present, 1 -> absent.""" + # Bit 0 = 0: Speed present (raw=1500 -> 15.00 km/h) + data_with_speed = bytearray([0x00, 0x00, 0xDC, 0x05]) + result = characteristic.parse_value(data_with_speed) + assert result.instantaneous_speed == pytest.approx(15.0) + + # Bit 0 = 1: Speed absent + data_without_speed = bytearray([0x01, 0x00]) + result = characteristic.parse_value(data_without_speed) + assert result.instantaneous_speed is None + + def test_speed_hundredth_resolution(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify speed uses 0.01 km/h resolution (raw / 100).""" + # Bit 0 = 0: speed present, raw = 1234 -> 12.34 km/h + data = bytearray([0x00, 0x00, 0xD2, 0x04]) + result = characteristic.parse_value(data) + assert result.instantaneous_speed == pytest.approx(12.34) + + def test_cadence_half_resolution(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify cadence uses 0.5 rpm resolution (raw / 2).""" + # Flags = 0x0005 (bit 0 set + bit 2 -> no speed, cadence present) + # Cadence raw = 145 -> 72.5 rpm + data = bytearray([0x05, 0x00, 0x91, 0x00]) + result = characteristic.parse_value(data) + assert result.instantaneous_cadence == pytest.approx(72.5) + + def test_signed_power_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify power fields are signed (can be negative).""" + # Flags = 0x00C1 (bit 0 + bit 6 + bit 7 -> no speed, inst+avg power) + # Inst power = -50 (0xFFCE), Avg power = -25 (0xFFE7) + data = bytearray( + [ + 0xC1, + 0x00, # Flags + 0xCE, + 0xFF, # Inst power = -50 + 0xE7, + 0xFF, # Avg power = -25 + ] + ) + result = characteristic.parse_value(data) + assert result.instantaneous_power == -50 + assert result.average_power == -25 + + def test_resistance_level_scaling(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify resistance level is scaled by 10 (raw * 10).""" + # Flags = 0x0021 (bit 0 + bit 5 -> no speed, resistance present) + data = bytearray([0x21, 0x00, 0x0C]) # raw = 12 -> 120.0 + result = characteristic.parse_value(data) + assert result.resistance_level == pytest.approx(120.0) diff --git a/tests/gatt/characteristics/test_pm10_concentration.py b/tests/gatt/characteristics/test_pm10_concentration.py index eff4c1d4..4f6c549d 100644 --- a/tests/gatt/characteristics/test_pm10_concentration.py +++ b/tests/gatt/characteristics/test_pm10_concentration.py @@ -1,7 +1,9 @@ -"""Test PM10 concentration characteristic.""" +"""Test PM10 concentration characteristic parsing.""" from __future__ import annotations +import math + import pytest from bluetooth_sig.gatt.characteristics import PM10ConcentrationCharacteristic @@ -27,19 +29,41 @@ def valid_test_data(self) -> list[CharacteristicTestData]: """Valid PM10 concentration test data.""" return [ CharacteristicTestData( - input_data=bytearray([0x32, 0x00]), expected_value=50.0, description="50.0 µg/m³ PM10 concentration" + input_data=bytearray([0x32, 0x80]), # 50 in IEEE 11073 SFLOAT + expected_value=50.0, + description="50.0 kg/m\u00b3 PM10 concentration", ), CharacteristicTestData( - input_data=bytearray([0x64, 0x00]), expected_value=100.0, description="100.0 µg/m³ PM10 concentration" + input_data=bytearray([0x64, 0x80]), # 100 in IEEE 11073 SFLOAT + expected_value=100.0, + description="100.0 kg/m\u00b3 PM10 concentration", ), ] def test_pm10_concentration_parsing(self, characteristic: PM10ConcentrationCharacteristic) -> None: """Test PM10 concentration characteristic parsing.""" - # Test metadata - assert characteristic.unit == "µg/m³" + assert characteristic.unit == "kg/m\u00b3" + assert characteristic.python_type is float - # Test normal parsing - test_data = bytearray([0x32, 0x00]) # 50 µg/m³ little endian + test_data = bytearray([0x32, 0x80]) # mantissa=50, exponent=0 → 50.0 parsed = characteristic.parse_value(test_data) - assert parsed == 50 + assert isinstance(parsed, float) + assert parsed == 50.0 + + def test_pm10_concentration_special_values(self, characteristic: PM10ConcentrationCharacteristic) -> None: + """Test PM10 concentration special values per IEEE 11073 SFLOAT.""" + # Test NaN special value (0x07FF) + result = characteristic.parse_value(bytearray([0xFF, 0x07])) + assert math.isnan(result) + + # Test NRes special value (0x0800) + result = characteristic.parse_value(bytearray([0x00, 0x08])) + assert math.isnan(result) + + # Test positive infinity (0x07FE) + result = characteristic.parse_value(bytearray([0xFE, 0x07])) + assert math.isinf(result) and result > 0 + + # Test negative infinity (0x0802) + result = characteristic.parse_value(bytearray([0x02, 0x08])) + assert math.isinf(result) and result < 0 diff --git a/tests/gatt/characteristics/test_pm1_concentration.py b/tests/gatt/characteristics/test_pm1_concentration.py index 058da73c..ca876dbb 100644 --- a/tests/gatt/characteristics/test_pm1_concentration.py +++ b/tests/gatt/characteristics/test_pm1_concentration.py @@ -1,7 +1,9 @@ -"""Test PM1 concentration characteristic.""" +"""Test PM1 concentration characteristic parsing.""" from __future__ import annotations +import math + import pytest from bluetooth_sig.gatt.characteristics import PM1ConcentrationCharacteristic @@ -27,43 +29,42 @@ def valid_test_data(self) -> list[CharacteristicTestData]: """Valid PM1 concentration test data.""" return [ CharacteristicTestData( - input_data=bytearray([0x0A, 0x00]), expected_value=10.0, description="10.0 µg/m³ (good)" + input_data=bytearray([0x0A, 0x80]), # 10 in IEEE 11073 SFLOAT + expected_value=10.0, + description="10.0 kg/m\u00b3 PM1 concentration", ), CharacteristicTestData( - input_data=bytearray([0x32, 0x00]), expected_value=50.0, description="50.0 µg/m³ (moderate)" + input_data=bytearray([0x32, 0x80]), # 50 in IEEE 11073 SFLOAT + expected_value=50.0, + description="50.0 kg/m\u00b3 PM1 concentration", ), ] def test_pm1_concentration_parsing(self, characteristic: PM1ConcentrationCharacteristic) -> None: """Test PM1 concentration characteristic parsing.""" - # Test metadata - assert characteristic.unit == "µg/m³" + assert characteristic.unit == "kg/m\u00b3" + assert characteristic.python_type is float - # Test normal parsing - test_data = bytearray([0x32, 0x00]) # 50 µg/m³ little endian + # IEEE 11073 SFLOAT: exponent in top 4 bits, mantissa in lower 12 + test_data = bytearray([0x64, 0x80]) # mantissa=100, exponent=0 → 100.0 parsed = characteristic.parse_value(test_data) - assert parsed == 50 - - def test_pm1_concentration_boundary_values(self, characteristic: PM1ConcentrationCharacteristic) -> None: - """Test PM1 concentration boundary values.""" - # Clean air - data_min = bytearray([0x00, 0x00]) - assert characteristic.parse_value(data_min) == 0.0 - - # Maximum - data_max = bytearray([0xFF, 0xFF]) - assert characteristic.parse_value(data_max) == 65535.0 - - def test_pm1_concentration_air_quality_levels(self, characteristic: PM1ConcentrationCharacteristic) -> None: - """Test PM1 concentration air quality levels.""" - # Good (10 µg/m³) - data_good = bytearray([0x0A, 0x00]) - assert characteristic.parse_value(data_good) == 10.0 - - # Moderate (50 µg/m³) - data_moderate = bytearray([0x32, 0x00]) - assert characteristic.parse_value(data_moderate) == 50.0 - - # Unhealthy (150 µg/m³) - data_unhealthy = bytearray([0x96, 0x00]) - assert characteristic.parse_value(data_unhealthy) == 150.0 + assert isinstance(parsed, float) + assert parsed == 100.0 + + def test_pm1_concentration_special_values(self, characteristic: PM1ConcentrationCharacteristic) -> None: + """Test PM1 concentration special values per IEEE 11073 SFLOAT.""" + # Test NaN special value (0x07FF) + result = characteristic.parse_value(bytearray([0xFF, 0x07])) + assert math.isnan(result) + + # Test NRes special value (0x0800) + result = characteristic.parse_value(bytearray([0x00, 0x08])) + assert math.isnan(result) + + # Test positive infinity (0x07FE) + result = characteristic.parse_value(bytearray([0xFE, 0x07])) + assert math.isinf(result) and result > 0 + + # Test negative infinity (0x0802) + result = characteristic.parse_value(bytearray([0x02, 0x08])) + assert math.isinf(result) and result < 0 diff --git a/tests/gatt/characteristics/test_pm25_concentration.py b/tests/gatt/characteristics/test_pm25_concentration.py index bd76b33a..49c7ef5e 100644 --- a/tests/gatt/characteristics/test_pm25_concentration.py +++ b/tests/gatt/characteristics/test_pm25_concentration.py @@ -2,6 +2,8 @@ from __future__ import annotations +import math + import pytest from bluetooth_sig.gatt.characteristics import PM25ConcentrationCharacteristic @@ -26,19 +28,41 @@ def valid_test_data(self) -> list[CharacteristicTestData]: """Valid PM2.5 concentration test data.""" return [ CharacteristicTestData( - input_data=bytearray([0x19, 0x00]), expected_value=25, description="25 µg/m³ PM2.5 concentration" + input_data=bytearray([0x19, 0x80]), # 25 in IEEE 11073 SFLOAT + expected_value=25.0, + description="25.0 kg/m\u00b3 PM2.5 concentration", ), CharacteristicTestData( - input_data=bytearray([0x32, 0x00]), expected_value=50, description="50 µg/m³ PM2.5 concentration" + input_data=bytearray([0x32, 0x80]), # 50 in IEEE 11073 SFLOAT + expected_value=50.0, + description="50.0 kg/m\u00b3 PM2.5 concentration", ), ] def test_pm25_concentration_parsing(self, characteristic: PM25ConcentrationCharacteristic) -> None: """Test PM2.5 concentration characteristic parsing.""" - # Test metadata - assert characteristic.unit == "µg/m³" + assert characteristic.unit == "kg/m\u00b3" + assert characteristic.python_type is float - # Test normal parsing - test_data = bytearray([0x19, 0x00]) # 25 µg/m³ little endian + test_data = bytearray([0x19, 0x80]) # mantissa=25, exponent=0 → 25.0 parsed = characteristic.parse_value(test_data) - assert parsed == 25 + assert isinstance(parsed, float) + assert parsed == 25.0 + + def test_pm25_concentration_special_values(self, characteristic: PM25ConcentrationCharacteristic) -> None: + """Test PM2.5 concentration special values per IEEE 11073 SFLOAT.""" + # Test NaN special value (0x07FF) + result = characteristic.parse_value(bytearray([0xFF, 0x07])) + assert math.isnan(result) + + # Test NRes special value (0x0800) + result = characteristic.parse_value(bytearray([0x00, 0x08])) + assert math.isnan(result) + + # Test positive infinity (0x07FE) + result = characteristic.parse_value(bytearray([0xFE, 0x07])) + assert math.isinf(result) and result > 0 + + # Test negative infinity (0x0802) + result = characteristic.parse_value(bytearray([0x02, 0x08])) + assert math.isinf(result) and result < 0 diff --git a/tests/gatt/characteristics/test_rower_data.py b/tests/gatt/characteristics/test_rower_data.py new file mode 100644 index 00000000..da846473 --- /dev/null +++ b/tests/gatt/characteristics/test_rower_data.py @@ -0,0 +1,194 @@ +"""Tests for Rower Data characteristic.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.rower_data import ( + RowerData, + RowerDataCharacteristic, + RowerDataFlags, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestRowerDataCharacteristic(CommonCharacteristicTests): + """Tests for RowerDataCharacteristic.""" + + characteristic_cls = RowerDataCharacteristic + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + return RowerDataCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AD1" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + # Case 1: Flags only -- bit 0 set (MORE_DATA), all fields absent + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=RowerData( + flags=RowerDataFlags.MORE_DATA, + ), + description="Flags only, no optional fields", + ), + # Case 2: Stroke Rate + Stroke Count (bit 0 = 0) + HR (bit 9) + # Flags = 0x0200, Stroke Rate raw=60 -> 30.0, Count=150, HR=140 + CharacteristicTestData( + input_data=bytearray( + [ + 0x00, + 0x02, # Flags = 0x0200 + 0x3C, # Stroke rate raw = 60 -> 30.0 + 0x96, + 0x00, # Stroke count = 150 + 0x8C, # HR = 140 + ] + ), + expected_value=RowerData( + flags=RowerDataFlags.HEART_RATE_PRESENT, + stroke_rate=30.0, + stroke_count=150, + heart_rate=140, + ), + description="Stroke rate, count, and heart rate", + ), + # Case 3: All fields present + # Flags = 0x1FFE (bits 1-12 set, bit 0 clear -> stroke fields present) + # Stroke Rate raw=50 -> 25.0, Count=200 + # Avg Stroke Rate raw=48 -> 24.0 + # Total Distance=5000 (uint24), Inst Pace=120, Avg Pace=125 + # Inst Power=250 (sint16), Avg Power=240 (sint16) + # Resistance raw=5 -> 50.0 + # Energy: total=500, /hr=300, /min=5 + # HR=130, MET raw=65 -> 6.5, Elapsed=3600, Remaining=1200 + CharacteristicTestData( + input_data=bytearray( + [ + 0xFE, + 0x1F, # Flags = 0x1FFE + 0x32, # Stroke rate raw = 50 -> 25.0 + 0xC8, + 0x00, # Stroke count = 200 + 0x30, # Avg stroke rate raw = 48 -> 24.0 + 0x88, + 0x13, + 0x00, # Total distance = 5000 (uint24) + 0x78, + 0x00, # Inst pace = 120 + 0x7D, + 0x00, # Avg pace = 125 + 0xFA, + 0x00, # Inst power = 250 (sint16) + 0xF0, + 0x00, # Avg power = 240 (sint16) + 0x05, # Resistance raw = 5 -> 50.0 + 0xF4, + 0x01, # Total energy = 500 + 0x2C, + 0x01, # Energy/hr = 300 + 0x05, # Energy/min = 5 + 0x82, # HR = 130 + 0x41, # MET raw = 65 -> 6.5 + 0x10, + 0x0E, # Elapsed = 3600 + 0xB0, + 0x04, # Remaining = 1200 + ] + ), + expected_value=RowerData( + flags=RowerDataFlags(0x1FFE), + stroke_rate=25.0, + stroke_count=200, + average_stroke_rate=24.0, + total_distance=5000, + instantaneous_pace=120, + average_pace=125, + instantaneous_power=250, + average_power=240, + resistance_level=50.0, + total_energy=500, + energy_per_hour=300, + energy_per_minute=5, + heart_rate=130, + metabolic_equivalent=6.5, + elapsed_time=3600, + remaining_time=1200, + ), + description="All fields present", + ), + ] + + def test_more_data_inverted_logic(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify bit 0 inversion: 0 -> Stroke Rate+Count present, 1 -> absent.""" + # Bit 0 = 0: Stroke Rate + Count present + data_with_strokes = bytearray([0x00, 0x00, 0x3C, 0x0A, 0x00]) + result = characteristic.parse_value(data_with_strokes) + assert result.stroke_rate == 30.0 + assert result.stroke_count == 10 + + # Bit 0 = 1: Stroke fields absent + data_without_strokes = bytearray([0x01, 0x00]) + result = characteristic.parse_value(data_without_strokes) + assert result.stroke_rate is None + assert result.stroke_count is None + + def test_dual_field_bit0_gating(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify bit 0 gates both Stroke Rate and Stroke Count together.""" + # Bit 0 = 0 + bit 2 (total distance): stroke fields + distance + data = bytearray( + [ + 0x04, + 0x00, # Flags = 0x0004 (bit 2 set, bit 0 clear) + 0x14, # Stroke rate raw = 20 -> 10.0 + 0x64, + 0x00, # Stroke count = 100 + 0xE8, + 0x03, + 0x00, # Total distance = 1000 (uint24) + ] + ) + result = characteristic.parse_value(data) + assert result.stroke_rate == 10.0 + assert result.stroke_count == 100 + assert result.total_distance == 1000 + + def test_stroke_rate_half_resolution(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify stroke rate uses 0.5 resolution (raw / 2).""" + # Bit 0 = 0: stroke fields present, raw stroke rate = 45 -> 22.5 + data = bytearray([0x00, 0x00, 0x2D, 0x00, 0x00]) + result = characteristic.parse_value(data) + assert result.stroke_rate == pytest.approx(22.5) + + def test_signed_power_fields(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify power fields are signed (can be negative).""" + # Flags = 0x0061 (bit 0 + bit 5 + bit 6 -> no strokes, inst+avg power) + # Inst power = -100 (0xFF9C as signed), Avg power = -50 (0xFFCE) + data = bytearray( + [ + 0x61, + 0x00, # Flags + 0x9C, + 0xFF, # Inst power = -100 + 0xCE, + 0xFF, # Avg power = -50 + ] + ) + result = characteristic.parse_value(data) + assert result.instantaneous_power == -100 + assert result.average_power == -50 + + def test_resistance_level_scaling(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify resistance level is scaled by 10 (raw * 10).""" + # Flags = 0x0081 (bit 0 + bit 7 -> no strokes, resistance present) + data = bytearray([0x81, 0x00, 0x08]) # raw = 8 -> 80.0 + result = characteristic.parse_value(data) + assert result.resistance_level == pytest.approx(80.0) diff --git a/tests/gatt/characteristics/test_stair_climber_data.py b/tests/gatt/characteristics/test_stair_climber_data.py new file mode 100644 index 00000000..a22c36bf --- /dev/null +++ b/tests/gatt/characteristics/test_stair_climber_data.py @@ -0,0 +1,143 @@ +"""Tests for Stair Climber Data characteristic.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.stair_climber_data import ( + StairClimberData, + StairClimberDataCharacteristic, + StairClimberDataFlags, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestStairClimberDataCharacteristic(CommonCharacteristicTests): + """Tests for StairClimberDataCharacteristic.""" + + characteristic_cls = StairClimberDataCharacteristic + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + return StairClimberDataCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2AD0" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + # Case 1: Flags only — bit 0 set (MORE_DATA), all fields absent + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=StairClimberData( + flags=StairClimberDataFlags.MORE_DATA, + ), + description="Flags only, no optional fields", + ), + # Case 2: Floors present (bit 0 = 0) + Steps Per Minute (bit 1) + # Flags = 0x0002, Floors = 5, Steps/min = 30 + CharacteristicTestData( + input_data=bytearray([0x02, 0x00, 0x05, 0x00, 0x1E, 0x00]), + expected_value=StairClimberData( + flags=StairClimberDataFlags.STEPS_PER_MINUTE_PRESENT, + floors=5, + steps_per_minute=30, + ), + description="Floors and steps per minute", + ), + # Case 3: All fields present + # Flags = 0x03FE (bits 1-9 set, bit 0 clear → floors present) + # Floors=10, Steps/min=50, Avg=45, Elev=100, Stride=200 + # Energy: total=500, /hr=300, /min=5 + # HR=120, MET=5.0 (raw 50), Elapsed=1800, Remaining=600 + CharacteristicTestData( + input_data=bytearray( + [ + 0xFE, + 0x03, # Flags = 0x03FE + 0x0A, + 0x00, # Floors = 10 + 0x32, + 0x00, # Steps/min = 50 + 0x2D, + 0x00, # Avg step rate = 45 + 0x64, + 0x00, # Pos elevation = 100 + 0xC8, + 0x00, # Stride count = 200 + 0xF4, + 0x01, # Total energy = 500 + 0x2C, + 0x01, # Energy/hr = 300 + 0x05, # Energy/min = 5 + 0x78, # HR = 120 + 0x32, # MET = 50 → 5.0 + 0x08, + 0x07, # Elapsed = 1800 + 0x58, + 0x02, # Remaining = 600 + ] + ), + expected_value=StairClimberData( + flags=StairClimberDataFlags(0x03FE), + floors=10, + steps_per_minute=50, + average_step_rate=45, + positive_elevation_gain=100, + stride_count=200, + total_energy=500, + energy_per_hour=300, + energy_per_minute=5, + heart_rate=120, + metabolic_equivalent=5.0, + elapsed_time=1800, + remaining_time=600, + ), + description="All fields present", + ), + ] + + def test_more_data_inverted_logic(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify bit 0 inversion: 0 → Floors present, 1 → absent.""" + # Bit 0 = 0: Floors present + data_with_floors = bytearray([0x00, 0x00, 0x03, 0x00]) + result = characteristic.parse_value(data_with_floors) + assert result.floors == 3 + + # Bit 0 = 1: Floors absent + data_without_floors = bytearray([0x01, 0x00]) + result = characteristic.parse_value(data_without_floors) + assert result.floors is None + + def test_energy_triplet_gated_by_single_bit(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify bit 5 gates all three energy fields together.""" + # Flags = 0x0021 (bit 0 set + bit 5 set → no floors, energy present) + data = bytearray( + [ + 0x21, + 0x00, # Flags + 0x64, + 0x00, # Total energy = 100 + 0x32, + 0x00, # Energy/hr = 50 + 0x0A, # Energy/min = 10 + ] + ) + result = characteristic.parse_value(data) + assert result.floors is None + assert result.total_energy == 100 + assert result.energy_per_hour == 50 + assert result.energy_per_minute == 10 + + def test_metabolic_equivalent_scaling(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify MET field is scaled by 0.1 (raw / 10).""" + # Flags = 0x0081 (bit 0 + bit 7 → no floors, MET present) + data = bytearray([0x81, 0x00, 0x4B]) # raw MET = 75 → 7.5 + result = characteristic.parse_value(data) + assert result.metabolic_equivalent == pytest.approx(7.5) diff --git a/tests/gatt/characteristics/test_step_climber_data.py b/tests/gatt/characteristics/test_step_climber_data.py new file mode 100644 index 00000000..50b09dee --- /dev/null +++ b/tests/gatt/characteristics/test_step_climber_data.py @@ -0,0 +1,157 @@ +"""Tests for Step Climber Data characteristic.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.step_climber_data import ( + StepClimberData, + StepClimberDataCharacteristic, + StepClimberDataFlags, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestStepClimberDataCharacteristic(CommonCharacteristicTests): + """Tests for StepClimberDataCharacteristic.""" + + characteristic_cls = StepClimberDataCharacteristic + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + return StepClimberDataCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2ACF" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + # Case 1: Flags only — bit 0 set (MORE_DATA), all fields absent + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=StepClimberData( + flags=StepClimberDataFlags.MORE_DATA, + ), + description="Flags only, no optional fields", + ), + # Case 2: Floors + Step Count (bit 0 = 0) + HR (bit 5) + # Flags = 0x0020, Floors = 8, Step Count = 150, HR = 95 + CharacteristicTestData( + input_data=bytearray( + [ + 0x20, + 0x00, # Flags = 0x0020 + 0x08, + 0x00, # Floors = 8 + 0x96, + 0x00, # Step Count = 150 + 0x5F, # HR = 95 + ] + ), + expected_value=StepClimberData( + flags=StepClimberDataFlags.HEART_RATE_PRESENT, + floors=8, + step_count=150, + heart_rate=95, + ), + description="Floors, step count, and heart rate", + ), + # Case 3: All fields present + # Flags = 0x01FE (bits 1-8 set, bit 0 clear → floors + step count present) + # Floors=12, Steps=300, Steps/min=40, Avg=35, Elev=50 + # Energy: total=250, /hr=200, /min=3 + # HR=130, MET=6.5 (raw 65), Elapsed=900, Remaining=300 + CharacteristicTestData( + input_data=bytearray( + [ + 0xFE, + 0x01, # Flags = 0x01FE + 0x0C, + 0x00, # Floors = 12 + 0x2C, + 0x01, # Step Count = 300 + 0x28, + 0x00, # Steps/min = 40 + 0x23, + 0x00, # Avg step rate = 35 + 0x32, + 0x00, # Pos elevation = 50 + 0xFA, + 0x00, # Total energy = 250 + 0xC8, + 0x00, # Energy/hr = 200 + 0x03, # Energy/min = 3 + 0x82, # HR = 130 + 0x41, # MET = 65 → 6.5 + 0x84, + 0x03, # Elapsed = 900 + 0x2C, + 0x01, # Remaining = 300 + ] + ), + expected_value=StepClimberData( + flags=StepClimberDataFlags(0x01FE), + floors=12, + step_count=300, + steps_per_minute=40, + average_step_rate=35, + positive_elevation_gain=50, + total_energy=250, + energy_per_hour=200, + energy_per_minute=3, + heart_rate=130, + metabolic_equivalent=6.5, + elapsed_time=900, + remaining_time=300, + ), + description="All fields present", + ), + ] + + def test_more_data_inverted_logic(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify bit 0 inversion: 0 → Floors + Step Count present, 1 → absent.""" + # Bit 0 = 0: Floors + Step Count present + data_with = bytearray([0x00, 0x00, 0x03, 0x00, 0x64, 0x00]) + result = characteristic.parse_value(data_with) + assert result.floors == 3 + assert result.step_count == 100 + + # Bit 0 = 1: both absent + data_without = bytearray([0x01, 0x00]) + result = characteristic.parse_value(data_without) + assert result.floors is None + assert result.step_count is None + + def test_dual_field_bit0_gating(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify that bit 0 gates both Floors and Step Count together.""" + # Bit 0 = 0, bit 1 = 1 (steps per minute present) + # Floors = 2, Step Count = 50, Steps/min = 20 + data = bytearray( + [ + 0x02, + 0x00, # Flags = 0x0002 + 0x02, + 0x00, # Floors = 2 + 0x32, + 0x00, # Step Count = 50 + 0x14, + 0x00, # Steps/min = 20 + ] + ) + result = characteristic.parse_value(data) + assert result.floors == 2 + assert result.step_count == 50 + assert result.steps_per_minute == 20 + + def test_metabolic_equivalent_scaling(self, characteristic: BaseCharacteristic[Any]) -> None: + """Verify MET field is scaled by 0.1 (raw / 10).""" + # Flags = 0x0041 (bit 0 + bit 6 → no floors, MET present) + data = bytearray([0x41, 0x00, 0x50]) # raw MET = 80 → 8.0 + result = characteristic.parse_value(data) + assert result.metabolic_equivalent == pytest.approx(8.0) diff --git a/tests/gatt/characteristics/test_treadmill_data.py b/tests/gatt/characteristics/test_treadmill_data.py new file mode 100644 index 00000000..325e3c52 --- /dev/null +++ b/tests/gatt/characteristics/test_treadmill_data.py @@ -0,0 +1,263 @@ +"""Tests for Treadmill Data characteristic.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.treadmill_data import ( + TreadmillData, + TreadmillDataCharacteristic, + TreadmillDataFlags, +) + +from .test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests + + +class TestTreadmillDataCharacteristic(CommonCharacteristicTests): + """Tests for TreadmillDataCharacteristic.""" + + characteristic_cls = TreadmillDataCharacteristic + + @pytest.fixture + def characteristic(self) -> BaseCharacteristic[Any]: + return TreadmillDataCharacteristic() + + @pytest.fixture + def expected_uuid(self) -> str: + return "2ACD" + + @pytest.fixture + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + # Case 1: Flags only -- bit 0 set (MORE_DATA), all fields absent + CharacteristicTestData( + input_data=bytearray([0x01, 0x00]), + expected_value=TreadmillData( + flags=TreadmillDataFlags.MORE_DATA, + ), + description="Flags only, no optional fields", + ), + # Case 2: Speed (bit 0 = 0) + Average Speed (bit 1) + HR (bit 8) + # Flags = 0x0102, Speed raw=1250 -> 12.50 km/h, + # Avg Speed raw=1200 -> 12.00 km/h, HR=155 + CharacteristicTestData( + input_data=bytearray( + [ + 0x02, + 0x01, # Flags = 0x0102 + 0xE2, + 0x04, # Inst speed raw = 1250 -> 12.50 + 0xB0, + 0x04, # Avg speed raw = 1200 -> 12.00 + 0x9B, # HR = 155 + ] + ), + expected_value=TreadmillData( + flags=TreadmillDataFlags.AVERAGE_SPEED_PRESENT | TreadmillDataFlags.HEART_RATE_PRESENT, + instantaneous_speed=12.50, + average_speed=12.00, + heart_rate=155, + ), + description="Speed + Average Speed + Heart Rate", + ), + # Case 3: All fields present (comprehensive) + # Flags = 0x1FFE (bits 1-12 set, bit 0=0 for speed present) + # Speed raw=800 -> 8.00, Avg Speed raw=750 -> 7.50 + # Distance = 5000 (uint24), Incl raw=-50 -> -5.0%, Ramp raw=30 -> 3.0 deg + # Pos elev raw=100 -> 10.0m, Neg elev raw=50 -> 5.0m + # Inst pace = 300, Avg pace = 310 + # Energy: total=500, /hr=600, /min=10 + # HR=140, MET raw=85 -> 8.5, Elapsed=1800, Remaining=600 + # Force=-100, Power=250 + CharacteristicTestData( + input_data=bytearray( + [ + 0xFE, + 0x1F, # Flags = 0x1FFE + 0x20, + 0x03, # Speed raw = 800 -> 8.00 + 0xEE, + 0x02, # Avg speed raw = 750 -> 7.50 + 0x88, + 0x13, + 0x00, # Distance = 5000 + 0xCE, + 0xFF, # Incl raw = -50 -> -5.0 + 0x1E, + 0x00, # Ramp raw = 30 -> 3.0 + 0x64, + 0x00, # Pos elev raw = 100 -> 10.0 + 0x32, + 0x00, # Neg elev raw = 50 -> 5.0 + 0x2C, + 0x01, # Inst pace = 300 + 0x36, + 0x01, # Avg pace = 310 + 0xF4, + 0x01, # Total energy = 500 + 0x58, + 0x02, # Energy/hr = 600 + 0x0A, # Energy/min = 10 + 0x8C, # HR = 140 + 0x55, # MET raw = 85 -> 8.5 + 0x08, + 0x07, # Elapsed = 1800 + 0x58, + 0x02, # Remaining = 600 + 0x9C, + 0xFF, # Force = -100 (signed) + 0xFA, + 0x00, # Power = 250 (signed) + ] + ), + expected_value=TreadmillData( + flags=TreadmillDataFlags(0x1FFE), + instantaneous_speed=8.00, + average_speed=7.50, + total_distance=5000, + inclination=-5.0, + ramp_angle_setting=3.0, + positive_elevation_gain=10.0, + negative_elevation_gain=5.0, + instantaneous_pace=300, + average_pace=310, + total_energy=500, + energy_per_hour=600, + energy_per_minute=10, + heart_rate=140, + metabolic_equivalent=8.5, + elapsed_time=1800, + remaining_time=600, + force_on_belt=-100, + power_output=250, + ), + description="All fields present", + ), + ] + + def test_inverted_bit0_speed_present_when_bit_clear( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 0 = 0 means Instantaneous Speed IS present (inverted).""" + # Flags = 0x0000: bit 0 clear -> speed present + data = bytearray([0x00, 0x00, 0xE8, 0x03]) # Speed raw = 1000 -> 10.00 + result = characteristic.parse_value(data) + assert result.instantaneous_speed == 10.00 + + def test_inverted_bit0_speed_absent_when_bit_set( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 0 = 1 means Instantaneous Speed IS absent (inverted).""" + data = bytearray([0x01, 0x00]) # Flags = 0x0001: MORE_DATA set + result = characteristic.parse_value(data) + assert result.instantaneous_speed is None + + def test_inclination_and_ramp_dual_field_gating( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 3 gates both Inclination and Ramp Angle (4 bytes).""" + # Flags = 0x0009: bit 0=1 (no speed), bit 3=1 (incl+ramp) + # Incl raw=25 -> 2.5%, Ramp raw=-15 -> -1.5 deg + data = bytearray( + [ + 0x09, + 0x00, # Flags + 0x19, + 0x00, # Incl raw = 25 -> 2.5 + 0xF1, + 0xFF, # Ramp raw = -15 -> -1.5 + ] + ) + result = characteristic.parse_value(data) + assert result.instantaneous_speed is None + assert result.inclination == 2.5 + assert result.ramp_angle_setting == -1.5 + + def test_elevation_gain_dual_field_gating( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 4 gates both Positive and Negative Elevation Gain (4 bytes).""" + # Flags = 0x0011: bit 0=1 (no speed), bit 4=1 (elevation) + data = bytearray( + [ + 0x11, + 0x00, # Flags + 0xC8, + 0x00, # Pos elev raw = 200 -> 20.0 m + 0x96, + 0x00, # Neg elev raw = 150 -> 15.0 m + ] + ) + result = characteristic.parse_value(data) + assert result.positive_elevation_gain == 20.0 + assert result.negative_elevation_gain == 15.0 + + def test_force_and_power_dual_field_gating( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Bit 12 gates both Force On Belt and Power Output (4 bytes, signed).""" + # Flags = 0x1001: bit 0=1 (no speed), bit 12=1 (force+power) + # Force = -200 (sint16), Power = 350 (sint16) + data = bytearray( + [ + 0x01, + 0x10, # Flags = 0x1001 + 0x38, + 0xFF, # Force = -200 + 0x5E, + 0x01, # Power = 350 + ] + ) + result = characteristic.parse_value(data) + assert result.force_on_belt == -200 + assert result.power_output == 350 + + def test_speed_hundredths_scaling( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Speed fields use d=-2 scaling (raw/100 km/h).""" + # Flags = 0x0002: bit 0=0 (speed present), bit 1=1 (avg speed) + # Inst speed raw=1234 -> 12.34, Avg speed raw=1111 -> 11.11 + data = bytearray( + [ + 0x02, + 0x00, # Flags + 0xD2, + 0x04, # Inst speed raw = 1234 + 0x57, + 0x04, # Avg speed raw = 1111 + ] + ) + result = characteristic.parse_value(data) + assert result.instantaneous_speed == 12.34 + assert result.average_speed == 11.11 + + def test_negative_inclination( + self, + characteristic: BaseCharacteristic[Any], + ) -> None: + """Inclination can be negative (downhill).""" + # Flags = 0x0009: bit 0=1 (no speed), bit 3=1 (incl+ramp) + # Incl raw=-100 -> -10.0%, Ramp raw=0 -> 0.0 deg + data = bytearray( + [ + 0x09, + 0x00, # Flags + 0x9C, + 0xFF, # Incl raw = -100 -> -10.0 + 0x00, + 0x00, # Ramp raw = 0 -> 0.0 + ] + ) + result = characteristic.parse_value(data) + assert result.inclination == -10.0 + assert result.ramp_angle_setting == 0.0 diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py index 799dde8b..2df41244 100644 --- a/tests/integration/test_examples.py +++ b/tests/integration/test_examples.py @@ -223,7 +223,7 @@ def _fake_import( fromlist: tuple[str, ...] = (), level: int = 0, ) -> object: - root = name.split(".")[0] + root = name.split(".", maxsplit=1)[0] # Simulate missing optional back-ends if root in ("bleak", "bleak_retry_connector", "simplepyble", "simpleble"): raise ImportError(f"Simulated missing optional backend: {root}") @@ -248,7 +248,7 @@ def _fake_import( fromlist: tuple[str, ...] = (), level: int = 0, ) -> object: - root = name.split(".")[0] + root = name.split(".", maxsplit=1)[0] if root in ("bleak", "bleak_retry_connector"): raise ModuleNotFoundError(f"Simulated missing optional backend: {root}") return real_import(name, globals_, locals_, fromlist, level) @@ -273,7 +273,7 @@ def _fake_import( fromlist: tuple[str, ...] = (), level: int = 0, ) -> object: - root = name.split(".")[0] + root = name.split(".", maxsplit=1)[0] if root == "simplepyble": raise ModuleNotFoundError(f"Simulated missing optional backend: {root}") return real_import(name, globals_, locals_, fromlist, level) diff --git a/tests/registry/test_yaml_units.py b/tests/registry/test_yaml_units.py index e2df5584..d3f712d2 100644 --- a/tests/registry/test_yaml_units.py +++ b/tests/registry/test_yaml_units.py @@ -62,7 +62,7 @@ def test_manual_unit_priority(self) -> None: # Create instance and check manual unit takes precedence pm25_char = PM25ConcentrationCharacteristic() unit = pm25_char.unit - assert unit == "µg/m³", f"PM25 unit should be manual (µg/m³) from class definition, got {unit}" + assert unit == "kg/m³", f"PM25 unit should be manual (kg/m³) from class definition, got {unit}" def test_unknown_characteristic_unit(self) -> None: """Test behaviour with characteristics not in YAML.""" diff --git a/tests/stream/test_pairing.py b/tests/stream/test_pairing.py index e160d306..1ddb3463 100644 --- a/tests/stream/test_pairing.py +++ b/tests/stream/test_pairing.py @@ -286,7 +286,7 @@ def _make_ttl_buffer( translator=translator, required_uuids={temp_uuid, humid_uuid}, group_key=lambda _uuid, _parsed: "room-1", - on_pair=lambda results: paired.append(results), + on_pair=paired.append, max_age_seconds=max_age_seconds, clock=clock, ) @@ -370,7 +370,7 @@ def group_key(_uuid: str, _parsed: Any) -> str: translator=translator, required_uuids={temp_uuid, humid_uuid}, group_key=group_key, - on_pair=lambda r: paired.append(r), + on_pair=paired.append, max_age_seconds=10.0, clock=clock, ) @@ -463,7 +463,7 @@ def test_completed_increments_on_pair(self) -> None: translator=translator, required_uuids={temp_uuid, humid_uuid}, group_key=lambda _u, _p: "g", - on_pair=lambda r: paired.append(r), + on_pair=paired.append, ) buf.ingest(temp_uuid, bytes([0x0A, 0x00]))