Compare commits

...

47 Commits

Author SHA1 Message Date
Lucas Abel ec35f5b118 pandora: add annotations import 2023-11-06 08:30:12 +00:00
Gilles Boccon-Gibod c67ca4a09e Merge pull request #324 from google/gbg/hotfix-002
fix typo
2023-10-31 20:58:19 +01:00
Gilles Boccon-Gibod 94506220d3 fix typo 2023-10-31 12:18:28 -07:00
Gilles Boccon-Gibod dbd865a484 Merge pull request #323 from google/gbg/device-hive
Device hive
2023-10-31 16:44:18 +01:00
Gilles Boccon-Gibod 9d2f3e932a format 2023-10-29 11:32:00 -07:00
Gilles Boccon-Gibod 49d32f5b5b add netsim.ini info 2023-10-29 10:26:34 -07:00
Gilles Boccon-Gibod f7b74c0bcb add hive to index page 2023-10-29 10:03:31 -07:00
Gilles Boccon-Gibod c75cb0c7b7 fix css 2023-10-29 09:58:37 -07:00
Gilles Boccon-Gibod a63b335149 wip 2023-10-29 09:36:17 -07:00
Gilles Boccon-Gibod d8517ce407 add links 2023-10-29 08:53:25 -07:00
Gilles Boccon-Gibod ad13b11464 wip 2023-10-29 08:53:23 -07:00
Gilles Boccon-Gibod 99bc92d53d wip (+5 squashed commits)
Squashed commits:
[53c6c53] wip
[66f482c] wip
[b003315] wip
[f6f9d9e] wip
[4c95c7b] wip
2023-10-29 08:50:25 -07:00
Josh Wu 72199f5615 Add address resolution offload to config 2023-10-24 17:04:43 -07:00
skarnataki 78b8b50082 fixed lint errors 2023-10-19 17:19:49 -07:00
skarnataki 3ab64ce00d Fixed lint and pre-commit errors. 2023-10-19 17:19:49 -07:00
skarnataki 651e44e0b6 Submitting review comment fix: header function and extra lines.
Executed formatter on file.
2023-10-19 17:19:49 -07:00
skarnataki 963fa41a49 Submitting review comment fix: header function and extra lines. 2023-10-19 17:19:49 -07:00
skarnataki 493f4f8b95 Submitting review comment fix: header function and spacing 2023-10-19 17:19:49 -07:00
skarnataki fc1bf36ace Review changes comment fix. Classes/Subclass/dataclass. Enum constants.
Naming conventions
2023-10-19 17:19:49 -07:00
skarnataki 5ddee17411 Commit to fix review comments for dataclass and subclass, shifting contants to Message Class
Commit for enum and dataclass
2023-10-19 17:19:49 -07:00
skarnataki 5ce353bcde Review comment Fix 2023-10-19 17:19:49 -07:00
SneKarnataki 16d33199eb Change in sdp.py file while testing hid profile,
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x.decode('utf8')) changed to
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x)
as we were facing error "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa1 in position 4: invalid start byte" while fetching sdp records.
2023-10-19 17:19:49 -07:00
SneKarnataki e02303a448 Submitting the initial version of HID Profile files
Includes:
1. HID Host implementation - hid.py
2. HID application to test Host with 3rd party HID Device application - run_hid_host.py
3. HID supporting files for testing - hid_report_parser.py & hid_key_map.py

Commands to run the application:
Default application:
python run_hid_host.py classic1.json usb:0 <device bd-addr>

Menu options for testing (Get/Set):
python run_hid_host.py classic1.json usb:0 <device bd-addr> test-mode

CuttleFish:tcp-client:127.0.0.1:7300

Application used for testing as Device : Bluetooth Keyboard & Mouse-5.3.0.apk

Note: Change in sdp.py file while testing hid profile,
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x.decode('utf8')) changed to
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x)
as we were facing error "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa1 in position 4: invalid start byte" while fetching sdp records.
2023-10-19 17:19:49 -07:00
Fahad Afroze 36fc966ad6 Trial checkin code 2023-10-19 17:19:49 -07:00
skarnataki 644f74400d Trial to commit in dhavan repo 2023-10-19 17:19:49 -07:00
dhavan b7cd451ddb Hid profile implemenation. Empty file 2023-10-19 17:19:49 -07:00
Gilles Boccon-Gibod 88392efca4 Merge pull request #312 from google/gbg/android-remote-hci
remote hci android app
2023-10-17 13:30:49 +02:00
zxzxwu 907f2acc7e Merge pull request #318 from zxzxwu/l2cap_refactor
Cleanup legacy L2CAP API usage
2023-10-17 14:22:45 +08:00
Gilles Boccon-Gibod 6616477bcf Merge pull request #319 from google/gbg/bt-spec-version-5-4
add constant for 5.4
2023-10-11 18:44:03 -07:00
Gilles Boccon-Gibod 5b173cb879 add constant for 5.4 2023-10-11 17:47:21 -07:00
zxzxwu 8b04161da3 Merge pull request #317 from zxzxwu/pytest
Add missing @pytest.mark.asyncio decorator
2023-10-11 15:16:35 +08:00
Josh Wu 5a85765360 Cleanup legacy L2CAP API 2023-10-11 14:33:44 +08:00
Josh Wu 333940919b Add missing @pytest.mark.asyncio decorator 2023-10-11 13:52:06 +08:00
Gilles Boccon-Gibod b9476be9ad Merge pull request #315 from google/gbg/company-ids
update to latest list of company ids
2023-10-10 22:13:16 -07:00
Gilles Boccon-Gibod 704c60491c Merge pull request #313 from benquike/pair_fix
Allow turning on BLE in classic pairing mode
2023-10-10 21:30:24 -07:00
Gilles Boccon-Gibod 4a8e612c6e update rust list 2023-10-10 21:29:39 -07:00
Gilles Boccon-Gibod 4e71ec5738 remove stale comment 2023-10-10 20:36:48 -07:00
uael 7255a09705 ci: add python avatar tests 2023-10-09 23:37:23 +02:00
zxzxwu c2bf6b5f13 Merge pull request #289 from zxzxwu/l2cap_refactor
Refactor L2CAP API
2023-10-09 23:27:25 +08:00
Gilles Boccon-Gibod d8e699b588 use the new yaml file instead of the previous CSV file 2023-10-07 23:10:49 -07:00
zxzxwu 3e4d4705f5 Merge pull request #314 from zxzxwu/sec_pandora
Pandora: Handle exception in WaitSecurity()
2023-10-08 01:42:45 +08:00
Josh Wu c8b2804446 Pandora: Handle exception in WaitSecurity() 2023-10-07 21:17:01 +08:00
Josh Wu e732f2589f Refactor L2CAP API 2023-10-07 20:01:15 +08:00
zxzxwu aec5543081 Merge pull request #310 from zxzxwu/avdtp
Typing AVDTP
2023-10-07 19:50:56 +08:00
Josh Wu e03d90ca57 Add typing for MediaCodecCapabilities members 2023-10-07 19:32:19 +08:00
Josh Wu 495ce62d9c Typing AVDTP 2023-10-07 19:32:19 +08:00
Hui Peng fbc3959a5a Allow turning on BLE in classic pairing mode 2023-10-06 19:54:18 -07:00
74 changed files with 4541 additions and 1070 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Python Avatar
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
test:
name: Avatar [${{ matrix.shard }}]
runs-on: ubuntu-latest
strategy:
matrix:
shard: [
1/24, 2/24, 3/24, 4/24,
5/24, 6/24, 7/24, 8/24,
9/24, 10/24, 11/24, 12/24,
13/24, 14/24, 15/24, 16/24,
17/24, 18/24, 19/24, 20/24,
21/24, 22/24, 23/24, 24/24,
]
steps:
- uses: actions/checkout@v3
- name: Set Up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Install
run: |
python -m pip install --upgrade pip
python -m pip install .[avatar]
- name: Rootcanal
run: nohup python -m rootcanal > rootcanal.log &
- name: Test
run: |
avatar --list | grep -Ev '^=' > test-names.txt
timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }})
- name: Rootcanal Logs
run: cat rootcanal.log
+1
View File
@@ -47,6 +47,7 @@
"protobuf",
"psms",
"pyee",
"Pyodide",
"pyusb",
"rfcomm",
"ROHC",
+17 -13
View File
@@ -24,6 +24,7 @@ import time
import click
from bumble import l2cap
from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
@@ -85,6 +86,7 @@ DEFAULT_LINGER_TIME = 1.0
DEFAULT_RFCOMM_CHANNEL = 8
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
@@ -197,6 +199,7 @@ class PacketType(enum.IntEnum):
PACKET_FLAG_LAST = 1
# -----------------------------------------------------------------------------
# Sender
# -----------------------------------------------------------------------------
@@ -659,17 +662,19 @@ class L2capClient(StreamedPacketIO):
self.mps = mps
self.ready = asyncio.Event()
async def on_connection(self, connection):
async def on_connection(self, connection: Connection) -> None:
connection.on('disconnection', self.on_disconnection)
# Connect a new L2CAP channel
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
try:
l2cap_channel = await connection.open_l2cap_channel(
psm=self.psm,
max_credits=self.max_credits,
mtu=self.mtu,
mps=self.mps,
l2cap_channel = await connection.create_l2cap_channel(
spec=l2cap.LeCreditBasedChannelSpec(
psm=self.psm,
max_credits=self.max_credits,
mtu=self.mtu,
mps=self.mps,
)
)
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
except Exception as error:
@@ -695,7 +700,7 @@ class L2capClient(StreamedPacketIO):
class L2capServer(StreamedPacketIO):
def __init__(
self,
device,
device: Device,
psm=DEFAULT_L2CAP_PSM,
max_credits=DEFAULT_L2CAP_MAX_CREDITS,
mtu=DEFAULT_L2CAP_MTU,
@@ -706,12 +711,11 @@ class L2capServer(StreamedPacketIO):
self.ready = asyncio.Event()
# Listen for incoming L2CAP CoC connections
device.register_l2cap_channel_server(
psm=psm,
server=self.on_l2cap_channel,
max_credits=max_credits,
mtu=mtu,
mps=mps,
device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=psm, mtu=mtu, mps=mps, max_credits=max_credits
),
handler=self.on_l2cap_channel,
)
print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow'))
+8 -2
View File
@@ -21,6 +21,7 @@ import struct
import logging
import click
from bumble import l2cap
from bumble.colors import color
from bumble.device import Device, Peer
from bumble.core import AdvertisingData
@@ -204,7 +205,7 @@ class GattlinkHubBridge(GattlinkL2capEndpoint, Device.Listener):
# -----------------------------------------------------------------------------
class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
def __init__(self, device):
def __init__(self, device: Device):
super().__init__()
self.device = device
self.peer = None
@@ -218,7 +219,12 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
# Listen for incoming L2CAP CoC connections
psm = 0xFB
device.register_l2cap_channel_server(0xFB, self.on_coc)
device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=0xFB,
),
handler=self.on_coc,
)
print(f'### Listening for CoC connection on PSM {psm}')
# Setup the Gattlink service
+14 -12
View File
@@ -20,6 +20,7 @@ import logging
import os
import click
from bumble import l2cap
from bumble.colors import color
from bumble.transport import open_transport_or_link
from bumble.device import Device
@@ -47,14 +48,13 @@ class ServerBridge:
self.tcp_host = tcp_host
self.tcp_port = tcp_port
async def start(self, device):
async def start(self, device: Device) -> None:
# Listen for incoming L2CAP CoC connections
device.register_l2cap_channel_server(
psm=self.psm,
server=self.on_coc,
max_credits=self.max_credits,
mtu=self.mtu,
mps=self.mps,
device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
),
handler=self.on_coc,
)
print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
@@ -195,11 +195,13 @@ class ClientBridge:
# Connect a new L2CAP channel
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
try:
l2cap_channel = await connection.open_l2cap_channel(
psm=self.psm,
max_credits=self.max_credits,
mtu=self.mtu,
mps=self.mps,
l2cap_channel = await connection.create_l2cap_channel(
spec=l2cap.LeCreditBasedChannelSpec(
psm=self.psm,
max_credits=self.max_credits,
mtu=self.mtu,
mps=self.mps,
)
)
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
except Exception as error:
+1 -1
View File
@@ -306,6 +306,7 @@ async def pair(
# Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read
if mode == 'le':
device.le_enabled = True
device.add_service(
Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
@@ -326,7 +327,6 @@ async def pair(
# Select LE or Classic
if mode == 'classic':
device.classic_enabled = True
device.le_enabled = False
device.classic_smp_enabled = ctkd
# Get things going
+1 -1
View File
@@ -641,7 +641,7 @@ class Speaker:
self.device.on('connection', self.on_bluetooth_connection)
# Create a listener to wait for AVDTP connections
self.listener = Listener(Listener.create_registrar(self.device))
self.listener = Listener.for_device(self.device)
self.listener.on('connection', self.on_avdtp_connection)
print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
+303 -177
View File
File diff suppressed because it is too large Load Diff
+813 -173
View File
File diff suppressed because it is too large Load Diff
+99 -10
View File
@@ -33,6 +33,8 @@ from typing import (
Tuple,
Type,
Union,
cast,
overload,
TYPE_CHECKING,
)
@@ -151,6 +153,7 @@ from .utils import (
CompositeEventEmitter,
setup_event_forwarding,
composite_listener,
deprecated,
)
from .keys import (
KeyStore,
@@ -670,9 +673,7 @@ class Connection(CompositeEventEmitter):
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
self.device.send_l2cap_pdu(self.handle, cid, pdu)
def create_l2cap_connector(self, psm):
return self.device.create_l2cap_connector(self, psm)
@deprecated("Please use create_l2cap_channel()")
async def open_l2cap_channel(
self,
psm,
@@ -682,6 +683,23 @@ class Connection(CompositeEventEmitter):
):
return await self.device.open_l2cap_channel(self, psm, max_credits, mtu, mps)
@overload
async def create_l2cap_channel(
self, spec: l2cap.ClassicChannelSpec
) -> l2cap.ClassicChannel:
...
@overload
async def create_l2cap_channel(
self, spec: l2cap.LeCreditBasedChannelSpec
) -> l2cap.LeCreditBasedChannel:
...
async def create_l2cap_channel(
self, spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec]
) -> Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]:
return await self.device.create_l2cap_channel(connection=self, spec=spec)
async def disconnect(
self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
) -> None:
@@ -829,6 +847,9 @@ class DeviceConfiguration:
self.connectable = config.get('connectable', self.connectable)
self.discoverable = config.get('discoverable', self.discoverable)
self.gatt_services = config.get('gatt_services', self.gatt_services)
self.address_resolution_offload = config.get(
'address_resolution_offload', self.address_resolution_offload
)
# Load or synthesize an IRK
irk = config.get('irk')
@@ -1180,15 +1201,11 @@ class Device(CompositeEventEmitter):
return None
def create_l2cap_connector(self, connection, psm):
return lambda: self.l2cap_channel_manager.connect(connection, psm)
def create_l2cap_registrar(self, psm):
return lambda handler: self.register_l2cap_server(psm, handler)
@deprecated("Please use create_l2cap_server()")
def register_l2cap_server(self, psm, server) -> int:
return self.l2cap_channel_manager.register_server(psm, server)
@deprecated("Please use create_l2cap_server()")
def register_l2cap_channel_server(
self,
psm,
@@ -1201,6 +1218,7 @@ class Device(CompositeEventEmitter):
psm, server, max_credits, mtu, mps
)
@deprecated("Please use create_l2cap_channel()")
async def open_l2cap_channel(
self,
connection,
@@ -1213,6 +1231,74 @@ class Device(CompositeEventEmitter):
connection, psm, max_credits, mtu, mps
)
@overload
async def create_l2cap_channel(
self,
connection: Connection,
spec: l2cap.ClassicChannelSpec,
) -> l2cap.ClassicChannel:
...
@overload
async def create_l2cap_channel(
self,
connection: Connection,
spec: l2cap.LeCreditBasedChannelSpec,
) -> l2cap.LeCreditBasedChannel:
...
async def create_l2cap_channel(
self,
connection: Connection,
spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec],
) -> Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]:
if isinstance(spec, l2cap.ClassicChannelSpec):
return await self.l2cap_channel_manager.create_classic_channel(
connection=connection, spec=spec
)
if isinstance(spec, l2cap.LeCreditBasedChannelSpec):
return await self.l2cap_channel_manager.create_le_credit_based_channel(
connection=connection, spec=spec
)
@overload
def create_l2cap_server(
self,
spec: l2cap.ClassicChannelSpec,
handler: Optional[Callable[[l2cap.ClassicChannel], Any]] = None,
) -> l2cap.ClassicChannelServer:
...
@overload
def create_l2cap_server(
self,
spec: l2cap.LeCreditBasedChannelSpec,
handler: Optional[Callable[[l2cap.LeCreditBasedChannel], Any]] = None,
) -> l2cap.LeCreditBasedChannelServer:
...
def create_l2cap_server(
self,
spec: Union[l2cap.ClassicChannelSpec, l2cap.LeCreditBasedChannelSpec],
handler: Union[
Callable[[l2cap.ClassicChannel], Any],
Callable[[l2cap.LeCreditBasedChannel], Any],
None,
] = None,
) -> Union[l2cap.ClassicChannelServer, l2cap.LeCreditBasedChannelServer]:
if isinstance(spec, l2cap.ClassicChannelSpec):
return self.l2cap_channel_manager.create_classic_server(
spec=spec,
handler=cast(Callable[[l2cap.ClassicChannel], Any], handler),
)
elif isinstance(spec, l2cap.LeCreditBasedChannelSpec):
return self.l2cap_channel_manager.create_le_credit_based_server(
handler=cast(Callable[[l2cap.LeCreditBasedChannel], Any], handler),
spec=spec,
)
else:
raise ValueError(f'Unexpected mode {spec}')
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
@@ -1222,7 +1308,7 @@ class Device(CompositeEventEmitter):
self.host.send_command(command, check_result), self.command_timeout
)
except asyncio.TimeoutError as error:
logger.warning('!!! Command timed out')
logger.warning(f'!!! Command {command.name} timed out')
raise CommandTimeoutError() from error
async def power_on(self) -> None:
@@ -1323,6 +1409,9 @@ class Device(CompositeEventEmitter):
# Done
self.powered_on = True
async def reset(self) -> None:
await self.host.reset()
async def power_off(self) -> None:
if self.powered_on:
await self.host.flush()
+3 -1
View File
@@ -121,6 +121,7 @@ HCI_VERSION_BLUETOOTH_CORE_5_0 = 9
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
HCI_VERSION_BLUETOOTH_CORE_5_4 = 13
HCI_VERSION_NAMES = {
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B',
@@ -135,7 +136,8 @@ HCI_VERSION_NAMES = {
HCI_VERSION_BLUETOOTH_CORE_5_0: 'HCI_VERSION_BLUETOOTH_CORE_5_0',
HCI_VERSION_BLUETOOTH_CORE_5_1: 'HCI_VERSION_BLUETOOTH_CORE_5_1',
HCI_VERSION_BLUETOOTH_CORE_5_2: 'HCI_VERSION_BLUETOOTH_CORE_5_2',
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3'
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3',
HCI_VERSION_BLUETOOTH_CORE_5_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4',
}
# LMP Version
+332
View File
@@ -0,0 +1,332 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
from dataclasses import dataclass
import logging
import asyncio
import enum
from pyee import EventEmitter
from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
from . import core, l2cap # type: ignore
from .colors import color # type: ignore
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError # type: ignore
if TYPE_CHECKING:
from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
# fmt: on
HID_CONTROL_PSM = 0x0011
HID_INTERRUPT_PSM = 0x0013
class Message:
message_type: MessageType
# Report types
class ReportType(enum.IntEnum):
OTHER_REPORT = 0x00
INPUT_REPORT = 0x01
OUTPUT_REPORT = 0x02
FEATURE_REPORT = 0x03
# Handshake parameters
class Handshake(enum.IntEnum):
SUCCESSFUL = 0x00
NOT_READY = 0x01
ERR_INVALID_REPORT_ID = 0x02
ERR_UNSUPPORTED_REQUEST = 0x03
ERR_UNKNOWN = 0x0E
ERR_FATAL = 0x0F
# Message Type
class MessageType(enum.IntEnum):
HANDSHAKE = 0x00
CONTROL = 0x01
GET_REPORT = 0x04
SET_REPORT = 0x05
GET_PROTOCOL = 0x06
SET_PROTOCOL = 0x07
DATA = 0x0A
# Protocol modes
class ProtocolMode(enum.IntEnum):
BOOT_PROTOCOL = 0x00
REPORT_PROTOCOL = 0x01
# Control Operations
class ControlCommand(enum.IntEnum):
SUSPEND = 0x03
EXIT_SUSPEND = 0x04
VIRTUAL_CABLE_UNPLUG = 0x05
# Class Method to derive header
@classmethod
def header(cls, lower_bits: int = 0x00) -> bytes:
return bytes([(cls.message_type << 4) | lower_bits])
# HIDP messages
@dataclass
class GetReportMessage(Message):
report_type: int
report_id: int
buffer_size: int
message_type = Message.MessageType.GET_REPORT
def __bytes__(self) -> bytes:
packet_bytes = bytearray()
packet_bytes.append(self.report_id)
packet_bytes.extend(
[(self.buffer_size & 0xFF), ((self.buffer_size >> 8) & 0xFF)]
)
if self.report_type == Message.ReportType.OTHER_REPORT:
return self.header(self.report_type) + packet_bytes
else:
return self.header(0x08 | self.report_type) + packet_bytes
@dataclass
class SetReportMessage(Message):
report_type: int
data: bytes
message_type = Message.MessageType.SET_REPORT
def __bytes__(self) -> bytes:
return self.header(self.report_type) + self.data
@dataclass
class GetProtocolMessage(Message):
message_type = Message.MessageType.GET_PROTOCOL
def __bytes__(self) -> bytes:
return self.header()
@dataclass
class SetProtocolMessage(Message):
protocol_mode: int
message_type = Message.MessageType.SET_PROTOCOL
def __bytes__(self) -> bytes:
return self.header(self.protocol_mode)
@dataclass
class Suspend(Message):
message_type = Message.MessageType.CONTROL
def __bytes__(self) -> bytes:
return self.header(Message.ControlCommand.SUSPEND)
@dataclass
class ExitSuspend(Message):
message_type = Message.MessageType.CONTROL
def __bytes__(self) -> bytes:
return self.header(Message.ControlCommand.EXIT_SUSPEND)
@dataclass
class VirtualCableUnplug(Message):
message_type = Message.MessageType.CONTROL
def __bytes__(self) -> bytes:
return self.header(Message.ControlCommand.VIRTUAL_CABLE_UNPLUG)
@dataclass
class SendData(Message):
data: bytes
message_type = Message.MessageType.DATA
def __bytes__(self) -> bytes:
return self.header(Message.ReportType.OUTPUT_REPORT) + self.data
# -----------------------------------------------------------------------------
class Host(EventEmitter):
l2cap_ctrl_channel: Optional[l2cap.ClassicChannel]
l2cap_intr_channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device, connection: Connection) -> None:
super().__init__()
self.device = device
self.connection = connection
self.l2cap_ctrl_channel = None
self.l2cap_intr_channel = None
# Register ourselves with the L2CAP channel manager
device.register_l2cap_server(HID_CONTROL_PSM, self.on_connection)
device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_connection)
async def connect_control_channel(self) -> None:
# Create a new L2CAP connection - control channel
try:
self.l2cap_ctrl_channel = await self.device.l2cap_channel_manager.connect(
self.connection, HID_CONTROL_PSM
)
except ProtocolError:
logging.exception(f'L2CAP connection failed.')
raise
assert self.l2cap_ctrl_channel is not None
# Become a sink for the L2CAP channel
self.l2cap_ctrl_channel.sink = self.on_ctrl_pdu
async def connect_interrupt_channel(self) -> None:
# Create a new L2CAP connection - interrupt channel
try:
self.l2cap_intr_channel = await self.device.l2cap_channel_manager.connect(
self.connection, HID_INTERRUPT_PSM
)
except ProtocolError:
logging.exception(f'L2CAP connection failed.')
raise
assert self.l2cap_intr_channel is not None
# Become a sink for the L2CAP channel
self.l2cap_intr_channel.sink = self.on_intr_pdu
async def disconnect_interrupt_channel(self) -> None:
if self.l2cap_intr_channel is None:
raise InvalidStateError('invalid state')
channel = self.l2cap_intr_channel
self.l2cap_intr_channel = None
await channel.disconnect()
async def disconnect_control_channel(self) -> None:
if self.l2cap_ctrl_channel is None:
raise InvalidStateError('invalid state')
channel = self.l2cap_ctrl_channel
self.l2cap_ctrl_channel = None
await channel.disconnect()
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ New L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
if l2cap_channel.psm == HID_CONTROL_PSM:
self.l2cap_ctrl_channel = l2cap_channel
self.l2cap_ctrl_channel.sink = self.on_ctrl_pdu
else:
self.l2cap_intr_channel = l2cap_channel
self.l2cap_intr_channel.sink = self.on_intr_pdu
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
def on_ctrl_pdu(self, pdu: bytes) -> None:
logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
# Here we will receive all kinds of packets, parse and then call respective callbacks
message_type = pdu[0] >> 4
param = pdu[0] & 0x0F
if message_type == Message.MessageType.HANDSHAKE:
logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
self.emit('handshake', Message.Handshake(param))
elif message_type == Message.MessageType.DATA:
logger.debug('<<< HID CONTROL DATA')
self.emit('data', pdu)
elif message_type == Message.MessageType.CONTROL:
if param == Message.ControlCommand.SUSPEND:
logger.debug('<<< HID SUSPEND')
self.emit('suspend', pdu)
elif param == Message.ControlCommand.EXIT_SUSPEND:
logger.debug('<<< HID EXIT SUSPEND')
self.emit('exit_suspend', pdu)
elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
self.emit('virtual_cable_unplug')
else:
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
else:
logger.debug('<<< HID CONTROL DATA')
self.emit('data', pdu)
def on_intr_pdu(self, pdu: bytes) -> None:
logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
self.emit("data", pdu)
def get_report(self, report_type: int, report_id: int, buffer_size: int) -> None:
msg = GetReportMessage(
report_type=report_type, report_id=report_id, buffer_size=buffer_size
)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL GET REPORT, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def set_report(self, report_type: int, data: bytes):
msg = SetReportMessage(report_type=report_type, data=data)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SET REPORT, PDU:{hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def get_protocol(self):
msg = GetProtocolMessage()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL GET PROTOCOL, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def set_protocol(self, protocol_mode: int):
msg = SetProtocolMessage(protocol_mode=protocol_mode)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SET PROTOCOL, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def send_pdu_on_ctrl(self, msg: bytes) -> None:
self.l2cap_ctrl_channel.send_pdu(msg) # type: ignore
def send_pdu_on_intr(self, msg: bytes) -> None:
self.l2cap_intr_channel.send_pdu(msg) # type: ignore
def send_data(self, data):
msg = SendData(data)
hid_message = bytes(msg)
logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
self.send_pdu_on_intr(hid_message)
def suspend(self):
msg = Suspend()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SUSPEND, PDU:{hid_message.hex()}')
self.send_pdu_on_ctrl(msg)
def exit_suspend(self):
msg = ExitSuspend()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL EXIT SUSPEND, PDU:{hid_message.hex()}')
self.send_pdu_on_ctrl(msg)
def virtual_cable_unplug(self):
msg = VirtualCableUnplug()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(msg)
+221 -76
View File
@@ -17,6 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import dataclasses
import enum
import logging
import struct
@@ -38,6 +39,7 @@ from typing import (
TYPE_CHECKING,
)
from .utils import deprecated
from .colors import color
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
from .hci import (
@@ -167,6 +169,34 @@ L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE = 0x01
# pylint: disable=invalid-name
@dataclasses.dataclass
class ClassicChannelSpec:
psm: Optional[int] = None
mtu: int = L2CAP_MIN_BR_EDR_MTU
@dataclasses.dataclass
class LeCreditBasedChannelSpec:
psm: Optional[int] = None
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
def __post_init__(self):
if (
self.max_credits < 1
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
):
raise ValueError('max credits out of range')
if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
raise ValueError('MTU too small')
if (
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
):
raise ValueError('MPS out of range')
class L2CAP_PDU:
'''
See Bluetooth spec @ Vol 3, Part A - 3 DATA PACKET FORMAT
@@ -676,7 +706,7 @@ class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
# -----------------------------------------------------------------------------
class Channel(EventEmitter):
class ClassicChannel(EventEmitter):
class State(enum.IntEnum):
# States
CLOSED = 0x00
@@ -990,7 +1020,7 @@ class Channel(EventEmitter):
# -----------------------------------------------------------------------------
class LeConnectionOrientedChannel(EventEmitter):
class LeCreditBasedChannel(EventEmitter):
"""
LE Credit-based Connection Oriented Channel
"""
@@ -1004,11 +1034,13 @@ class LeConnectionOrientedChannel(EventEmitter):
CONNECTION_ERROR = 5
out_queue: Deque[bytes]
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
connection_result: Optional[asyncio.Future[LeCreditBasedChannel]]
disconnection_result: Optional[asyncio.Future[None]]
in_sdu: Optional[bytes]
out_sdu: Optional[bytes]
state: State
connection: Connection
sink: Optional[Callable[[bytes], Any]]
def __init__(
self,
@@ -1071,7 +1103,7 @@ class LeConnectionOrientedChannel(EventEmitter):
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
async def connect(self) -> LeConnectionOrientedChannel:
async def connect(self) -> LeCreditBasedChannel:
# Check that we're in the right state
if self.state != self.State.INIT:
raise InvalidStateError('not in a connectable state')
@@ -1342,15 +1374,67 @@ class LeConnectionOrientedChannel(EventEmitter):
)
# -----------------------------------------------------------------------------
class ClassicChannelServer(EventEmitter):
def __init__(
self,
manager: ChannelManager,
psm: int,
handler: Optional[Callable[[ClassicChannel], Any]],
mtu: int,
) -> None:
super().__init__()
self.manager = manager
self.handler = handler
self.psm = psm
self.mtu = mtu
def on_connection(self, channel: ClassicChannel) -> None:
self.emit('connection', channel)
if self.handler:
self.handler(channel)
def close(self) -> None:
if self.psm in self.manager.servers:
del self.manager.servers[self.psm]
# -----------------------------------------------------------------------------
class LeCreditBasedChannelServer(EventEmitter):
def __init__(
self,
manager: ChannelManager,
psm: int,
handler: Optional[Callable[[LeCreditBasedChannel], Any]],
max_credits: int,
mtu: int,
mps: int,
) -> None:
super().__init__()
self.manager = manager
self.handler = handler
self.psm = psm
self.max_credits = max_credits
self.mtu = mtu
self.mps = mps
def on_connection(self, channel: LeCreditBasedChannel) -> None:
self.emit('connection', channel)
if self.handler:
self.handler(channel)
def close(self) -> None:
if self.psm in self.manager.le_coc_servers:
del self.manager.le_coc_servers[self.psm]
# -----------------------------------------------------------------------------
class ChannelManager:
identifiers: Dict[int, int]
channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]]
servers: Dict[int, Callable[[Channel], Any]]
le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]]
le_coc_servers: Dict[
int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
]
channels: Dict[int, Dict[int, Union[ClassicChannel, LeCreditBasedChannel]]]
servers: Dict[int, ClassicChannelServer]
le_coc_channels: Dict[int, Dict[int, LeCreditBasedChannel]]
le_coc_servers: Dict[int, LeCreditBasedChannelServer]
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
_host: Optional[Host]
@@ -1429,21 +1513,6 @@ class ChannelManager:
raise RuntimeError('no free CID')
@staticmethod
def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
if (
max_credits < 1
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
):
raise ValueError('max credits out of range')
if mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
raise ValueError('MTU too small')
if (
mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
or mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
):
raise ValueError('MPS out of range')
def next_identifier(self, connection: Connection) -> int:
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
self.identifiers[connection.handle] = identifier
@@ -1458,8 +1527,22 @@ class ChannelManager:
if cid in self.fixed_channels:
del self.fixed_channels[cid]
def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
if psm == 0:
@deprecated("Please use create_classic_server")
def register_server(
self,
psm: int,
server: Callable[[ClassicChannel], Any],
) -> int:
return self.create_classic_server(
handler=server, spec=ClassicChannelSpec(psm=psm)
).psm
def create_classic_server(
self,
spec: ClassicChannelSpec,
handler: Optional[Callable[[ClassicChannel], Any]] = None,
) -> ClassicChannelServer:
if not spec.psm:
# Find a free PSM
for candidate in range(
L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2
@@ -1468,62 +1551,75 @@ class ChannelManager:
continue
if candidate in self.servers:
continue
psm = candidate
spec.psm = candidate
break
else:
raise InvalidStateError('no free PSM')
else:
# Check that the PSM isn't already in use
if psm in self.servers:
if spec.psm in self.servers:
raise ValueError('PSM already in use')
# Check that the PSM is valid
if psm % 2 == 0:
if spec.psm % 2 == 0:
raise ValueError('invalid PSM (not odd)')
check = psm >> 8
check = spec.psm >> 8
while check:
if check % 2 != 0:
raise ValueError('invalid PSM')
check >>= 8
self.servers[psm] = server
self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
return psm
return self.servers[spec.psm]
@deprecated("Please use create_le_credit_based_server()")
def register_le_coc_server(
self,
psm: int,
server: Callable[[LeConnectionOrientedChannel], Any],
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
server: Callable[[LeCreditBasedChannel], Any],
max_credits: int,
mtu: int,
mps: int,
) -> int:
self.check_le_coc_parameters(max_credits, mtu, mps)
return self.create_le_credit_based_server(
spec=LeCreditBasedChannelSpec(
psm=None if psm == 0 else psm, mtu=mtu, mps=mps, max_credits=max_credits
),
handler=server,
).psm
if psm == 0:
def create_le_credit_based_server(
self,
spec: LeCreditBasedChannelSpec,
handler: Optional[Callable[[LeCreditBasedChannel], Any]] = None,
) -> LeCreditBasedChannelServer:
if not spec.psm:
# Find a free PSM
for candidate in range(
L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1
):
if candidate in self.le_coc_servers:
continue
psm = candidate
spec.psm = candidate
break
else:
raise InvalidStateError('no free PSM')
else:
# Check that the PSM isn't already in use
if psm in self.le_coc_servers:
if spec.psm in self.le_coc_servers:
raise ValueError('PSM already in use')
self.le_coc_servers[psm] = (
server,
max_credits or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
mtu or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
mps or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
self,
spec.psm,
handler,
max_credits=spec.max_credits,
mtu=spec.mtu,
mps=spec.mps,
)
return psm
return self.le_coc_servers[spec.psm]
def on_disconnection(self, connection_handle: int, _reason: int) -> None:
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
@@ -1650,13 +1746,13 @@ class ChannelManager:
logger.debug(
f'creating server channel with cid={source_cid} for psm {request.psm}'
)
channel = Channel(
self, connection, cid, request.psm, source_cid, L2CAP_MIN_BR_EDR_MTU
channel = ClassicChannel(
self, connection, cid, request.psm, source_cid, server.mtu
)
connection_channels[source_cid] = channel
# Notify
server(channel)
server.on_connection(channel)
channel.on_connection_request(request)
else:
logger.warning(
@@ -1878,7 +1974,7 @@ class ChannelManager:
self, connection: Connection, cid: int, request
) -> None:
if request.le_psm in self.le_coc_servers:
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
server = self.le_coc_servers[request.le_psm]
# Check that the CID isn't already used
le_connection_channels = self.le_coc_channels.setdefault(
@@ -1892,8 +1988,8 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier,
destination_cid=0,
mtu=mtu,
mps=mps,
mtu=server.mtu,
mps=server.mps,
initial_credits=0,
# pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED,
@@ -1911,8 +2007,8 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier,
destination_cid=0,
mtu=mtu,
mps=mps,
mtu=server.mtu,
mps=server.mps,
initial_credits=0,
# pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
@@ -1925,18 +2021,18 @@ class ChannelManager:
f'creating LE CoC server channel with cid={source_cid} for psm '
f'{request.le_psm}'
)
channel = LeConnectionOrientedChannel(
channel = LeCreditBasedChannel(
self,
connection,
request.le_psm,
source_cid,
request.source_cid,
mtu,
mps,
server.mtu,
server.mps,
request.initial_credits,
request.mtu,
request.mps,
max_credits,
server.max_credits,
True,
)
connection_channels[source_cid] = channel
@@ -1949,16 +2045,16 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier,
destination_cid=source_cid,
mtu=mtu,
mps=mps,
initial_credits=max_credits,
mtu=server.mtu,
mps=server.mps,
initial_credits=server.max_credits,
# pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL,
),
)
# Notify
server(channel)
server.on_connection(channel)
else:
logger.info(
f'No LE server for connection 0x{connection.handle:04X} '
@@ -2013,37 +2109,51 @@ class ChannelManager:
channel.on_credits(credit.credits)
def on_channel_closed(self, channel: Channel) -> None:
def on_channel_closed(self, channel: ClassicChannel) -> None:
connection_channels = self.channels.get(channel.connection.handle)
if connection_channels:
if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid]
@deprecated("Please use create_le_credit_based_channel()")
async def open_le_coc(
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
) -> LeConnectionOrientedChannel:
self.check_le_coc_parameters(max_credits, mtu, mps)
) -> LeCreditBasedChannel:
return await self.create_le_credit_based_channel(
connection=connection,
spec=LeCreditBasedChannelSpec(
psm=psm, max_credits=max_credits, mtu=mtu, mps=mps
),
)
async def create_le_credit_based_channel(
self,
connection: Connection,
spec: LeCreditBasedChannelSpec,
) -> LeCreditBasedChannel:
# Find a free CID for the new channel
connection_channels = self.channels.setdefault(connection.handle, {})
source_cid = self.find_free_le_cid(connection_channels)
if source_cid is None: # Should never happen!
raise RuntimeError('all CIDs already in use')
if spec.psm is None:
raise ValueError('PSM cannot be None')
# Create the channel
logger.debug(f'creating coc channel with cid={source_cid} for psm {psm}')
channel = LeConnectionOrientedChannel(
logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
channel = LeCreditBasedChannel(
manager=self,
connection=connection,
le_psm=psm,
le_psm=spec.psm,
source_cid=source_cid,
destination_cid=0,
mtu=mtu,
mps=mps,
mtu=spec.mtu,
mps=spec.mps,
credits=0,
peer_mtu=0,
peer_mps=0,
peer_credits=max_credits,
peer_credits=spec.max_credits,
connected=False,
)
connection_channels[source_cid] = channel
@@ -2062,7 +2172,15 @@ class ChannelManager:
return channel
async def connect(self, connection: Connection, psm: int) -> Channel:
@deprecated("Please use create_classic_channel()")
async def connect(self, connection: Connection, psm: int) -> ClassicChannel:
return await self.create_classic_channel(
connection=connection, spec=ClassicChannelSpec(psm=psm)
)
async def create_classic_channel(
self, connection: Connection, spec: ClassicChannelSpec
) -> ClassicChannel:
# NOTE: this implementation hard-codes BR/EDR
# Find a free CID for a new channel
@@ -2071,10 +2189,20 @@ class ChannelManager:
if source_cid is None: # Should never happen!
raise RuntimeError('all CIDs already in use')
if spec.psm is None:
raise ValueError('PSM cannot be None')
# Create the channel
logger.debug(f'creating client channel with cid={source_cid} for psm {psm}')
channel = Channel(
self, connection, L2CAP_SIGNALING_CID, psm, source_cid, L2CAP_MIN_BR_EDR_MTU
logger.debug(
f'creating client channel with cid={source_cid} for psm {spec.psm}'
)
channel = ClassicChannel(
self,
connection,
L2CAP_SIGNALING_CID,
spec.psm,
source_cid,
spec.mtu,
)
connection_channels[source_cid] = channel
@@ -2086,3 +2214,20 @@ class ChannelManager:
raise e
return channel
# -----------------------------------------------------------------------------
# Deprecated Classes
# -----------------------------------------------------------------------------
class Channel(ClassicChannel):
@deprecated("Please use ClassicChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
class LeConnectionOrientedChannel(LeCreditBasedChannel):
@deprecated("Please use LeCreditBasedChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
+1
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from bumble.pairing import PairingConfig, PairingDelegate
from dataclasses import dataclass
from typing import Any, Dict
+1
View File
@@ -14,6 +14,7 @@
"""Generic & dependency free Bumble (reference) device."""
from __future__ import annotations
from bumble import transport
from bumble.core import (
BT_GENERIC_AUDIO_SERVICE,
+1
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import bumble.device
import grpc
+11 -13
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import contextlib
import grpc
@@ -450,21 +451,18 @@ class SecurityService(SecurityServicer):
'security_request': pair,
}
# register event handlers
for event, listener in listeners.items():
connection.on(event, listener)
with contextlib.closing(EventWatcher()) as watcher:
# register event handlers
for event, listener in listeners.items():
watcher.on(connection, event, listener)
# security level already reached
if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty())
# security level already reached
if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty())
self.log.debug('Wait for security...')
kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty()
# remove event handlers
for event, listener in listeners.items():
connection.remove_listener(event, listener) # type: ignore
self.log.debug('Wait for security...')
kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty()
# wait for `authenticate` to finish if any
if authenticate_task is not None:
+1
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import contextlib
import functools
import grpc
+6 -1
View File
@@ -19,6 +19,8 @@
import struct
import logging
from typing import List
from bumble import l2cap
from ..core import AdvertisingData
from ..device import Device, Connection
from ..gatt import (
@@ -149,7 +151,10 @@ class AshaService(TemplateService):
channel.sink = on_data
# let the server find a free PSM
self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8)
self.psm = device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(psm=self.psm, max_credits=8),
handler=on_coc,
).psm
self.le_psm_out_characteristic = Characteristic(
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
Characteristic.Properties.READ,
+6 -6
View File
@@ -42,12 +42,12 @@ class HeartRateService(TemplateService):
RESET_ENERGY_EXPENDED = 0x01
class BodySensorLocation(IntEnum):
OTHER = (0,)
CHEST = (1,)
WRIST = (2,)
FINGER = (3,)
HAND = (4,)
EAR_LOBE = (5,)
OTHER = 0
CHEST = 1
WRIST = 2
FINGER = 3
HAND = 4
EAR_LOBE = 5
FOOT = 6
class HeartRateMeasurement:
+9 -7
View File
@@ -674,7 +674,7 @@ class Multiplexer(EventEmitter):
acceptor: Optional[Callable[[int], bool]]
dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
@@ -887,7 +887,7 @@ class Multiplexer(EventEmitter):
# -----------------------------------------------------------------------------
class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.Channel]
l2cap_channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device, connection: Connection) -> None:
self.device = device
@@ -898,8 +898,8 @@ class Client:
async def start(self) -> Multiplexer:
# Create a new L2CAP connection
try:
self.l2cap_channel = await self.device.l2cap_channel_manager.connect(
self.connection, RFCOMM_PSM
self.l2cap_channel = await self.connection.create_l2cap_channel(
spec=l2cap.ClassicChannelSpec(RFCOMM_PSM)
)
except ProtocolError as error:
logger.warning(f'L2CAP connection failed: {error}')
@@ -936,7 +936,9 @@ class Server(EventEmitter):
self.acceptors = {}
# Register ourselves with the L2CAP channel manager
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
device.create_l2cap_server(
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM), handler=self.on_connection
)
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
if channel:
@@ -960,11 +962,11 @@ class Server(EventEmitter):
self.acceptors[channel] = acceptor
return channel
def on_connection(self, l2cap_channel: l2cap.Channel) -> None:
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
def on_l2cap_channel_open(self, l2cap_channel: l2cap.Channel) -> None:
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel
+11 -8
View File
@@ -167,7 +167,7 @@ class DataElement:
UUID: lambda x: DataElement(
DataElement.UUID, core.UUID.from_bytes(bytes(reversed(x)))
),
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x.decode('utf8')),
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x),
BOOLEAN: lambda x: DataElement(DataElement.BOOLEAN, x[0] == 1),
SEQUENCE: lambda x: DataElement(
DataElement.SEQUENCE, DataElement.list_from_bytes(x)
@@ -229,7 +229,7 @@ class DataElement:
return DataElement(DataElement.UUID, value)
@staticmethod
def text_string(value: str) -> DataElement:
def text_string(value: bytes) -> DataElement:
return DataElement(DataElement.TEXT_STRING, value)
@staticmethod
@@ -376,7 +376,7 @@ class DataElement:
raise ValueError('invalid value_size')
elif self.type == DataElement.UUID:
data = bytes(reversed(bytes(self.value)))
elif self.type in (DataElement.TEXT_STRING, DataElement.URL):
elif self.type == DataElement.URL:
data = self.value.encode('utf8')
elif self.type == DataElement.BOOLEAN:
data = bytes([1 if self.value else 0])
@@ -758,7 +758,7 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
# -----------------------------------------------------------------------------
class Client:
channel: Optional[l2cap.Channel]
channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device) -> None:
self.device = device
@@ -766,8 +766,9 @@ class Client:
self.channel = None
async def connect(self, connection: Connection) -> None:
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
self.channel = result
self.channel = await connection.create_l2cap_channel(
spec=l2cap.ClassicChannelSpec(SDP_PSM)
)
async def disconnect(self) -> None:
if self.channel:
@@ -921,7 +922,7 @@ class Client:
# -----------------------------------------------------------------------------
class Server:
CONTINUATION_STATE = bytes([0x01, 0x43])
channel: Optional[l2cap.Channel]
channel: Optional[l2cap.ClassicChannel]
Service = NewType('Service', List[ServiceAttribute])
service_records: Dict[int, Service]
current_response: Union[None, bytes, Tuple[int, List[int]]]
@@ -933,7 +934,9 @@ class Server:
self.current_response = None
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
l2cap_channel_manager.create_classic_server(
spec=l2cap.ClassicChannelSpec(psm=SDP_PSM), handler=self.on_connection
)
def send_response(self, response):
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
+17
View File
@@ -21,6 +21,7 @@ import logging
import traceback
import collections
import sys
import warnings
from typing import (
Awaitable,
Set,
@@ -427,3 +428,19 @@ def wrap_async(function):
Wraps the provided function in an async function.
"""
return partial(async_call, function)
def deprecated(msg: str):
"""
Throw deprecation warning before execution
"""
def wrapper(function):
@wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, DeprecationWarning)
return function(*args, **kwargs)
return inner
return wrapper
+17 -8
View File
@@ -10,7 +10,7 @@ nav:
- Contributing: development/contributing.md
- Code Style: development/code_style.md
- Use Cases:
- Overview: use_cases/index.md
- use_cases/index.md
- Use Case 1: use_cases/use_case_1.md
- Use Case 2: use_cases/use_case_2.md
- Use Case 3: use_cases/use_case_3.md
@@ -23,7 +23,7 @@ nav:
- GATT: components/gatt.md
- Security Manager: components/security_manager.md
- Transports:
- Overview: transports/index.md
- transports/index.md
- Serial: transports/serial.md
- USB: transports/usb.md
- PTY: transports/pty.md
@@ -37,14 +37,14 @@ nav:
- Android Emulator: transports/android_emulator.md
- File: transports/file.md
- Drivers:
- Overview: drivers/index.md
- drivers/index.md
- Realtek: drivers/realtek.md
- API:
- Guide: api/guide.md
- Examples: api/examples.md
- Reference: api/reference.md
- Apps & Tools:
- Overview: apps_and_tools/index.md
- apps_and_tools/index.md
- Console: apps_and_tools/console.md
- Bench: apps_and_tools/bench.md
- Speaker: apps_and_tools/speaker.md
@@ -57,19 +57,24 @@ nav:
- USB Probe: apps_and_tools/usb_probe.md
- Link Relay: apps_and_tools/link_relay.md
- Hardware:
- Overview: hardware/index.md
- hardware/index.md
- Platforms:
- Overview: platforms/index.md
- platforms/index.md
- macOS: platforms/macos.md
- Linux: platforms/linux.md
- Windows: platforms/windows.md
- Android: platforms/android.md
- Zephyr: platforms/zephyr.md
- Examples:
- Overview: examples/index.md
- examples/index.md
- Extras:
- Overview: extras/index.md
- extras/index.md
- Android Remote HCI: extras/android_remote_hci.md
- Hive:
- hive/index.md
- Speaker: hive/web/speaker/speaker.html
- Scanner: hive/web/scanner/scanner.html
- Heart Rate Monitor: hive/web/heart_rate_monitor/heart_rate_monitor.html
copyright: Copyright 2021-2023 Google LLC
@@ -78,6 +83,8 @@ theme:
logo: 'images/logo.png'
favicon: 'images/favicon.ico'
custom_dir: 'theme'
features:
- navigation.indexes
plugins:
- mkdocstrings:
@@ -102,6 +109,8 @@ markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.tabbed:
alternate_style: true
- codehilite:
guess_lang: false
- toc:
+59
View File
@@ -0,0 +1,59 @@
HIVE
====
Welcome to the Bumble Hive.
This is a collection of apps and virtual devices that can run entirely in a browser page.
The code for the apps and devices, as well as the Bumble runtime code, runs via [Pyodide](https://pyodide.org/).
Pyodide is a Python distribution for the browser and Node.js based on WebAssembly.
The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical
Bluetooth controller.
The apps and devices in the hive can be accessed by following the links below. Each
page has a settings button that may be used to configure the WebSocket URL to use for
the virtual HCI connection. This will typically be the WebSocket URL for a `netsim`
daemon.
There is also a [TOML index](index.toml) that can be used by tools to know at which URL to access
each of the apps and devices, as well as their names and short descriptions.
!!! tip "Using `netsim`"
When the `netsimd` daemon is running (for example when using the Android Emulator that
is included in Android Studio), the daemon listens for connections on a TCP port.
To find out what this TCP port is, you can read the `netsim.ini` file that `netsimd`
creates, it includes a line with `web.port=<tcp-port>` (for example `web.port=7681`).
The location of the `netsim.ini` file is platform-specific.
=== "macOS"
On macOS, the directory where `netsim.ini` is stored is $TMPDIR
```bash
$ cat $TMPDIR/netsim.ini
```
=== "Linux"
On Linux, the directory where `netsim.ini` is stored is $XDG_RUNTIME_DIR
```bash
$ cat $XDG_RUNTIME_DIR/netsim.ini
```
!!! tip "Using a local radio"
You can connect the hive virtual apps and devices to a local Bluetooth radio, like,
for example, a USB dongle.
For that, you need to run a local HCI bridge to bridge a local HCI device to a WebSocket
that a web page can connect to.
Use the `bumble-hci-bridge` app, with the host transport set to a WebSocket server on an
available port (ex: `ws-server:_:7682`) and the controller transport set to the transport
name for the radio you want to use (ex: `usb:0` for the first USB dongle)
Applications
------------
* [Scanner](web/scanner/scanner.html) - Scans for BLE devices.
Virtual Devices
---------------
* [Speaker](web/speaker/speaker.html) - Virtual speaker that plays audio in a browser page.
* [Heart Rate Monitor](web/heart_rate_monitor/heart_rate_monitor.html) - Virtual heart rate monitor.
+21
View File
@@ -0,0 +1,21 @@
version = "1.0.0"
base_url = "https://google.github.io/bumble/hive/web"
default_hci_query_param = "hci"
[[index]]
name = "speaker"
description = "Bumble Virtual Speaker"
type = "Device"
url = "speaker/speaker.html"
[[index]]
name = "scanner"
description = "Simple Scanner Application"
type = "Application"
url = "scanner/scanner.html"
[[index]]
name = "heart-rate-monitor"
description = "Virtual Heart Rate Monitor"
type = "Device"
url = "heart_rate_monitor/heart_rate_monitor.html"
+1
View File
@@ -0,0 +1 @@
../../../../../web/bumble.js
@@ -0,0 +1 @@
../../../../../../web/heart_rate_monitor/heart_rate_monitor.html
@@ -0,0 +1 @@
../../../../../../web/heart_rate_monitor/heart_rate_monitor.js
@@ -0,0 +1 @@
../../../../../../web/heart_rate_monitor/heart_rate_monitor.py
+1
View File
@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.css
+1
View File
@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.html
+1
View File
@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.js
+1
View File
@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.py
+1
View File
@@ -0,0 +1 @@
../../../../../../web/speaker/logo.svg
+1
View File
@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.css
+1
View File
@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.html
+1
View File
@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.js
+1
View File
@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.py
+1
View File
@@ -0,0 +1 @@
../../../../../web/ui.js
+12
View File
@@ -152,11 +152,23 @@ Some platforms support features that not all platforms support
See the [Platforms page](platforms/index.md) for details.
Hive
----
The Hive is a collection of example apps and virtual devices that are implemented using the
Python Bumble API, running entirely in a web page. This is a convenient way to try out some
of the examples without any Python installation, when you have some other virtual Bluetooth
device that you can connect to or from, such as the Android Emulator.
See the [Bumble Hive](hive/index.md) for details.
Roadmap
-------
Future features to be considered include:
* More profiles
* More device examples
* Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc)
* Bindings for languages other than Python
@@ -14,7 +14,7 @@ connections.
## Moniker
The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
where `<options>` is a ','-separated list of `<name>=<value>` pairs`.
where `<options>` is a comma-separated list of `<name>=<value>` pairs.
The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).
+12
View File
@@ -29,6 +29,7 @@ from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.profiles.device_information_service import DeviceInformationService
from bumble.profiles.heart_rate_service import HeartRateService
from bumble.utils import AsyncRunner
# -----------------------------------------------------------------------------
@@ -98,6 +99,17 @@ async def main():
)
)
# Notify subscribers of the current value as soon as they subscribe
@heart_rate_service.heart_rate_measurement_characteristic.on('subscription')
def on_subscription(connection, notify_enabled, indicate_enabled):
if notify_enabled or indicate_enabled:
AsyncRunner.spawn(
device.notify_subscriber(
connection,
heart_rate_service.heart_rate_measurement_characteristic,
)
)
# Go!
await device.power_on()
await device.start_advertising(auto_restart=True)
+248
View File
@@ -0,0 +1,248 @@
# shift map
# letters
shift_map = {
'a': 'A',
'b': 'B',
'c': 'C',
'd': 'D',
'e': 'E',
'f': 'F',
'g': 'G',
'h': 'H',
'i': 'I',
'j': 'J',
'k': 'K',
'l': 'L',
'm': 'M',
'n': 'N',
'o': 'O',
'p': 'P',
'q': 'Q',
'r': 'R',
's': 'S',
't': 'T',
'u': 'U',
'v': 'V',
'w': 'W',
'x': 'X',
'y': 'Y',
'z': 'Z',
# numbers
'1': '!',
'2': '@',
'3': '#',
'4': '$',
'5': '%',
'6': '^',
'7': '&',
'8': '*',
'9': '(',
'0': ')',
# symbols
'-': '_',
'=': '+',
'[': '{',
']': '}',
'\\': '|',
';': ':',
'\'': '"',
',': '<',
'.': '>',
'/': '?',
'`': '~',
}
# hex map
# modifier keys
mod_keys = {
'00': '',
'01': 'left_ctrl',
'02': 'left_shift',
'04': 'left_alt',
'08': 'left_meta',
'10': 'right_ctrl',
'20': 'right_shift',
'40': 'right_alt',
'80': 'right_meta',
}
# base keys
base_keys = {
# meta
'00': '', # none
'01': 'error_ovf',
# letters
'04': 'a',
'05': 'b',
'06': 'c',
'07': 'd',
'08': 'e',
'09': 'f',
'0a': 'g',
'0b': 'h',
'0c': 'i',
'0d': 'j',
'0e': 'k',
'0f': 'l',
'10': 'm',
'11': 'n',
'12': 'o',
'13': 'p',
'14': 'q',
'15': 'r',
'16': 's',
'17': 't',
'18': 'u',
'19': 'v',
'1a': 'w',
'1b': 'x',
'1c': 'y',
'1d': 'z',
# numbers
'1e': '1',
'1f': '2',
'20': '3',
'21': '4',
'22': '5',
'23': '6',
'24': '7',
'25': '8',
'26': '9',
'27': '0',
# misc
'28': 'enter', # enter \n
'29': 'esc',
'2a': 'backspace',
'2b': 'tab',
'2c': 'spacebar', # space
'2d': '-',
'2e': '=',
'2f': '[',
'30': ']',
'31': '\\',
'32': '=',
'33': '_SEMICOLON',
'34': 'KEY_APOSTROPHE',
'35': 'KEY_GRAVE',
'36': 'KEY_COMMA',
'37': 'KEY_DOT',
'38': 'KEY_SLASH',
'39': 'KEY_CAPSLOCK',
'3a': 'KEY_F1',
'3b': 'KEY_F2',
'3c': 'KEY_F3',
'3d': 'KEY_F4',
'3e': 'KEY_F5',
'3f': 'KEY_F6',
'40': 'KEY_F7',
'41': 'KEY_F8',
'42': 'KEY_F9',
'43': 'KEY_F10',
'44': 'KEY_F11',
'45': 'KEY_F12',
'46': 'KEY_SYSRQ',
'47': 'KEY_SCROLLLOCK',
'48': 'KEY_PAUSE',
'49': 'KEY_INSERT',
'4a': 'KEY_HOME',
'4b': 'KEY_PAGEUP',
'4c': 'KEY_DELETE',
'4d': 'KEY_END',
'4e': 'KEY_PAGEDOWN',
'4f': 'KEY_RIGHT',
'50': 'KEY_LEFT',
'51': 'KEY_DOWN',
'52': 'KEY_UP',
'53': 'KEY_NUMLOCK',
'54': 'KEY_KPSLASH',
'55': 'KEY_KPASTERISK',
'56': 'KEY_KPMINUS',
'57': 'KEY_KPPLUS',
'58': 'KEY_KPENTER',
'59': 'KEY_KP1',
'5a': 'KEY_KP2',
'5b': 'KEY_KP3',
'5c': 'KEY_KP4',
'5d': 'KEY_KP5',
'5e': 'KEY_KP6',
'5f': 'KEY_KP7',
'60': 'KEY_KP8',
'61': 'KEY_KP9',
'62': 'KEY_KP0',
'63': 'KEY_KPDOT',
'64': 'KEY_102ND',
'65': 'KEY_COMPOSE',
'66': 'KEY_POWER',
'67': 'KEY_KPEQUAL',
'68': 'KEY_F13',
'69': 'KEY_F14',
'6a': 'KEY_F15',
'6b': 'KEY_F16',
'6c': 'KEY_F17',
'6d': 'KEY_F18',
'6e': 'KEY_F19',
'6f': 'KEY_F20',
'70': 'KEY_F21',
'71': 'KEY_F22',
'72': 'KEY_F23',
'73': 'KEY_F24',
'74': 'KEY_OPEN',
'75': 'KEY_HELP',
'76': 'KEY_PROPS',
'77': 'KEY_FRONT',
'78': 'KEY_STOP',
'79': 'KEY_AGAIN',
'7a': 'KEY_UNDO',
'7b': 'KEY_CUT',
'7c': 'KEY_COPY',
'7d': 'KEY_PASTE',
'7e': 'KEY_FIND',
'7f': 'KEY_MUTE',
'80': 'KEY_VOLUMEUP',
'81': 'KEY_VOLUMEDOWN',
'85': 'KEY_KPCOMMA',
'87': 'KEY_RO',
'88': 'KEY_KATAKANAHIRAGANA',
'89': 'KEY_YEN',
'8a': 'KEY_HENKAN',
'8b': 'KEY_MUHENKAN',
'8c': 'KEY_KPJPCOMMA',
'90': 'KEY_HANGEUL',
'91': 'KEY_HANJA',
'92': 'KEY_KATAKANA',
'93': 'KEY_HIRAGANA',
'94': 'KEY_ZENKAKUHANKAKU',
'b6': 'KEY_KPLEFTPAREN',
'b7': 'KEY_KPRIGHTPAREN',
'e0': 'KEY_LEFTCTRL',
'e1': 'KEY_LEFTSHIFT',
'e2': 'KEY_LEFTALT',
'e3': 'KEY_LEFTMETA',
'e4': 'KEY_RIGHTCTRL',
'e5': 'KEY_RIGHTSHIFT',
'e6': 'KEY_RIGHTALT',
'e7': 'KEY_RIGHTMETA',
'e8': 'KEY_MEDIA_PLAYPAUSE',
'e9': 'KEY_MEDIA_STOPCD',
'ea': 'KEY_MEDIA_PREVIOUSSONG',
'eb': 'KEY_MEDIA_NEXTSONG',
'ec': 'KEY_MEDIA_EJECTCD',
'ed': 'KEY_MEDIA_VOLUMEUP',
'ee': 'KEY_MEDIA_VOLUMEDOWN',
'ef': 'KEY_MEDIA_MUTE',
'f0': 'KEY_MEDIA_WWW',
'f1': 'KEY_MEDIA_BACK',
'f2': 'KEY_MEDIA_FORWARD',
'f3': 'KEY_MEDIA_STOP',
'f4': 'KEY_MEDIA_FIND',
'f5': 'KEY_MEDIA_SCROLLUP',
'f6': 'KEY_MEDIA_SCROLLDOWN',
'f7': 'KEY_MEDIA_EDIT',
'f8': 'KEY_MEDIA_SLEEP',
'f9': 'KEY_MEDIA_COFFEE',
'fa': 'KEY_MEDIA_REFRESH',
'fb': 'KEY_MEDIA_CALC',
}
+159
View File
@@ -0,0 +1,159 @@
from bumble.colors import color
from hid_key_map import base_keys, mod_keys, shift_map
# ------------------------------------------------------------------------------
def get_key(modifier: str, key: str) -> str:
if modifier == '22':
modifier = '02'
if modifier in mod_keys:
modifier = mod_keys[modifier]
else:
return ''
if key in base_keys:
key = base_keys[key]
else:
return ''
if (modifier == 'left_shift' or modifier == 'right_shift') and key in shift_map:
key = shift_map[key]
return key
class Keyboard:
def __init__(self): # type: ignore
self.report = [
[ # Bit array for Modifier keys
0, # Right GUI - (usually the Windows key)
0, # Right ALT
0, # Right Shift
0, # Right Control
0, # Left GUI - (usually the Windows key)
0, # Left ALT
0, # Left Shift
0, # Left Control
],
0x00, # Vendor reserved
'', # Rest is space for 6 keys
'',
'',
'',
'',
'',
]
def decode_keyboard_report(self, input_report: bytes, report_length: int) -> None:
if report_length >= 8:
modifier = input_report[1]
self.report[0] = [int(x) for x in '{0:08b}'.format(modifier)]
self.report[0].reverse() # type: ignore
modifier_key = str((modifier & 0x22).to_bytes(1, "big").hex())
keycodes = []
for k in range(3, report_length):
keycodes.append(str(input_report[k].to_bytes(1, "big").hex()))
self.report[k - 1] = get_key(modifier_key, keycodes[k - 3])
else:
print(color('Warning: Not able to parse report', 'yellow'))
def print_keyboard_report(self) -> None:
print(color('\tKeyboard Input Received', 'green', None, 'bold'))
print(color(f'Keys:', 'white', None, 'bold'))
for i in range(1, 7):
print(
color(f' Key{i}{" ":>8s}= ', 'cyan', None, 'bold'), self.report[i + 1]
)
print(color(f'\nModifier Keys:', 'white', None, 'bold'))
print(
color(f' Left Ctrl : ', 'cyan'),
f'{self.report[0][0] == 1!s:<5}', # type: ignore
color(f' Left Shift : ', 'cyan'),
f'{self.report[0][1] == 1!s:<5}', # type: ignore
color(f' Left ALT : ', 'cyan'),
f'{self.report[0][2] == 1!s:<5}', # type: ignore
color(f' Left GUI : ', 'cyan'),
f'{self.report[0][3] == 1!s:<5}\n', # type: ignore
color(f' Right Ctrl : ', 'cyan'),
f'{self.report[0][4] == 1!s:<5}', # type: ignore
color(f' Right Shift : ', 'cyan'),
f'{self.report[0][5] == 1!s:<5}', # type: ignore
color(f' Right ALT : ', 'cyan'),
f'{self.report[0][6] == 1!s:<5}', # type: ignore
color(f' Right GUI : ', 'cyan'),
f'{self.report[0][7] == 1!s:<5}', # type: ignore
)
# ------------------------------------------------------------------------------
class Mouse:
def __init__(self): # type: ignore
self.report = [
[ # Bit array for Buttons
0, # Button 1 (primary/trigger
0, # Button 2 (secondary)
0, # Button 3 (tertiary)
0, # Button 4
0, # Button 5
0, # unused padding bits
0, # unused padding bits
0, # unused padding bits
],
0, # X
0, # Y
0, # Wheel
0, # AC Pan
]
def decode_mouse_report(self, input_report: bytes, report_length: int) -> None:
self.report[0] = [int(x) for x in '{0:08b}'.format(input_report[1])]
self.report[0].reverse() # type: ignore
self.report[1] = input_report[2]
self.report[2] = input_report[3]
if report_length in [5, 6]:
self.report[3] = input_report[4]
self.report[4] = input_report[5] if report_length == 6 else 0
def print_mouse_report(self) -> None:
print(color('\tMouse Input Received', 'green', None, 'bold'))
print(
color(f' Button 1 (primary/trigger) = ', 'cyan'),
self.report[0][0] == 1, # type: ignore
color(f'\n Button 2 (secondary) = ', 'cyan'),
self.report[0][1] == 1, # type: ignore
color(f'\n Button 3 (tertiary) = ', 'cyan'),
self.report[0][2] == 1, # type: ignore
color(f'\n Button4 = ', 'cyan'),
self.report[0][3] == 1, # type: ignore
color(f'\n Button5 = ', 'cyan'),
self.report[0][4] == 1, # type: ignore
color(f'\n X (X-axis displacement) = ', 'cyan'),
self.report[1],
color(f'\n Y (Y-axis displacement) = ', 'cyan'),
self.report[2],
color(f'\n Wheel = ', 'cyan'),
self.report[3],
color(f'\n AC PAN = ', 'cyan'),
self.report[4],
)
# ------------------------------------------------------------------------------
class ReportParser:
@staticmethod
def parse_input_report(input_report: bytes) -> None:
report_id = input_report[0] # pylint: disable=unsubscriptable-object
report_length = len(input_report)
# Keyboard input report (report id = 1)
if report_id == 1 and report_length >= 8:
keyboard = Keyboard() # type: ignore
keyboard.decode_keyboard_report(input_report, report_length)
keyboard.print_keyboard_report()
# Mouse input report (report id = 2)
elif report_id == 2 and report_length in [4, 5, 6]:
mouse = Mouse() # type: ignore
mouse.decode_mouse_report(input_report, report_length)
mouse.print_mouse_report()
else:
print(color(f'Warning: Parse Error Report ID {report_id}', 'yellow'))
+1 -1
View File
@@ -131,7 +131,7 @@ async def main():
await device.power_on()
# Create a listener to wait for AVDTP connections
listener = Listener(Listener.create_registrar(device))
listener = Listener.for_device(device)
listener.on('connection', on_avdtp_connection)
if len(sys.argv) >= 5:
+1 -1
View File
@@ -179,7 +179,7 @@ async def main():
await stream_packets(read, protocol)
else:
# Create a listener to wait for AVDTP connections
listener = Listener(Listener.create_registrar(device), version=(1, 2))
listener = Listener.for_device(device=device, version=(1, 2))
listener.on(
'connection', lambda protocol: on_avdtp_connection(read, protocol)
)
+6 -3
View File
@@ -21,6 +21,7 @@ import sys
import os
import logging
from bumble import l2cap
from bumble.core import AdvertisingData
from bumble.device import Device
from bumble.transport import open_transport_or_link
@@ -95,8 +96,10 @@ async def main():
channel.sink = on_data
psm = device.register_l2cap_channel_server(0, on_coc, 8)
print(f'### LE_PSM_OUT = {psm}')
server = device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(max_credits=8), handler=on_coc
)
print(f'### LE_PSM_OUT = {server.psm}')
# Add the ASHA service to the GATT server
read_only_properties_characteristic = Characteristic(
@@ -147,7 +150,7 @@ async def main():
ASHA_LE_PSM_OUT_CHARACTERISTIC,
Characteristic.Properties.READ,
Characteristic.READABLE,
struct.pack('<H', psm),
struct.pack('<H', server.psm),
)
device.add_service(
Service(
+540
View File
@@ -0,0 +1,540 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import sys
import os
import logging
from bumble.colors import color
import bumble.core
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import (
BT_L2CAP_PROTOCOL_ID,
BT_HIDP_PROTOCOL_ID,
BT_HUMAN_INTERFACE_DEVICE_SERVICE,
BT_BR_EDR_TRANSPORT,
)
from bumble.hci import Address
from bumble.hid import Host, Message
from bumble.sdp import (
Client as SDP_Client,
DataElement,
ServiceAttribute,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_ALL_ATTRIBUTES_RANGE,
SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
)
from hid_report_parser import ReportParser
# -----------------------------------------------------------------------------
# SDP attributes for Bluetooth HID devices
SDP_HID_SERVICE_NAME_ATTRIBUTE_ID = 0x0100
SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID = 0x0101
SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID = 0x0102
SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID = 0x0200 # [DEPRECATED]
SDP_HID_PARSER_VERSION_ATTRIBUTE_ID = 0x0201
SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID = 0x0202
SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID = 0x0203
SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID = 0x0204
SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID = 0x0205
SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0x0206
SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID = 0x0207
SDP_HID_SDP_DISABLE_ATTRIBUTE_ID = 0x0208 # [DEPRECATED]
SDP_HID_BATTERY_POWER_ATTRIBUTE_ID = 0x0209
SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID = 0x020A
SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID = 0x020B # DEPRECATED]
SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID = 0x020C
SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID = 0x020D
SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID = 0x020E
SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID = 0x020F
SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
# -----------------------------------------------------------------------------
async def get_hid_device_sdp_record(device, connection):
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
if sdp_client:
print(color('Connected to SDP Server', 'blue'))
else:
print(color('Failed to connect to SDP Server', 'red'))
# List BT HID Device service in the root browse group
service_record_handles = await sdp_client.search_services(
[BT_HUMAN_INTERFACE_DEVICE_SERVICE]
)
if len(service_record_handles) < 1:
await sdp_client.disconnect()
raise Exception(
color(f'BT HID Device service not found on peer device!!!!', 'red')
)
# For BT_HUMAN_INTERFACE_DEVICE_SERVICE service, get all its attributes
for service_record_handle in service_record_handles:
attributes = await sdp_client.get_attributes(
service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
)
print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow'))
print(color(f'SDP attributes for HID device', 'magenta'))
for attribute in attributes:
if attribute.id == SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID:
print(
color(' Service Record Handle : ', 'cyan'),
hex(attribute.value.value),
)
elif attribute.id == SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
print(
color(' Service Class : ', 'cyan'), attribute.value.value[0].value
)
elif attribute.id == SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID:
print(
color(' SDP Browse Group List : ', 'cyan'),
attribute.value.value[0].value,
)
elif attribute.id == SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' BT_L2CAP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[0].value[0].value,
)
print(
color(' PSM for Bluetooth HID Control channel : ', 'cyan'),
hex(attribute.value.value[0].value[1].value),
)
print(
color(' BT_HIDP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[1].value[0].value,
)
elif attribute.id == SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID:
print(
color(' Lanugage : ', 'cyan'), hex(attribute.value.value[0].value)
)
print(
color(' Encoding : ', 'cyan'), hex(attribute.value.value[1].value)
)
print(
color(' PrimaryLanguageBaseID : ', 'cyan'),
hex(attribute.value.value[2].value),
)
elif attribute.id == SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' BT_HUMAN_INTERFACE_DEVICE_SERVICE ', 'cyan'),
attribute.value.value[0].value[0].value,
)
print(
color(' HID Profileversion number : ', 'cyan'),
hex(attribute.value.value[0].value[1].value),
)
elif attribute.id == SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' BT_L2CAP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[0].value[0].value[0].value,
)
print(
color(' PSM for Bluetooth HID Interrupt channel : ', 'cyan'),
hex(attribute.value.value[0].value[0].value[1].value),
)
print(
color(' BT_HIDP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[0].value[1].value[0].value,
)
elif attribute.id == SDP_HID_SERVICE_NAME_ATTRIBUTE_ID:
print(color(' Service Name: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID:
print(color(' Service Description: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID:
print(color(' Provider Name: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID:
print(color(' Release Number: ', 'cyan'), hex(attribute.value.value))
elif attribute.id == SDP_HID_PARSER_VERSION_ATTRIBUTE_ID:
print(
color(' HID Parser Version: ', 'cyan'), hex(attribute.value.value)
)
elif attribute.id == SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID:
print(
color(' HIDDeviceSubclass: ', 'cyan'), hex(attribute.value.value)
)
elif attribute.id == SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID:
print(color(' HIDCountryCode: ', 'cyan'), hex(attribute.value.value))
elif attribute.id == SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID:
print(color(' HIDVirtualCable: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID:
print(color(' HIDReconnectInitiate: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' HID Report Descriptor type: ', 'cyan'),
hex(attribute.value.value[0].value[0].value),
)
print(
color(' HID Report DescriptorList: ', 'cyan'),
attribute.value.value[0].value[1].value,
)
elif attribute.id == SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID:
print(
color(' HID LANGID Base Language: ', 'cyan'),
hex(attribute.value.value[0].value[0].value),
)
print(
color(' HID LANGID Base Bluetooth String Offset: ', 'cyan'),
hex(attribute.value.value[0].value[1].value),
)
elif attribute.id == SDP_HID_BATTERY_POWER_ATTRIBUTE_ID:
print(color(' HIDBatteryPower: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID:
print(color(' HIDRemoteWake: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID:
print(
color(' HIDProfileVersion : ', 'cyan'), hex(attribute.value.value)
)
elif attribute.id == SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID:
print(
color(' HIDSupervisionTimeout: ', 'cyan'),
hex(attribute.value.value),
)
elif attribute.id == SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID:
print(
color(' HIDNormallyConnectable: ', 'cyan'), attribute.value.value
)
elif attribute.id == SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID:
print(color(' HIDBootDevice: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID:
print(
color(' HIDSSRHostMaxLatency: ', 'cyan'),
hex(attribute.value.value),
)
elif attribute.id == SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID:
print(
color(' HIDSSRHostMinTimeout: ', 'cyan'),
hex(attribute.value.value),
)
else:
print(
color(
f' Warning: Attribute ID: {attribute.id} match not found.\n Attribute Info: {attribute}',
'yellow',
)
)
await sdp_client.disconnect()
# -----------------------------------------------------------------------------
async def get_stream_reader(pipe) -> asyncio.StreamReader:
loop = asyncio.get_event_loop()
reader = asyncio.StreamReader(loop=loop)
protocol = asyncio.StreamReaderProtocol(reader)
await loop.connect_read_pipe(lambda: protocol, pipe)
return reader
# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) < 4:
print(
'Usage: run_hid_host.py <device-config> <transport-spec> '
'<bluetooth-address> [test-mode]'
)
print('example: run_hid_host.py classic1.json usb:0 E1:CA:72:48:C4:E8/P')
return
def on_hid_data_cb(pdu):
report_type = pdu[0] & 0x0F
if len(pdu) == 1:
print(color(f'Warning: No report received', 'yellow'))
return
report_length = len(pdu[1:])
report_id = pdu[1]
if report_type != Message.ReportType.OTHER_REPORT:
print(
color(
f' Report type = {report_type}, Report length = {report_length}, Report id = {report_id}',
'blue',
None,
'bold',
)
)
if (report_length <= 1) or (report_id == 0):
return
if report_type == Message.ReportType.INPUT_REPORT:
ReportParser.parse_input_report(pdu[1:]) # type: ignore
async def handle_virtual_cable_unplug():
await hid_host.disconnect_interrupt_channel()
await hid_host.disconnect_control_channel()
await device.keystore.delete(target_address) # type: ignore
await connection.disconnect()
def on_hid_virtual_cable_unplug_cb():
asyncio.create_task(handle_virtual_cable_unplug())
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< CONNECTED')
# Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
await device.power_on()
# Connect to a peer
target_address = sys.argv[3]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
# Request authentication
print('*** Authenticating...')
await connection.authenticate()
print('*** Authenticated...')
# Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
await get_hid_device_sdp_record(device, connection)
# Create HID host and start it
print('@@@ Starting HID Host...')
hid_host = Host(device, connection)
# Register for HID data call back
hid_host.on('data', on_hid_data_cb)
# Register for virtual cable unplug call back
hid_host.on('virtual_cable_unplug', on_hid_virtual_cable_unplug_cb)
async def menu():
reader = await get_stream_reader(sys.stdin)
while True:
print(
"\n************************ HID Host Menu *****************************\n"
)
print(" 1. Connect Control Channel")
print(" 2. Connect Interrupt Channel")
print(" 3. Disconnect Control Channel")
print(" 4. Disconnect Interrupt Channel")
print(" 5. Get Report")
print(" 6. Set Report")
print(" 7. Set Protocol Mode")
print(" 8. Get Protocol Mode")
print(" 9. Send Report")
print("10. Suspend")
print("11. Exit Suspend")
print("12. Virtual Cable Unplug")
print("13. Disconnect device")
print("14. Delete Bonding")
print("15. Re-connect to device")
print("\nEnter your choice : \n")
choice = await reader.readline()
choice = choice.decode('utf-8').strip()
if choice == '1':
await hid_host.connect_control_channel()
elif choice == '2':
await hid_host.connect_interrupt_channel()
elif choice == '3':
await hid_host.disconnect_control_channel()
elif choice == '4':
await hid_host.disconnect_interrupt_channel()
elif choice == '5':
print(" 1. Report ID 0x02")
print(" 2. Report ID 0x03")
print(" 3. Report ID 0x05")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '1':
hid_host.get_report(1, 2, 3)
elif choice1 == '2':
hid_host.get_report(2, 3, 2)
elif choice1 == '3':
hid_host.get_report(3, 5, 3)
else:
print('Incorrect option selected')
elif choice == '6':
print(" 1. Report type 1 and Report id 0x01")
print(" 2. Report type 2 and Report id 0x03")
print(" 3. Report type 3 and Report id 0x05")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '1':
# data includes first octet as report id
data = bytearray(
[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01]
)
hid_host.set_report(1, data)
elif choice1 == '2':
data = bytearray([0x03, 0x01, 0x01])
hid_host.set_report(2, data)
elif choice1 == '3':
data = bytearray([0x05, 0x01, 0x01, 0x01])
hid_host.set_report(3, data)
else:
print('Incorrect option selected')
elif choice == '7':
print(" 0. Boot")
print(" 1. Report")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '0':
hid_host.set_protocol(Message.ProtocolMode.BOOT_PROTOCOL)
elif choice1 == '1':
hid_host.set_protocol(Message.ProtocolMode.REPORT_PROTOCOL)
else:
print('Incorrect option selected')
elif choice == '8':
hid_host.get_protocol()
elif choice == '9':
print(" 1. Report ID 0x01")
print(" 2. Report ID 0x03")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '1':
data = bytearray(
[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
)
hid_host.send_data(data)
elif choice1 == '2':
data = bytearray([0x03, 0x00, 0x0D, 0xFD, 0x00, 0x00])
hid_host.send_data(data)
else:
print('Incorrect option selected')
elif choice == '10':
hid_host.suspend()
elif choice == '11':
hid_host.exit_suspend()
elif choice == '12':
hid_host.virtual_cable_unplug()
try:
await device.keystore.delete(target_address)
except KeyError:
print('Device not found or Device already unpaired.')
elif choice == '13':
peer_address = Address.from_string_for_transport(
target_address, transport=BT_BR_EDR_TRANSPORT
)
connection = device.find_connection_by_bd_addr(
peer_address, transport=BT_BR_EDR_TRANSPORT
)
if connection is not None:
await connection.disconnect()
else:
print("Already disconnected from device")
elif choice == '14':
try:
await device.keystore.delete(target_address)
print("Unpair successful")
except KeyError:
print('Device not found or Device already unpaired.')
elif choice == '15':
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
)
await connection.authenticate()
await connection.encrypt()
else:
print("Invalid option selected.")
if (len(sys.argv) > 4) and (sys.argv[4] == 'test-mode'):
# Enabling menu for testing
await menu()
else:
# HID Connection
# Control channel
await hid_host.connect_control_channel()
# Interrupt Channel
await hid_host.connect_interrupt_channel()
await hci_source.wait_for_termination()
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())
@@ -113,7 +113,7 @@ class MainActivity : ComponentActivity() {
val tcpPort = intent.getIntExtra("port", -1)
if (tcpPort >= 0) {
appViewModel.tcpPort = tcpPport
appViewModel.tcpPort = tcpPort
}
setContent {
@@ -1,5 +1,5 @@
[versions]
agp = "8.3.0-alpha05"
agp = "8.3.0-alpha11"
kotlin = "1.8.10"
core-ktx = "1.9.0"
junit = "4.13.2"
@@ -1,6 +1,6 @@
#Sun Aug 06 12:53:26 PDT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-rc-2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
File diff suppressed because it is too large Load Diff
+4
View File
@@ -91,9 +91,13 @@ development =
mypy == 1.5.0
nox >= 2022
pylint == 2.15.8
pyyaml >= 6.0
types-appdirs >= 1.4.3
types-invoke >= 1.7.3
types-protobuf >= 4.21.0
avatar =
pandora-avatar == 0.0.5
rootcanal == 1.3.0 ; python_version>='3.10'
documentation =
mkdocs >= 1.4.0
mkdocs-material >= 8.5.6
+1 -1
View File
@@ -125,7 +125,7 @@ def lint(ctx, disable='C,R', errors_only=False):
print(f">>> Running the linter{qualifier}...")
try:
ctx.run(f"pylint {' '.join(options)} bumble apps examples tasks.py")
print("The linter is happy. ✅ 😊 🐝'")
print("The linter is happy. ✅ 😊 🐝")
except UnexpectedExit as exc:
print("Please check your code against the linter messages. ❌")
raise Exit(code=1) from exc
+1 -1
View File
@@ -185,7 +185,7 @@ async def test_source_sink_1():
sink.on('rtp_packet', on_rtp_packet)
# Create a listener to wait for AVDTP connections
listener = Listener(Listener.create_registrar(two_devices.devices[1]))
listener = Listener.for_device(two_devices.devices[1])
listener.on('connection', on_avdtp_connection)
async def make_connection():
+4 -2
View File
@@ -45,12 +45,14 @@ def test_messages():
]
message = Get_Capabilities_Response(capabilities)
parsed = Message.create(
AVDTP_GET_CAPABILITIES, Message.RESPONSE_ACCEPT, message.payload
AVDTP_GET_CAPABILITIES, Message.MessageType.RESPONSE_ACCEPT, message.payload
)
assert message.payload == parsed.payload
message = Set_Configuration_Command(3, 4, capabilities)
parsed = Message.create(AVDTP_SET_CONFIGURATION, Message.COMMAND, message.payload)
parsed = Message.create(
AVDTP_SET_CONFIGURATION, Message.MessageType.COMMAND, message.payload
)
assert message.payload == parsed.payload
+3
View File
@@ -94,6 +94,7 @@ def temporary_file():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_basic(temporary_file):
with open(temporary_file, mode='w', encoding='utf-8') as file:
file.write("{}")
@@ -125,6 +126,7 @@ async def test_basic(temporary_file):
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_parsing(temporary_file):
with open(temporary_file, mode='w', encoding='utf-8') as file:
file.write(JSON1)
@@ -137,6 +139,7 @@ async def test_parsing(temporary_file):
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_default_namespace(temporary_file):
with open(temporary_file, mode='w', encoding='utf-8') as file:
file.write(JSON1)
+26 -9
View File
@@ -22,7 +22,11 @@ import random
import pytest
from bumble.core import ProtocolError
from bumble.l2cap import L2CAP_Connection_Request
from bumble.l2cap import (
L2CAP_Connection_Request,
ClassicChannelSpec,
LeCreditBasedChannelSpec,
)
from .test_utils import TwoDevices
@@ -80,7 +84,9 @@ async def test_basic_connection():
# Check that if there's no one listening, we can't connect
with pytest.raises(ProtocolError):
l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
l2cap_channel = await devices.connections[0].create_l2cap_channel(
spec=LeCreditBasedChannelSpec(psm)
)
# Now add a listener
incoming_channel = None
@@ -95,8 +101,12 @@ async def test_basic_connection():
channel.sink = on_data
devices.devices[1].register_l2cap_channel_server(psm, on_coc)
l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
devices.devices[1].create_l2cap_server(
spec=LeCreditBasedChannelSpec(psm=1234), handler=on_coc
)
l2cap_channel = await devices.connections[0].create_l2cap_channel(
spec=LeCreditBasedChannelSpec(psm)
)
messages = (bytes([1, 2, 3]), bytes([4, 5, 6]), bytes(10000))
for message in messages:
@@ -138,10 +148,13 @@ async def transfer_payload(max_credits, mtu, mps):
channel.sink = on_data
psm = devices.devices[1].register_l2cap_channel_server(
psm=0, server=on_coc, max_credits=max_credits, mtu=mtu, mps=mps
server = devices.devices[1].create_l2cap_server(
spec=LeCreditBasedChannelSpec(max_credits=max_credits, mtu=mtu, mps=mps),
handler=on_coc,
)
l2cap_channel = await devices.connections[0].create_l2cap_channel(
spec=LeCreditBasedChannelSpec(server.psm)
)
l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
messages = [bytes([1, 2, 3, 4, 5, 6, 7]) * x for x in (3, 10, 100, 789)]
for message in messages:
@@ -189,8 +202,12 @@ async def test_bidirectional_transfer():
def on_client_data(data):
client_received.append(data)
psm = devices.devices[1].register_l2cap_channel_server(psm=0, server=on_server_coc)
client_channel = await devices.connections[0].open_l2cap_channel(psm)
server = devices.devices[1].create_l2cap_server(
spec=LeCreditBasedChannelSpec(), handler=on_server_coc
)
client_channel = await devices.connections[0].create_l2cap_channel(
spec=LeCreditBasedChannelSpec(server.psm)
)
client_channel.sink = on_client_data
messages = [bytes([1, 2, 3, 4, 5, 6, 7]) * x for x in (3, 10, 100)]
+12 -8
View File
@@ -18,6 +18,7 @@
import asyncio
import logging
import os
import pytest
from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID
from bumble.sdp import (
@@ -99,13 +100,13 @@ def test_data_elements() -> None:
e = DataElement(DataElement.UUID, UUID('61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'))
basic_check(e)
e = DataElement(DataElement.TEXT_STRING, 'hello')
e = DataElement(DataElement.TEXT_STRING, b'hello')
basic_check(e)
e = DataElement(DataElement.TEXT_STRING, 'hello' * 60)
e = DataElement(DataElement.TEXT_STRING, b'hello' * 60)
basic_check(e)
e = DataElement(DataElement.TEXT_STRING, 'hello' * 20000)
e = DataElement(DataElement.TEXT_STRING, b'hello' * 20000)
basic_check(e)
e = DataElement(DataElement.BOOLEAN, True)
@@ -121,7 +122,7 @@ def test_data_elements() -> None:
DataElement.SEQUENCE,
[
DataElement(DataElement.BOOLEAN, True),
DataElement(DataElement.TEXT_STRING, 'hello'),
DataElement(DataElement.TEXT_STRING, b'hello'),
],
)
basic_check(e)
@@ -133,7 +134,7 @@ def test_data_elements() -> None:
DataElement.ALTERNATIVE,
[
DataElement(DataElement.BOOLEAN, True),
DataElement(DataElement.TEXT_STRING, 'hello'),
DataElement(DataElement.TEXT_STRING, b'hello'),
],
)
basic_check(e)
@@ -151,19 +152,19 @@ def test_data_elements() -> None:
e = DataElement.uuid(UUID.from_16_bits(1234))
basic_check(e)
e = DataElement.text_string('hello')
e = DataElement.text_string(b'hello')
basic_check(e)
e = DataElement.boolean(True)
basic_check(e)
e = DataElement.sequence(
[DataElement.signed_integer(0, 1), DataElement.text_string('hello')]
[DataElement.signed_integer(0, 1), DataElement.text_string(b'hello')]
)
basic_check(e)
e = DataElement.alternative(
[DataElement.signed_integer(0, 1), DataElement.text_string('hello')]
[DataElement.signed_integer(0, 1), DataElement.text_string(b'hello')]
)
basic_check(e)
@@ -202,6 +203,7 @@ def sdp_records():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_search():
# Setup connections
devices = TwoDevices()
@@ -224,6 +226,7 @@ async def test_service_search():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_attribute():
# Setup connections
devices = TwoDevices()
@@ -244,6 +247,7 @@ async def test_service_attribute():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_search_attribute():
# Setup connections
devices = TwoDevices()
+13 -13
View File
@@ -14,25 +14,25 @@
# -----------------------------------------------------------------------------
# This script generates a python-syntax list of dictionary entries for the
# company IDs listed at: https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/
# The input to this script is the CSV file that can be obtained at that URL
# company IDs listed at:
# https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/company_identifiers/company_identifiers.yaml
# The input to this script is the YAML file that can be obtained at that URL
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import sys
import csv
import yaml
# -----------------------------------------------------------------------------
with open(sys.argv[1], newline='') as csvfile:
reader = csv.reader(csvfile, delimiter=',', quotechar='"')
lines = []
for row in reader:
if len(row) == 3 and row[1].startswith('0x'):
company_id = row[1]
company_name = row[2]
escaped_company_name = company_name.replace('"', '\\"')
lines.append(f' {company_id}: "{escaped_company_name}"')
with open(sys.argv[1], "r") as yaml_file:
root = yaml.safe_load(yaml_file)
companies = {}
for company in root["company_identifiers"]:
companies[company["value"]] = company["name"]
print(',\n'.join(reversed(lines)))
for company_id in sorted(companies.keys()):
company_name = companies[company_id]
escaped_company_name = company_name.replace('"', '\\"')
print(f' 0x{company_id:04X}: "{escaped_company_name}",')
+155 -58
View File
@@ -5,11 +5,11 @@ function bufferToHex(buffer) {
class PacketSource {
constructor(pyodide) {
this.parser = pyodide.runPython(`
from bumble.transport.common import PacketParser
class ProxiedPacketParser(PacketParser):
def feed_data(self, js_data):
super().feed_data(bytes(js_data.to_py()))
ProxiedPacketParser()
from bumble.transport.common import PacketParser
class ProxiedPacketParser(PacketParser):
def feed_data(self, js_data):
super().feed_data(bytes(js_data.to_py()))
ProxiedPacketParser()
`);
}
@@ -18,74 +18,171 @@ class PacketSource {
}
data_received(data) {
console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
//console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
this.parser.feed_data(data);
}
}
class PacketSink {
constructor(writer) {
this.writer = writer;
}
on_packet(packet) {
if (!this.writer) {
return;
}
const buffer = packet.toJs({create_proxies : false});
packet.destroy();
console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
//console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
// TODO: create an async queue here instead of blindly calling write without awaiting
this.writer(buffer);
}
}
export async function connectWebSocketTransport(pyodide, hciWsUrl) {
return new Promise((resolve, reject) => {
let resolved = false;
let ws = new WebSocket(hciWsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
console.log("WebSocket open");
resolve({
packet_source,
packet_sink
});
resolved = true;
}
ws.onclose = () => {
console.log("WebSocket close");
if (!resolved) {
reject(`Failed to connect to ${hciWsUrl}`)
}
}
ws.onmessage = (event) => {
packet_source.data_received(event.data);
}
const packet_source = new PacketSource(pyodide);
const packet_sink = new PacketSink((packet) => ws.send(packet));
})
class LogEvent extends Event {
constructor(message) {
super('log');
this.message = message;
}
}
export async function loadBumble(pyodide, bumblePackage) {
// Load the Bumble module
await pyodide.loadPackage("micropip");
await pyodide.runPythonAsync(`
import micropip
await micropip.install("${bumblePackage}")
package_list = micropip.list()
print(package_list)
`)
export class Bumble extends EventTarget {
constructor(pyodide) {
super();
this.pyodide = pyodide;
}
// Mount a filesystem so that we can persist data like the Key Store
let mountDir = "/bumble";
pyodide.FS.mkdir(mountDir);
pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir);
async loadRuntime(bumblePackage) {
// Load pyodide if it isn't provided.
if (this.pyodide === undefined) {
this.log('Loading Pyodide');
this.pyodide = await loadPyodide();
}
// Sync previously persisted filesystem data into memory
pyodide.FS.syncfs(true, () => {
console.log("FS synced in")
});
// Load the Bumble module
bumblePackage ||= 'bumble';
console.log('Installing micropip');
this.log(`Installing ${bumblePackage}`)
await this.pyodide.loadPackage('micropip');
await this.pyodide.runPythonAsync(`
import micropip
await micropip.install('${bumblePackage}')
package_list = micropip.list()
print(package_list)
`)
// Mount a filesystem so that we can persist data like the Key Store
let mountDir = '/bumble';
this.pyodide.FS.mkdir(mountDir);
this.pyodide.FS.mount(this.pyodide.FS.filesystems.IDBFS, { root: '.' }, mountDir);
// Sync previously persisted filesystem data into memory
await new Promise(resolve => {
this.pyodide.FS.syncfs(true, () => {
console.log('FS synced in');
resolve();
});
})
// Setup the HCI source and sink
this.packetSource = new PacketSource(this.pyodide);
this.packetSink = new PacketSink();
}
log(message) {
this.dispatchEvent(new LogEvent(message));
}
async connectWebSocketTransport(hciWsUrl) {
return new Promise((resolve, reject) => {
let resolved = false;
let ws = new WebSocket(hciWsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
this.log('WebSocket open');
resolve();
resolved = true;
}
ws.onclose = () => {
this.log('WebSocket close');
if (!resolved) {
reject(`Failed to connect to ${hciWsUrl}`);
}
}
ws.onmessage = (event) => {
this.packetSource.data_received(event.data);
}
this.packetSink.writer = (packet) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(packet);
}
}
this.closeTransport = async () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
})
}
async loadApp(appUrl) {
this.log('Loading app');
const script = await (await fetch(appUrl)).text();
await this.pyodide.runPythonAsync(script);
const pythonMain = this.pyodide.globals.get('main');
const app = await pythonMain(this.packetSource, this.packetSink);
if (app.on) {
app.on('key_store_update', this.onKeystoreUpdate.bind(this));
}
this.log('App is ready!');
return app;
}
onKeystoreUpdate() {
// Sync the FS
this.pyodide.FS.syncfs(() => {
console.log('FS synced out');
});
}
}
export async function setupSimpleApp(appUrl, bumbleControls, log) {
// Load Bumble
log('Loading Bumble');
const bumble = new Bumble();
bumble.addEventListener('log', (event) => {
log(event.message);
})
const params = (new URL(document.location)).searchParams;
await bumble.loadRuntime(params.get('package'));
log('Bumble is ready!')
const app = await bumble.loadApp(appUrl);
bumbleControls.connector = async (hciWsUrl) => {
try {
// Connect the WebSocket HCI transport
await bumble.connectWebSocketTransport(hciWsUrl);
// Start the app
await app.start();
return true;
} catch (err) {
log(err);
return false;
}
}
bumbleControls.stopper = async () => {
// Stop the app
await app.stop();
// Close the HCI transport
await bumble.closeTransport();
}
bumbleControls.onBumbleLoaded();
return app;
}
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
<script type="module" src="../ui.js"></script>
<script type="module" src="heart_rate_monitor.js"></script>
<style>
#hr-value {
font-family: sans-serif;
font-size: xx-large;
}
</style>
</head>
<body>
<bumble-controls id="bumble-controls"></bumble-controls><hr>
<span class="material-symbols-outlined">
cardiology
</span>
<span id="hr-value">60</span>
<br>
<button id="hr-up-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_upward</button>
<button id="hr-down-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_downward</button>
<hr>
<textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
</body>
</html>
@@ -0,0 +1,30 @@
import {setupSimpleApp} from '../bumble.js';
const logOutput = document.querySelector('#log-output');
function logToOutput(message) {
console.log(message);
logOutput.value += message + '\n';
}
let heartRate = 60;
const heartRateText = document.querySelector('#hr-value')
function setHeartRate(newHeartRate) {
heartRate = newHeartRate;
heartRateText.innerHTML = heartRate;
app.set_heart_rate(heartRate);
}
// Setup the UI
const bumbleControls = document.querySelector('#bumble-controls');
document.querySelector('#hr-up-button').addEventListener('click', () => {
setHeartRate(heartRate + 1);
})
document.querySelector('#hr-down-button').addEventListener('click', () => {
setHeartRate(heartRate - 1);
})
// Setup the app
const app = await setupSimpleApp('heart_rate_monitor.py', bumbleControls, logToOutput);
logToOutput('Click the Bluetooth button to start');
@@ -0,0 +1,119 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import struct
from bumble.core import AdvertisingData
from bumble.device import Device
from bumble.hci import HCI_Reset_Command
from bumble.profiles.device_information_service import DeviceInformationService
from bumble.profiles.heart_rate_service import HeartRateService
from bumble.utils import AsyncRunner
# -----------------------------------------------------------------------------
class HeartRateMonitor:
def __init__(self, hci_source, hci_sink):
self.heart_rate = 60
self.device = Device.with_hci(
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
)
device_information_service = DeviceInformationService(
manufacturer_name='ACME',
model_number='HR-102',
serial_number='7654321',
hardware_revision='1.1.3',
software_revision='2.5.6',
system_id=(0x123456, 0x8877665544),
)
self.heart_rate_service = HeartRateService(
read_heart_rate_measurement=lambda _: HeartRateService.HeartRateMeasurement(
heart_rate=self.heart_rate,
sensor_contact_detected=True,
),
body_sensor_location=HeartRateService.BodySensorLocation.WRIST,
reset_energy_expended=self.reset_energy_expended,
)
# Notify subscribers of the current value as soon as they subscribe
@self.heart_rate_service.heart_rate_measurement_characteristic.on(
'subscription'
)
def on_subscription(_, notify_enabled, indicate_enabled):
if notify_enabled or indicate_enabled:
self.notify_heart_rate()
self.device.add_services([device_information_service, self.heart_rate_service])
self.device.advertising_data = bytes(
AdvertisingData(
[
(
AdvertisingData.FLAGS,
bytes(
[
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
| AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG
]
),
),
(
AdvertisingData.COMPLETE_LOCAL_NAME,
bytes('Bumble Heart', 'utf-8'),
),
(
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
bytes(self.heart_rate_service.uuid),
),
(AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)),
]
)
)
async def start(self):
print('### Starting Monitor')
await self.device.power_on()
await self.device.start_advertising(auto_restart=True)
print('### Monitor started')
async def stop(self):
# TODO: replace this once a proper reset is implemented in the lib.
await self.device.host.send_command(HCI_Reset_Command())
await self.device.power_off()
print('### Monitor stopped')
def notify_heart_rate(self):
AsyncRunner.spawn(
self.device.notify_subscribers(
self.heart_rate_service.heart_rate_measurement_characteristic
)
)
def set_heart_rate(self, heart_rate):
self.heart_rate = heart_rate
self.notify_heart_rate()
def reset_energy_expended(self, _):
print('<<< Reset Energy Expended')
# -----------------------------------------------------------------------------
def main(hci_source, hci_sink):
return HeartRateMonitor(hci_source, hci_sink)
+3
View File
@@ -0,0 +1,3 @@
body {
font-family: monospace;
}
+14 -122
View File
@@ -1,129 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
<style>
body {
font-family: monospace;
}
table, th, td {
padding: 2px;
white-space: pre;
border: 1px solid black;
border-collapse: collapse;
}
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="scanner.css">
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
<script type="module" src="../ui.js"></script>
<script type="module" src="scanner.js"></script>
</style>
</style>
</head>
<body>
<button id="connectButton" disabled>Connect</button>
<br />
<br />
<div>Log Output</div><br>
<textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
<div id="scanTableContainer"><table></table></div>
<script type="module">
import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
</script>
<script type="module">
import { loadBumble, connectWebSocketTransport } from "../bumble.js"
let pyodide;
let output;
function logToOutput(s) {
output.value += s + "\n";
console.log(s);
}
async function run() {
const params = (new URL(document.location)).searchParams;
const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
try {
// Create a WebSocket HCI transport
let transport
try {
transport = await connectWebSocketTransport(pyodide, hciWsUrl);
} catch (error) {
logToOutput(error);
return;
}
// Run the scanner example
const script = await (await fetch("scanner.py")).text();
await pyodide.runPythonAsync(script);
const pythonMain = pyodide.globals.get("main");
logToOutput("Starting scanner...");
await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
logToOutput("Scanner running");
} catch (err) {
logToOutput(err);
}
}
function onScanUpdate(scanEntries) {
scanEntries = scanEntries.toJs();
const scanTable = document.createElement("table");
const tableHeader = document.createElement("tr");
for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
const header = document.createElement("th");
header.appendChild(document.createTextNode(name));
tableHeader.appendChild(header);
}
scanTable.appendChild(tableHeader);
scanEntries.forEach(entry => {
const row = document.createElement("tr");
const addressCell = document.createElement("td");
addressCell.appendChild(document.createTextNode(entry.address));
row.appendChild(addressCell);
const addressTypeCell = document.createElement("td");
addressTypeCell.appendChild(document.createTextNode(entry.address_type));
row.appendChild(addressTypeCell);
const rssiCell = document.createElement("td");
rssiCell.appendChild(document.createTextNode(entry.rssi));
row.appendChild(rssiCell);
const dataCell = document.createElement("td");
dataCell.appendChild(document.createTextNode(entry.data));
row.appendChild(dataCell);
scanTable.appendChild(row);
});
const scanTableContainer = document.getElementById("scanTableContainer");
scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
return true;
}
async function main() {
output = document.getElementById("output");
// Load pyodide
logToOutput("Loading Pyodide");
pyodide = await loadPyodide();
// Load Bumble
logToOutput("Loading Bumble");
const params = (new URL(document.location)).searchParams;
const bumblePackage = params.get("package") || "bumble";
await loadBumble(pyodide, bumblePackage);
logToOutput("Ready!")
// Enable the Connect button
const connectButton = document.getElementById("connectButton");
connectButton.disabled = false
connectButton.addEventListener("click", run)
}
main();
</script>
<bumble-controls id="bumble-controls"></bumble-controls><hr>
<textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
<scan-list id="scan-list"></scan-list>
</body>
</html>
</html>
+68
View File
@@ -0,0 +1,68 @@
import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
import {setupSimpleApp} from '../bumble.js';
class ScanList extends LitElement {
static properties = {
listItems: {state: true},
};
static styles = css`
table, th, td {
padding: 2px;
white-space: pre;
border: 1px solid black;
border-collapse: collapse;
}
`;
constructor() {
super();
this.listItems = [];
}
render() {
if (this.listItems.length === 0) {
return '';
}
return html`
<table>
<thead>
<tr>
${Object.keys(this.listItems[0]).map(i => html`<th>${i}</th>`)}
</tr>
</thead>
<tbody>
${this.listItems.map(i => html`
<tr>
${Object.keys(i).map(key => html`<td>${i[key]}</td>`)}
</tr>
`)}
</tbody>
</table>
`;
}
}
customElements.define('scan-list', ScanList);
const logOutput = document.querySelector('#log-output');
function logToOutput(message) {
console.log(message);
logOutput.value += message + '\n';
}
function onUpdate(scanResults) {
const items = scanResults.toJs({create_proxies : false}).map(entry => (
{ address: entry.address, address_type: entry.address_type, rssi: entry.rssi, data: entry.data }
));
scanResults.destroy();
scanList.listItems = items;
}
// Setup the UI
const scanList = document.querySelector('#scan-list');
const bumbleControls = document.querySelector('#bumble-controls');
// Setup the app
const app = await setupSimpleApp('scanner.py', bumbleControls, logToOutput);
app.on('update', onUpdate);
logToOutput('Click the Bluetooth button to start');
+45 -25
View File
@@ -15,39 +15,59 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import time
from bumble.device import Device
from bumble.hci import HCI_Reset_Command
# -----------------------------------------------------------------------------
class ScanEntry:
def __init__(self, advertisement):
self.address = advertisement.address.to_string(False)
self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
advertisement.address.address_type
]
self.rssi = advertisement.rssi
self.data = advertisement.data.to_string("\n")
class Scanner:
class ScanEntry:
def __init__(self, advertisement):
self.address = advertisement.address.to_string(False)
self.address_type = (
'Public',
'Random',
'Public Identity',
'Random Identity',
)[advertisement.address.address_type]
self.rssi = advertisement.rssi
self.data = advertisement.data.to_string('\n')
def __init__(self, hci_source, hci_sink):
super().__init__()
self.device = Device.with_hci(
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
)
self.scan_entries = {}
self.listeners = {}
self.device.on('advertisement', self.on_advertisement)
# -----------------------------------------------------------------------------
class ScannerListener(Device.Listener):
def __init__(self, callback):
self.callback = callback
self.entries = {}
async def start(self):
print('### Starting Scanner')
self.scan_entries = {}
self.emit_update()
await self.device.power_on()
await self.device.start_scanning()
print('### Scanner started')
async def stop(self):
# TODO: replace this once a proper reset is implemented in the lib.
await self.device.host.send_command(HCI_Reset_Command())
await self.device.power_off()
print('### Scanner stopped')
def emit_update(self):
if listener := self.listeners.get('update'):
listener(list(self.scan_entries.values()))
def on(self, event_name, listener):
self.listeners[event_name] = listener
def on_advertisement(self, advertisement):
self.entries[advertisement.address] = ScanEntry(advertisement)
self.callback(list(self.entries.values()))
self.scan_entries[advertisement.address] = self.ScanEntry(advertisement)
self.emit_update()
# -----------------------------------------------------------------------------
async def main(hci_source, hci_sink, callback):
print('### Starting Scanner')
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
device.listener = ScannerListener(callback)
await device.power_on()
await device.start_scanning()
print('### Scanner started')
def main(hci_source, hci_sink):
return Scanner(hci_source, hci_sink)
+10 -1
View File
@@ -11,7 +11,16 @@ body, h1, h2, h3, h4, h5, h6 {
border: none;
border-radius: 4px;
padding: 8px;
display: inline-block;
display: none;
margin: 4px;
}
#progressText {
background-color: rgb(179, 208, 146);
border: none;
border-radius: 4px;
padding: 8px;
display: none;
margin: 4px;
}
+6 -4
View File
@@ -2,13 +2,14 @@
<html>
<head>
<title>Bumble Speaker</title>
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
<script type="module" src="speaker.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="speaker.css">
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
<script type="module" src="speaker.js"></script>
<script type="module" src="../ui.js"></script>
</head>
<body>
<h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
<div id="errorText"></div>
<div id="speaker">
<table><tr>
<td>
@@ -25,7 +26,8 @@
<span id="streamStateText">IDLE</span>
<span id="connectionStateText">NOT CONNECTED</span>
<div id="controlsDiv">
<button id="audioOnButton">Audio On</button>
<bumble-controls id="bumble-controls"></bumble-controls>
<button id="audioOnButton" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>volume_up</button>
</div>
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
<audio id="audio"></audio>
+37 -105
View File
@@ -1,4 +1,4 @@
import { loadBumble, connectWebSocketTransport } from "../bumble.js";
import {setupSimpleApp} from '../bumble.js';
(function () {
'use strict';
@@ -8,7 +8,6 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
let bytesReceivedText;
let streamStateText;
let connectionStateText;
let errorText;
let audioOnButton;
let mediaSource;
let sourceBuffer;
@@ -19,15 +18,14 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
let audioFrequencyData;
let packetsReceived = 0;
let bytesReceived = 0;
let audioState = "stopped";
let streamState = "IDLE";
let audioState = 'stopped';
let streamState = 'IDLE';
let fftCanvas;
let fftCanvasContext;
let bandwidthCanvas;
let bandwidthCanvasContext;
let bandwidthBinCount;
let bandwidthBins = [];
let pyodide;
const FFT_WIDTH = 800;
const FFT_HEIGHT = 256;
@@ -44,18 +42,16 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
function initUI() {
audioOnButton = document.getElementById("audioOnButton");
codecText = document.getElementById("codecText");
packetsReceivedText = document.getElementById("packetsReceivedText");
bytesReceivedText = document.getElementById("bytesReceivedText");
streamStateText = document.getElementById("streamStateText");
errorText = document.getElementById("errorText");
connectionStateText = document.getElementById("connectionStateText");
audioOnButton = document.getElementById('audioOnButton');
codecText = document.getElementById('codecText');
packetsReceivedText = document.getElementById('packetsReceivedText');
bytesReceivedText = document.getElementById('bytesReceivedText');
streamStateText = document.getElementById('streamStateText');
connectionStateText = document.getElementById('connectionStateText');
audioOnButton.onclick = () => startAudio();
audioOnButton.onclick = startAudio;
codecText.innerText = "AAC";
setErrorText("");
codecText.innerText = 'AAC';
requestAnimationFrame(onAnimationFrame);
}
@@ -68,62 +64,36 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
function initAudioElement() {
audioElement = document.getElementById("audio");
audioElement = document.getElementById('audio');
audioElement.src = URL.createObjectURL(mediaSource);
// audioElement.controls = true;
}
function initAnalyzer() {
fftCanvas = document.getElementById("fftCanvas");
fftCanvas = document.getElementById('fftCanvas');
fftCanvas.width = FFT_WIDTH
fftCanvas.height = FFT_HEIGHT
fftCanvasContext = fftCanvas.getContext('2d');
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
bandwidthCanvas = document.getElementById("bandwidthCanvas");
bandwidthCanvas = document.getElementById('bandwidthCanvas');
bandwidthCanvas.width = BANDWIDTH_WIDTH
bandwidthCanvas.height = BANDWIDTH_HEIGHT
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)';
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
}
async function initBumble() {
// Load pyodide
console.log("Loading Pyodide");
pyodide = await loadPyodide();
// Load Bumble
console.log("Loading Bumble");
const params = (new URL(document.location)).searchParams;
const bumblePackage = params.get("package") || "bumble";
await loadBumble(pyodide, bumblePackage);
console.log("Ready!")
const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
try {
// Create a WebSocket HCI transport
let transport
try {
transport = await connectWebSocketTransport(pyodide, hciWsUrl);
} catch (error) {
console.error(error);
setErrorText(error);
return;
}
// Run the scanner example
const script = await (await fetch("speaker.py")).text();
await pyodide.runPythonAsync(script);
const pythonMain = pyodide.globals.get("main");
console.log("Starting speaker...");
await pythonMain(transport.packet_source, transport.packet_sink, onEvent);
console.log("Speaker running");
} catch (err) {
console.log(err);
}
const bumbleControls = document.querySelector('#bumble-controls');
const app = await setupSimpleApp('speaker.py', bumbleControls, console.log);
app.on('start', onStart);
app.on('stop', onStop);
app.on('suspend', onSuspend);
app.on('connection', onConnection);
app.on('disconnection', onDisconnection);
app.on('audio', onAudio);
}
function startAnalyzer() {
@@ -144,15 +114,6 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
bandwidthBins = [];
}
function setErrorText(message) {
errorText.innerText = message;
if (message.length == 0) {
errorText.style.display = "none";
} else {
errorText.style.display = "inline-block";
}
}
function setStreamState(state) {
streamState = state;
streamStateText.innerText = streamState;
@@ -162,7 +123,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
// FFT
if (audioAnalyzer !== undefined) {
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
const barCount = audioFrequencyBinCount;
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
@@ -174,7 +135,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
// Bandwidth
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)';
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
for (let t = 0; t < bandwidthBins.length; t++) {
@@ -188,7 +149,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
function onMediaSourceOpen() {
console.log(this.readyState);
sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
sourceBuffer = mediaSource.addSourceBuffer('audio/aac');
}
function onMediaSourceClose() {
@@ -201,41 +162,30 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
async function startAudio() {
try {
console.log("starting audio...");
console.log('starting audio...');
audioOnButton.disabled = true;
audioState = "starting";
audioState = 'starting';
await audioElement.play();
console.log("audio started");
audioState = "playing";
console.log('audio started');
audioState = 'playing';
startAnalyzer();
} catch (error) {
console.error(`play failed: ${error}`);
audioState = "stopped";
audioState = 'stopped';
audioOnButton.disabled = false;
}
}
async function onEvent(name, params) {
// Dispatch the message.
const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}`
const handler = eventHandlers[handlerName];
if (handler !== undefined) {
handler(params);
} else {
console.warn(`unhandled event: ${name}`)
}
}
function onStart() {
setStreamState("STARTED");
setStreamState('STARTED');
}
function onStop() {
setStreamState("STOPPED");
setStreamState('STOPPED');
}
function onSuspend() {
setStreamState("SUSPENDED");
setStreamState('SUSPENDED');
}
function onConnection(params) {
@@ -243,13 +193,13 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
function onDisconnection(params) {
connectionStateText.innerText = "DISCONNECTED";
connectionStateText.innerText = 'DISCONNECTED';
}
function onAudio(python_packet) {
const packet = python_packet.toJs({create_proxies : false});
python_packet.destroy();
if (audioState != "stopped") {
if (audioState != 'stopped') {
// Queue the audio packet.
sourceBuffer.appendBuffer(packet);
}
@@ -265,25 +215,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
}
function onKeystoreupdate() {
// Sync the FS
pyodide.FS.syncfs(() => {
console.log("FS synced out")
});
}
const eventHandlers = {
onStart,
onStop,
onSuspend,
onConnection,
onDisconnection,
onAudio,
onKeystoreupdate
}
window.onload = (event) => {
init();
}
}());
+33 -31
View File
@@ -47,6 +47,7 @@ from bumble.a2dp import (
)
from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket
from bumble.hci import HCI_Reset_Command
# -----------------------------------------------------------------------------
@@ -95,15 +96,14 @@ class Speaker:
STARTED = 2
SUSPENDED = 3
def __init__(self, hci_source, hci_sink, emit_event, codec, discover):
def __init__(self, hci_source, hci_sink, codec):
self.hci_source = hci_source
self.hci_sink = hci_sink
self.emit_event = emit_event
self.js_listeners = {}
self.codec = codec
self.discover = discover
self.device = None
self.connection = None
self.listener = None
self.avdtp_listener = None
self.packets_received = 0
self.bytes_received = 0
self.stream_state = Speaker.StreamState.IDLE
@@ -164,7 +164,7 @@ class Speaker:
def on_key_store_update(self):
print("Key Store updated")
self.emit_event('keystoreupdate', None)
self.emit('key_store_update')
def on_bluetooth_connection(self, connection):
print(f'Connection: {connection}')
@@ -172,15 +172,12 @@ class Speaker:
connection.on('disconnection', self.on_bluetooth_disconnection)
peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = connection.peer_address.to_string(False)
self.emit_event(
'connection', {'peer_name': peer_name, 'peer_address': peer_address}
)
self.emit('connection', {'peer_name': peer_name, 'peer_address': peer_address})
def on_bluetooth_disconnection(self, reason):
print(f'Disconnection ({reason})')
self.connection = None
AsyncRunner.spawn(self.advertise())
self.emit_event('disconnection', None)
self.emit('disconnection', None)
def on_avdtp_connection(self, protocol):
print('Audio Stream Open')
@@ -198,27 +195,23 @@ class Speaker:
# Listen for close events
protocol.on('close', self.on_avdtp_close)
# Discover all endpoints on the remote device is requested
if self.discover:
AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
def on_avdtp_close(self):
print("Audio Stream Closed")
def on_sink_start(self):
print("Sink Started")
self.stream_state = self.StreamState.STARTED
self.emit_event('start', None)
self.emit('start', None)
def on_sink_stop(self):
print("Sink Stopped")
self.stream_state = self.StreamState.STOPPED
self.emit_event('stop', None)
self.emit('stop', None)
def on_sink_suspend(self):
print("Sink Suspended")
self.stream_state = self.StreamState.SUSPENDED
self.emit_event('suspend', None)
self.emit('suspend', None)
def on_sink_configuration(self, config):
print("Sink Configuration:")
@@ -234,11 +227,7 @@ class Speaker:
def on_rtp_packet(self, packet):
self.packets_received += 1
self.bytes_received += len(packet.payload)
self.emit_event("audio", self.audio_extractor.extract_audio(packet))
async def advertise(self):
await self.device.set_discoverable(True)
await self.device.set_connectable(True)
self.emit("audio", self.audio_extractor.extract_audio(packet))
async def connect(self, address):
# Connect to the source
@@ -257,7 +246,7 @@ class Speaker:
print('*** Encryption on')
protocol = await Protocol.connect(connection)
self.listener.set_server(connection, protocol)
self.avdtp_listener.set_server(connection, protocol)
self.on_avdtp_connection(protocol)
async def discover_remote_endpoints(self, protocol):
@@ -266,6 +255,13 @@ class Speaker:
for endpoint in endpoints:
print('@@@', endpoint)
def on(self, event_name, listener):
self.js_listeners[event_name] = listener
def emit(self, event_name, event=None):
if listener := self.js_listeners.get(event_name):
listener(event)
async def run(self, connect_address):
# Create a device
device_config = DeviceConfiguration()
@@ -296,8 +292,8 @@ class Speaker:
self.device.on('key_store_update', self.on_key_store_update)
# Create a listener to wait for AVDTP connections
self.listener = Listener(Listener.create_registrar(self.device))
self.listener.on('connection', self.on_avdtp_connection)
self.avdtp_listener = Listener.for_device(self.device)
self.avdtp_listener.on('connection', self.on_avdtp_connection)
print(f'Speaker ready to play, codec={self.codec}')
@@ -309,13 +305,19 @@ class Speaker:
print("Connection timed out")
return
else:
# Start being discoverable and connectable
# We'll wait for a connection
print("Waiting for connection...")
await self.advertise()
async def start(self):
await self.run(None)
async def stop(self):
# TODO: replace this once a proper reset is implemented in the lib.
await self.device.host.send_command(HCI_Reset_Command())
await self.device.power_off()
print('Speaker stopped')
# -----------------------------------------------------------------------------
async def main(hci_source, hci_sink, emit_event):
# logging.basicConfig(level='DEBUG')
speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False)
await speaker.run(None)
def main(hci_source, hci_sink):
return Speaker(hci_source, hci_sink, "aac")
+102
View File
@@ -0,0 +1,102 @@
import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
class BumbleControls extends LitElement {
constructor() {
super();
this.bumbleLoaded = false;
this.connected = false;
}
render() {
return html`
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<dialog id="settings-dialog" @close=${this.onSettingsDialogClose} style="font-family:sans-serif">
<p>WebSocket URL for HCI transport</p>
<form>
<input id="settings-hci-url-input" type="text" size="50"></input>
<button value="cancel" formmethod="dialog">Cancel</button>
<button @click=${this.saveSettings}>Save</button>
</form>
</dialog>
<button @click=${this.openSettingsDialog} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>settings</button>
<button @click=${this.connectBluetooth} ?disabled=${!this.canConnect()} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>bluetooth</button>
<button @click=${this.stop} ?disabled=${!this.connected} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>stop</button>
`
}
get settingsHciUrlInput() {
return this.renderRoot.querySelector('#settings-hci-url-input');
}
get settingsDialog() {
return this.renderRoot.querySelector('#settings-dialog');
}
canConnect() {
return this.bumbleLoaded && !this.connected && this.getHciUrl();
}
getHciUrl() {
// Look for a URL parameter setting first.
const params = (new URL(document.location)).searchParams;
let hciWsUrl = params.get("hci");
if (hciWsUrl) {
return hciWsUrl;
}
// Try to load the setting from storage.
hciWsUrl = localStorage.getItem("hciWsUrl");
if (hciWsUrl) {
return hciWsUrl;
}
// Finally, default to nothing.
return null;
}
openSettingsDialog() {
const hciUrl = this.getHciUrl();
if (hciUrl) {
this.settingsHciUrlInput.value = hciUrl;
} else {
// Start with default, assuming port 7681.
this.settingsHciUrlInput.value = "ws://localhost:7681/v1/websocket/bt"
}
this.settingsDialog.showModal();
}
onSettingsDialogClose() {
if (this.settingsDialog.returnValue === "cancel") {
return;
}
if (this.settingsHciUrlInput.value) {
localStorage.setItem("hciWsUrl", this.settingsHciUrlInput.value);
} else {
localStorage.removeItem("hciWsUrl");
}
this.requestUpdate();
}
saveSettings(event) {
event.preventDefault();
this.settingsDialog.close(this.settingsHciUrlInput.value);
}
async connectBluetooth() {
this.connected = await this.connector(this.getHciUrl());
this.requestUpdate();
}
async stop() {
await this.stopper();
this.connected = false;
this.requestUpdate();
}
onBumbleLoaded() {
this.bumbleLoaded = true;
this.requestUpdate();
}
}
customElements.define('bumble-controls', BumbleControls);