Skip to content

Commit 668b0bc

Browse files
authored
Merge pull request #38 from tannewt/support_ancs
Add Apple Notification support and fix HID
2 parents 795dd52 + 3240b53 commit 668b0bc

30 files changed

+465
-201
lines changed

adafruit_ble/__init__.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,10 @@
2121
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2222
# THE SOFTWARE.
2323
"""
24-
`adafruit_ble`
25-
====================================================
2624
2725
This module provides higher-level BLE (Bluetooth Low Energy) functionality,
2826
building on the native `_bleio` module.
2927
30-
* Author(s): Dan Halbert and Scott Shawcroft for Adafruit Industries
31-
32-
Implementation Notes
33-
--------------------
34-
35-
**Hardware:**
36-
37-
Adafruit Feather nRF52840 Express <https://www.adafruit.com/product/4062>
38-
Adafruit Circuit Playground Bluefruit <https://www.adafruit.com/product/4333>
39-
40-
**Software and Dependencies:**
41-
42-
* Adafruit CircuitPython firmware for the supported boards:
43-
https://github.com/adafruit/circuitpython/releases
44-
4528
"""
4629
#pylint: disable=wrong-import-position
4730
import sys
@@ -129,6 +112,31 @@ def connected(self):
129112
"""True if the connection to the peer is still active."""
130113
return self._bleio_connection.connected
131114

115+
@property
116+
def paired(self):
117+
"""True if the paired to the peer."""
118+
return self._bleio_connection.paired
119+
120+
@property
121+
def connection_interval(self):
122+
"""Time between transmissions in milliseconds. Will be multiple of 1.25ms. Lower numbers
123+
increase speed and decrease latency but increase power consumption.
124+
125+
When setting connection_interval, the peer may reject the new interval and
126+
`connection_interval` will then remain the same.
127+
128+
Apple has additional guidelines that dictate should be a multiple of 15ms except if HID
129+
is available. When HID is available Apple devices may accept 11.25ms intervals."""
130+
return self._bleio_connection.connection_interval
131+
132+
@connection_interval.setter
133+
def connection_interval(self, value):
134+
self._bleio_connection.connection_interval = value
135+
136+
def pair(self, *, bond=True):
137+
"""Pair to the peer to increase security of the connection."""
138+
return self._bleio_connection.pair(bond=bond)
139+
132140
def disconnect(self):
133141
"""Disconnect from peer."""
134142
self._bleio_connection.disconnect()
@@ -243,7 +251,7 @@ def connections(self):
243251
"""A tuple of active `BLEConnection` objects."""
244252
connections = self._adapter.connections
245253
wrapped_connections = [None] * len(connections)
246-
for i, connection in enumerate(self._adapter.connections):
254+
for i, connection in enumerate(connections):
247255
if connection not in self._connection_cache:
248256
self._connection_cache[connection] = BLEConnection(connection)
249257
wrapped_connections[i] = self._connection_cache[connection]

adafruit_ble/advertising/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ class Advertisement:
209209
# RANDOM_TARGET_ADDRESS = 0x18
210210
# """Random target address (chosen randomly)."""
211211
# APPEARANCE = 0x19
212-
# # self.add_field(AdvertisingPacket.APPEARANCE, struct.pack("<H", appearance))
213-
# """Appearance."""
212+
appearance = Struct("<H", advertising_data_type=0x19)
213+
"""Appearance."""
214214
# DEVICE_ADDRESS = 0x1B
215215
# """LE Bluetooth device address."""
216216
# ROLE = 0x1C

adafruit_ble/advertising/standard.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2121
# THE SOFTWARE.
2222
"""
23-
`standard`
23+
:py:mod:`~adafruit_ble.advertising.standard`
2424
====================================================
2525
2626
This module provides BLE standard defined advertisements. The Advertisements are single purpose
@@ -84,8 +84,11 @@ def __iter__(self):
8484
def append(self, service):
8585
"""Append a service to the list."""
8686
if isinstance(service.uuid, StandardUUID) and service not in self._standard_services:
87-
self._standard_services.append(service)
87+
self._standard_services.append(service.uuid)
8888
self._update(self._standard_service_fields[0], self._standard_services)
89+
elif isinstance(service.uuid, VendorUUID) and service not in self._vendor_services:
90+
self._vendor_services.append(service.uuid)
91+
self._update(self._vendor_service_fields[0], self._vendor_services)
8992

9093
# TODO: Differentiate between complete and incomplete lists.
9194
def extend(self, services):

adafruit_ble/characteristics/__init__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2121
# THE SOFTWARE.
2222
"""
23-
:py:mod:`~adafruit_ble.characteristics`
24-
====================================================
2523
2624
This module provides core BLE characteristic classes that are used within Services.
2725
@@ -92,7 +90,7 @@ class Characteristic:
9290

9391
def __init__(self, *, uuid=None, properties=0,
9492
read_perm=Attribute.OPEN, write_perm=Attribute.OPEN,
95-
max_length=20, fixed_length=False, initial_value=None):
93+
max_length=None, fixed_length=False, initial_value=None):
9694
self.field_name = None # Set by Service during basic binding
9795

9896
if uuid:
@@ -127,9 +125,9 @@ def __bind_locally(self, service, initial_value):
127125
initial_value = bytes(self.max_length)
128126
max_length = self.max_length
129127
if max_length is None and initial_value is None:
130-
max_length = 20
131-
initial_value = bytes(max_length)
132-
if max_length is None:
128+
max_length = 0
129+
initial_value = b""
130+
elif max_length is None:
133131
max_length = len(initial_value)
134132
return _bleio.Characteristic.add_to_service(
135133
service.bleio_service, self.uuid.bleio_uuid, initial_value=initial_value,
@@ -143,6 +141,8 @@ def __get__(self, service, cls=None):
143141

144142
def __set__(self, service, value):
145143
self._ensure_bound(service, value)
144+
if value is None:
145+
value = b""
146146
bleio_characteristic = service.bleio_characteristics[self.field_name]
147147
bleio_characteristic.value = value
148148

@@ -201,7 +201,7 @@ def __init__(self, struct_format, *, uuid=None, properties=0,
201201
self._struct_format = struct_format
202202
self._expected_size = struct.calcsize(struct_format)
203203
if initial_value:
204-
initial_value = struct.pack(self._struct_format, initial_value)
204+
initial_value = struct.pack(self._struct_format, *initial_value)
205205
super().__init__(uuid=uuid, initial_value=initial_value,
206206
max_length=self._expected_size, fixed_length=True,
207207
properties=properties, read_perm=read_perm, write_perm=write_perm)

adafruit_ble/characteristics/int.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ def __init__(self, format_string, min_value, max_value, *, uuid=None, properties
4141
self._min_value = min_value
4242
self._max_value = max_value
4343
if initial_value:
44-
initial_value = (initial_value,)
4544
if not self._min_value <= initial_value <= self._max_value:
4645
raise ValueError("initial_value out of range")
46+
initial_value = (initial_value,)
4747

4848

4949
super().__init__(format_string, uuid=uuid, properties=properties,

adafruit_ble/services/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2121
# THE SOFTWARE.
2222
"""
23-
:py:mod:`~adafruit_ble.services`
24-
====================================================
2523
2624
This module provides the top level Service definition.
2725

adafruit_ble/services/apple.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@
2727
2828
"""
2929

30+
import struct
31+
import time
32+
3033
from . import Service
3134
from ..uuid import VendorUUID
35+
from ..characteristics.stream import StreamIn, StreamOut
3236

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

48+
class _NotificationAttribute:
49+
def __init__(self, attribute_id, *, max_length=False):
50+
self._id = attribute_id
51+
self._max_length = max_length
52+
53+
def __get__(self, notification, cls):
54+
if self._id in notification._attribute_cache:
55+
return notification._attribute_cache[self._id]
56+
57+
if self._max_length:
58+
command = struct.pack("<BIBH", 0, notification.id, self._id, 255)
59+
else:
60+
command = struct.pack("<BIB", 0, notification.id, self._id)
61+
notification.control_point.write(command)
62+
while notification.data_source.in_waiting == 0:
63+
pass
64+
65+
_, _ = struct.unpack("<BI", notification.data_source.read(5))
66+
attribute_id, attribute_length = struct.unpack("<BH", notification.data_source.read(3))
67+
if attribute_id != self._id:
68+
raise RuntimeError("Data for other attribute")
69+
value = notification.data_source.read(attribute_length)
70+
value = value.decode("utf-8")
71+
notification._attribute_cache[self._id] = value
72+
return value
73+
74+
NOTIFICATION_CATEGORIES = (
75+
"Other",
76+
"IncomingCall",
77+
"MissedCall",
78+
"Voicemail",
79+
"Social",
80+
"Schedule",
81+
"Email",
82+
"News",
83+
"HealthAndFitness",
84+
"BusinessAndFinance",
85+
"Location",
86+
"Entertainment"
87+
)
88+
89+
class Notification:
90+
"""One notification that appears in the iOS notification center."""
91+
# pylint: disable=too-many-instance-attributes
92+
93+
app_id = _NotificationAttribute(0)
94+
"""String id of the app that generated the notification. It is not the name of the app. For
95+
example, Slack is "com.tinyspeck.chatlyio" and Twitter is "com.atebits.Tweetie2"."""
96+
97+
title = _NotificationAttribute(1, max_length=True)
98+
"""Title of the notification. Varies per app."""
99+
100+
subtitle = _NotificationAttribute(2, max_length=True)
101+
"""Subtitle of the notification. Varies per app."""
102+
103+
message = _NotificationAttribute(3, max_length=True)
104+
"""Message body of the notification. Varies per app."""
105+
106+
message_size = _NotificationAttribute(4)
107+
"""Total length of the message string."""
108+
109+
_raw_date = _NotificationAttribute(5)
110+
positive_action_label = _NotificationAttribute(6)
111+
"""Human readable label of the positive action."""
112+
113+
negative_action_label = _NotificationAttribute(7)
114+
"""Human readable label of the negative action."""
115+
116+
def __init__(self, notification_id, event_flags, category_id, category_count, *, control_point,
117+
data_source):
118+
self.id = notification_id # pylint: disable=invalid-name
119+
"""Integer id of the notification."""
120+
121+
self.removed = False
122+
"""True when the notification has been cleared on the iOS device."""
123+
124+
125+
self.silent = False
126+
self.important = False
127+
self.preexisting = False
128+
"""True if the notification existed before we connected to the iOS device."""
129+
130+
self.positive_action = False
131+
"""True if the notification has a positive action to respond with. For example, this could
132+
be answering a phone call."""
133+
134+
self.negative_action = False
135+
"""True if the notification has a negative action to respond with. For example, this could
136+
be declining a phone call."""
137+
138+
self.category_count = 0
139+
"""Number of other notifications with the same category."""
140+
141+
self.update(event_flags, category_id, category_count)
142+
143+
self._attribute_cache = {}
144+
145+
self.control_point = control_point
146+
self.data_source = data_source
147+
148+
def update(self, event_flags, category_id, category_count):
149+
"""Update the notification and clear the attribute cache."""
150+
self.category_id = category_id
151+
152+
self.category_count = category_count
153+
154+
self.silent = (event_flags & (1 << 0)) != 0
155+
self.important = (event_flags & (1 << 1)) != 0
156+
self.preexisting = (event_flags & (1 << 2)) != 0
157+
self.positive_action = (event_flags & (1 << 3)) != 0
158+
self.negative_action = (event_flags & (1 << 4)) != 0
159+
160+
self._attribute_cache = {}
161+
162+
def __str__(self):
163+
# pylint: disable=too-many-branches
164+
flags = []
165+
category = None
166+
if self.category_id < len(NOTIFICATION_CATEGORIES):
167+
category = NOTIFICATION_CATEGORIES[self.category_id]
168+
169+
if self.silent:
170+
flags.append("silent")
171+
if self.important:
172+
flags.append("important")
173+
if self.preexisting:
174+
flags.append("preexisting")
175+
if self.positive_action:
176+
flags.append("positive_action")
177+
if self.negative_action:
178+
flags.append("negative_action")
179+
return (category + " " +
180+
" ".join(flags) + " " +
181+
self.app_id + " " +
182+
str(self.title) + " " +
183+
str(self.subtitle) + " " +
184+
str(self.message))
185+
44186
class AppleNotificationService(Service):
45-
"""Notification service. Unimplemented."""
187+
"""Notification service."""
46188
uuid = VendorUUID("7905F431-B5CE-4E99-A40F-4B1E122D00D0")
47189

190+
control_point = StreamIn(uuid=VendorUUID("69D1D8F3-45E1-49A8-9821-9BBDFDAAD9D9"))
191+
data_source = StreamOut(uuid=VendorUUID("22EAC6E9-24D6-4BB5-BE44-B36ACE7C7BFB"),
192+
buffer_size=1024)
193+
notification_source = StreamOut(uuid=VendorUUID("9FBF120D-6301-42D9-8C58-25E699A21DBD"),
194+
buffer_size=8*100)
195+
196+
def __init__(self, service=None):
197+
super().__init__(service=service)
198+
self._active_notifications = {}
199+
200+
def _update(self):
201+
# Pylint is incorrectly inferring the type of self.notification_source so disable no-member.
202+
while self.notification_source.in_waiting > 7: # pylint: disable=no-member
203+
buffer = self.notification_source.read(8) # pylint: disable=no-member
204+
event_id, event_flags, category_id, category_count, nid = struct.unpack("<BBBBI",
205+
buffer)
206+
if event_id == 0:
207+
self._active_notifications[nid] = Notification(nid, event_flags, category_id,
208+
category_count,
209+
control_point=self.control_point,
210+
data_source=self.data_source)
211+
yield self._active_notifications[nid]
212+
elif event_id == 1:
213+
self._active_notifications[nid].update(event_flags, category_id, category_count)
214+
yield None
215+
elif event_id == 2:
216+
self._active_notifications[nid].removed = True
217+
del self._active_notifications[nid]
218+
yield None
219+
220+
def wait_for_new_notifications(self, timeout=None):
221+
"""Waits for new notifications and yields them. Returns on timeout, update, disconnect or
222+
clear."""
223+
start_time = time.monotonic()
224+
while timeout is None or timeout > time.monotonic() - start_time:
225+
try:
226+
new_notification = next(self._update())
227+
except StopIteration:
228+
return
229+
if new_notification:
230+
yield new_notification
231+
232+
@property
233+
def active_notifications(self):
234+
"""A dictionary of active notifications keyed by id."""
235+
for _ in self._update():
236+
pass
237+
return self._active_notifications
238+
48239
class AppleMediaService(Service):
49240
"""View and control currently playing media. Unimplemented."""
50241
uuid = VendorUUID("89D3502B-0F36-433A-8EF4-C502AD55F8DC")

0 commit comments

Comments
 (0)