From 18d491b7ab0a29aeb70544ac160a224d29958cd9 Mon Sep 17 00:00:00 2001 From: Florent de Lamotte Date: Mon, 24 Mar 2025 16:55:37 +0100 Subject: [PATCH] Initial commit --- LICENSE | 21 + README.md | 6 + dist/meshcore-0.1-py3-none-any.whl | Bin 0 -> 7134 bytes dist/meshcore-0.1.tar.gz | Bin 0 -> 6278 bytes pyproject.toml | 24 ++ src/meshcore/__init__.py | 5 + src/meshcore/mclib.py | 626 +++++++++++++++++++++++++++++ 7 files changed, 682 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dist/meshcore-0.1-py3-none-any.whl create mode 100644 dist/meshcore-0.1.tar.gz create mode 100644 pyproject.toml create mode 100644 src/meshcore/__init__.py create mode 100644 src/meshcore/mclib.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9de1dd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Florent de Lamotte + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..784f0ff --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Python meshcore + +Bindings to access your [MeshCore](https://meshcore.co.uk) companion radio nodes in python. + +Used by [mccli](https//github.com/fdlamotte/mccli). + diff --git a/dist/meshcore-0.1-py3-none-any.whl b/dist/meshcore-0.1-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..124b9f7dcb9c8c1c58b5cdd977e6b1a58ef33bfc GIT binary patch literal 7134 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 z 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2c27e99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "meshcore" +version = "0.1" +authors = [ + { name="Florent de Lamotte", email="florent@frizoncorrea.fr" }, +] +description = "Base classes for communicating with meshcore companion radios" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +license = "MIT" +license-files = ["LICEN[CS]E*"] +dependencies = [ "bleak", "pyserial-asyncio" ] + +[project.urls] +Homepage = "https://github.com/mccli" +Issues = "https://github.com/mccli/issues" diff --git a/src/meshcore/__init__.py b/src/meshcore/__init__.py new file mode 100644 index 0000000..e24d980 --- /dev/null +++ b/src/meshcore/__init__.py @@ -0,0 +1,5 @@ +from meshcore.mclib import printerr +from meshcore.mclib import MeshCore +from meshcore.mclib import TCPConnection +from meshcore.mclib import BLEConnection +from meshcore.mclib import SerialConnection diff --git a/src/meshcore/mclib.py b/src/meshcore/mclib.py new file mode 100644 index 0000000..fc06f66 --- /dev/null +++ b/src/meshcore/mclib.py @@ -0,0 +1,626 @@ +""" + mccli.py : CLI interface to MeschCore BLE companion app +""" +import asyncio +import sys +import serial_asyncio + +from bleak import BleakClient, BleakScanner +from bleak.backends.characteristic import BleakGATTCharacteristic +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from bleak.exc import BleakDeviceNotFoundError + +UART_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" +UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" + +def printerr (s) : + sys.stderr.write(str(s)) + sys.stderr.write("\n") + sys.stderr.flush() + +class SerialConnection: + def __init__(self, port, baudrate): + self.port = port + self.baudrate = baudrate + self.frame_started = False + self.frame_size = 0 + self.header = b"" + self.inframe = b"" + + class MCSerialClientProtocol(asyncio.Protocol): + def __init__(self, cx): + self.cx = cx + + def connection_made(self, transport): + self.cx.transport = transport +# printerr('port opened') + transport.serial.rts = False # You can manipulate Serial object via transport + + def data_received(self, data): +# printerr('data received') + self.cx.handle_rx(data) + + def connection_lost(self, exc): + printerr('port closed') + + def pause_writing(self): + printerr('pause writing') + + def resume_writing(self): + printerr('resume writing') + + async def connect(self): + """ + Connects to the device + """ + loop = asyncio.get_running_loop() + await serial_asyncio.create_serial_connection( + loop, lambda: self.MCSerialClientProtocol(self), + self.port, baudrate=self.baudrate) + + printerr("Serial Connexion started") + return self.port + + def set_mc(self, mc) : + self.mc = mc + + def handle_rx(self, data: bytearray): + headerlen = len(self.header) + framelen = len(self.inframe) + if not self.frame_started : # wait start of frame + if len(data) >= 3 - headerlen: + self.header = self.header + data[:3-headerlen] + self.frame_started = True + self.frame_size = int.from_bytes(self.header[1:], byteorder='little') + self.handle_rx(data[3-headerlen:]) + else: + self.header = self.header + data + else: + if framelen + len(data) < self.frame_size: + self.inframe = self.inframe + data + else: + self.inframe = self.inframe + data[:self.frame_size-framelen] + if not self.mc is None: + self.mc.handle_rx(self.inframe) + self.frame_started = False + self.header = b"" + self.inframe = b"" + if framelen + len(data) > self.frame_size: + self.handle_rx(data[self.frame_size-framelen:]) + + async def send(self, data): + size = len(data) + pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + data +# printerr(f"sending pkt : {pkt}") + self.transport.write(pkt) + +class TCPConnection: + def __init__(self, host, port): + self.host = host + self.port = port + self.transport = None + self.frame_started = False + self.frame_size = 0 + self.header = b"" + self.inframe = b"" + + class MCClientProtocol: + def __init__(self, cx): + self.cx = cx + + def connection_made(self, transport): + self.cx.transport = transport + + def data_received(self, data): + self.cx.handle_rx(data) + + def error_received(self, exc): + printerr(f'Error received: {exc}') + + def connection_lost(self, exc): + printerr('The server closed the connection') + + async def connect(self): + """ + Connects to the device + """ + loop = asyncio.get_running_loop() + await loop.create_connection( + lambda: self.MCClientProtocol(self), + self.host, self.port) + + printerr("TCP Connexion started") + return self.host + + def set_mc(self, mc) : + self.mc = mc + + def handle_rx(self, data: bytearray): + headerlen = len(self.header) + framelen = len(self.inframe) + if not self.frame_started : # wait start of frame + if len(data) >= 3 - headerlen: + self.header = self.header + data[:3-headerlen] + self.frame_started = True + self.frame_size = int.from_bytes(self.header[1:], byteorder='little') + self.handle_rx(data[3-headerlen:]) + else: + self.header = self.header + data + else: + if framelen + len(data) < self.frame_size: + self.inframe = self.inframe + data + else: + self.inframe = self.inframe + data[:self.frame_size-framelen] + if not self.mc is None: + self.mc.handle_rx(self.inframe) + self.frame_started = False + self.header = b"" + self.inframe = b"" + if framelen + len(data) > self.frame_size: + self.handle_rx(data[self.frame_size-framelen:]) + + async def send(self, data): + size = len(data) + pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + data + self.transport.write(pkt) + +class BLEConnection: + def __init__(self, address): + """ Constructor : specify address """ + self.address = address + self.client = None + self.rx_char = None + self.mc = None + + async def connect(self): + """ + Connects to the device + + Returns : the address used for connection + """ + def match_meshcore_device(_: BLEDevice, adv: AdvertisementData): + """ Filter to mach MeshCore devices """ + if not adv.local_name is None\ + and adv.local_name.startswith("MeshCore")\ + and (self.address is None or self.address in adv.local_name) : + return True + return False + + if self.address is None or self.address == "" or len(self.address.split(":")) != 6 : + scanner = BleakScanner() + printerr("Scanning for devices") + device = await scanner.find_device_by_filter(match_meshcore_device) + if device is None : + return None + printerr(f"Found device : {device}") + self.client = BleakClient(device) + self.address = self.client.address + else: + self.client = BleakClient(self.address) + + try: + await self.client.connect(disconnected_callback=self.handle_disconnect) + except BleakDeviceNotFoundError: + return None + except TimeoutError: + return None + + await self.client.start_notify(UART_TX_CHAR_UUID, self.handle_rx) + + nus = self.client.services.get_service(UART_SERVICE_UUID) + self.rx_char = nus.get_characteristic(UART_RX_CHAR_UUID) + + printerr("BLE Connexion started") + return self.address + + def handle_disconnect(self, _: BleakClient): + """ Callback to handle disconnection """ + printerr ("Device was disconnected, goodbye.") + # cancelling all tasks effectively ends the program + for task in asyncio.all_tasks(): + task.cancel() + + def set_mc(self, mc) : + self.mc = mc + + def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray): + if not self.mc is None: + self.mc.handle_rx(data) + + async def send(self, data): + await self.client.write_gatt_char(self.rx_char, bytes(data), response=False) + +class MeshCore: + """ + Interface to a BLE MeshCore device + """ + self_info={} + contacts={} + + def __init__(self, cx): + """ Constructor : specify address """ + self.time = 0 + self.result = asyncio.Future() + self.contact_nb = 0 + self.rx_sem = asyncio.Semaphore(0) + self.ack_ev = asyncio.Event() + self.login_resp = asyncio.Future() + self.status_resp = asyncio.Future() + + self.cx = cx + cx.set_mc(self) + + async def connect(self) : + await self.send_appstart() + + def handle_rx(self, data: bytearray): + """ Callback to handle received data """ + match data[0]: + case 0: # ok + if len(data) == 5 : # an integer + self.result.set_result(int.from_bytes(data[1:5], byteorder='little')) + else: + self.result.set_result(True) + case 1: # error + if len(data) > 1: + res = {} + res["error_code"] = data[1] + self.result.set_result(res) # error code if fw > 1.4 + else: + self.result.set_result(False) + case 2: # contact start + self.contact_nb = int.from_bytes(data[1:5], byteorder='little') + self.contacts={} + case 3: # contact + c = {} + c["public_key"] = data[1:33].hex() + c["type"] = data[33] + c["flags"] = data[34] + c["out_path_len"] = int.from_bytes(data[35:36], signed=True) + plen = int.from_bytes(data[35:36], signed=True) + if plen == -1 : + plen = 0 + c["out_path"] = data[36:36+plen].hex() + c["adv_name"] = data[100:132].decode().replace("\0","") + c["last_advert"] = int.from_bytes(data[132:136], byteorder='little') + c["adv_lat"] = int.from_bytes(data[136:140], byteorder='little',signed=True)/1e6 + c["adv_lon"] = int.from_bytes(data[140:144], byteorder='little',signed=True)/1e6 + c["lastmod"] = int.from_bytes(data[144:148], byteorder='little') + self.contacts[c["adv_name"]]=c + case 4: # end of contacts + self.result.set_result(self.contacts) + case 5: # self info + self.self_info["adv_type"] = data[1] + self.self_info["tx_power"] = data[2] + self.self_info["max_tx_power"] = data[3] + self.self_info["public_key"] = data[4:36].hex() + self.self_info["adv_lat"] = int.from_bytes(data[36:40], byteorder='little', signed=True)/1e6 + self.self_info["adv_lon"] = int.from_bytes(data[40:44], byteorder='little', signed=True)/1e6 + #self.self_info["reserved_44:48"] = data[44:48].hex() + self.self_info["radio_freq"] = int.from_bytes(data[48:52], byteorder='little') / 1000 + self.self_info["radio_bw"] = int.from_bytes(data[52:56], byteorder='little') / 1000 + self.self_info["radio_sf"] = data[56] + self.self_info["radio_cr"] = data[57] + self.self_info["name"] = data[58:].decode() + self.result.set_result(True) + case 6: # msg sent + res = {} + res["type"] = data[1] + res["expected_ack"] = bytes(data[2:6]) + res["suggested_timeout"] = int.from_bytes(data[6:10], byteorder='little') + self.result.set_result(res) + case 7: # contact msg recv + res = {} + res["type"] = "PRIV" + res["pubkey_prefix"] = data[1:7].hex() + res["path_len"] = data[7] + res["txt_type"] = data[8] + res["sender_timestamp"] = int.from_bytes(data[9:13], byteorder='little') + if data[8] == 2 : # signed packet + res["signature"] = data[13:17].hex() + res["text"] = data[17:].decode() + else : + res["text"] = data[13:].decode() + self.result.set_result(res) + case 16: # a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) + res = {} + res["type"] = "PRIV" + res["SNR"] = int.from_bytes(data[1:2], byteorder='little', signed=True) * 4; + res["pubkey_prefix"] = data[4:10].hex() + res["path_len"] = data[10] + res["txt_type"] = data[11] + res["sender_timestamp"] = int.from_bytes(data[12:16], byteorder='little') + if data[11] == 2 : # signed packet + res["signature"] = data[16:20].hex() + res["text"] = data[20:].decode() + else : + res["text"] = data[16:].decode() + self.result.set_result(res) + case 8 : # chanel msg recv + res = {} + res["type"] = "CHAN" + res["channel_idx"] = data[1] + res["path_len"] = data[2] + res["txt_type"] = data[3] + res["sender_timestamp"] = int.from_bytes(data[4:8], byteorder='little') + res["text"] = data[8:].decode() + self.result.set_result(res) + case 17: # a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) + res = {} + res["type"] = "CHAN" + res["SNR"] = int.from_bytes(data[1:2], byteorder='little', signed=True) * 4; + res["channel_idx"] = data[4] + res["path_len"] = data[5] + res["txt_type"] = data[6] + res["sender_timestamp"] = int.from_bytes(data[7:11], byteorder='little') + res["text"] = data[11:].decode() + self.result.set_result(res) + case 9: # current time + self.result.set_result(int.from_bytes(data[1:5], byteorder='little')) + case 10: # no more msgs + self.result.set_result(False) + case 11: # contact + self.result.set_result("meshcore://" + data[1:].hex()) + case 12: # battery voltage + self.result.set_result(int.from_bytes(data[1:2], byteorder='little')) + case 13: # device info + res = {} + res["fw ver"] = data[1] + if data[1] >= 3: + res["max_contacts"] = data[2] * 2 + res["max_channels"] = data[3] + res["ble_pin"] = int.from_bytes(data[4:8], byteorder='little') + res["fw_build"] = data[8:20].decode().replace("\0","") + res["model"] = data[20:60].decode().replace("\0","") + res["ver"] = data[60:80].decode().replace("\0","") + self.result.set_result(res) + # push notifications + case 0x80: + printerr ("Advertisment received") + case 0x81: + printerr ("Code path update") + case 0x82: + self.ack_ev.set() + printerr ("Received ACK") + case 0x83: + self.rx_sem.release() + printerr ("Msgs are waiting") + case 0x84: + printerr ("Received raw data") + res = {} + res["SNR"] = data[1] / 4 + res["RSSI"] = data[2] + res["payload"] = data[4:].hex() + print(res) + case 0x85: + self.login_resp.set_result(True) + + printerr ("Login success") + case 0x86: + self.login_resp.set_result(False) + printerr ("Login failed") + case 0x87: + res = {} + res["pubkey_pre"] = data[2:8].hex() + res["bat"] = int.from_bytes(data[8:10], byteorder='little') + res["tx_queue_len"] = int.from_bytes(data[10:12], byteorder='little') + res["free_queue_len"] = int.from_bytes(data[12:14], byteorder='little') + res["last_rssi"] = int.from_bytes(data[14:16], byteorder='little', signed=True) + res["nb_recv"] = int.from_bytes(data[16:20], byteorder='little', signed=False) + res["nb_sent"] = int.from_bytes(data[20:24], byteorder='little', signed=False) + res["airtime"] = int.from_bytes(data[24:28], byteorder='little') + res["uptime"] = int.from_bytes(data[28:32], byteorder='little') + res["sent_flood"] = int.from_bytes(data[32:36], byteorder='little') + res["sent_direct"] = int.from_bytes(data[36:40], byteorder='little') + res["recv_flood"] = int.from_bytes(data[40:44], byteorder='little') + res["recv_direct"] = int.from_bytes(data[44:48], byteorder='little') + res["full_evts"] = int.from_bytes(data[48:50], byteorder='little') + res["last_snr"] = int.from_bytes(data[50:52], byteorder='little', signed=True) / 4 + res["direct_dups"] = int.from_bytes(data[52:54], byteorder='little') + res["flood_dups"] = int.from_bytes(data[54:56], byteorder='little') + self.status_resp.set_result(res) + data_hex = data[8:].hex() + printerr (f"Status response: {data_hex}") + #printerr(res) + case 0x88: + printerr ("Received log data") + # unhandled + case _: + printerr (f"Unhandled data received {data}") + + async def send(self, data, timeout = 5): + """ Helper function to synchronously send (and receive) data to the node """ + self.result = asyncio.Future() + try: + await self.cx.send(data) + res = await asyncio.wait_for(self.result, timeout) + return res + except TimeoutError : + printerr ("Timeout while sending message ...") + return False + + async def send_only(self, data): # don't wait reply + await self.cx.send(data) + + async def send_appstart(self): + """ Send APPSTART to the node """ + b1 = bytearray(b'\x01\x03 mccli') + return await self.send(b1) + + async def send_device_qeury(self): + return await self.send(b"\x16\x03"); + + async def send_advert(self): + """ Make the node send an advertisement """ + return await self.send(b"\x07") + + async def set_name(self, name): + """ Changes the name of the node """ + return await self.send(b'\x08' + name.encode("ascii")) + + async def set_coords(self, lat, lon): + return await self.send(b'\x0e'\ + + int(lat*1e6).to_bytes(4, 'little', signed=True)\ + + int(lon*1e6).to_bytes(4, 'little', signed=True)\ + + int(0).to_bytes(4, 'little')) + + async def reboot(self): + await self.send_only(b'\x13reboot') + return True + + async def get_bat(self): + return await self.send(b'\x14') + + async def get_time(self): + """ Get the time (epoch) of the node """ + self.time = await self.send(b"\x05") + return self.time + + async def set_time(self, val): + """ Sets a new epoch """ + return await self.send(b"\x06" + int(val).to_bytes(4, 'little')) + + async def set_tx_power(self, val): + """ Sets tx power """ + return await self.send(b"\x0c" + int(val).to_bytes(4, 'little')) + + async def set_radio (self, freq, bw, sf, cr): + """ Sets radio params """ + return await self.send(b"\x0b" \ + + int(float(freq)*1000).to_bytes(4, 'little')\ + + int(float(bw)*1000).to_bytes(4, 'little')\ + + int(sf).to_bytes(1, 'little')\ + + int(cr).to_bytes(1, 'little')) + + async def set_tuning (self, rx_dly, af): + """ Sets radio params """ + return await self.send(b"\x15" \ + + int(rx_dly).to_bytes(4, 'little')\ + + int(af).to_bytes(4, 'little')\ + + int(0).to_bytes(1, 'little')\ + + int(0).to_bytes(1, 'little')) + + async def set_devicepin (self, pin): + return await self.send(b"\x25" \ + + int(pin).to_bytes(4, 'little')) + + async def get_contacts(self): + """ Starts retreiving contacts """ + return await self.send(b"\x04") + + async def ensure_contacts(self): + if len(self.contacts) == 0 : + await self.get_contacts() + + async def reset_path(self, key): + data = b"\x0D" + key + return await self.send(data) + + async def share_contact(self, key): + data = b"\x10" + key + return await self.send(data) + + async def export_contact(self, key=b""): + data = b"\x11" + key + return await self.send(data) + + async def remove_contact(self, key): + data = b"\x0f" + key + return await self.send(data) + + async def set_out_path(self, contact, path): + contact["out_path"] = path + contact["out_path_len"] = -1 + contact["out_path_len"] = int(len(path) / 2) + + async def update_contact(self, contact): + out_path_hex = contact["out_path"] + out_path_hex = out_path_hex + (128-len(out_path_hex)) * "0" + adv_name_hex = contact["adv_name"].encode().hex() + adv_name_hex = adv_name_hex + (64-len(adv_name_hex)) * "0" + data = b"\x09" \ + + bytes.fromhex(contact["public_key"])\ + + contact["type"].to_bytes(1)\ + + contact["flags"].to_bytes(1)\ + + contact["out_path_len"].to_bytes(1, 'little', signed=True)\ + + bytes.fromhex(out_path_hex)\ + + bytes.fromhex(adv_name_hex)\ + + contact["last_advert"].to_bytes(4, 'little')\ + + int(contact["adv_lat"]*1e6).to_bytes(4, 'little', signed=True)\ + + int(contact["adv_lon"]*1e6).to_bytes(4, 'little', signed=True) + return await self.send(data) + + async def send_login(self, dst, pwd): + self.login_resp = asyncio.Future() + data = b"\x1a" + dst + pwd.encode("ascii") + return await self.send(data) + + async def wait_login(self, timeout = 5): + try : + return await asyncio.wait_for(self.login_resp, timeout) + except TimeoutError : + printerr ("Timeout ...") + return False + + async def send_statusreq(self, dst): + self.status_resp = asyncio.Future() + data = b"\x1b" + dst + return await self.send(data) + + async def wait_status(self, timeout = 5): + try : + return await asyncio.wait_for(self.status_resp, timeout) + except TimeoutError : + printerr ("Timeout...") + return False + + async def send_cmd(self, dst, cmd): + """ Send a cmd to a node """ + timestamp = (await self.get_time()).to_bytes(4, 'little') + data = b"\x02\x01\x00" + timestamp + dst + cmd.encode("ascii") + #self.ack_ev.clear() # no ack ? + return await self.send(data) + + async def send_msg(self, dst, msg): + """ Send a message to a node """ + timestamp = (await self.get_time()).to_bytes(4, 'little') + data = b"\x02\x00\x00" + timestamp + dst + msg.encode("ascii") + self.ack_ev.clear() + return await self.send(data) + + async def send_chan_msg(self, chan, msg): + """ Send a message to a public channel """ + timestamp = (await self.get_time()).to_bytes(4, 'little') + data = b"\x03\x00" + chan.to_bytes(1, 'little') + timestamp + msg.encode("ascii") + return await self.send(data) + + async def get_msg(self): + """ Get message from the node (stored in queue) """ + res = await self.send(b"\x0A", 1) + if res is False : + self.rx_sem=asyncio.Semaphore(0) # reset semaphore as there are no msgs in queue + return res + + async def wait_msg(self, timeout=-1): + """ Wait for a message """ + if timeout == -1 : + await self.rx_sem.acquire() + return True + + try: + await asyncio.wait_for(self.rx_sem.acquire(), timeout) + return True + except TimeoutError : + printerr("Timeout waiting msg") + return False + + async def wait_ack(self, timeout=6): + """ Wait ack """ + try: + await asyncio.wait_for(self.ack_ev.wait(), timeout) + return True + except TimeoutError : + printerr("Timeout waiting ack") + return False