diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8f9fb7f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Module", + "type": "python", + "request": "launch", + "module": "meshtastic", + "args": ["--debug", "--ble", "--device", "24:62:AB:DD:DF:3A"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 70802fa..293ed1e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ - "Meshtastic" + "Meshtastic", + "TORADIO" ] } \ No newline at end of file diff --git a/README.md b/README.md index f195f6f..745d0eb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ For the rough notes/implementation plan see [TODO](https://github.com/meshtastic This pip package will also install a "meshtastic" commandline executable, which displays packets sent over the network as JSON and lets you see serial debugging information from the meshtastic devices. The source code for this tool is also a good [example](https://github.com/meshtastic/Meshtastic-python/blob/master/meshtastic/__main__.py) of a 'complete' application that uses the meshtastic python API. +## Bluetooth support + +(Alpha level feature) +This library supports connecting to Meshtastic devices over either USB (serial) or Bluetooth. Before connecting to the device you must [pair](https://docs.ubuntu.com/core/en/stacks/bluetooth/bluez/docs/reference/pairing/outbound.html) your PC with it. +We use the Bleak library, but a very particular version (due to a bleak [bug](https://github.com/hbldh/bleak/issues/139)): python3 -m pip install git+https://github.com/pliniofpa/bleak.git@cbad754205b8dbbe1def448b18d04c65cf5a75e7 + ## Required device software version This API and tool both require that the device is running Meshtastic 0.6.0 or later. diff --git a/TODO.md b/TODO.md index 329e3b2..411b8e5 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ ## Before beta +- ./bin/run.sh --debug --ble --device 24:62:AB:DD:DF:3A +- merge my local fixes to bleak: /home/kevinh/.local/lib/python3.8/site-packages/bleak/backends/bluezdbus/ - update nodedb as nodes change - radioConfig - getter/setter syntax: https://www.python-course.eu/python3_properties.php - let user change radio params via commandline options diff --git a/meshtastic/__init__.py b/meshtastic/__init__.py index 25eada6..83baf99 100644 --- a/meshtastic/__init__.py +++ b/meshtastic/__init__.py @@ -1,5 +1,5 @@ """ -## an API for Meshtastic devices +# an API for Meshtastic devices Primary class: StreamInterface Install with pip: "[pip3 install meshtastic](https://pypi.org/project/meshtastic/)" @@ -7,26 +7,26 @@ Source code on [github](https://github.com/meshtastic/Meshtastic-python) properties of StreamInterface: -- radioConfig - Current radio configuration and device settings, if you write to this the new settings will be applied to +- radioConfig - Current radio configuration and device settings, if you write to this the new settings will be applied to the device. -- nodes - The database of received nodes. Includes always up-to-date location and username information for each +- nodes - The database of received nodes. Includes always up-to-date location and username information for each node in the mesh. This is a read-only datastructure. - myNodeInfo - Contains read-only information about the local radio device (software version, hardware version, etc) -## Published PubSub topics +# Published PubSub topics We use a [publish-subscribe](https://pypubsub.readthedocs.io/en/v4.0.3/) model to communicate asynchronous events. Available topics: - meshtastic.connection.established - published once we've successfully connected to the radio and downloaded the node DB - meshtastic.connection.lost - published once we've lost our link to the radio -- meshtastic.receive.position(packet) - delivers a received packet as a dictionary, if you only care about a particular +- meshtastic.receive.position(packet) - delivers a received packet as a dictionary, if you only care about a particular type of packet, you should subscribe to the full topic name. If you want to see all packets, simply subscribe to "meshtastic.receive". - meshtastic.receive.user(packet) - meshtastic.receive.data(packet) - meshtastic.node.updated(node = NodeInfo) - published when a node in the DB changes (appears, location changed, username changed, etc...) -## Example Usage +# Example Usage ``` import meshtastic from pubsub import pub @@ -35,16 +35,20 @@ def onReceive(packet): # called when a packet arrives print(f"Received: {packet}") def onConnection(): # called when we (re)connect to the radio - interface.sendText("hello mesh") # defaults to broadcast, specify a destination ID if you wish + # defaults to broadcast, specify a destination ID if you wish + interface.sendText("hello mesh") pub.subscribe(onReceive, "meshtastic.receive") pub.subscribe(onConnection, "meshtastic.connection.established") -interface = meshtastic.StreamInterface() # By default will try to find a meshtastic device, otherwise provide a device path like /dev/ttyUSB0 +# By default will try to find a meshtastic device, otherwise provide a device path like /dev/ttyUSB0 +interface = meshtastic.StreamInterface() ``` """ +import asyncio +from bleak import BleakClient import google.protobuf.json_format import serial import threading @@ -82,7 +86,6 @@ class MeshInterface: self.debugOut = debugOut self.nodes = None # FIXME self.isConnected = False - self._startConfig() def sendText(self, text, destinationId=BROADCAST_ADDR): """Send a utf8 string to some other node, if the node has a display it will also be shown on the device. @@ -104,7 +107,7 @@ class MeshInterface: self.sendPacket(meshPacket, destinationId) def sendPacket(self, meshPacket, destinationId=BROADCAST_ADDR): - """Send a MeshPacket to the specified node (or if unspecified, broadcast). + """Send a MeshPacket to the specified node (or if unspecified, broadcast). You probably don't want this - use sendData instead.""" toRadio = mesh_pb2.ToRadio() # FIXME add support for non broadcast addresses @@ -195,8 +198,15 @@ class MeshInterface: if "longitudeI" in position: position["longitude"] = position["longitudeI"] * 1e-7 - def _nodeNumToId(self, num): + """Map a node node number to a node ID + + Arguments: + num {int} -- Node number + + Returns: + string -- Node ID + """ if num == BROADCAST_NUM: return BROADCAST_ADDR @@ -221,7 +231,7 @@ class MeshInterface: asDict["toId"] = self._nodeNumToId(asDict["to"]) # We could provide our objects as DotMaps - which work with . notation or as dictionaries - #asObj = DotMap(asDict) + # asObj = DotMap(asDict) topic = None if meshPacket.decoded.HasField("position"): topic = "meshtastic.receive.position" @@ -240,11 +250,39 @@ class MeshInterface: pub.sendMessage(topic, packet=asDict, interface=self) +# Our standard BLE characteristics +TORADIO_UUID = "f75c76d2-129e-4dad-a1dd-7866124401e7" +FROMRADIO_UUID = "8ba2bcc2-ee02-4a55-a531-c525c5e454d5" +FROMNUM_UUID = "ed9da18c-a800-4f66-a670-aa7547e34453" + + +class BLEInterface(MeshInterface): + def __init__(self, address, debugOut=None): + self.address = address + MeshInterface.__init__(self, debugOut=debugOut) + + async def close(self): + await self.client.disconnect() + + async def run(self, loop): + self.client = BleakClient(self.address, loop=loop) + try: + logging.debug(f"Connecting to {self.address}") + await self.client.connect() + logging.debug("Connected to device") + fromradio = await self.client.read_gatt_char(FROMRADIO_UUID) + print(f"****** fromradio {fromradio}") + except Exception as e: + logging.error(e) + finally: + await self.close() + + class StreamInterface(MeshInterface): """Interface class for meshtastic devices over a stream link (serial, TCP, etc)""" def __init__(self, devPath=None, debugOut=None): - """Constructor, opens a connection to a specified serial port, or if unspecified try to + """Constructor, opens a connection to a specified serial port, or if unspecified try to find one Meshtastic device by probing Keyword Arguments: @@ -275,6 +313,7 @@ class StreamInterface(MeshInterface): self._rxThread = threading.Thread(target=self.__reader, args=()) self._rxThread.start() MeshInterface.__init__(self, debugOut=debugOut) + self._startConfig() def _sendToRadio(self, toRadio): """Send a ToRadio protobuf to the device""" diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index b6d557c..e3e423d 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -1,7 +1,8 @@ #!python3 +import asyncio import argparse -from . import StreamInterface, test +from . import StreamInterface, BLEInterface, test import logging import sys from pubsub import pub @@ -95,6 +96,9 @@ def main(): parser.add_argument("--test", help="Run stress test against all connected Meshtastic devices", action="store_true") + parser.add_argument("--ble", help="hack for testing BLE code", + action="store_true") + global args args = parser.parse_args() logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) @@ -115,7 +119,12 @@ def main(): logfile = open(args.seriallog, 'w+', buffering=1) # line buffering subscribe() - client = StreamInterface(args.device, debugOut=logfile) + if args.ble: + client = BLEInterface(args.device, debugOut=logfile) + loop = asyncio.get_event_loop() + loop.run_until_complete(client.run(loop)) + else: + client = StreamInterface(args.device, debugOut=logfile) if __name__ == "__main__": diff --git a/meshtastic/ble.py b/meshtastic/ble.py new file mode 100644 index 0000000..b502ccd --- /dev/null +++ b/meshtastic/ble.py @@ -0,0 +1,12 @@ +import asyncio +from bleak import discover + + +async def run(): + devices = await discover() + for d in devices: + print(d) + +def bleScan(): + loop = asyncio.get_event_loop() + loop.run_until_complete(run()) diff --git a/setup.py b/setup.py index 8c1044f..e728ceb 100644 --- a/setup.py +++ b/setup.py @@ -26,8 +26,8 @@ setup( packages=["meshtastic"], include_package_data=True, install_requires=["pyserial>=3.4", "protobuf>=3.6.1", - "pypubsub>=4.0.3", "dotmap>=1.3.14"], - python_requires='>=3', + "pypubsub>=4.0.3", "dotmap>=1.3.14", "bleak>=0.6.1"], + python_requires='>=3.4', entry_points={ "console_scripts": [ "meshtastic=meshtastic.__main__:main"