aioacaia 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aioacaia/__init__.py +1 -0
- aioacaia/acaiascale.py +406 -0
- aioacaia/const.py +19 -0
- aioacaia/decode.py +189 -0
- aioacaia/exceptions.py +19 -0
- aioacaia/helpers.py +152 -0
- aioacaia/py.typed +0 -0
- aioacaia-0.1.2.dist-info/LICENSE +674 -0
- aioacaia-0.1.2.dist-info/METADATA +57 -0
- aioacaia-0.1.2.dist-info/RECORD +12 -0
- aioacaia-0.1.2.dist-info/WHEEL +5 -0
- aioacaia-0.1.2.dist-info/top_level.txt +1 -0
aioacaia/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .acaiascale import AcaiaScale
|
aioacaia/acaiascale.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Client to interact with Acaia scales."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from bleak import BleakClient, BleakGATTCharacteristic, BLEDevice
|
|
14
|
+
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
|
15
|
+
|
|
16
|
+
from .const import (
|
|
17
|
+
DEFAULT_CHAR_ID,
|
|
18
|
+
HEARTBEAT_INTERVAL,
|
|
19
|
+
NOTIFY_CHAR_ID,
|
|
20
|
+
OLD_STYLE_CHAR_ID,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import AcaiaDeviceNotFound, AcaiaError
|
|
23
|
+
from .const import UnitMass
|
|
24
|
+
from .decode import Message, Settings, decode
|
|
25
|
+
from .helpers import encode, encode_id, encode_notification_request
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(kw_only=True)
|
|
31
|
+
class AcaiaDeviceState:
|
|
32
|
+
"""Data class for acaia scale info data."""
|
|
33
|
+
|
|
34
|
+
battery_level: int
|
|
35
|
+
units: UnitMass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AcaiaScale:
|
|
39
|
+
"""Representation of an acaia scale."""
|
|
40
|
+
|
|
41
|
+
_default_char_id = DEFAULT_CHAR_ID
|
|
42
|
+
_notify_char_id = NOTIFY_CHAR_ID
|
|
43
|
+
|
|
44
|
+
_msg_types = {
|
|
45
|
+
"tare": encode(4, [0]),
|
|
46
|
+
"startTimer": encode(13, [0, 0]),
|
|
47
|
+
"stopTimer": encode(13, [0, 2]),
|
|
48
|
+
"resetTimer": encode(13, [0, 1]),
|
|
49
|
+
"heartbeat": encode(0, [2, 0]),
|
|
50
|
+
"getSettings": encode(6, [0] * 16),
|
|
51
|
+
"notificationRequest": encode_notification_request(),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
address_or_ble_device: str | BLEDevice,
|
|
57
|
+
is_new_style_scale: bool = True,
|
|
58
|
+
notify_callback: Callable[[], None] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Initialize the scale."""
|
|
61
|
+
|
|
62
|
+
self._is_new_style_scale = is_new_style_scale
|
|
63
|
+
self._client: BleakClient | None = None
|
|
64
|
+
|
|
65
|
+
self.address_or_ble_device = address_or_ble_device
|
|
66
|
+
|
|
67
|
+
# tasks
|
|
68
|
+
self.heartbeat_task: asyncio.Task | None = None
|
|
69
|
+
self.process_queue_task: asyncio.Task | None = None
|
|
70
|
+
|
|
71
|
+
# timer related
|
|
72
|
+
self.timer_running = False
|
|
73
|
+
self._timer_start: float | None = None
|
|
74
|
+
self._timer_stop: float | None = None
|
|
75
|
+
|
|
76
|
+
# connection diagnostics
|
|
77
|
+
self.connected = False
|
|
78
|
+
self._timestamp_last_command: float | None = None
|
|
79
|
+
self.last_disconnect_time: float | None = None
|
|
80
|
+
|
|
81
|
+
self._device_state: AcaiaDeviceState | None = None
|
|
82
|
+
self._weight: float | None = None
|
|
83
|
+
|
|
84
|
+
# queue
|
|
85
|
+
self._queue: asyncio.Queue = asyncio.Queue()
|
|
86
|
+
self._add_to_queue_lock = asyncio.Lock()
|
|
87
|
+
|
|
88
|
+
self._msg_types["auth"] = encode_id(is_pyxis_style=is_new_style_scale)
|
|
89
|
+
|
|
90
|
+
if not is_new_style_scale:
|
|
91
|
+
# for old style scales, the default char id is the same as the notify char id
|
|
92
|
+
self._default_char_id = self._notify_char_id = OLD_STYLE_CHAR_ID
|
|
93
|
+
|
|
94
|
+
self._notify_callback: Callable[[], None] | None = notify_callback
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def mac(self) -> str:
|
|
98
|
+
"""Return the mac address of the scale in upper case."""
|
|
99
|
+
return (
|
|
100
|
+
self.address_or_ble_device.upper()
|
|
101
|
+
if isinstance(self.address_or_ble_device, str)
|
|
102
|
+
else self.address_or_ble_device.address.upper()
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def device_state(self) -> AcaiaDeviceState | None:
|
|
107
|
+
"""Return the device info of the scale."""
|
|
108
|
+
return self._device_state
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def weight(self) -> float | None:
|
|
112
|
+
"""Return the weight of the scale."""
|
|
113
|
+
return self._weight
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def timer(self) -> int:
|
|
117
|
+
"""Return the current timer value in seconds."""
|
|
118
|
+
if self._timer_start is None:
|
|
119
|
+
return 0
|
|
120
|
+
if self.timer_running:
|
|
121
|
+
return int(time.time() - self._timer_start)
|
|
122
|
+
if self._timer_stop is None:
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
return int(self._timer_stop - self._timer_start)
|
|
126
|
+
|
|
127
|
+
def device_disconnected_handler(
|
|
128
|
+
self,
|
|
129
|
+
client: BleakClient | None = None, # pylint: disable=unused-argument
|
|
130
|
+
notify: bool = True,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Callback for device disconnected."""
|
|
133
|
+
|
|
134
|
+
_LOGGER.debug(
|
|
135
|
+
"Scale with address %s disconnected through disconnect handler",
|
|
136
|
+
self.mac,
|
|
137
|
+
)
|
|
138
|
+
self.timer_running = False
|
|
139
|
+
self.connected = False
|
|
140
|
+
self.last_disconnect_time = time.time()
|
|
141
|
+
self.async_empty_queue_and_cancel_tasks()
|
|
142
|
+
if notify and self._notify_callback:
|
|
143
|
+
self._notify_callback()
|
|
144
|
+
|
|
145
|
+
async def _write_msg(self, char_id: str, payload: bytearray) -> None:
|
|
146
|
+
"""wrapper for writing to the device."""
|
|
147
|
+
if self._client is None:
|
|
148
|
+
raise AcaiaError("Client not initialized")
|
|
149
|
+
try:
|
|
150
|
+
await self._client.write_gatt_char(char_id, payload)
|
|
151
|
+
self._timestamp_last_command = time.time()
|
|
152
|
+
except BleakDeviceNotFoundError as ex:
|
|
153
|
+
self.connected = False
|
|
154
|
+
raise AcaiaDeviceNotFound("Device not found") from ex
|
|
155
|
+
except BleakError as ex:
|
|
156
|
+
self.connected = False
|
|
157
|
+
raise AcaiaError("Error writing to device") from ex
|
|
158
|
+
except TimeoutError as ex:
|
|
159
|
+
self.connected = False
|
|
160
|
+
raise AcaiaError("Timeout writing to device") from ex
|
|
161
|
+
except Exception as ex:
|
|
162
|
+
self.connected = False
|
|
163
|
+
raise AcaiaError("Unknown error writing to device") from ex
|
|
164
|
+
|
|
165
|
+
def async_empty_queue_and_cancel_tasks(self) -> None:
|
|
166
|
+
"""Empty the queue."""
|
|
167
|
+
|
|
168
|
+
while not self._queue.empty():
|
|
169
|
+
self._queue.get_nowait()
|
|
170
|
+
self._queue.task_done()
|
|
171
|
+
|
|
172
|
+
if self.heartbeat_task and not self.heartbeat_task.done():
|
|
173
|
+
self.heartbeat_task.cancel()
|
|
174
|
+
|
|
175
|
+
if self.process_queue_task and not self.process_queue_task.done():
|
|
176
|
+
self.process_queue_task.cancel()
|
|
177
|
+
|
|
178
|
+
async def process_queue(self) -> None:
|
|
179
|
+
"""Task to process the queue in the background."""
|
|
180
|
+
while True:
|
|
181
|
+
try:
|
|
182
|
+
if not self.connected:
|
|
183
|
+
self.async_empty_queue_and_cancel_tasks()
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
char_id, payload = await self._queue.get()
|
|
187
|
+
await self._write_msg(char_id, payload)
|
|
188
|
+
self._queue.task_done()
|
|
189
|
+
await asyncio.sleep(0.1)
|
|
190
|
+
|
|
191
|
+
except asyncio.CancelledError:
|
|
192
|
+
self.connected = False
|
|
193
|
+
return
|
|
194
|
+
except (AcaiaDeviceNotFound, AcaiaError) as ex:
|
|
195
|
+
self.connected = False
|
|
196
|
+
_LOGGER.debug("Error writing to device: %s", ex)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
async def connect(
|
|
200
|
+
self,
|
|
201
|
+
callback: (
|
|
202
|
+
Callable[[BleakGATTCharacteristic, bytearray], Awaitable[None] | None]
|
|
203
|
+
| None
|
|
204
|
+
) = None,
|
|
205
|
+
setup_tasks: bool = True,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Connect the bluetooth client."""
|
|
208
|
+
|
|
209
|
+
if self.connected:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
if self.last_disconnect_time and self.last_disconnect_time > (time.time() - 15):
|
|
213
|
+
_LOGGER.debug(
|
|
214
|
+
"Scale has recently been disconnected, waiting 15 seconds before reconnecting"
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
self._client = BleakClient(
|
|
219
|
+
address_or_ble_device=self.address_or_ble_device,
|
|
220
|
+
disconnected_callback=self.device_disconnected_handler,
|
|
221
|
+
)
|
|
222
|
+
self._client.get_services()
|
|
223
|
+
try:
|
|
224
|
+
await self._client.connect()
|
|
225
|
+
except BleakError as ex:
|
|
226
|
+
msg = "Error during connecting to device"
|
|
227
|
+
_LOGGER.debug("%s: %s", msg, ex)
|
|
228
|
+
raise AcaiaError(msg) from ex
|
|
229
|
+
except TimeoutError as ex:
|
|
230
|
+
msg = "Timeout during connecting to device"
|
|
231
|
+
_LOGGER.debug("%s: %s", msg, ex)
|
|
232
|
+
raise AcaiaError(msg) from ex
|
|
233
|
+
except Exception as ex:
|
|
234
|
+
msg = "Unknown error during connecting to device"
|
|
235
|
+
_LOGGER.debug("%s: %s", msg, ex)
|
|
236
|
+
raise AcaiaError(msg) from ex
|
|
237
|
+
|
|
238
|
+
self.connected = True
|
|
239
|
+
_LOGGER.debug("Connected to Acaia scale")
|
|
240
|
+
|
|
241
|
+
if callback is None:
|
|
242
|
+
callback = self.on_bluetooth_data_received
|
|
243
|
+
try:
|
|
244
|
+
await self._client.start_notify(
|
|
245
|
+
char_specifier=self._notify_char_id,
|
|
246
|
+
callback=(
|
|
247
|
+
self.on_bluetooth_data_received if callback is None else callback
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
await asyncio.sleep(0.1)
|
|
251
|
+
except BleakError as ex:
|
|
252
|
+
msg = "Error subscribing to notifications"
|
|
253
|
+
_LOGGER.debug("%s: %s", msg, ex)
|
|
254
|
+
raise AcaiaError(msg) from ex
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
await self.auth()
|
|
258
|
+
if callback is not None:
|
|
259
|
+
await self.send_weight_notification_request()
|
|
260
|
+
except BleakDeviceNotFoundError as ex:
|
|
261
|
+
raise AcaiaDeviceNotFound("Device not found") from ex
|
|
262
|
+
except BleakError as ex:
|
|
263
|
+
raise AcaiaError("Error during authentication") from ex
|
|
264
|
+
|
|
265
|
+
if setup_tasks:
|
|
266
|
+
self._setup_tasks()
|
|
267
|
+
|
|
268
|
+
def _setup_tasks(self) -> None:
|
|
269
|
+
"""Setup background tasks"""
|
|
270
|
+
if not self.heartbeat_task or self.heartbeat_task.done():
|
|
271
|
+
self.heartbeat_task = asyncio.create_task(self.send_heartbeats())
|
|
272
|
+
if not self.process_queue_task or self.process_queue_task.done():
|
|
273
|
+
self.process_queue_task = asyncio.create_task(self.process_queue())
|
|
274
|
+
|
|
275
|
+
async def auth(self) -> None:
|
|
276
|
+
"""Send auth message to scale, if subscribed to notifications returns Settings object"""
|
|
277
|
+
await self._queue.put((self._default_char_id, self._msg_types["auth"]))
|
|
278
|
+
|
|
279
|
+
async def send_weight_notification_request(self) -> None:
|
|
280
|
+
"""Tell the scale to send weight notifications"""
|
|
281
|
+
|
|
282
|
+
await self._queue.put(
|
|
283
|
+
(self._default_char_id, self._msg_types["notificationRequest"])
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def send_heartbeats(self) -> None:
|
|
287
|
+
"""Task to send heartbeats in the background."""
|
|
288
|
+
while True:
|
|
289
|
+
try:
|
|
290
|
+
if not self.connected:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
async with self._add_to_queue_lock:
|
|
294
|
+
_LOGGER.debug("Sending heartbeat")
|
|
295
|
+
if self._is_new_style_scale:
|
|
296
|
+
await self._queue.put(
|
|
297
|
+
(self._default_char_id, self._msg_types["auth"])
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
await self._queue.put(
|
|
301
|
+
(self._default_char_id, self._msg_types["heartbeat"])
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if self._is_new_style_scale:
|
|
305
|
+
await self._queue.put(
|
|
306
|
+
(self._default_char_id, self._msg_types["getSettings"])
|
|
307
|
+
)
|
|
308
|
+
await asyncio.sleep(
|
|
309
|
+
HEARTBEAT_INTERVAL if not self._is_new_style_scale else 1,
|
|
310
|
+
)
|
|
311
|
+
except asyncio.CancelledError:
|
|
312
|
+
self.connected = False
|
|
313
|
+
return
|
|
314
|
+
except asyncio.QueueFull as ex:
|
|
315
|
+
self.connected = False
|
|
316
|
+
_LOGGER.debug("Error sending heartbeat: %s", ex)
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
async def disconnect(self) -> None:
|
|
320
|
+
"""Clean disconnect from the scale"""
|
|
321
|
+
|
|
322
|
+
_LOGGER.debug("Disconnecting from scale")
|
|
323
|
+
self.connected = False
|
|
324
|
+
await self._queue.join()
|
|
325
|
+
if not self._client:
|
|
326
|
+
return
|
|
327
|
+
try:
|
|
328
|
+
await self._client.disconnect()
|
|
329
|
+
except BleakError as ex:
|
|
330
|
+
_LOGGER.debug("Error disconnecting from device: %s", ex)
|
|
331
|
+
else:
|
|
332
|
+
_LOGGER.debug("Disconnected from scale")
|
|
333
|
+
|
|
334
|
+
async def tare(self) -> None:
|
|
335
|
+
"""Tare the scale."""
|
|
336
|
+
if not self.connected:
|
|
337
|
+
await self.connect()
|
|
338
|
+
async with self._add_to_queue_lock:
|
|
339
|
+
await self._queue.put((self._default_char_id, self._msg_types["tare"]))
|
|
340
|
+
|
|
341
|
+
async def start_stop_timer(self) -> None:
|
|
342
|
+
"""Start/Stop the timer."""
|
|
343
|
+
if not self.connected:
|
|
344
|
+
await self.connect()
|
|
345
|
+
|
|
346
|
+
if not self.timer_running:
|
|
347
|
+
_LOGGER.debug('Sending "start" message.')
|
|
348
|
+
|
|
349
|
+
async with self._add_to_queue_lock:
|
|
350
|
+
await self._queue.put(
|
|
351
|
+
(self._default_char_id, self._msg_types["startTimer"])
|
|
352
|
+
)
|
|
353
|
+
self.timer_running = True
|
|
354
|
+
if not self._timer_start:
|
|
355
|
+
self._timer_start = time.time()
|
|
356
|
+
else:
|
|
357
|
+
_LOGGER.debug('Sending "stop" message.')
|
|
358
|
+
async with self._add_to_queue_lock:
|
|
359
|
+
await self._queue.put(
|
|
360
|
+
(self._default_char_id, self._msg_types["stopTimer"])
|
|
361
|
+
)
|
|
362
|
+
self.timer_running = False
|
|
363
|
+
self._timer_stop = time.time()
|
|
364
|
+
|
|
365
|
+
async def reset_timer(self) -> None:
|
|
366
|
+
"""Reset the timer."""
|
|
367
|
+
if not self.connected:
|
|
368
|
+
await self.connect()
|
|
369
|
+
async with self._add_to_queue_lock:
|
|
370
|
+
await self._queue.put(
|
|
371
|
+
(self._default_char_id, self._msg_types["resetTimer"])
|
|
372
|
+
)
|
|
373
|
+
self._timer_start = None
|
|
374
|
+
self._timer_stop = None
|
|
375
|
+
|
|
376
|
+
if self.timer_running:
|
|
377
|
+
async with self._add_to_queue_lock:
|
|
378
|
+
await self._queue.put(
|
|
379
|
+
(self._default_char_id, self._msg_types["startTimer"])
|
|
380
|
+
)
|
|
381
|
+
self._timer_start = time.time()
|
|
382
|
+
|
|
383
|
+
async def on_bluetooth_data_received(
|
|
384
|
+
self,
|
|
385
|
+
characteristic: BleakGATTCharacteristic, # pylint: disable=unused-argument
|
|
386
|
+
data: bytearray,
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Receive data from scale."""
|
|
389
|
+
msg = decode(data)[0]
|
|
390
|
+
|
|
391
|
+
if isinstance(msg, Settings):
|
|
392
|
+
self._device_state = AcaiaDeviceState(
|
|
393
|
+
battery_level=msg.battery, units=UnitMass(msg.units)
|
|
394
|
+
)
|
|
395
|
+
_LOGGER.debug(
|
|
396
|
+
"Got battery level %s, units %s", str(msg.battery), str(msg.units)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
elif isinstance(msg, Message):
|
|
400
|
+
self._weight = msg.value
|
|
401
|
+
if msg.timer_running is not None:
|
|
402
|
+
self.timer_running = msg.timer_running
|
|
403
|
+
_LOGGER.debug("Got weight %s", str(msg.value))
|
|
404
|
+
|
|
405
|
+
if self._notify_callback is not None:
|
|
406
|
+
self._notify_callback()
|
aioacaia/const.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Constants for aioacaia."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
DEFAULT_CHAR_ID: Final = "49535343-8841-43f4-a8d4-ecbe34729bb3"
|
|
7
|
+
NOTIFY_CHAR_ID: Final = "49535343-1e4d-4bd9-ba61-23c647249616"
|
|
8
|
+
OLD_STYLE_CHAR_ID: Final = "00002a80-0000-1000-8000-00805f9b34fb"
|
|
9
|
+
HEADER1: Final = 0xEF
|
|
10
|
+
HEADER2: Final = 0xDD
|
|
11
|
+
HEARTBEAT_INTERVAL: Final = 5
|
|
12
|
+
SCALE_START_NAMES: Final = ["ACAIA", "PYXIS", "LUNAR", "PROCH"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnitMass(StrEnum):
|
|
16
|
+
"""Unit of mass."""
|
|
17
|
+
|
|
18
|
+
GRAMS = "grams"
|
|
19
|
+
OUNCES = "ounces"
|
aioacaia/decode.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Message decoding functions, taken from pyacaia."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from bleak import BleakGATTCharacteristic
|
|
5
|
+
|
|
6
|
+
from .const import HEADER1, HEADER2
|
|
7
|
+
|
|
8
|
+
_LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Message:
|
|
12
|
+
"""Representation of a message from the scale."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, msg_type: int, payload: bytearray | list[int]) -> None:
|
|
15
|
+
self.msg_type = msg_type
|
|
16
|
+
self.payload = payload
|
|
17
|
+
self.value: float | None = None
|
|
18
|
+
self.button: str | None = None
|
|
19
|
+
self.time: int | None = None
|
|
20
|
+
self.timer_running: bool | None = None
|
|
21
|
+
|
|
22
|
+
if self.msg_type == 5:
|
|
23
|
+
self.value = self._decode_weight(payload)
|
|
24
|
+
|
|
25
|
+
elif self.msg_type == 11:
|
|
26
|
+
if payload[2] == 5:
|
|
27
|
+
self.value = self._decode_weight(payload[3:])
|
|
28
|
+
elif payload[2] == 7:
|
|
29
|
+
self.time = self._decode_time(payload[3:])
|
|
30
|
+
_LOGGER.debug(
|
|
31
|
+
"heartbeat response (weight: %s, time: %s)", self.value, self.time
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
elif self.msg_type == 7:
|
|
35
|
+
self.time = self._decode_time(payload)
|
|
36
|
+
_LOGGER.debug("timer: %s", self.time)
|
|
37
|
+
|
|
38
|
+
elif self.msg_type == 8:
|
|
39
|
+
if payload[0] == 0 and payload[1] == 5:
|
|
40
|
+
self.button = "tare"
|
|
41
|
+
self.value = self._decode_weight(payload[2:])
|
|
42
|
+
_LOGGER.debug("tare (weight: %s)", self.value)
|
|
43
|
+
elif payload[0] == 8 and payload[1] == 5:
|
|
44
|
+
self.button = "start"
|
|
45
|
+
self.timer_running = True
|
|
46
|
+
self.value = self._decode_weight(payload[2:])
|
|
47
|
+
_LOGGER.debug("start (weight: %s)", self.value)
|
|
48
|
+
elif payload[0] == 10 and payload[1] == 7:
|
|
49
|
+
self.button = "stop"
|
|
50
|
+
self.timer_running = False
|
|
51
|
+
self.time = self._decode_time(payload[2:])
|
|
52
|
+
self.value = self._decode_weight(payload[6:])
|
|
53
|
+
_LOGGER.debug("stop time: %s, weight: %s", self.time, self.value)
|
|
54
|
+
elif payload[0] == 10 and payload[1] == 5: # stop for new scale
|
|
55
|
+
self.button = "stop"
|
|
56
|
+
self.timer_running = False
|
|
57
|
+
elif payload[0] == 9 and payload[1] == 7:
|
|
58
|
+
self.button = "reset"
|
|
59
|
+
self.time = self._decode_time(payload[2:])
|
|
60
|
+
self.value = self._decode_weight(payload[6:])
|
|
61
|
+
_LOGGER.debug("reset time: %s, weight: %s", self.time, self.value)
|
|
62
|
+
else:
|
|
63
|
+
self.button = "unknownbutton"
|
|
64
|
+
_LOGGER.debug("unknownbutton %s", str(payload))
|
|
65
|
+
|
|
66
|
+
else:
|
|
67
|
+
_LOGGER.debug("message: %s, payload %s", msg_type, payload)
|
|
68
|
+
|
|
69
|
+
def _decode_weight(self, weight_payload):
|
|
70
|
+
value = ((weight_payload[1] & 0xFF) << 8) + (weight_payload[0] & 0xFF)
|
|
71
|
+
unit = weight_payload[4] & 0xFF
|
|
72
|
+
if unit == 1:
|
|
73
|
+
value /= 10.0
|
|
74
|
+
elif unit == 2:
|
|
75
|
+
value /= 100.0
|
|
76
|
+
elif unit == 3:
|
|
77
|
+
value /= 1000.0
|
|
78
|
+
elif unit == 4:
|
|
79
|
+
value /= 10000.0
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(f"unit value not in range {unit}")
|
|
82
|
+
|
|
83
|
+
if (weight_payload[5] & 0x02) == 0x02:
|
|
84
|
+
value *= -1
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
def _decode_time(self, time_payload):
|
|
88
|
+
value = (time_payload[0] & 0xFF) * 60
|
|
89
|
+
value = value + (time_payload[1])
|
|
90
|
+
value = value + (time_payload[2] / 10.0)
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Settings:
|
|
95
|
+
"""Representation of the settings from the scale."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, payload: bytearray) -> None:
|
|
98
|
+
# payload[0] is unknown
|
|
99
|
+
self.battery = payload[1] & 0x7F
|
|
100
|
+
if payload[2] == 2:
|
|
101
|
+
self.units = "grams"
|
|
102
|
+
elif payload[2] == 5:
|
|
103
|
+
self.units = "ounces"
|
|
104
|
+
else:
|
|
105
|
+
self.units = "grams"
|
|
106
|
+
# payload[2 and 3] is unknown
|
|
107
|
+
self.auto_off = payload[4] * 5
|
|
108
|
+
# payload[5] is unknown
|
|
109
|
+
self.beep_on = payload[6] == 1
|
|
110
|
+
# payload[7-9] unknown
|
|
111
|
+
_LOGGER.debug(
|
|
112
|
+
"settings: battery=%s %s, auto_off=%s, beep=%s",
|
|
113
|
+
self.battery,
|
|
114
|
+
self.units,
|
|
115
|
+
self.auto_off,
|
|
116
|
+
self.beep_on,
|
|
117
|
+
)
|
|
118
|
+
_LOGGER.debug(
|
|
119
|
+
"unknown settings: %s",
|
|
120
|
+
str(
|
|
121
|
+
[
|
|
122
|
+
payload[0],
|
|
123
|
+
payload[1] & 0x80,
|
|
124
|
+
payload[3],
|
|
125
|
+
payload[5],
|
|
126
|
+
payload[7],
|
|
127
|
+
payload[8],
|
|
128
|
+
payload[9],
|
|
129
|
+
]
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decode(byte_msg: bytearray):
|
|
135
|
+
"""Return a tuple - first element is the message, or None
|
|
136
|
+
if one not yet found. Second is are the remaining
|
|
137
|
+
bytes, which can be empty
|
|
138
|
+
Messages are encoded as the encode() function above,
|
|
139
|
+
min message length is 6 bytes
|
|
140
|
+
HEADER1 (0xef)
|
|
141
|
+
HEADER1 (0xdd)
|
|
142
|
+
command
|
|
143
|
+
length (including this byte, excluding checksum)
|
|
144
|
+
payload of length-1 bytes
|
|
145
|
+
checksum byte1
|
|
146
|
+
checksum byte2
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
msg_start = -1
|
|
150
|
+
|
|
151
|
+
for i in range(len(byte_msg) - 1):
|
|
152
|
+
if byte_msg[i] == HEADER1 and byte_msg[i + 1] == HEADER2:
|
|
153
|
+
msg_start = i
|
|
154
|
+
break
|
|
155
|
+
if msg_start < 0 or len(byte_msg) - msg_start < 6:
|
|
156
|
+
return (None, byte_msg)
|
|
157
|
+
|
|
158
|
+
msg_end = msg_start + byte_msg[msg_start + 3] + 5
|
|
159
|
+
|
|
160
|
+
if msg_end > len(byte_msg):
|
|
161
|
+
return (None, byte_msg)
|
|
162
|
+
|
|
163
|
+
if msg_start > 0:
|
|
164
|
+
_LOGGER.debug("Ignoring %s bytes before header", i)
|
|
165
|
+
|
|
166
|
+
cmd = byte_msg[msg_start + 2]
|
|
167
|
+
if cmd == 12:
|
|
168
|
+
msg_type = byte_msg[msg_start + 4]
|
|
169
|
+
payload_in = byte_msg[msg_start + 5 : msg_end]
|
|
170
|
+
return (Message(msg_type, payload_in), byte_msg[msg_end:])
|
|
171
|
+
if cmd == 8:
|
|
172
|
+
return (Settings(byte_msg[msg_start + 3 :]), byte_msg[msg_end:])
|
|
173
|
+
|
|
174
|
+
_LOGGER.debug(
|
|
175
|
+
"Non event notification message command %s %s",
|
|
176
|
+
str(cmd),
|
|
177
|
+
str(byte_msg[msg_start:msg_end]),
|
|
178
|
+
)
|
|
179
|
+
return (None, byte_msg[msg_end:])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def notification_handler(sender: BleakGATTCharacteristic, data: bytearray) -> None:
|
|
183
|
+
"""Sample for callback for handling incoming notifications from the scale."""
|
|
184
|
+
msg = decode(data)[0]
|
|
185
|
+
if isinstance(msg, Settings):
|
|
186
|
+
print(f"Battery: {msg.battery}")
|
|
187
|
+
print(f"Units: {msg.units}")
|
|
188
|
+
elif isinstance(msg, Message):
|
|
189
|
+
print(f"Weight: {msg.value}")
|
aioacaia/exceptions.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions for aioacaia."""
|
|
2
|
+
|
|
3
|
+
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AcaiaScaleException(Exception):
|
|
7
|
+
"""Base class for exceptions in this module."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AcaiaDeviceNotFound(BleakDeviceNotFoundError):
|
|
11
|
+
"""Exception when no device is found."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AcaiaError(BleakError):
|
|
15
|
+
"""Exception for general bleak errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AcaiaUnknownDevice(Exception):
|
|
19
|
+
"""Exception for unknown devices."""
|