Merge lp:~kaxing/checkbox/bt4-bluez5 into lp:checkbox

Proposed by Yung Shen
Status: Merged
Approved by: Sylvain Pineau
Approved revision: 4279
Merged at revision: 4320
Proposed branch: lp:~kaxing/checkbox/bt4-bluez5
Merge into: lp:checkbox
Diff against target: 498 lines (+477/-0)
3 files modified
providers/plainbox-provider-checkbox/bin/bt_connect (+135/-0)
providers/plainbox-provider-checkbox/bin/bt_helper.py (+300/-0)
providers/plainbox-provider-checkbox/jobs/bluetooth.txt.in (+42/-0)
To merge this branch: bzr merge lp:~kaxing/checkbox/bt4-bluez5
Reviewer Review Type Date Requested Status
Sylvain Pineau (community) Approve
Yung Shen (community) Needs Resubmitting
Review via email: mp+290716@code.launchpad.net

Description of the change

context inherited from: https://code.launchpad.net/~cypressyew/checkbox/bt4-HOGP/+merge/288612

Add a manifest request for asking tester about BT 4.x capability.
Add a BT 4.x specific jos for HOGP devices (keyboard/mouse).
Based on bt_helper for Bluez 5.x support

known issue:
Unable to get the input() in plainbox, looking for alternative ways.

To post a comment you must log in.
Revision history for this message
Maciej Kisielewski (kissiel) wrote :

I proposed a few improvements over tiny details inline.

Revision history for this message
Yung Shen (kaxing) wrote :

@kissiel Thanks for the review, will update base on your feedbacks. But for naming about unpairing() still needs a bit of advising. Please check.

Revision history for this message
Po-Hsu Lin (cypressyew) wrote :

One small note for the break in the for loop

Revision history for this message
Yung Shen (kaxing) wrote :

Update few in-line comment replies.

Revision history for this message
Maciej Kisielewski (kissiel) wrote :

Following up the discussion in the inline comments

Revision history for this message
Yung Shen (kaxing) wrote :

replies updating!

lp:~kaxing/checkbox/bt4-bluez5 updated
4276. By Yung Shen

Update bt_connect: with new unpair_all and new-way of description for HOGP jobs

Revision history for this message
Yung Shen (kaxing) wrote :

Updating everything based on previous in-line comments.

For print(flush=True) I've file a new bug for further discussing.(if anyone interested)
https://bugs.launchpad.net/plainbox/+bug/1569808

review: Needs Resubmitting
Revision history for this message
Yung Shen (kaxing) wrote :

Verified on a targeting system(bt4, xenial/bluez5), everything works as expected.

Only one kind of failure the script won't able to capture:
the host system/bluez thinks it's paired but the device still blinking.
but I think this is left to tester's judgment as they are user-interact-verify jobs.

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

I've proposed a workaround to the print(flush=True), see my inline suggestion.

review: Needs Fixing
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

idem for the mouse job

Revision history for this message
Yung Shen (kaxing) wrote :

I've removed the part that requires instant flush, so the bt_connect now is not effected to the buffered issue. those print() within flush=True is working as expected.

review: Needs Resubmitting
Revision history for this message
Po-Hsu Lin (cypressyew) wrote :

Hi Yung,
from the revision history, it seems that the branch was not re-submitted successfully, the latest one is still rev 4276, Apr. 13

Revision history for this message
Yung Shen (kaxing) wrote :

Hey @cypressyew, that's about right, I didn't made any changes since I've already removed the "buffered" part that mentioned in previous comments in revno 4276.

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

I tried to run the new tests using:

plainbox run -i 2013.com.canonical.plainbox::collect-manifest -i .*bluetooth4.*

I didn't work for two reasons:

1. Manifest entries does not support the requires statement
2. The two new jobs needs the following line to use manifest properly:

imports: from 2013.com.canonical.plainbox import manifest

review: Needs Fixing
Revision history for this message
Yung Shen (kaxing) wrote :

Thanks for the hint, fixed manifest and add bug number in comment for buffered print().

review: Needs Resubmitting
lp:~kaxing/checkbox/bt4-bluez5 updated
4277. By Yung Shen

Fix bluetooth.txt.in: add missing 'imports' for manifest requirements

4278. By Yung Shen

Add bug number to bt_connect about print(flush=True) buffering issue

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

One minor fix and it should be good to go, see below

review: Needs Fixing
Revision history for this message
Po-Hsu Lin (cypressyew) wrote :

And I think we could remove the TODO part in the comment, as this is quite mature now. (Convert print to logging might be the last thing)

Revision history for this message
Yung Shen (kaxing) wrote :

So I tried logging with level=logging.info it appears there are a lot informations going on, And I think we will need to discuss this, will keep my fake logging outputs at the moment so the cli work as expected.

Revision history for this message
Yung Shen (kaxing) :
review: Needs Resubmitting
lp:~kaxing/checkbox/bt4-bluez5 updated
4279. By Yung Shen

Update bluetooth.txt.in remove unecessary bits under manifest

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Those logging info could indeed be very useful. Let's merge this version and iterate.

A big +1 to all contributors. Thanks

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'providers/plainbox-provider-checkbox/bin/bt_connect'
--- providers/plainbox-provider-checkbox/bin/bt_connect 1970-01-01 00:00:00 +0000
+++ providers/plainbox-provider-checkbox/bin/bt_connect 2016-04-21 10:03:47 +0000
@@ -0,0 +1,135 @@
1#!/usr/bin/env python3
2#
3# This file is part of Checkbox.
4#
5# Copyright 2016 Canonical Ltd.
6#
7# Authors:
8# Po-Hsu Lin <po-hsu.lin@canonical.com>
9# Yung Shen <yung.shen@canonical.com>
10#
11# Checkbox is free software: you can redistribute it and/or modify
12# it under the terms of the GNU General Public License version 3,
13# as published by the Free Software Foundation.
14#
15# Checkbox is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
22
23import sys
24import time
25
26import bt_helper
27
28from argparse import ArgumentParser
29
30
31def unpair_all(devices, manager):
32 """ Unpairing paired devices and scanning again for rerun jobs."""
33 for dev in devices:
34 try:
35 print("INFO: Unpairing", dev)
36 dev.unpair()
37 except bt_helper.BtException as exc:
38 print("Warning: Unpairing failed", exc)
39 else:
40 # print(flush=True) to bypass plainbox output buffer,
41 # see LP: #1569808 for more details.
42 print("Please reset the device to pairing mode in 13 seconds",
43 flush=True)
44 time.sleep(13)
45 print("INFO: Re-scaning for devices in pairing mode", flush=True)
46 manager.scan()
47
48
49def main():
50 """Add argument parser here and do most of the job."""
51 parser = ArgumentParser(description=("Bluetooth auto paring and connect. "
52 "Please select one option."))
53 group = parser.add_mutually_exclusive_group(required=True)
54 group.add_argument("--mac", type=str,
55 help="Pair with a given MAC, not using scan result,")
56 group.add_argument("--mouse", action="store_const",
57 const="input-mouse", dest="target",
58 help="List and pair with mouse devices")
59 group.add_argument("--keyboard", action="store_const",
60 const="input-keyboard", dest="target",
61 help="List and pair with keyboard devices")
62 args = parser.parse_args()
63
64 manager = bt_helper.BtManager()
65 # Power on bluetooth adapter and scanning devices in advance.
66 manager.ensure_adapters_powered()
67 manager.scan()
68
69 if args.mac:
70 # TODO check MAC format
71 print("INFO: Trying to pair with {}".format(args.mac))
72 device = list(manager.get_bt_devices(filters={'Address': args.mac}))
73 paired_device = list(manager.get_bt_devices(
74 filters={'Address': args.mac, 'Paired': True}))
75 if not device:
76 print("ERROR: No pairable device found, terminating")
77 return 1
78
79 unpair_all(paired_device, manager)
80
81 for dev in device:
82 try:
83 dev.pair()
84 except bt_helper.BtException as exc:
85 print("ERROR: Unable to pair: ", exc)
86 return 1
87 else:
88 print("INFO: Device paired")
89 return 0
90 else:
91 print("INFO: Listing targeting devices")
92 # Listing device based on RSSI
93 paired_targets = list(manager.get_bt_devices(category=bt_helper.BT_ANY,
94 filters={'Paired': True, 'Icon': args.target}))
95 if not paired_targets:
96 print("INFO: No paired targeting devices found")
97 manager.scan()
98 else:
99 unpair_all(paired_targets, manager)
100
101 target_devices = sorted(manager.get_bt_devices(
102 category=bt_helper.BT_ANY, filters={
103 'Paired': False, 'Icon': args.target}),
104 key=lambda x: int(x.rssi or -255), reverse=True)
105 if not target_devices:
106 print("ERROR: No target devices found, terminating")
107 return 1
108 print("INFO: Detected devices (sorted by RSSI; highest first).")
109 # let's assing numbers to devices
110 devices = dict(enumerate(target_devices, 1))
111 for num, dev in devices.items():
112 print("{}. {} (RSSI: {})".format(num, dev, dev.rssi))
113 chosen = False
114 while not chosen:
115 print("Which one would you like to connect to? (0 to exit)")
116 num = input()
117 # TODO: enter as default to 1st device
118 if num == '0':
119 return 1
120 chosen = num.isnumeric() and int(num) in devices.keys()
121 print("INFO: {} chosen.".format(devices[int(num)]))
122 print("INFO: Pairing selected device..")
123 try:
124 devices[int(num)].pair()
125 except bt_helper.BtException as exc:
126 print("ERROR: something wrong: ", exc)
127 return 1
128 else:
129 print("Paired successfully.")
130 return 0
131 # capture all other silence failures
132 return 1
133
134if __name__ == "__main__":
135 sys.exit(main())
0136
=== added file 'providers/plainbox-provider-checkbox/bin/bt_helper.py'
--- providers/plainbox-provider-checkbox/bin/bt_helper.py 1970-01-01 00:00:00 +0000
+++ providers/plainbox-provider-checkbox/bin/bt_helper.py 2016-04-21 10:03:47 +0000
@@ -0,0 +1,300 @@
1# Copyright 2016 Canonical Ltd.
2# Written by:
3# Maciej Kisielewski <maciej.kisielewski@canonical.com>
4#
5# This is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License version 3,
7# as published by the Free Software Foundation.
8#
9# This file is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this file. If not, see <http://www.gnu.org/licenses/>.
16"""
17This module provides a set of abstractions to ease the process of automating
18typical Bluetooth task like scanning for devices and pairing with them.
19
20It talks with BlueZ stack using dbus.
21"""
22import logging
23
24import dbus
25import dbus.service
26import dbus.mainloop.glib
27from gi.repository import GObject
28
29logger = logging.getLogger(__file__)
30logger.addHandler(logging.StreamHandler())
31
32IFACE = 'org.bluez.Adapter1'
33ADAPTER_IFACE = 'org.bluez.Adapter1'
34DEVICE_IFACE = 'org.bluez.Device1'
35AGENT_IFACE = 'org.bluez.Agent1'
36
37dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
38
39# To get additional Bluetoot CoDs, check
40# https://www.bluetooth.com/specifications/assigned-numbers/baseband
41BT_ANY = 0
42BT_KEYBOARD = int('0x2540', 16)
43
44
45class BtException(Exception):
46 pass
47
48
49class BtManager:
50 """ Main point of contact with dbus factoring bt objects. """
51 def __init__(self, verbose=False):
52 if verbose:
53 logger.setLevel(logging.DEBUG)
54 self._bus = dbus.SystemBus()
55 self._bt_root = self._bus.get_object('org.bluez', '/')
56 self._manager = dbus.Interface(
57 self._bt_root, 'org.freedesktop.DBus.ObjectManager')
58 self._main_loop = GObject.MainLoop()
59 self._register_agent()
60
61 def _register_agent(self):
62 path = "/bt_helper/agent"
63 BtAgent(self._bus, path)
64 obj = self._bus.get_object('org.bluez', "/org/bluez")
65 agent_manager = dbus.Interface(obj, "org.bluez.AgentManager1")
66 agent_manager.RegisterAgent(path, 'NoInputNoOutput')
67 logger.info("Agent registered")
68
69 def _get_objects_by_iface(self, iface_name):
70 for path, ifaces in self._manager.GetManagedObjects().items():
71 if ifaces.get(iface_name):
72 yield self._bus.get_object('org.bluez', path)
73
74 def get_bt_adapters(self):
75 """Yield BtAdapter objects for each BT adapter found."""
76 for adapter in self._get_objects_by_iface(ADAPTER_IFACE):
77 yield BtAdapter(dbus.Interface(adapter, ADAPTER_IFACE), self)
78
79 def get_bt_devices(self, category=BT_ANY, filters={}):
80 """Yields BtDevice objects currently known to the system.
81
82 filters - specifies the characteristics of that a BT device must have
83 to be yielded. The keys of filters dictionary represent names of
84 parameters (as specified by the bluetooth DBus Api and represented by
85 DBus proxy object), and its values must match proxy values.
86 I.e. {'Paired': False}. For a full list of Parameters see:
87 http://git.kernel.org/cgit/bluetooth/bluez.git/tree/doc/device-api.txt
88
89 Note that this function returns objects corresponding to BT devices
90 that were seen last time scanning was done."""
91 for device in self._get_objects_by_iface(DEVICE_IFACE):
92 obj = self.get_object_by_path(device.object_path)[DEVICE_IFACE]
93 try:
94 if category != BT_ANY:
95 if obj['Class'] != category:
96 continue
97 rejected = False
98 for filter in filters:
99 if obj[filter] != filters[filter]:
100 rejected = True
101 break
102 if rejected:
103 continue
104 yield BtDevice(dbus.Interface(device, DEVICE_IFACE), self)
105 except KeyError as exc:
106 logger.info('Property %s not found on device %s',
107 exc, device.object_path)
108 continue
109
110 def get_prop_iface(self, obj):
111 return dbus.Interface(self._bus.get_object(
112 'org.bluez', obj.object_path), 'org.freedesktop.DBus.Properties')
113
114 def get_object_by_path(self, path):
115 return self._manager.GetManagedObjects()[path]
116
117 def get_proxy_by_path(self, path):
118 return self._bus.get_object('org.bluez', path)
119
120 def wait(self):
121 self._main_loop.run()
122
123 def quit_loop(self):
124 self._main_loop.quit()
125
126 def ensure_adapters_powered(self):
127 for adapter in self.get_bt_adapters():
128 adapter.ensure_powered()
129
130 def scan(self, timeout=10):
131 """Scan for BT devices visible to all adapters.'"""
132 self._bus.add_signal_receiver(
133 interfaces_added,
134 dbus_interface="org.freedesktop.DBus.ObjectManager",
135 signal_name="InterfacesAdded")
136 self._bus.add_signal_receiver(
137 properties_changed,
138 dbus_interface="org.freedesktop.DBus.Properties",
139 signal_name="PropertiesChanged",
140 arg0="org.bluez.Device1",
141 path_keyword="path")
142 for adapter in self._get_objects_by_iface(ADAPTER_IFACE):
143 try:
144 dbus.Interface(adapter, ADAPTER_IFACE).StopDiscovery()
145 except dbus.exceptions.DBusException:
146 pass
147 dbus.Interface(adapter, ADAPTER_IFACE).StartDiscovery()
148 GObject.timeout_add_seconds(timeout, self._scan_timeout)
149 self._main_loop.run()
150
151 def get_devices(self, timeout=10, rescan=True):
152 """Scan for and list all devices visible to all adapters."""
153 if rescan:
154 self.scan(timeout)
155 return list(self.get_bt_devices())
156
157 def _scan_timeout(self):
158 for adapter in self._get_objects_by_iface(ADAPTER_IFACE):
159 dbus.Interface(adapter, ADAPTER_IFACE).StopDiscovery()
160 self._main_loop.quit()
161
162
163class BtAdapter:
164 def __init__(self, dbus_iface, bt_mgr):
165 self._if = dbus_iface
166 self._bt_mgr = bt_mgr
167 self._prop_if = bt_mgr.get_prop_iface(dbus_iface)
168
169 def set_bool_prop(self, prop_name, value):
170 self._prop_if.Set(IFACE, prop_name, dbus.Boolean(value))
171
172 def ensure_powered(self):
173 """Turn the adapter on, and do nothing if already on."""
174 powered = self._prop_if.Get(IFACE, 'Powered')
175 logger.info('Powering on {}'.format(
176 self._if.object_path.split('/')[-1]))
177 if powered:
178 logger.info('Device already powered')
179 return
180 try:
181 self.set_bool_prop('Powered', True)
182 logger.info('Powered on')
183 except Exception as exc:
184 logging.error('Failed to power on - {}'.format(
185 exc.get_dbus_message()))
186
187
188class BtDevice:
189 def __init__(self, dbus_iface, bt_mgr):
190 self._if = dbus_iface
191 self._obj = bt_mgr.get_object_by_path(
192 self._if.object_path)[DEVICE_IFACE]
193 self._bt_mgr = bt_mgr
194 self._prop_if = bt_mgr.get_prop_iface(dbus_iface)
195 self._pair_outcome = None
196
197 def __str__(self):
198 return "{} ({})".format(self.name, self.address)
199
200 def __repr__(self):
201 return "<BtDevice name:{}, address:{}>".format(self.name, self.address)
202
203 def pair(self):
204 """Pair the device.
205
206 This function will try pairing with the device and block until device
207 is paired, error occured or default timeout elapsed (whichever comes
208 first).
209 """
210 self._prop_if.Set(DEVICE_IFACE, 'Trusted', True)
211 self._if.Pair(
212 reply_handler=self._pair_ok, error_handler=self._pair_error)
213 self._bt_mgr.wait()
214 if self._pair_outcome:
215 raise BtException(self._pair_outcome)
216 try:
217 self._if.Connect()
218 except dbus.exceptions.DBusException as exc:
219 logging.error('Failed to connect - {}'.format(
220 exc.get_dbus_message()))
221
222 def unpair(self):
223 self._if.Disconnect()
224 adapter = self._bt_mgr.get_proxy_by_path(self._obj['Adapter'])
225 dbus.Interface(adapter, ADAPTER_IFACE).RemoveDevice(self._if)
226
227 @property
228 def name(self):
229 return self._obj.get('Name', '<Unnamed>')
230
231 @property
232 def address(self):
233 return self._obj['Address']
234
235 @property
236 def rssi(self):
237 return self._obj.get('RSSI', None)
238
239 def _pair_ok(self):
240 logger.info('%s successfully paired', self.name)
241 self._pair_outcome = None
242 self._bt_mgr.quit_loop()
243
244 def _pair_error(self, error):
245 logger.warning('Pairing of %s device failed. %s', self.name, error)
246 self._pair_outcome = error
247 self._bt_mgr.quit_loop()
248
249
250class Rejected(dbus.DBusException):
251 _dbus_error_name = "org.bluez.Error.Rejected"
252
253
254class BtAgent(dbus.service.Object):
255 """Agent authenticating everything that is possible."""
256 @dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
257 def AuthorizeService(self, device, uuid):
258 logger.info("AuthorizeService (%s, %s)", device, uuid)
259
260 @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="u")
261 def RequestPasskey(self, device):
262 logger.info("RequestPasskey (%s)", device)
263 passkey = input("Enter passkey: ")
264 return dbus.UInt32(passkey)
265
266 @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="s")
267 def RequestPinCode(self, device):
268 logger.info("RequestPinCode (%s)", device)
269 return input("Enter PIN Code: ")
270
271 @dbus.service.method(AGENT_IFACE, in_signature="ouq", out_signature="")
272 def DisplayPasskey(self, device, passkey, entered):
273 print("DisplayPasskey (%s, %06u entered %u)" %
274 (device, passkey, entered), flush=True)
275
276 @dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
277 def DisplayPinCode(self, device, pincode):
278 logger.info("DisplayPinCode (%s, %s)", device, pincode)
279 print('Type following pin on your device: {}'.format(pincode),
280 flush=True)
281
282 @dbus.service.method(AGENT_IFACE, in_signature="ou", out_signature="")
283 def RequestConfirmation(self, device, passkey):
284 logger.info("RequestConfirmation (%s, %06d)", device, passkey)
285
286 @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="")
287 def RequestAuthorization(self, device):
288 logger.info("RequestAuthorization (%s)", device)
289
290 @dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
291 def Cancel(self):
292 logger.info("Cancelled")
293
294
295def properties_changed(interface, changed, invalidated, path):
296 logger.info('Property changed for device @ %s. Change: %s', path, changed)
297
298
299def interfaces_added(path, interfaces):
300 logger.info('Added new bt interfaces: %s @ %s', interfaces, path)
0301
=== modified file 'providers/plainbox-provider-checkbox/jobs/bluetooth.txt.in'
--- providers/plainbox-provider-checkbox/jobs/bluetooth.txt.in 2016-01-12 10:28:35 +0000
+++ providers/plainbox-provider-checkbox/jobs/bluetooth.txt.in 2016-04-21 10:03:47 +0000
@@ -1,3 +1,8 @@
1unit: manifest entry
2id: has_bt_smart
3_name: Bluetooth Smart (4.0 or later) Support
4value-type: bool
5
1plugin: shell6plugin: shell
2category_id: 2013.com.canonical.plainbox::bluetooth7category_id: 2013.com.canonical.plainbox::bluetooth
3id: bluetooth/detect-output8id: bluetooth/detect-output
@@ -157,3 +162,40 @@
157 retrieves it again using Bluetooth and verifies the checksum to ensure the162 retrieves it again using Bluetooth and verifies the checksum to ensure the
158 transfer didn't corrupt the file.163 transfer didn't corrupt the file.
159164
165plugin: user-interact-verify
166category_id: 2013.com.canonical.plainbox::bluetooth
167id: bluetooth4/HOGP-mouse
168depends: bluetooth/detect-output
169imports: from 2013.com.canonical.plainbox import manifest
170requires:
171 manifest.has_bt_smart == 'True'
172 package.name == 'bluez' and package.version >= '5.37'
173estimated_duration: 30.0
174command: bt_connect-weak-logging --mouse
175_purpose:
176 This test will check that you can use a HID Over GATT Profile (HOGP) with your Bluetooth Smart mouse.
177_steps:
178 1. Enable a Bluetooth smart mouse, and put it into paring mode.
179 2. Commence the test to do the auto-pairing, you will be asked to select targeting mouse from the list.
180 3. After it's paired and connected, perform actions such as moving the pointer, right and left button clicks and double clicks.
181_verification:
182 Did the Bluetooth Smart mouse work as expected?
183
184plugin: user-interact-verify
185category_id: 2013.com.canonical.plainbox::bluetooth
186id: bluetooth4/HOGP-keyboard
187depends: bluetooth/detect-output
188imports: from 2013.com.canonical.plainbox import manifest
189requires:
190 manifest.has_bt_smart == 'True'
191 package.name == 'bluez' and package.version >= '5.37'
192estimated_duration: 30.0
193command: bt_connect --keyboard
194_purpose:
195 This test will check that you can use a HID Over GATT Profile (HOGP) with your Bluetooth Smart keyboard.
196_steps:
197 1. Enable a Bluetooth Smart keyboard, and put it into paring mode.
198 2. Commence the test to do the auto-pairing, you will be asked to select targeting keyboard from the list.
199 3. After it's paired and connected, enter some text with your keyboard and close the small input test tool.
200_verification:
201 Did the Bluetooth Smart keyboard work as expected?

Subscribers

People subscribed via source and target branches