Skip to content

Commit 6d1fe54

Browse files
committed
fixes
1 parent 2a0f305 commit 6d1fe54

File tree

12 files changed

+1126
-551
lines changed

12 files changed

+1126
-551
lines changed

src/artisanlib/acaia.py

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@
2424
from bleak.backends.characteristic import BleakGATTCharacteristic # pylint: disable=unused-import
2525

2626
try:
27-
from PyQt6.QtCore import QObject # @UnusedImport @Reimport @UnresolvedImport
27+
from PyQt6.QtCore import pyqtSignal, pyqtSlot # @UnusedImport @Reimport @UnresolvedImport
2828
except ImportError:
29-
from PyQt5.QtCore import QObject # type: ignore # @UnusedImport @Reimport @UnresolvedImport
29+
from PyQt5.QtCore import pyqtSignal, pyqtSlot # type: ignore # @UnusedImport @Reimport @UnresolvedImport
3030

3131
from artisanlib.ble_port import ClientBLE
3232
from artisanlib.async_comm import AsyncIterable, IteratorReader
33-
from artisanlib.scale import Scale
33+
from artisanlib.scale import Scale, ScaleSpecs
3434
from artisanlib.util import float2float
3535

3636

@@ -185,8 +185,12 @@ class ACAIA_TIMER(IntEnum):
185185

186186

187187

188-
class AcaiaBLE(QObject, ClientBLE): # pyright: ignore [reportGeneralTypeIssues] # Argument to class must be a base class
188+
class AcaiaBLE(ClientBLE): # pyright: ignore [reportGeneralTypeIssues] # Argument to class must be a base class
189189

190+
weight_changed_signal = pyqtSignal(float) # delivers new weight in g with decimals for accurate conversion
191+
battery_changed_signal = pyqtSignal(int) # delivers new batter level in %
192+
connected_signal = pyqtSignal() # issued on connect
193+
disconnected_signal = pyqtSignal() # issued on disconnect
190194

191195
# Acaia message constants
192196
HEADER1:Final[bytes] = b'\xef'
@@ -212,8 +216,7 @@ def __init__(self, connected_handler:Optional[Callable[[], None]] = None,
212216
self.scale_class:SCALE_CLASS = SCALE_CLASS.MODERN
213217

214218
# Protocol parser variables
215-
self._read_queue : asyncio.Queue[bytes] = asyncio.Queue(maxsize=200)
216-
self._input_stream = IteratorReader(AsyncIterable(self._read_queue))
219+
self._read_queue : Optional[asyncio.Queue[bytes]] = None
217220

218221
self.id_sent:bool = False # ID is sent once after first data is received from scale
219222
self.fast_notifications_sent:bool = False # after connect we switch fast notification on to receive first reading fast
@@ -250,13 +253,6 @@ def __init__(self, connected_handler:Optional[Callable[[], None]] = None,
250253
self.add_notify(ACAIA_UMBRA_NOTIFY_UUID, self.notify_callback)
251254
self.add_write(ACAIA_UMBRA_SERVICE_UUID, ACAIA_UMBRA_WRITE_UUID)
252255

253-
def set_connected_handler(self, connected_handler:Optional[Callable[[], None]]) -> None:
254-
self._connected_handler = connected_handler
255-
256-
def set_disconnected_handler(self, disconnected_handler:Optional[Callable[[], None]]) -> None:
257-
self._disconnected_handler = disconnected_handler
258-
259-
260256
# protocol parser
261257

262258

@@ -288,11 +284,13 @@ def on_connect(self) -> None:
288284
self.set_heartbeat(0) # disable heartbeat
289285
if self._connected_handler is not None:
290286
self._connected_handler()
287+
self.connected_signal.emit()
291288

292289
def on_disconnect(self) -> None:
293290
_log.debug('disconnected')
294291
if self._disconnected_handler is not None:
295292
self._disconnected_handler()
293+
self.disconnected_signal.emit()
296294

297295

298296
##
@@ -357,7 +355,7 @@ def update_weight(self, value:Optional[float]) -> None:
357355
# if value is fresh and reading is stable
358356
if value_rounded != self.weight: # and stable:
359357
self.weight = value_rounded
360-
self.weight_changed(self.weight)
358+
self.weight_changed_signal.emit(self.weight)
361359
_log.debug('new weight: %s', self.weight)
362360

363361
# returns length of consumed data or -1 on error
@@ -373,7 +371,7 @@ def parse_battery_event(self, payload:bytes) -> int:
373371
b = payload[0]
374372
if 0 <= b <= 100:
375373
self.battery = int(payload[0])
376-
self.battery_changed(self.battery)
374+
self.battery_changed_signal.emit(self.battery)
377375
_log.debug('battery: %s', self.battery)
378376
return EVENT_LEN.BATTERY
379377

@@ -503,7 +501,7 @@ def parse_status(self, payload:bytes) -> None:
503501
# byte 1: battery level (7 bits of second byte) + TIMER_START (1bit)
504502
if payload and len(payload) > 1:
505503
self.battery = int(payload[1] & ~(1 << 7))
506-
self.battery_changed(self.battery)
504+
self.battery_changed_signal.emit(self.battery)
507505
_log.debug('battery: %s%%', self.battery)
508506

509507
# byte 2:
@@ -737,7 +735,7 @@ def fast_notifications(self) -> None:
737735
###
738736

739737
def notify_callback(self, _sender:'BleakGATTCharacteristic', data:bytearray) -> None:
740-
if hasattr(self, '_async_loop_thread') and self._async_loop_thread is not None:
738+
if hasattr(self, '_async_loop_thread') and self._async_loop_thread is not None and self._read_queue is not None:
741739
asyncio.run_coroutine_threadsafe(
742740
self._read_queue.put(bytes(data)),
743741
self._async_loop_thread.loop)
@@ -752,7 +750,9 @@ def notify_callback(self, _sender:'BleakGATTCharacteristic', data:bytearray) ->
752750
# 6 data: d[4:10] = b'\x02\x14\x02<\x14\x00'
753751
# 2 crc: d[10:12] = b'\x00W\x18' # calculated over "data_len+data"
754752

755-
async def reader(self, stream:IteratorReader) -> None:
753+
async def reader(self) -> None:
754+
self._read_queue = asyncio.Queue(maxsize=200) # queue needs to be started in the current async event loop!
755+
stream = IteratorReader(AsyncIterable(self._read_queue))
756756
while True:
757757
try:
758758
await stream.readuntil(self.HEADER1)
@@ -769,50 +769,68 @@ async def reader(self, stream:IteratorReader) -> None:
769769
else:
770770
_log.debug('CRC error: %s <- %s',self.crc(data),data)
771771
except Exception as e: # pylint: disable=broad-except
772-
_log.exception(e)
772+
_log.error(e)
773773

774774

775775
def on_start(self) -> None:
776776
if hasattr(self, '_async_loop_thread') and self._async_loop_thread is not None:
777777
# start the reader
778778
asyncio.run_coroutine_threadsafe(
779-
self.reader(self._input_stream),
779+
self.reader(),
780780
self._async_loop_thread.loop)
781781

782782

783-
def weight_changed(self, new_value:float) -> None: # pylint: disable=no-self-use
784-
del new_value
785-
786-
787-
def battery_changed(self, new_value:int) -> None: # pylint: disable=no-self-use
788-
del new_value
789-
790-
791-
792-
# AcaiaBLE and its super class are not allowed to hold __slots__
793-
class Acaia(AcaiaBLE, Scale): # pyright: ignore [reportGeneralTypeIssues] # Argument to class must be a base class
783+
class Acaia(Scale): # pyright: ignore [reportGeneralTypeIssues] # Argument to class must be a base class
794784

795785
def __init__(self, model:int, ident:Optional[str], name:Optional[str], connected_handler:Optional[Callable[[], None]] = None,
796786
disconnected_handler:Optional[Callable[[], None]] = None):
797-
Scale.__init__(self, model, ident, name)
798-
AcaiaBLE.__init__(self, connected_handler = connected_handler, disconnected_handler=disconnected_handler)
787+
super().__init__(model, ident, name)
788+
self.acaia = AcaiaBLE(connected_handler = connected_handler, disconnected_handler=disconnected_handler)
789+
self.acaia.weight_changed_signal.connect(self.weight_changed)
790+
self.acaia.connected_signal.connect(self.on_connect)
791+
self.acaia.disconnected_signal.connect(self.on_disconnect)
792+
793+
self.scale_connected = False
794+
795+
796+
def scan(self) -> None:
797+
devices = self.acaia.scan()
798+
acaia_devices:ScaleSpecs = []
799+
# for Acaia scales we filter by name
800+
for d, a in devices:
801+
name = (a.local_name or d.name)
802+
if name:
803+
match = next((f'{product_name} ({name})' for (name_prefix, product_name) in ACAIA_SCALE_NAMES
804+
if name and name.startswith(name_prefix)), None)
805+
if match is not None:
806+
acaia_devices.append((match, d.address))
807+
self.scanned_signal.emit(acaia_devices)
808+
809+
def is_connected(self) -> bool:
810+
return self.scale_connected
799811

800812
def connect_scale(self) -> None:
801-
_log.debug('connect_scale')
802-
self.start(address=self.ident)
813+
self.acaia.start(address=self.ident)
803814

804815
def disconnect_scale(self) -> None:
805-
self.stop()
816+
self.acaia.stop()
806817

818+
@pyqtSlot(float)
807819
def weight_changed(self, new_value:float) -> None:
808820
self.weight_changed_signal.emit(new_value)
809821

810822
def battery_changed(self, new_value:int) -> None:
811823
self.battery_changed_signal.emit(new_value)
812824

825+
@pyqtSlot()
826+
def on_connect(self) -> None:
827+
self.scale_connected = True
828+
self.connected_signal.emit()
829+
830+
@pyqtSlot()
813831
def on_disconnect(self) -> None:
832+
self.scale_connected = False
814833
self.disconnected_signal.emit()
815-
AcaiaBLE.on_disconnect(self)
816834

817835
def tare_scale(self) -> None:
818-
self.send_tare()
836+
self.acaia.send_tare()

src/artisanlib/ble_port.py

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
from bleak import BleakScanner, BleakClient
2121
from bleak.exc import BleakCharacteristicNotFoundError
2222

23+
try:
24+
from PyQt6.QtCore import QObject # @UnusedImport @Reimport @UnresolvedImport
25+
except ImportError:
26+
from PyQt5.QtCore import QObject # type: ignore # @UnusedImport @Reimport @UnresolvedImport
27+
2328
from artisanlib.async_comm import AsyncLoopThread
2429

2530
from typing import Optional, Callable, Union, Dict, List, Set, Tuple, Awaitable, TYPE_CHECKING
@@ -185,7 +190,7 @@ def scan_and_connect(self,
185190
_log.error('exception in scan_and_connect: %s', fut.exception())
186191
return None, None
187192

188-
def disconnect(self, client:'BleakClient') -> bool:
193+
def disconnect_ble(self, client:'BleakClient') -> bool:
189194
if hasattr(self, '_asyncLoopThread') and self._asyncLoopThread is not None:
190195
# don't wait for completion not to block caller (note: ble device will not be discovered until fully disconnected)
191196
asyncio.run_coroutine_threadsafe(client.disconnect(), self._asyncLoopThread.loop)
@@ -221,7 +226,7 @@ def stop_notify(self, client:'BleakClient', uuid:str) -> None:
221226

222227

223228

224-
class ClientBLE:
229+
class ClientBLE(QObject): # pyright:ignore[reportGeneralTypeIssues] # error: Argument to class must be a base class
225230

226231
# NOTE: __slots__ are incompatible with multiple inheritance mixings in subclasses (e.g. with QObject)
227232
# __slots__ = [ '_running', '_async_loop_thread', '_ble_client', '_connected_service_uuid', '_disconnected_event',
@@ -230,6 +235,7 @@ class ClientBLE:
230235
# '_logging' ]
231236

232237
def __init__(self) -> None:
238+
super().__init__()
233239
# internals
234240
self._running:bool = False # if True we keep reconnecting
235241
self._async_loop_thread: Optional[AsyncLoopThread] = None # the asyncio AsyncLoopThread object
@@ -274,7 +280,7 @@ def stop_notifications(self) -> None:
274280

275281
def _disconnect(self) -> None:
276282
if self._ble_client is not None and self._ble_client.is_connected:
277-
ble.disconnect(self._ble_client)
283+
ble.disconnect_ble(self._ble_client)
278284

279285
# returns the service UUID connected to or None
280286
def connected(self) -> Optional[str]:
@@ -394,6 +400,26 @@ async def _connect_and_keep_alive(self,case_sensitive:bool,scan_timeout:float, c
394400
self._keep_alive())
395401

396402

403+
def scan(self, scan_timeout:float = 3.0) -> 'List[Tuple[BLEDevice, AdvertisementData]]':
404+
try:
405+
if not hasattr(self, '_async_loop_thread') or self._async_loop_thread is None:
406+
self._running = True # enable automatic reconnects
407+
self._async_loop_thread = AsyncLoopThread()
408+
# run scan in async loop
409+
coro = BleakScanner.discover(
410+
timeout=scan_timeout,
411+
return_adv=True)
412+
res = asyncio.run_coroutine_threadsafe(
413+
coro,
414+
self._async_loop_thread.loop).result()
415+
_log.debug('scan_ble ended')
416+
if res:
417+
return list(res.values())
418+
except Exception as e: # pylint: disable=broad-except
419+
_log.exception(e)
420+
return []
421+
422+
397423
def start(self, case_sensitive:bool=True, scan_timeout:float=6, connect_timeout:float=6, address:Optional[str] = None) -> None:
398424
_log.debug('start')
399425
if self._running:
@@ -487,25 +513,3 @@ def on_stop(self) -> None: # pylint: disable=no-self-use
487513
...
488514
def heartbeat(self) -> None: # pylint: disable=no-self-use
489515
...
490-
491-
492-
##
493-
494-
# scans for named BLE devices providing any of the provided servie_uuids
495-
# returns a list of triples (name, address, Optional[BLEDevice])
496-
def scan_ble(timeout: float = 3.0) -> 'List[Tuple[BLEDevice, AdvertisementData]]':
497-
_log.debug('scan_ble(%s) started', timeout)
498-
coro = BleakScanner.discover(
499-
timeout=timeout,
500-
return_adv=True)
501-
try:
502-
loop = asyncio.get_running_loop()
503-
res = asyncio.run_coroutine_threadsafe(coro, loop).result()
504-
except RuntimeError:
505-
res = asyncio.run(coro)
506-
_log.debug('scan_ble ended')
507-
if res:
508-
# _log.debug('scan_ble results: %s', res.values())
509-
return list(res.values())
510-
_log.debug('scan_ble returned no results')
511-
return []

0 commit comments

Comments
 (0)