Skip to content

Add Apple Notification support and fix HID #38

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Dec 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions adafruit_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
`adafruit_ble`
====================================================

This module provides higher-level BLE (Bluetooth Low Energy) functionality,
building on the native `_bleio` module.

* Author(s): Dan Halbert and Scott Shawcroft for Adafruit Industries

Implementation Notes
--------------------

**Hardware:**

Adafruit Feather nRF52840 Express <https://www.adafruit.com/product/4062>
Adafruit Circuit Playground Bluefruit <https://www.adafruit.com/product/4333>

**Software and Dependencies:**

* Adafruit CircuitPython firmware for the supported boards:
https://github.com/adafruit/circuitpython/releases

"""
#pylint: disable=wrong-import-position
import sys
Expand Down Expand Up @@ -129,6 +112,31 @@ def connected(self):
"""True if the connection to the peer is still active."""
return self._bleio_connection.connected

@property
def paired(self):
"""True if the paired to the peer."""
return self._bleio_connection.paired

@property
def connection_interval(self):
"""Time between transmissions in milliseconds. Will be multiple of 1.25ms. Lower numbers
increase speed and decrease latency but increase power consumption.

When setting connection_interval, the peer may reject the new interval and
`connection_interval` will then remain the same.

Apple has additional guidelines that dictate should be a multiple of 15ms except if HID
is available. When HID is available Apple devices may accept 11.25ms intervals."""
return self._bleio_connection.connection_interval

@connection_interval.setter
def connection_interval(self, value):
self._bleio_connection.connection_interval = value

def pair(self, *, bond=True):
"""Pair to the peer to increase security of the connection."""
return self._bleio_connection.pair(bond=bond)

def disconnect(self):
"""Disconnect from peer."""
self._bleio_connection.disconnect()
Expand Down Expand Up @@ -243,7 +251,7 @@ def connections(self):
"""A tuple of active `BLEConnection` objects."""
connections = self._adapter.connections
wrapped_connections = [None] * len(connections)
for i, connection in enumerate(self._adapter.connections):
for i, connection in enumerate(connections):
if connection not in self._connection_cache:
self._connection_cache[connection] = BLEConnection(connection)
wrapped_connections[i] = self._connection_cache[connection]
Expand Down
4 changes: 2 additions & 2 deletions adafruit_ble/advertising/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ class Advertisement:
# RANDOM_TARGET_ADDRESS = 0x18
# """Random target address (chosen randomly)."""
# APPEARANCE = 0x19
# # self.add_field(AdvertisingPacket.APPEARANCE, struct.pack("<H", appearance))
# """Appearance."""
appearance = Struct("<H", advertising_data_type=0x19)
"""Appearance."""
# DEVICE_ADDRESS = 0x1B
# """LE Bluetooth device address."""
# ROLE = 0x1C
Expand Down
7 changes: 5 additions & 2 deletions adafruit_ble/advertising/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
`standard`
:py:mod:`~adafruit_ble.advertising.standard`
====================================================

This module provides BLE standard defined advertisements. The Advertisements are single purpose
Expand Down Expand Up @@ -84,8 +84,11 @@ def __iter__(self):
def append(self, service):
"""Append a service to the list."""
if isinstance(service.uuid, StandardUUID) and service not in self._standard_services:
self._standard_services.append(service)
self._standard_services.append(service.uuid)
self._update(self._standard_service_fields[0], self._standard_services)
elif isinstance(service.uuid, VendorUUID) and service not in self._vendor_services:
self._vendor_services.append(service.uuid)
self._update(self._vendor_service_fields[0], self._vendor_services)

# TODO: Differentiate between complete and incomplete lists.
def extend(self, services):
Expand Down
14 changes: 7 additions & 7 deletions adafruit_ble/characteristics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
:py:mod:`~adafruit_ble.characteristics`
====================================================

This module provides core BLE characteristic classes that are used within Services.

Expand Down Expand Up @@ -92,7 +90,7 @@ class Characteristic:

def __init__(self, *, uuid=None, properties=0,
read_perm=Attribute.OPEN, write_perm=Attribute.OPEN,
max_length=20, fixed_length=False, initial_value=None):
max_length=None, fixed_length=False, initial_value=None):
self.field_name = None # Set by Service during basic binding

if uuid:
Expand Down Expand Up @@ -127,9 +125,9 @@ def __bind_locally(self, service, initial_value):
initial_value = bytes(self.max_length)
max_length = self.max_length
if max_length is None and initial_value is None:
max_length = 20
initial_value = bytes(max_length)
if max_length is None:
max_length = 0
initial_value = b""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max_length of 0 is not very useful. I think this should throw an error if both max_length and initial_value are unspecified.

I was worried that UARTService might break also because it was assuming a default of 20, but I see that ComplexCharacteristicstill has a default of 20. Should ComplexCharacteristic set default length=None but also a similar check and not allow None for both length and initial_value? I think so. Then StreamIn and StreamOut would explicitly pass in 20, or have add an optional max_length=20 parameter, or maybe pass in all the length-related parameters.

And other services that assume a default of 20 should be checked and modified if necessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I started tweaking this but I think it's a bigger rabbit hole than it first appears. I'd rather wait to tackle this later with BLE MIDI.

It is a rabbit hole because the 20 bytes is knowledge that this layer shouldn't actually have because it varies with the MTU. Hardcoding it here limits the throughput of stream characteristics. I agree there should be a check that initial value or max length is set for fixed characteristics but it's best put in _bleio where the stack has knowledge of its limits.

I confirmed that the Color Picker demo still works due to the 20 default in ComplexCharacteristic.

elif max_length is None:
max_length = len(initial_value)
return _bleio.Characteristic.add_to_service(
service.bleio_service, self.uuid.bleio_uuid, initial_value=initial_value,
Expand All @@ -143,6 +141,8 @@ def __get__(self, service, cls=None):

def __set__(self, service, value):
self._ensure_bound(service, value)
if value is None:
value = b""
bleio_characteristic = service.bleio_characteristics[self.field_name]
bleio_characteristic.value = value

Expand Down Expand Up @@ -201,7 +201,7 @@ def __init__(self, struct_format, *, uuid=None, properties=0,
self._struct_format = struct_format
self._expected_size = struct.calcsize(struct_format)
if initial_value:
initial_value = struct.pack(self._struct_format, initial_value)
initial_value = struct.pack(self._struct_format, *initial_value)
super().__init__(uuid=uuid, initial_value=initial_value,
max_length=self._expected_size, fixed_length=True,
properties=properties, read_perm=read_perm, write_perm=write_perm)
Expand Down
2 changes: 1 addition & 1 deletion adafruit_ble/characteristics/int.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ def __init__(self, format_string, min_value, max_value, *, uuid=None, properties
self._min_value = min_value
self._max_value = max_value
if initial_value:
initial_value = (initial_value,)
if not self._min_value <= initial_value <= self._max_value:
raise ValueError("initial_value out of range")
initial_value = (initial_value,)


super().__init__(format_string, uuid=uuid, properties=properties,
Expand Down
2 changes: 0 additions & 2 deletions adafruit_ble/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
:py:mod:`~adafruit_ble.services`
====================================================

This module provides the top level Service definition.

Expand Down
193 changes: 192 additions & 1 deletion adafruit_ble/services/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@

"""

import struct
import time

from . import Service
from ..uuid import VendorUUID
from ..characteristics.stream import StreamIn, StreamOut

__version__ = "0.0.0-auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_BLE.git"
Expand All @@ -41,10 +45,197 @@ class UnknownApple1Service(Service):
"""Unknown service. Unimplemented."""
uuid = VendorUUID("9fa480e0-4967-4542-9390-d343dc5d04ae")

class _NotificationAttribute:
def __init__(self, attribute_id, *, max_length=False):
self._id = attribute_id
self._max_length = max_length

def __get__(self, notification, cls):
if self._id in notification._attribute_cache:
return notification._attribute_cache[self._id]

if self._max_length:
command = struct.pack("<BIBH", 0, notification.id, self._id, 255)
else:
command = struct.pack("<BIB", 0, notification.id, self._id)
notification.control_point.write(command)
while notification.data_source.in_waiting == 0:
pass

_, _ = struct.unpack("<BI", notification.data_source.read(5))
attribute_id, attribute_length = struct.unpack("<BH", notification.data_source.read(3))
if attribute_id != self._id:
raise RuntimeError("Data for other attribute")
value = notification.data_source.read(attribute_length)
value = value.decode("utf-8")
notification._attribute_cache[self._id] = value
return value

NOTIFICATION_CATEGORIES = (
"Other",
"IncomingCall",
"MissedCall",
"Voicemail",
"Social",
"Schedule",
"Email",
"News",
"HealthAndFitness",
"BusinessAndFinance",
"Location",
"Entertainment"
)

class Notification:
"""One notification that appears in the iOS notification center."""
# pylint: disable=too-many-instance-attributes

app_id = _NotificationAttribute(0)
"""String id of the app that generated the notification. It is not the name of the app. For
example, Slack is "com.tinyspeck.chatlyio" and Twitter is "com.atebits.Tweetie2"."""

title = _NotificationAttribute(1, max_length=True)
"""Title of the notification. Varies per app."""

subtitle = _NotificationAttribute(2, max_length=True)
"""Subtitle of the notification. Varies per app."""

message = _NotificationAttribute(3, max_length=True)
"""Message body of the notification. Varies per app."""

message_size = _NotificationAttribute(4)
"""Total length of the message string."""

_raw_date = _NotificationAttribute(5)
positive_action_label = _NotificationAttribute(6)
"""Human readable label of the positive action."""

negative_action_label = _NotificationAttribute(7)
"""Human readable label of the negative action."""

def __init__(self, notification_id, event_flags, category_id, category_count, *, control_point,
data_source):
self.id = notification_id # pylint: disable=invalid-name
"""Integer id of the notification."""

self.removed = False
"""True when the notification has been cleared on the iOS device."""


self.silent = False
self.important = False
self.preexisting = False
"""True if the notification existed before we connected to the iOS device."""

self.positive_action = False
"""True if the notification has a positive action to respond with. For example, this could
be answering a phone call."""

self.negative_action = False
"""True if the notification has a negative action to respond with. For example, this could
be declining a phone call."""

self.category_count = 0
"""Number of other notifications with the same category."""

self.update(event_flags, category_id, category_count)

self._attribute_cache = {}

self.control_point = control_point
self.data_source = data_source

def update(self, event_flags, category_id, category_count):
"""Update the notification and clear the attribute cache."""
self.category_id = category_id

self.category_count = category_count

self.silent = (event_flags & (1 << 0)) != 0
self.important = (event_flags & (1 << 1)) != 0
self.preexisting = (event_flags & (1 << 2)) != 0
self.positive_action = (event_flags & (1 << 3)) != 0
self.negative_action = (event_flags & (1 << 4)) != 0

self._attribute_cache = {}

def __str__(self):
# pylint: disable=too-many-branches
flags = []
category = None
if self.category_id < len(NOTIFICATION_CATEGORIES):
category = NOTIFICATION_CATEGORIES[self.category_id]

if self.silent:
flags.append("silent")
if self.important:
flags.append("important")
if self.preexisting:
flags.append("preexisting")
if self.positive_action:
flags.append("positive_action")
if self.negative_action:
flags.append("negative_action")
return (category + " " +
" ".join(flags) + " " +
self.app_id + " " +
str(self.title) + " " +
str(self.subtitle) + " " +
str(self.message))

class AppleNotificationService(Service):
"""Notification service. Unimplemented."""
"""Notification service."""
uuid = VendorUUID("7905F431-B5CE-4E99-A40F-4B1E122D00D0")

control_point = StreamIn(uuid=VendorUUID("69D1D8F3-45E1-49A8-9821-9BBDFDAAD9D9"))
data_source = StreamOut(uuid=VendorUUID("22EAC6E9-24D6-4BB5-BE44-B36ACE7C7BFB"),
buffer_size=1024)
notification_source = StreamOut(uuid=VendorUUID("9FBF120D-6301-42D9-8C58-25E699A21DBD"),
buffer_size=8*100)

def __init__(self, service=None):
super().__init__(service=service)
self._active_notifications = {}

def _update(self):
# Pylint is incorrectly inferring the type of self.notification_source so disable no-member.
while self.notification_source.in_waiting > 7: # pylint: disable=no-member
buffer = self.notification_source.read(8) # pylint: disable=no-member
event_id, event_flags, category_id, category_count, nid = struct.unpack("<BBBBI",
buffer)
if event_id == 0:
self._active_notifications[nid] = Notification(nid, event_flags, category_id,
category_count,
control_point=self.control_point,
data_source=self.data_source)
yield self._active_notifications[nid]
elif event_id == 1:
self._active_notifications[nid].update(event_flags, category_id, category_count)
yield None
elif event_id == 2:
self._active_notifications[nid].removed = True
del self._active_notifications[nid]
yield None

def wait_for_new_notifications(self, timeout=None):
"""Waits for new notifications and yields them. Returns on timeout, update, disconnect or
clear."""
start_time = time.monotonic()
while timeout is None or timeout > time.monotonic() - start_time:
try:
new_notification = next(self._update())
except StopIteration:
return
if new_notification:
yield new_notification

@property
def active_notifications(self):
"""A dictionary of active notifications keyed by id."""
for _ in self._update():
pass
return self._active_notifications

class AppleMediaService(Service):
"""View and control currently playing media. Unimplemented."""
uuid = VendorUUID("89D3502B-0F36-433A-8EF4-C502AD55F8DC")
Loading