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

1"""Indoor Positioning advertisement data (AD 0x25, CSS Part A §1.14). 

2 

3Decodes the Indoor Positioning AD type whose configuration byte drives 

4which optional coordinate, power and altitude fields are present. 

5""" 

6 

7from __future__ import annotations 

8 

9from enum import IntFlag 

10 

11import msgspec 

12 

13from bluetooth_sig.gatt.characteristics.utils import DataParser 

14 

15 

16class IndoorPositioningConfig(IntFlag): 

17 """Configuration byte flags for Indoor Positioning AD (CSS Part A §1.14). 

18 

19 The configuration byte is the first octet of the AD data and determines 

20 which optional fields are present in the payload. 

21 """ 

22 

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 

31 

32 

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) 

40 

41 

42class IndoorPositioningData(msgspec.Struct, frozen=True, kw_only=True): 

43 """Parsed Indoor Positioning AD data (CSS Part A, §1.14). 

44 

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. 

48 

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. 

60 

61 """ 

62 

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 

73 

74 @classmethod 

75 def decode(cls, data: bytes | bytearray) -> IndoorPositioningData: 

76 """Decode Indoor Positioning AD data. 

77 

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). 

82 

83 Args: 

84 data: Raw AD data bytes (excluding length and AD type). 

85 

86 Returns: 

87 Parsed IndoorPositioningData. 

88 

89 """ 

90 config = IndoorPositioningConfig(DataParser.parse_int8(data, 0, signed=False)) 

91 offset = 1 

92 

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 

102 

103 if not is_local: 

104 if config & IndoorPositioningConfig.LATITUDE_PRESENT: 

105 latitude = DataParser.parse_int32(data, offset, signed=True) 

106 offset += 4 

107 

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 

115 

116 if config & IndoorPositioningConfig.LOCAL_EAST_PRESENT: 

117 local_east = DataParser.parse_int16(data, offset, signed=True) 

118 offset += 2 

119 

120 if config & IndoorPositioningConfig.TX_POWER_PRESENT: 

121 tx_power = DataParser.parse_int8(data, offset, signed=True) 

122 offset += 1 

123 

124 if config & IndoorPositioningConfig.FLOOR_NUMBER_PRESENT: 

125 floor_number = DataParser.parse_int8(data, offset, signed=False) 

126 offset += 1 

127 

128 if config & IndoorPositioningConfig.ALTITUDE_PRESENT: 

129 altitude = DataParser.parse_int16(data, offset, signed=False) 

130 offset += 2 

131 

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) 

135 

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 ) 

148 

149 

150__all__ = [ 

151 "IndoorPositioningConfig", 

152 "IndoorPositioningData", 

153 "LOCATION_FLAGS_MASK", 

154]