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