Coverage for src / bluetooth_sig / gatt / characteristics / templates / scaled.py: 85%

146 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-03 16:41 +0000

1"""Scaled value templates with configurable resolution and offset. 

2 

3Covers ScaledTemplate (abstract), ScaledUint8/16/24/32, ScaledSint8/16/24/32, 

4and PercentageTemplate. 

5""" 

6 

7from __future__ import annotations 

8 

9from abc import abstractmethod 

10 

11from ...constants import ( 

12 PERCENTAGE_MAX, 

13 SINT8_MAX, 

14 SINT8_MIN, 

15 SINT16_MAX, 

16 SINT16_MIN, 

17 SINT24_MAX, 

18 SINT24_MIN, 

19 UINT8_MAX, 

20 UINT16_MAX, 

21 UINT24_MAX, 

22 UINT32_MAX, 

23) 

24from ...context import CharacteristicContext 

25from ...exceptions import InsufficientDataError, ValueRangeError 

26from ..utils.extractors import ( 

27 SINT8, 

28 SINT16, 

29 SINT24, 

30 SINT32, 

31 UINT8, 

32 UINT16, 

33 UINT24, 

34 UINT32, 

35 RawExtractor, 

36) 

37from ..utils.translators import ( 

38 IDENTITY, 

39 IdentityTranslator, 

40 LinearTranslator, 

41) 

42from .base import CodingTemplate 

43 

44 

45class ScaledTemplate(CodingTemplate[float]): 

46 """Base class for scaled integer templates. 

47 

48 Handles common scaling logic: value = (raw + offset) * scale_factor 

49 Subclasses implement raw parsing/encoding and range checking. 

50 

51 Exposes `extractor` and `translator` for pipeline access. 

52 """ 

53 

54 _extractor: RawExtractor 

55 _translator: LinearTranslator 

56 

57 def __init__(self, scale_factor: float, offset: int) -> None: 

58 """Initialize with scale factor and offset. 

59 

60 Args: 

61 scale_factor: Factor to multiply raw value by 

62 offset: Offset to add to raw value before scaling 

63 

64 """ 

65 self._translator = LinearTranslator(scale_factor=scale_factor, offset=offset) 

66 

67 @property 

68 def scale_factor(self) -> float: 

69 """Get the scale factor.""" 

70 return self._translator.scale_factor 

71 

72 @property 

73 def offset(self) -> int: 

74 """Get the offset.""" 

75 return self._translator.offset 

76 

77 @property 

78 def extractor(self) -> RawExtractor: 

79 """Get the byte extractor for pipeline access.""" 

80 return self._extractor 

81 

82 @property 

83 def translator(self) -> LinearTranslator: 

84 """Get the value translator for pipeline access.""" 

85 return self._translator 

86 

87 def decode_value( 

88 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True 

89 ) -> float: 

90 """Parse scaled integer value.""" 

91 raw_value = self._extractor.extract(data, offset) 

92 return self._translator.translate(raw_value) 

93 

94 def encode_value(self, value: float, *, validate: bool = True) -> bytearray: 

95 """Encode scaled value to bytes.""" 

96 raw_value = self._translator.untranslate(value) 

97 if validate: 

98 self._check_range(raw_value) 

99 return self._extractor.pack(raw_value) 

100 

101 @abstractmethod 

102 def _check_range(self, raw: int) -> None: 

103 """Check if raw value is in valid range.""" 

104 

105 @classmethod 

106 def from_scale_offset(cls, scale_factor: float, offset: int) -> ScaledTemplate: 

107 """Create instance using scale factor and offset. 

108 

109 Args: 

110 scale_factor: Factor to multiply raw value by 

111 offset: Offset to add to raw value before scaling 

112 

113 Returns: 

114 ScaledTemplate instance 

115 

116 """ 

117 return cls(scale_factor=scale_factor, offset=offset) 

118 

119 @classmethod 

120 def from_letter_method(cls, M: int, d: int, b: int) -> ScaledTemplate: # noqa: N803 

121 """Create instance using Bluetooth SIG M, d, b parameters. 

122 

123 The GSS representation formula is: value = raw * M * 10^d * 2^b 

124 

125 Args: 

126 M: Multiplier factor 

127 d: Decimal exponent (10^d) 

128 b: Binary exponent (2^b) 

129 

130 Returns: 

131 ScaledTemplate instance 

132 

133 """ 

134 scale_factor = M * (10**d) * (2**b) 

135 return cls(scale_factor=scale_factor, offset=0) 

136 

137 

138class ScaledUint16Template(ScaledTemplate): 

139 """Template for scaled 16-bit unsigned integer. 

140 

141 Used for values that need decimal precision encoded as integers. 

142 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. 

143 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) 

144 Example: Temperature 25.5°C stored as 2550 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 

145 """ 

146 

147 _extractor = UINT16 

148 

149 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

150 """Initialize with scale factor and offset. 

151 

152 Args: 

153 scale_factor: Factor to multiply raw value by 

154 offset: Offset to add to raw value before scaling 

155 

156 """ 

157 super().__init__(scale_factor, offset) 

158 

159 @property 

160 def data_size(self) -> int: 

161 """Size: 2 bytes.""" 

162 return 2 

163 

164 def _check_range(self, raw: int) -> None: 

165 """Check range for uint16.""" 

166 if not 0 <= raw <= UINT16_MAX: 

167 raise ValueError(f"Scaled value {raw} out of range for uint16") 

168 

169 

170class ScaledSint16Template(ScaledTemplate): 

171 """Template for scaled 16-bit signed integer. 

172 

173 Used for signed values that need decimal precision encoded as integers. 

174 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. 

175 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) 

176 Example: Temperature -10.5°C stored as -1050 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 

177 """ 

178 

179 _extractor = SINT16 

180 

181 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: 

182 """Initialize with scale factor and offset. 

183 

184 Args: 

185 scale_factor: Factor to multiply raw value by 

186 offset: Offset to add to raw value before scaling 

187 

188 """ 

189 super().__init__(scale_factor, offset) 

190 

191 @property 

192 def data_size(self) -> int: 

193 """Size: 2 bytes.""" 

194 return 2 

195 

196 def _check_range(self, raw: int) -> None: 

197 """Check range for sint16.""" 

198 if not SINT16_MIN <= raw <= SINT16_MAX: 

199 raise ValueError(f"Scaled value {raw} out of range for sint16") 

200 

201 

202class ScaledSint8Template(ScaledTemplate): 

203 """Template for scaled 8-bit signed integer. 

204 

205 Used for signed values that need decimal precision encoded as integers. 

206 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. 

207 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) 

208 Example: Temperature with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 

209 """ 

210 

211 _extractor = SINT8 

212 

213 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

214 """Initialize with scale factor and offset. 

215 

216 Args: 

217 scale_factor: Factor to multiply raw value by 

218 offset: Offset to add to raw value before scaling 

219 

220 """ 

221 super().__init__(scale_factor, offset) 

222 

223 @property 

224 def data_size(self) -> int: 

225 """Size: 1 byte.""" 

226 return 1 

227 

228 def _check_range(self, raw: int) -> None: 

229 """Check range for sint8.""" 

230 if not SINT8_MIN <= raw <= SINT8_MAX: 

231 raise ValueError(f"Scaled value {raw} out of range for sint8") 

232 

233 

234class ScaledUint8Template(ScaledTemplate): 

235 """Template for scaled 8-bit unsigned integer. 

236 

237 Used for unsigned values that need decimal precision encoded as integers. 

238 Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. 

239 Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) 

240 Example: Uncertainty with scale_factor=0.1, offset=0 or M=1, d=-1, b=0 

241 """ 

242 

243 _extractor = UINT8 

244 

245 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

246 """Initialize with scale factor and offset. 

247 

248 Args: 

249 scale_factor: Factor to multiply raw value by 

250 offset: Offset to add to raw value before scaling 

251 

252 """ 

253 super().__init__(scale_factor, offset) 

254 

255 @property 

256 def data_size(self) -> int: 

257 """Size: 1 byte.""" 

258 return 1 

259 

260 def _check_range(self, raw: int) -> None: 

261 """Check range for uint8.""" 

262 if not 0 <= raw <= UINT8_MAX: 

263 raise ValueError(f"Scaled value {raw} out of range for uint8") 

264 

265 

266class ScaledUint32Template(ScaledTemplate): 

267 """Template for scaled 32-bit unsigned integer with configurable resolution and offset.""" 

268 

269 _extractor = UINT32 

270 

271 def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None: 

272 """Initialize with scale factor and offset. 

273 

274 Args: 

275 scale_factor: Factor to multiply raw value by (e.g., 0.1 for 1 decimal place) 

276 offset: Offset to add to raw value before scaling 

277 

278 """ 

279 super().__init__(scale_factor, offset) 

280 

281 @property 

282 def data_size(self) -> int: 

283 """Size: 4 bytes.""" 

284 return 4 

285 

286 def _check_range(self, raw: int) -> None: 

287 """Check range for uint32.""" 

288 if not 0 <= raw <= UINT32_MAX: 

289 raise ValueError(f"Scaled value {raw} out of range for uint32") 

290 

291 

292class ScaledUint24Template(ScaledTemplate): 

293 """Template for scaled 24-bit unsigned integer with configurable resolution and offset. 

294 

295 Used for values encoded in 3 bytes as unsigned integers. 

296 Example: Illuminance 1000 lux stored as bytes with scale_factor=1.0, offset=0 

297 """ 

298 

299 _extractor = UINT24 

300 

301 def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: 

302 """Initialize with scale factor and offset. 

303 

304 Args: 

305 scale_factor: Factor to multiply raw value by 

306 offset: Offset to add to raw value before scaling 

307 

308 """ 

309 super().__init__(scale_factor, offset) 

310 

311 @property 

312 def data_size(self) -> int: 

313 """Size: 3 bytes.""" 

314 return 3 

315 

316 def _check_range(self, raw: int) -> None: 

317 """Check range for uint24.""" 

318 if not 0 <= raw <= UINT24_MAX: 

319 raise ValueError(f"Scaled value {raw} out of range for uint24") 

320 

321 

322class ScaledSint24Template(ScaledTemplate): 

323 """Template for scaled 24-bit signed integer with configurable resolution and offset. 

324 

325 Used for signed values encoded in 3 bytes. 

326 Example: Elevation 500.00m stored as bytes with scale_factor=0.01, offset=0 

327 """ 

328 

329 _extractor = SINT24 

330 

331 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: 

332 """Initialize with scale factor and offset. 

333 

334 Args: 

335 scale_factor: Factor to multiply raw value by 

336 offset: Offset to add to raw value before scaling 

337 

338 """ 

339 super().__init__(scale_factor, offset) 

340 

341 @property 

342 def data_size(self) -> int: 

343 """Size: 3 bytes.""" 

344 return 3 

345 

346 def _check_range(self, raw: int) -> None: 

347 """Check range for sint24.""" 

348 if not SINT24_MIN <= raw <= SINT24_MAX: 

349 raise ValueError(f"Scaled value {raw} out of range for sint24") 

350 

351 

352class ScaledSint32Template(ScaledTemplate): 

353 """Template for scaled 32-bit signed integer with configurable resolution and offset. 

354 

355 Used for signed values encoded in 4 bytes. 

356 Example: Longitude -180.0 to 180.0 degrees stored with scale_factor=1e-7 

357 """ 

358 

359 _extractor = SINT32 

360 

361 def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: 

362 """Initialize with scale factor and offset. 

363 

364 Args: 

365 scale_factor: Factor to multiply raw value by 

366 offset: Offset to add to raw value before scaling 

367 

368 """ 

369 super().__init__(scale_factor, offset) 

370 

371 @property 

372 def data_size(self) -> int: 

373 """Size: 4 bytes.""" 

374 return 4 

375 

376 def _check_range(self, raw: int) -> None: 

377 """Check range for sint32.""" 

378 sint32_min = -(2**31) 

379 sint32_max = (2**31) - 1 

380 if not sint32_min <= raw <= sint32_max: 

381 raise ValueError(f"Scaled value {raw} out of range for sint32") 

382 

383 

384class PercentageTemplate(CodingTemplate[int]): 

385 """Template for percentage values (0-100%) using uint8.""" 

386 

387 @property 

388 def data_size(self) -> int: 

389 """Size: 1 byte.""" 

390 return 1 

391 

392 @property 

393 def extractor(self) -> RawExtractor: 

394 """Get uint8 extractor.""" 

395 return UINT8 

396 

397 @property 

398 def translator(self) -> IdentityTranslator: 

399 """Return identity translator since validation is separate from translation.""" 

400 return IDENTITY 

401 

402 def decode_value( 

403 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True 

404 ) -> int: 

405 """Parse percentage value.""" 

406 if validate and len(data) < offset + 1: 

407 raise InsufficientDataError("percentage", data[offset:], 1) 

408 value = self.extractor.extract(data, offset) 

409 # Only validate range if validation is enabled 

410 if validate and not 0 <= value <= PERCENTAGE_MAX: 

411 raise ValueRangeError("percentage", value, 0, PERCENTAGE_MAX) 

412 return self.translator.translate(value) 

413 

414 def encode_value(self, value: int, *, validate: bool = True) -> bytearray: 

415 """Encode percentage value to bytes.""" 

416 if validate and not 0 <= value <= PERCENTAGE_MAX: 

417 raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})") 

418 raw = self.translator.untranslate(value) 

419 return self.extractor.pack(raw)