Coverage for src / bluetooth_sig / types / advertising / indoor_positioning.py: 100%
64 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Indoor Positioning advertisement data (AD 0x25, CSS Part A §1.14).
3Decodes the Indoor Positioning AD type whose configuration byte drives
4which optional coordinate, power and altitude fields are present.
5"""
7from __future__ import annotations
9from enum import IntFlag
11import msgspec
13from bluetooth_sig.gatt.characteristics.utils import DataParser
16class IndoorPositioningConfig(IntFlag):
17 """Configuration byte flags for Indoor Positioning AD (CSS Part A §1.14).
19 The configuration byte is the first octet of the AD data and determines
20 which optional fields are present in the payload.
21 """
23 COORDINATE_SYSTEM_LOCAL = 0x01 # 0 = WGS84, 1 = local
24 LATITUDE_PRESENT = 0x02
25 LONGITUDE_PRESENT = 0x04
26 LOCAL_NORTH_PRESENT = 0x08
27 LOCAL_EAST_PRESENT = 0x10
28 TX_POWER_PRESENT = 0x20
29 FLOOR_NUMBER_PRESENT = 0x40
30 ALTITUDE_PRESENT = 0x80
33# Mask combining all location-bearing flag bits (used for uncertainty check)
34LOCATION_FLAGS_MASK = (
35 IndoorPositioningConfig.LATITUDE_PRESENT
36 | IndoorPositioningConfig.LONGITUDE_PRESENT
37 | IndoorPositioningConfig.LOCAL_NORTH_PRESENT
38 | IndoorPositioningConfig.LOCAL_EAST_PRESENT
39)
42class IndoorPositioningData(msgspec.Struct, frozen=True, kw_only=True):
43 """Parsed Indoor Positioning AD data (CSS Part A, §1.14).
45 The configuration byte determines which optional fields are present.
46 When ``is_local_coordinates`` is ``False``, WGS84 latitude/longitude
47 fields are used; when ``True``, local north/east fields are used.
49 Attributes:
50 config: Raw configuration flags for reference.
51 is_local_coordinates: ``True`` for local coordinate system, ``False`` for WGS84.
52 latitude: WGS84 latitude in units of 1e-7 degrees (present when bit 1 set, WGS84 mode).
53 longitude: WGS84 longitude in units of 1e-7 degrees (present when bit 2 set, WGS84 mode).
54 local_north: Local north coordinate in 0.01 m units (present when bit 3 set, local mode).
55 local_east: Local east coordinate in 0.01 m units (present when bit 4 set, local mode).
56 tx_power: Transmit power level in dBm.
57 floor_number: Floor number (offset by -20, so 0 means floor -20).
58 altitude: Altitude in 0.01 m units (interpretation depends on coordinate system).
59 uncertainty: Location uncertainty — bit 7 is stationary flag, bits 0-6 encode precision.
61 """
63 config: IndoorPositioningConfig = IndoorPositioningConfig(0)
64 is_local_coordinates: bool = False
65 latitude: int | None = None
66 longitude: int | None = None
67 local_north: int | None = None
68 local_east: int | None = None
69 tx_power: int | None = None
70 floor_number: int | None = None
71 altitude: int | None = None
72 uncertainty: int | None = None
74 @classmethod
75 def decode(cls, data: bytes | bytearray) -> IndoorPositioningData:
76 """Decode Indoor Positioning AD data.
78 DataParser raises ``InsufficientDataError`` automatically if the
79 payload is truncated mid-field. Optional trailing fields use
80 ``len(data) >= offset + N`` guards (same pattern as Heart Rate
81 Measurement for optional fields).
83 Args:
84 data: Raw AD data bytes (excluding length and AD type).
86 Returns:
87 Parsed IndoorPositioningData.
89 """
90 config = IndoorPositioningConfig(DataParser.parse_int8(data, 0, signed=False))
91 offset = 1
93 is_local = bool(config & IndoorPositioningConfig.COORDINATE_SYSTEM_LOCAL)
94 latitude: int | None = None
95 longitude: int | None = None
96 local_north: int | None = None
97 local_east: int | None = None
98 tx_power: int | None = None
99 floor_number: int | None = None
100 altitude: int | None = None
101 uncertainty: int | None = None
103 if not is_local:
104 if config & IndoorPositioningConfig.LATITUDE_PRESENT:
105 latitude = DataParser.parse_int32(data, offset, signed=True)
106 offset += 4
108 if config & IndoorPositioningConfig.LONGITUDE_PRESENT:
109 longitude = DataParser.parse_int32(data, offset, signed=True)
110 offset += 4
111 else:
112 if config & IndoorPositioningConfig.LOCAL_NORTH_PRESENT:
113 local_north = DataParser.parse_int16(data, offset, signed=True)
114 offset += 2
116 if config & IndoorPositioningConfig.LOCAL_EAST_PRESENT:
117 local_east = DataParser.parse_int16(data, offset, signed=True)
118 offset += 2
120 if config & IndoorPositioningConfig.TX_POWER_PRESENT:
121 tx_power = DataParser.parse_int8(data, offset, signed=True)
122 offset += 1
124 if config & IndoorPositioningConfig.FLOOR_NUMBER_PRESENT:
125 floor_number = DataParser.parse_int8(data, offset, signed=False)
126 offset += 1
128 if config & IndoorPositioningConfig.ALTITUDE_PRESENT:
129 altitude = DataParser.parse_int16(data, offset, signed=False)
130 offset += 2
132 # Uncertainty is optional — present only when any location field exists
133 if (config & LOCATION_FLAGS_MASK) and len(data) >= offset + 1:
134 uncertainty = DataParser.parse_int8(data, offset, signed=False)
136 return cls(
137 config=config,
138 is_local_coordinates=is_local,
139 latitude=latitude,
140 longitude=longitude,
141 local_north=local_north,
142 local_east=local_east,
143 tx_power=tx_power,
144 floor_number=floor_number,
145 altitude=altitude,
146 uncertainty=uncertainty,
147 )
150__all__ = [
151 "IndoorPositioningConfig",
152 "IndoorPositioningData",
153 "LOCATION_FLAGS_MASK",
154]