Add a BLE abstraction layer
This commit is contained in:
parent
89cf8ef67d
commit
bf2c000b57
42
tuhi.py
42
tuhi.py
|
@ -11,20 +11,50 @@
|
|||
# GNU General Public License for more details.
|
||||
#
|
||||
|
||||
from tuhi.dbusserver import TuhiDBusServer
|
||||
from gi.repository import GObject
|
||||
import logging
|
||||
import sys
|
||||
from gi.repository import GObject
|
||||
|
||||
from tuhi.dbusserver import TuhiDBusServer
|
||||
from tuhi.ble import BlueZDeviceManager
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger('tuhi')
|
||||
|
||||
WACOM_COMPANY_ID = 0x4755
|
||||
|
||||
|
||||
class Tuhi(GObject.Object):
|
||||
def __init__(self):
|
||||
self.server = TuhiDBusServer()
|
||||
self.bluez = BlueZDeviceManager()
|
||||
|
||||
self.bluez.connect('device-added', self._on_device_added)
|
||||
self.bluez.connect_to_bluez()
|
||||
|
||||
def _on_device_added(self, manager, device):
|
||||
if device.vendor_id != WACOM_COMPANY_ID:
|
||||
return
|
||||
|
||||
device.connect('connected', self._on_device_connected)
|
||||
device.connect_device()
|
||||
|
||||
def _on_device_connected(self, device):
|
||||
logger.debug('{}: connected'.format(device.address))
|
||||
|
||||
d = WacomDevice(device)
|
||||
d.start()
|
||||
|
||||
|
||||
def main(args):
|
||||
t = TuhiDBusServer()
|
||||
t = Tuhi()
|
||||
try:
|
||||
import tuhi.ble
|
||||
tuhi.ble.main(None)
|
||||
GObject.MainLoop().run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
t.cleanup()
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv)
|
||||
|
|
|
@ -0,0 +1,310 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from enum import Enum
|
||||
from gi.repository import GObject, GLib, Gio
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger('ble')
|
||||
|
||||
ORG_BLUEZ_GATTCHARACTERISTIC1 = 'org.bluez.GattCharacteristic1'
|
||||
ORG_BLUEZ_GATTSERVICE1 = 'org.bluez.GattService1'
|
||||
ORG_BLUEZ_DEVICE1 = 'org.bluez.Device1'
|
||||
ORG_BLUEZ_ADAPTER1 = 'org.bluez.Adapter1'
|
||||
|
||||
class BlueZCharacteristic(GObject.Object):
|
||||
"""
|
||||
Abstraction for a org.bluez.GattCharacteristic1 object
|
||||
|
||||
:param obj: the org.bluez.GattCharacteristic1 DBus proxy object
|
||||
"""
|
||||
def __init__(self, obj):
|
||||
self.obj = obj
|
||||
self.objpath = obj.get_object_path()
|
||||
self.interface = obj.get_interface(ORG_BLUEZ_GATTCHARACTERISTIC1)
|
||||
assert(self.interface is not None)
|
||||
|
||||
self.uuid = self.interface.get_cached_property('UUID').unpack()
|
||||
assert(self.uuid is not None)
|
||||
|
||||
self._property_callbacks = {}
|
||||
self.interface.connect('g-properties-changed',
|
||||
self._on_properties_changed)
|
||||
|
||||
def connect_property(self, propname, callback):
|
||||
"""
|
||||
Connect the property with the given name to the callback function
|
||||
provide. When the property chages, callback is invoked as:
|
||||
|
||||
callback(propname, value)
|
||||
"""
|
||||
self._property_callbacks[propname] = callback
|
||||
|
||||
def start_notify(self):
|
||||
self.interface.StartNotify()
|
||||
|
||||
def write_value(self, data):
|
||||
return self.interface.WriteValue('(aya{sv})', data, {})
|
||||
|
||||
def _on_properties_changed(self, obj, properties, invalidated_properties):
|
||||
properties = properties.unpack()
|
||||
for name, value in properties.items():
|
||||
try:
|
||||
self._property_callbacks[name](name, value)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return 'Characteristic {}:{}'.format(self.uuid, self.objpath)
|
||||
|
||||
|
||||
class BlueZDevice(GObject.Object):
|
||||
"""
|
||||
Abstraction for a org.bluez.Device1 object
|
||||
|
||||
The device initializes itself based on the given object manager and
|
||||
object, specifically: it resolves its services an gatt characteristics.
|
||||
|
||||
:param om: The ObjectManager for name org.bluez path /
|
||||
:param obj: The org.bluez.Device1 DBus proxy object
|
||||
|
||||
"""
|
||||
__gsignals__ = {
|
||||
"connected":
|
||||
(GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
"disconnected":
|
||||
(GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
}
|
||||
|
||||
def __init__(self, om, obj):
|
||||
GObject.Object.__init__(self)
|
||||
self.objpath = obj.get_object_path()
|
||||
self.obj = obj
|
||||
self.interface = obj.get_interface(ORG_BLUEZ_DEVICE1)
|
||||
assert(self.interface is not None)
|
||||
|
||||
self.address = self.interface.get_cached_property('Address').get_string()
|
||||
self.name = self.interface.get_cached_property('Name').get_string()
|
||||
self.uuids = self.interface.get_cached_property('UUIDs')
|
||||
self.vendor_id = 0
|
||||
md = self.interface.get_cached_property('ManufacturerData')
|
||||
if md is not None:
|
||||
self.vendor_id = md.keys()[0]
|
||||
|
||||
assert(self.name is not None)
|
||||
assert(self.address is not None)
|
||||
assert(self.uuids is not None)
|
||||
logger.debug('Device {} - {} - {}'.format(self.objpath, self.address, self.name))
|
||||
|
||||
self.characteristics = {}
|
||||
self.resolve(om)
|
||||
self.interface.connect('g-properties-changed', self._on_properties_changed)
|
||||
if self.interface.get_cached_property('Connected').get_boolean():
|
||||
self.emit('connected')
|
||||
|
||||
def resolve(self, om):
|
||||
"""
|
||||
Resolve the GattServices and GattCharacteristics. This function does
|
||||
not need to be called for existing objects but if a device comes in
|
||||
at runtime not all services may have been resolved by the time the
|
||||
org.bluez.Device1 shows up.
|
||||
"""
|
||||
objects = om.get_objects()
|
||||
self._resolve_gatt_services(objects)
|
||||
|
||||
def _resolve_gatt_services(self, objects):
|
||||
self.gatt_services = []
|
||||
for obj in objects:
|
||||
i = obj.get_interface(ORG_BLUEZ_GATTSERVICE1)
|
||||
if i is None:
|
||||
continue
|
||||
|
||||
device = i.get_cached_property('Device').get_string()
|
||||
if device != self.objpath:
|
||||
continue
|
||||
|
||||
logger.debug("GattService1: {} for device {}".format(obj.get_object_path(), device))
|
||||
self.gatt_services.append(obj)
|
||||
self._resolve_gatt_characteristics(obj, objects)
|
||||
|
||||
def _resolve_gatt_characteristics(self, service_obj, objects):
|
||||
for obj in objects:
|
||||
i = obj.get_interface(ORG_BLUEZ_GATTCHARACTERISTIC1)
|
||||
if i is None:
|
||||
continue
|
||||
|
||||
service = i.get_cached_property('Service').get_string()
|
||||
if service != service_obj.get_object_path():
|
||||
continue
|
||||
|
||||
chrc = BlueZCharacteristic(obj)
|
||||
if chrc.uuid in self.characteristics:
|
||||
continue
|
||||
|
||||
logger.debug("GattCharacteristic: {} for service {}".format(chrc.uuid, service))
|
||||
|
||||
self.characteristics[chrc.uuid] = chrc
|
||||
|
||||
def connect_device(self):
|
||||
"""
|
||||
Connect to the bluetooth device via bluez. This function is
|
||||
asynchronous and returns immediately.
|
||||
"""
|
||||
i = self.obj.get_interface(ORG_BLUEZ_DEVICE1)
|
||||
if i.get_cached_property('Connected').get_boolean():
|
||||
logger.info('{}: Device is already connected'.format(self.address))
|
||||
self.emit('connected')
|
||||
return
|
||||
|
||||
logger.info('{}: Connecting'.format(self.address))
|
||||
i.Connect(result_handler=self._on_connect_result)
|
||||
|
||||
def _on_connect_result(self, obj, result, user_data):
|
||||
if isinstance(result, Exception):
|
||||
logger.error('Connection failed: {}'.format(result))
|
||||
|
||||
def _on_properties_changed(self, obj, properties, invalidated_properties):
|
||||
properties = properties.unpack()
|
||||
|
||||
if 'Connected' in properties:
|
||||
if properties['Connected']:
|
||||
logger.info('Connection established')
|
||||
self.emit('connected')
|
||||
else:
|
||||
logger.info('Disconnected')
|
||||
self.emit('disconnected', self)
|
||||
|
||||
def connect_gatt_value(self, uuid, callback):
|
||||
"""
|
||||
Connects Value property changes of the given GATT Characteristics
|
||||
UUID to the callback.
|
||||
"""
|
||||
try:
|
||||
chrc = self.characteristics[uuid]
|
||||
chrc.connect_property('Value', callback)
|
||||
chrc.start_notify()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# this is wacom-specific, not BlueZDevice specific, needs to be moved
|
||||
# out somehow
|
||||
def _start_notifications(self):
|
||||
self._start_gatt_notification(WACOM_CHRC_LIVE_PEN_DATA_UUID,
|
||||
self._pen_data_changed_cb)
|
||||
self._start_gatt_notification(WACOM_OFFLINE_CHRC_PEN_DATA_UUID,
|
||||
self._pen_data_received_cb)
|
||||
self._start_gatt_notification(NORDIC_UART_CHRC_RX_UUID,
|
||||
self._receive_nordic_data_cb)
|
||||
|
||||
def _start_gatt_notification(self, uuid, callback):
|
||||
try:
|
||||
chrc = self.characteristics[uuid]
|
||||
chrc.connect_properties(callback)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _pen_data_changed_cb(self, obc, changed_props, invalidated_props):
|
||||
print('pen data changed')
|
||||
|
||||
def _pen_data_received_cb(self, obc, changed_props, invalidated_props):
|
||||
print('pen data received')
|
||||
|
||||
def _receive_nordic_data_cb(self, obc, changed_props, invalidated_props):
|
||||
print('nordic data received')
|
||||
|
||||
def start(self):
|
||||
self.retrieve_data()
|
||||
|
||||
def __repr__(self):
|
||||
return 'Device {}:{}'.format(self.name, self.objpath)
|
||||
|
||||
class BlueZDeviceManager(GObject.Object):
|
||||
"""
|
||||
Manager object that connects to org.bluez's root object and handles the
|
||||
devices. If device_filter_callback is set, it is called for each device
|
||||
and expected to return True if the device should be used or False if the
|
||||
device should be ignored.
|
||||
"""
|
||||
__gsignals__ = {
|
||||
"device-added":
|
||||
(GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
GObject.Object.__init__(self, **kwargs)
|
||||
self.devices = []
|
||||
|
||||
def connect_to_bluez(self):
|
||||
self._om = Gio.DBusObjectManagerClient.new_for_bus_sync(
|
||||
Gio.BusType.SYSTEM,
|
||||
Gio.DBusObjectManagerClientFlags.NONE,
|
||||
'org.bluez',
|
||||
'/',
|
||||
None,
|
||||
None,
|
||||
None)
|
||||
self._om.connect('object-added', self._on_om_object_added)
|
||||
self._om.connect('object-removed', self._on_om_object_removed)
|
||||
|
||||
# We rely on nested object paths, so let's sort the objects by
|
||||
# object path length and process them in order, this way we're
|
||||
# guaranteed that the objects we need already exist.
|
||||
for obj in self._om.get_objects():
|
||||
self._process_object(obj)
|
||||
|
||||
def _on_om_object_added(self, om, obj):
|
||||
"""Callback for ObjectManager's object-added"""
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug('Object added: {}'.format(objpath))
|
||||
needs_resolve = self._process_object(obj)
|
||||
|
||||
# we had at least one characteristic added, need to resolve the
|
||||
# devices.
|
||||
# FIXME: this isn't the most efficient way...
|
||||
if needs_resolve:
|
||||
for d in self.devices:
|
||||
d.resolve(om)
|
||||
|
||||
def _on_om_object_removed(self, om, obj):
|
||||
"""Callback for ObjectManager's object-removed"""
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug('Object removed: {}'.format(objpath))
|
||||
|
||||
def _process_object(self, obj):
|
||||
"""Process a single DBusProxyObject"""
|
||||
|
||||
if obj.get_interface(ORG_BLUEZ_ADAPTER1) is not None:
|
||||
self._process_adapter(obj)
|
||||
elif obj.get_interface(ORG_BLUEZ_DEVICE1) is not None:
|
||||
self._process_device(obj)
|
||||
elif obj.get_interface(ORG_BLUEZ_GATTCHARACTERISTIC1) is not None:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _process_adapter(self, obj):
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug('Adapter: {}'.format(objpath))
|
||||
# FIXME: call StartDiscovery if we want to pair
|
||||
|
||||
def _process_device(self, obj):
|
||||
objpath = obj.get_object_path()
|
||||
dev = BlueZDevice(self._om, obj)
|
||||
self.devices.append(dev)
|
||||
self.emit("device-added", dev)
|
||||
|
||||
def _process_characteristic(self, obj):
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug('Characteristic {}'.format(objpath))
|
||||
|
Loading…
Reference in New Issue