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

146 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +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 Args: 

124 M: Multiplier factor 

125 d: Decimal exponent (10^d) 

126 b: Offset to add to raw value before scaling 

127 

128 Returns: 

129 ScaledTemplate instance 

130 

131 """ 

132 scale_factor = M * (10**d) 

133 return cls(scale_factor=scale_factor, offset=b) 

134 

135 

136class ScaledUint16Template(ScaledTemplate): 

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

138 

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

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

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

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

143 """ 

144 

145 _extractor = UINT16 

146 

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

148 """Initialize with scale factor and offset. 

149 

150 Args: 

151 scale_factor: Factor to multiply raw value by 

152 offset: Offset to add to raw value before scaling 

153 

154 """ 

155 super().__init__(scale_factor, offset) 

156 

157 @property 

158 def data_size(self) -> int: 

159 """Size: 2 bytes.""" 

160 return 2 

161 

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

163 """Check range for uint16.""" 

164 if not 0 <= raw <= UINT16_MAX: 

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

166 

167 

168class ScaledSint16Template(ScaledTemplate): 

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

170 

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

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

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

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

175 """ 

176 

177 _extractor = SINT16 

178 

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

180 """Initialize with scale factor and offset. 

181 

182 Args: 

183 scale_factor: Factor to multiply raw value by 

184 offset: Offset to add to raw value before scaling 

185 

186 """ 

187 super().__init__(scale_factor, offset) 

188 

189 @property 

190 def data_size(self) -> int: 

191 """Size: 2 bytes.""" 

192 return 2 

193 

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

195 """Check range for sint16.""" 

196 if not SINT16_MIN <= raw <= SINT16_MAX: 

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

198 

199 

200class ScaledSint8Template(ScaledTemplate): 

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

202 

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

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

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

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

207 """ 

208 

209 _extractor = SINT8 

210 

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

212 """Initialize with scale factor and offset. 

213 

214 Args: 

215 scale_factor: Factor to multiply raw value by 

216 offset: Offset to add to raw value before scaling 

217 

218 """ 

219 super().__init__(scale_factor, offset) 

220 

221 @property 

222 def data_size(self) -> int: 

223 """Size: 1 byte.""" 

224 return 1 

225 

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

227 """Check range for sint8.""" 

228 if not SINT8_MIN <= raw <= SINT8_MAX: 

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

230 

231 

232class ScaledUint8Template(ScaledTemplate): 

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

234 

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

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

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

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

239 """ 

240 

241 _extractor = UINT8 

242 

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

244 """Initialize with scale factor and offset. 

245 

246 Args: 

247 scale_factor: Factor to multiply raw value by 

248 offset: Offset to add to raw value before scaling 

249 

250 """ 

251 super().__init__(scale_factor, offset) 

252 

253 @property 

254 def data_size(self) -> int: 

255 """Size: 1 byte.""" 

256 return 1 

257 

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

259 """Check range for uint8.""" 

260 if not 0 <= raw <= UINT8_MAX: 

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

262 

263 

264class ScaledUint32Template(ScaledTemplate): 

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

266 

267 _extractor = UINT32 

268 

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

270 """Initialize with scale factor and offset. 

271 

272 Args: 

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

274 offset: Offset to add to raw value before scaling 

275 

276 """ 

277 super().__init__(scale_factor, offset) 

278 

279 @property 

280 def data_size(self) -> int: 

281 """Size: 4 bytes.""" 

282 return 4 

283 

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

285 """Check range for uint32.""" 

286 if not 0 <= raw <= UINT32_MAX: 

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

288 

289 

290class ScaledUint24Template(ScaledTemplate): 

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

292 

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

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

295 """ 

296 

297 _extractor = UINT24 

298 

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

300 """Initialize with scale factor and offset. 

301 

302 Args: 

303 scale_factor: Factor to multiply raw value by 

304 offset: Offset to add to raw value before scaling 

305 

306 """ 

307 super().__init__(scale_factor, offset) 

308 

309 @property 

310 def data_size(self) -> int: 

311 """Size: 3 bytes.""" 

312 return 3 

313 

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

315 """Check range for uint24.""" 

316 if not 0 <= raw <= UINT24_MAX: 

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

318 

319 

320class ScaledSint24Template(ScaledTemplate): 

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

322 

323 Used for signed values encoded in 3 bytes. 

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

325 """ 

326 

327 _extractor = SINT24 

328 

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

330 """Initialize with scale factor and offset. 

331 

332 Args: 

333 scale_factor: Factor to multiply raw value by 

334 offset: Offset to add to raw value before scaling 

335 

336 """ 

337 super().__init__(scale_factor, offset) 

338 

339 @property 

340 def data_size(self) -> int: 

341 """Size: 3 bytes.""" 

342 return 3 

343 

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

345 """Check range for sint24.""" 

346 if not SINT24_MIN <= raw <= SINT24_MAX: 

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

348 

349 

350class ScaledSint32Template(ScaledTemplate): 

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

352 

353 Used for signed values encoded in 4 bytes. 

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

355 """ 

356 

357 _extractor = SINT32 

358 

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

360 """Initialize with scale factor and offset. 

361 

362 Args: 

363 scale_factor: Factor to multiply raw value by 

364 offset: Offset to add to raw value before scaling 

365 

366 """ 

367 super().__init__(scale_factor, offset) 

368 

369 @property 

370 def data_size(self) -> int: 

371 """Size: 4 bytes.""" 

372 return 4 

373 

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

375 """Check range for sint32.""" 

376 sint32_min = -(2**31) 

377 sint32_max = (2**31) - 1 

378 if not sint32_min <= raw <= sint32_max: 

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

380 

381 

382class PercentageTemplate(CodingTemplate[int]): 

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

384 

385 @property 

386 def data_size(self) -> int: 

387 """Size: 1 byte.""" 

388 return 1 

389 

390 @property 

391 def extractor(self) -> RawExtractor: 

392 """Get uint8 extractor.""" 

393 return UINT8 

394 

395 @property 

396 def translator(self) -> IdentityTranslator: 

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

398 return IDENTITY 

399 

400 def decode_value( 

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

402 ) -> int: 

403 """Parse percentage value.""" 

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

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

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

407 # Only validate range if validation is enabled 

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

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

410 return self.translator.translate(value) 

411 

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

413 """Encode percentage value to bytes.""" 

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

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

416 raw = self.translator.untranslate(value) 

417 return self.extractor.pack(raw)