forked from auracaster/bumble_mirror
2
.github/workflows/code-check.yml
vendored
2
.github/workflows/code-check.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -12,7 +12,9 @@
|
|||||||
"ASHA",
|
"ASHA",
|
||||||
"asyncio",
|
"asyncio",
|
||||||
"ATRAC",
|
"ATRAC",
|
||||||
|
"avctp",
|
||||||
"avdtp",
|
"avdtp",
|
||||||
|
"avrcp",
|
||||||
"bitpool",
|
"bitpool",
|
||||||
"bitstruct",
|
"bitstruct",
|
||||||
"BSCP",
|
"BSCP",
|
||||||
|
|||||||
@@ -184,8 +184,12 @@ def make_audio_source_service_sdp_records(service_record_handle, version=(1, 3))
|
|||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
DataElement.sequence(
|
DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
DataElement.sequence(
|
||||||
DataElement.unsigned_integer_16(version_int),
|
[
|
||||||
|
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
||||||
|
DataElement.unsigned_integer_16(version_int),
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -234,8 +238,12 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
|
|||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
DataElement.sequence(
|
DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
DataElement.sequence(
|
||||||
DataElement.unsigned_integer_16(version_int),
|
[
|
||||||
|
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
||||||
|
DataElement.unsigned_integer_16(version_int),
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
520
bumble/avc.py
Normal file
520
bumble/avc.py
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# Copyright 2021-2023 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
|
||||||
|
import enum
|
||||||
|
import struct
|
||||||
|
from typing import Dict, Type, Union, Tuple
|
||||||
|
|
||||||
|
from bumble.utils import OpenIntEnum
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Frame:
|
||||||
|
class SubunitType(enum.IntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.4
|
||||||
|
MONITOR = 0x00
|
||||||
|
AUDIO = 0x01
|
||||||
|
PRINTER = 0x02
|
||||||
|
DISC = 0x03
|
||||||
|
TAPE_RECORDER_OR_PLAYER = 0x04
|
||||||
|
TUNER = 0x05
|
||||||
|
CA = 0x06
|
||||||
|
CAMERA = 0x07
|
||||||
|
PANEL = 0x09
|
||||||
|
BULLETIN_BOARD = 0x0A
|
||||||
|
VENDOR_UNIQUE = 0x1C
|
||||||
|
EXTENDED = 0x1E
|
||||||
|
UNIT = 0x1F
|
||||||
|
|
||||||
|
class OperationCode(OpenIntEnum):
|
||||||
|
# 0x00 - 0x0F: Unit and subunit commands
|
||||||
|
VENDOR_DEPENDENT = 0x00
|
||||||
|
RESERVE = 0x01
|
||||||
|
PLUG_INFO = 0x02
|
||||||
|
|
||||||
|
# 0x10 - 0x3F: Unit commands
|
||||||
|
DIGITAL_OUTPUT = 0x10
|
||||||
|
DIGITAL_INPUT = 0x11
|
||||||
|
CHANNEL_USAGE = 0x12
|
||||||
|
OUTPUT_PLUG_SIGNAL_FORMAT = 0x18
|
||||||
|
INPUT_PLUG_SIGNAL_FORMAT = 0x19
|
||||||
|
GENERAL_BUS_SETUP = 0x1F
|
||||||
|
CONNECT_AV = 0x20
|
||||||
|
DISCONNECT_AV = 0x21
|
||||||
|
CONNECTIONS = 0x22
|
||||||
|
CONNECT = 0x24
|
||||||
|
DISCONNECT = 0x25
|
||||||
|
UNIT_INFO = 0x30
|
||||||
|
SUBUNIT_INFO = 0x31
|
||||||
|
|
||||||
|
# 0x40 - 0x7F: Subunit commands
|
||||||
|
PASS_THROUGH = 0x7C
|
||||||
|
GUI_UPDATE = 0x7D
|
||||||
|
PUSH_GUI_DATA = 0x7E
|
||||||
|
USER_ACTION = 0x7F
|
||||||
|
|
||||||
|
# 0xA0 - 0xBF: Unit and subunit commands
|
||||||
|
VERSION = 0xB0
|
||||||
|
POWER = 0xB2
|
||||||
|
|
||||||
|
subunit_type: SubunitType
|
||||||
|
subunit_id: int
|
||||||
|
opcode: OperationCode
|
||||||
|
operands: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def subclass(subclass):
|
||||||
|
# Infer the opcode from the class name
|
||||||
|
if subclass.__name__.endswith("CommandFrame"):
|
||||||
|
short_name = subclass.__name__.replace("CommandFrame", "")
|
||||||
|
category_class = CommandFrame
|
||||||
|
elif subclass.__name__.endswith("ResponseFrame"):
|
||||||
|
short_name = subclass.__name__.replace("ResponseFrame", "")
|
||||||
|
category_class = ResponseFrame
|
||||||
|
else:
|
||||||
|
raise ValueError(f"invalid subclass name {subclass.__name__}")
|
||||||
|
|
||||||
|
uppercase_indexes = [
|
||||||
|
i for i in range(len(short_name)) if short_name[i].isupper()
|
||||||
|
]
|
||||||
|
uppercase_indexes.append(len(short_name))
|
||||||
|
words = [
|
||||||
|
short_name[uppercase_indexes[i] : uppercase_indexes[i + 1]].upper()
|
||||||
|
for i in range(len(uppercase_indexes) - 1)
|
||||||
|
]
|
||||||
|
opcode_name = "_".join(words)
|
||||||
|
opcode = Frame.OperationCode[opcode_name]
|
||||||
|
category_class.subclasses[opcode] = subclass
|
||||||
|
return subclass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_bytes(data: bytes) -> Frame:
|
||||||
|
if data[0] >> 4 != 0:
|
||||||
|
raise ValueError("first 4 bits must be 0s")
|
||||||
|
|
||||||
|
ctype_or_response = data[0] & 0xF
|
||||||
|
subunit_type = Frame.SubunitType(data[1] >> 3)
|
||||||
|
subunit_id = data[1] & 7
|
||||||
|
|
||||||
|
if subunit_type == Frame.SubunitType.EXTENDED:
|
||||||
|
# Not supported
|
||||||
|
raise NotImplementedError("extended subunit types not supported")
|
||||||
|
|
||||||
|
if subunit_id < 5:
|
||||||
|
opcode_offset = 2
|
||||||
|
elif subunit_id == 5:
|
||||||
|
# Extended to the next byte
|
||||||
|
extension = data[2]
|
||||||
|
if extension == 0:
|
||||||
|
raise ValueError("extended subunit ID value reserved")
|
||||||
|
if extension == 0xFF:
|
||||||
|
subunit_id = 5 + 254 + data[3]
|
||||||
|
opcode_offset = 4
|
||||||
|
else:
|
||||||
|
subunit_id = 5 + extension
|
||||||
|
opcode_offset = 3
|
||||||
|
|
||||||
|
elif subunit_id == 6:
|
||||||
|
raise ValueError("reserved subunit ID")
|
||||||
|
|
||||||
|
opcode = Frame.OperationCode(data[opcode_offset])
|
||||||
|
operands = data[opcode_offset + 1 :]
|
||||||
|
|
||||||
|
# Look for a registered subclass
|
||||||
|
if ctype_or_response < 8:
|
||||||
|
# Command
|
||||||
|
ctype = CommandFrame.CommandType(ctype_or_response)
|
||||||
|
if c_subclass := CommandFrame.subclasses.get(opcode):
|
||||||
|
return c_subclass(
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
*c_subclass.parse_operands(operands),
|
||||||
|
)
|
||||||
|
return CommandFrame(ctype, subunit_type, subunit_id, opcode, operands)
|
||||||
|
else:
|
||||||
|
# Response
|
||||||
|
response = ResponseFrame.ResponseCode(ctype_or_response)
|
||||||
|
if r_subclass := ResponseFrame.subclasses.get(opcode):
|
||||||
|
return r_subclass(
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
*r_subclass.parse_operands(operands),
|
||||||
|
)
|
||||||
|
return ResponseFrame(response, subunit_type, subunit_id, opcode, operands)
|
||||||
|
|
||||||
|
def to_bytes(
|
||||||
|
self,
|
||||||
|
ctype_or_response: Union[CommandFrame.CommandType, ResponseFrame.ResponseCode],
|
||||||
|
) -> bytes:
|
||||||
|
# TODO: support extended subunit types and ids.
|
||||||
|
return (
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
ctype_or_response,
|
||||||
|
self.subunit_type << 3 | self.subunit_id,
|
||||||
|
self.opcode,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ self.operands
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_string(self, extra: str) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.__class__.__name__}({extra}"
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"opcode={self.opcode.name}, "
|
||||||
|
f"operands={self.operands.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
subunit_type: SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
self.subunit_type = subunit_type
|
||||||
|
self.subunit_id = subunit_id
|
||||||
|
self.opcode = opcode
|
||||||
|
self.operands = operands
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class CommandFrame(Frame):
|
||||||
|
class CommandType(OpenIntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.1
|
||||||
|
CONTROL = 0x00
|
||||||
|
STATUS = 0x01
|
||||||
|
SPECIFIC_INQUIRY = 0x02
|
||||||
|
NOTIFY = 0x03
|
||||||
|
GENERAL_INQUIRY = 0x04
|
||||||
|
|
||||||
|
subclasses: Dict[Frame.OperationCode, Type[CommandFrame]] = {}
|
||||||
|
ctype: CommandType
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: Frame.OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(subunit_type, subunit_id, opcode, operands)
|
||||||
|
self.ctype = ctype
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return self.to_bytes(self.ctype)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.to_string(f"ctype={self.ctype.name}, ")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ResponseFrame(Frame):
|
||||||
|
class ResponseCode(OpenIntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.2
|
||||||
|
NOT_IMPLEMENTED = 0x08
|
||||||
|
ACCEPTED = 0x09
|
||||||
|
REJECTED = 0x0A
|
||||||
|
IN_TRANSITION = 0x0B
|
||||||
|
IMPLEMENTED_OR_STABLE = 0x0C
|
||||||
|
CHANGED = 0x0D
|
||||||
|
INTERIM = 0x0F
|
||||||
|
|
||||||
|
subclasses: Dict[Frame.OperationCode, Type[ResponseFrame]] = {}
|
||||||
|
response: ResponseCode
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: Frame.OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(subunit_type, subunit_id, opcode, operands)
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return self.to_bytes(self.response)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.to_string(f"response={self.response.name}, ")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class VendorDependentFrame:
|
||||||
|
company_id: int
|
||||||
|
vendor_dependent_data: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
return (
|
||||||
|
struct.unpack(">I", b"\x00" + operands[:3])[0],
|
||||||
|
operands[3:],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_operands(self) -> bytes:
|
||||||
|
return struct.pack(">I", self.company_id)[1:] + self.vendor_dependent_data
|
||||||
|
|
||||||
|
def __init__(self, company_id: int, vendor_dependent_data: bytes):
|
||||||
|
self.company_id = company_id
|
||||||
|
self.vendor_dependent_data = vendor_dependent_data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class VendorDependentCommandFrame(VendorDependentFrame, CommandFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandFrame.CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
company_id: int,
|
||||||
|
vendor_dependent_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
|
||||||
|
CommandFrame.__init__(
|
||||||
|
self,
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.VENDOR_DEPENDENT,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"VendorDependentCommandFrame(ctype={self.ctype.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"company_id=0x{self.company_id:06X}, "
|
||||||
|
f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class VendorDependentResponseFrame(VendorDependentFrame, ResponseFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseFrame.ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
company_id: int,
|
||||||
|
vendor_dependent_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
|
||||||
|
ResponseFrame.__init__(
|
||||||
|
self,
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.VENDOR_DEPENDENT,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"VendorDependentResponseFrame(response={self.response.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"company_id=0x{self.company_id:06X}, "
|
||||||
|
f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PassThroughFrame:
|
||||||
|
"""
|
||||||
|
See AV/C Panel Subunit Specification 1.1 - 9.4 PASS THROUGH control command
|
||||||
|
"""
|
||||||
|
|
||||||
|
class StateFlag(enum.IntEnum):
|
||||||
|
PRESSED = 0
|
||||||
|
RELEASED = 1
|
||||||
|
|
||||||
|
class OperationId(OpenIntEnum):
|
||||||
|
SELECT = 0x00
|
||||||
|
UP = 0x01
|
||||||
|
DOWN = 0x01
|
||||||
|
LEFT = 0x03
|
||||||
|
RIGHT = 0x04
|
||||||
|
RIGHT_UP = 0x05
|
||||||
|
RIGHT_DOWN = 0x06
|
||||||
|
LEFT_UP = 0x07
|
||||||
|
LEFT_DOWN = 0x08
|
||||||
|
ROOT_MENU = 0x09
|
||||||
|
SETUP_MENU = 0x0A
|
||||||
|
CONTENTS_MENU = 0x0B
|
||||||
|
FAVORITE_MENU = 0x0C
|
||||||
|
EXIT = 0x0D
|
||||||
|
NUMBER_0 = 0x20
|
||||||
|
NUMBER_1 = 0x21
|
||||||
|
NUMBER_2 = 0x22
|
||||||
|
NUMBER_3 = 0x23
|
||||||
|
NUMBER_4 = 0x24
|
||||||
|
NUMBER_5 = 0x25
|
||||||
|
NUMBER_6 = 0x26
|
||||||
|
NUMBER_7 = 0x27
|
||||||
|
NUMBER_8 = 0x28
|
||||||
|
NUMBER_9 = 0x29
|
||||||
|
DOT = 0x2A
|
||||||
|
ENTER = 0x2B
|
||||||
|
CLEAR = 0x2C
|
||||||
|
CHANNEL_UP = 0x30
|
||||||
|
CHANNEL_DOWN = 0x31
|
||||||
|
PREVIOUS_CHANNEL = 0x32
|
||||||
|
SOUND_SELECT = 0x33
|
||||||
|
INPUT_SELECT = 0x34
|
||||||
|
DISPLAY_INFORMATION = 0x35
|
||||||
|
HELP = 0x36
|
||||||
|
PAGE_UP = 0x37
|
||||||
|
PAGE_DOWN = 0x38
|
||||||
|
POWER = 0x40
|
||||||
|
VOLUME_UP = 0x41
|
||||||
|
VOLUME_DOWN = 0x42
|
||||||
|
MUTE = 0x43
|
||||||
|
PLAY = 0x44
|
||||||
|
STOP = 0x45
|
||||||
|
PAUSE = 0x46
|
||||||
|
RECORD = 0x47
|
||||||
|
REWIND = 0x48
|
||||||
|
FAST_FORWARD = 0x49
|
||||||
|
EJECT = 0x4A
|
||||||
|
FORWARD = 0x4B
|
||||||
|
BACKWARD = 0x4C
|
||||||
|
ANGLE = 0x50
|
||||||
|
SUBPICTURE = 0x51
|
||||||
|
F1 = 0x71
|
||||||
|
F2 = 0x72
|
||||||
|
F3 = 0x73
|
||||||
|
F4 = 0x74
|
||||||
|
F5 = 0x75
|
||||||
|
VENDOR_UNIQUE = 0x7E
|
||||||
|
|
||||||
|
state_flag: StateFlag
|
||||||
|
operation_id: OperationId
|
||||||
|
operation_data: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
return (
|
||||||
|
PassThroughFrame.StateFlag(operands[0] >> 7),
|
||||||
|
PassThroughFrame.OperationId(operands[0] & 0x7F),
|
||||||
|
operands[1 : 1 + operands[1]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_operands(self):
|
||||||
|
return (
|
||||||
|
bytes([self.state_flag << 7 | self.operation_id, len(self.operation_data)])
|
||||||
|
+ self.operation_data
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
state_flag: StateFlag,
|
||||||
|
operation_id: OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
if len(operation_data) > 255:
|
||||||
|
raise ValueError("operation data must be <= 255 bytes")
|
||||||
|
self.state_flag = state_flag
|
||||||
|
self.operation_id = operation_id
|
||||||
|
self.operation_data = operation_data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class PassThroughCommandFrame(PassThroughFrame, CommandFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandFrame.CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
state_flag: PassThroughFrame.StateFlag,
|
||||||
|
operation_id: PassThroughFrame.OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
|
||||||
|
CommandFrame.__init__(
|
||||||
|
self,
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.PASS_THROUGH,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"PassThroughCommandFrame(ctype={self.ctype.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"state_flag={self.state_flag.name}, "
|
||||||
|
f"operation_id={self.operation_id.name}, "
|
||||||
|
f"operation_data={self.operation_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class PassThroughResponseFrame(PassThroughFrame, ResponseFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseFrame.ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
state_flag: PassThroughFrame.StateFlag,
|
||||||
|
operation_id: PassThroughFrame.OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
|
||||||
|
ResponseFrame.__init__(
|
||||||
|
self,
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.PASS_THROUGH,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"PassThroughResponseFrame(response={self.response.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"state_flag={self.state_flag.name}, "
|
||||||
|
f"operation_id={self.operation_id.name}, "
|
||||||
|
f"operation_data={self.operation_data.hex()})"
|
||||||
|
)
|
||||||
291
bumble/avctp.py
Normal file
291
bumble/avctp.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Copyright 2021-2023 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 enum import IntEnum
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import Callable, cast, Dict, Optional
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import l2cap
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
AVCTP_PSM = 0x0017
|
||||||
|
AVCTP_BROWSING_PSM = 0x001B
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class MessageAssembler:
|
||||||
|
Callback = Callable[[int, bool, bool, int, bytes], None]
|
||||||
|
|
||||||
|
transaction_label: int
|
||||||
|
pid: int
|
||||||
|
c_r: int
|
||||||
|
ipid: int
|
||||||
|
payload: bytes
|
||||||
|
number_of_packets: int
|
||||||
|
packets_received: int
|
||||||
|
|
||||||
|
def __init__(self, callback: Callback) -> None:
|
||||||
|
self.callback = callback
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.packets_received = 0
|
||||||
|
self.transaction_label = -1
|
||||||
|
self.pid = -1
|
||||||
|
self.c_r = -1
|
||||||
|
self.ipid = -1
|
||||||
|
self.payload = b''
|
||||||
|
self.number_of_packets = 0
|
||||||
|
self.packet_count = 0
|
||||||
|
|
||||||
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
|
self.packets_received += 1
|
||||||
|
|
||||||
|
transaction_label = pdu[0] >> 4
|
||||||
|
packet_type = Protocol.PacketType((pdu[0] >> 2) & 3)
|
||||||
|
c_r = (pdu[0] >> 1) & 1
|
||||||
|
ipid = pdu[0] & 1
|
||||||
|
|
||||||
|
if c_r == 0 and ipid != 0:
|
||||||
|
logger.warning("invalid IPID in command frame")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
pid_offset = 1
|
||||||
|
if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.START):
|
||||||
|
if self.transaction_label >= 0:
|
||||||
|
# We are already in a transaction
|
||||||
|
logger.warning("received START or SINGLE fragment while in transaction")
|
||||||
|
self.reset()
|
||||||
|
self.packets_received = 1
|
||||||
|
|
||||||
|
if packet_type == Protocol.PacketType.START:
|
||||||
|
self.number_of_packets = pdu[1]
|
||||||
|
pid_offset = 2
|
||||||
|
|
||||||
|
pid = struct.unpack_from(">H", pdu, pid_offset)[0]
|
||||||
|
self.payload += pdu[pid_offset + 2 :]
|
||||||
|
|
||||||
|
if packet_type in (Protocol.PacketType.CONTINUE, Protocol.PacketType.END):
|
||||||
|
if transaction_label != self.transaction_label:
|
||||||
|
logger.warning("transaction label does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if pid != self.pid:
|
||||||
|
logger.warning("PID does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if c_r != self.c_r:
|
||||||
|
logger.warning("C/R does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.packets_received > self.number_of_packets:
|
||||||
|
logger.warning("too many fragments in transaction")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if packet_type == Protocol.PacketType.END:
|
||||||
|
if self.packets_received != self.number_of_packets:
|
||||||
|
logger.warning("premature END")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.transaction_label = transaction_label
|
||||||
|
self.c_r = c_r
|
||||||
|
self.ipid = ipid
|
||||||
|
self.pid = pid
|
||||||
|
|
||||||
|
if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.END):
|
||||||
|
self.on_message_complete()
|
||||||
|
|
||||||
|
def on_message_complete(self):
|
||||||
|
try:
|
||||||
|
self.callback(
|
||||||
|
self.transaction_label,
|
||||||
|
self.c_r == 0,
|
||||||
|
self.ipid != 0,
|
||||||
|
self.pid,
|
||||||
|
self.payload,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.exception(color(f"!!! exception in callback: {error}", "red"))
|
||||||
|
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Protocol:
|
||||||
|
CommandHandler = Callable[[int, avc.CommandFrame], None]
|
||||||
|
command_handlers: Dict[int, CommandHandler] # Command handlers, by PID
|
||||||
|
ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
|
||||||
|
response_handlers: Dict[int, ResponseHandler] # Response handlers, by PID
|
||||||
|
next_transaction_label: int
|
||||||
|
message_assembler: MessageAssembler
|
||||||
|
|
||||||
|
class PacketType(IntEnum):
|
||||||
|
SINGLE = 0b00
|
||||||
|
START = 0b01
|
||||||
|
CONTINUE = 0b10
|
||||||
|
END = 0b11
|
||||||
|
|
||||||
|
def __init__(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
|
self.command_handlers = {}
|
||||||
|
self.response_handlers = {}
|
||||||
|
self.l2cap_channel = l2cap_channel
|
||||||
|
self.message_assembler = MessageAssembler(self.on_message)
|
||||||
|
|
||||||
|
# Register to receive PDUs from the channel
|
||||||
|
l2cap_channel.sink = self.on_pdu
|
||||||
|
l2cap_channel.on("open", self.on_l2cap_channel_open)
|
||||||
|
l2cap_channel.on("close", self.on_l2cap_channel_close)
|
||||||
|
|
||||||
|
def on_l2cap_channel_open(self):
|
||||||
|
logger.debug(color("<<< AVCTP channel open", "magenta"))
|
||||||
|
|
||||||
|
def on_l2cap_channel_close(self):
|
||||||
|
logger.debug(color("<<< AVCTP channel closed", "magenta"))
|
||||||
|
|
||||||
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
|
self.message_assembler.on_pdu(pdu)
|
||||||
|
|
||||||
|
def on_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f"<<< AVCTP Message: pid={pid}, "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"is_command={is_command}, "
|
||||||
|
f"ipid={ipid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for invalid PID responses.
|
||||||
|
if ipid:
|
||||||
|
logger.debug(f"received IPID for PID={pid}")
|
||||||
|
|
||||||
|
# Find the appropriate handler.
|
||||||
|
if is_command:
|
||||||
|
if pid not in self.command_handlers:
|
||||||
|
logger.warning(f"no command handler for PID {pid}")
|
||||||
|
self.send_ipid(transaction_label, pid)
|
||||||
|
return
|
||||||
|
|
||||||
|
command_frame = cast(avc.CommandFrame, avc.Frame.from_bytes(payload))
|
||||||
|
self.command_handlers[pid](transaction_label, command_frame)
|
||||||
|
else:
|
||||||
|
if pid not in self.response_handlers:
|
||||||
|
logger.warning(f"no response handler for PID {pid}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# By convention, for an ipid, send a None payload to the response handler.
|
||||||
|
if ipid:
|
||||||
|
response_frame = None
|
||||||
|
else:
|
||||||
|
response_frame = cast(avc.ResponseFrame, avc.Frame.from_bytes(payload))
|
||||||
|
|
||||||
|
self.response_handlers[pid](transaction_label, response_frame)
|
||||||
|
|
||||||
|
def send_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
):
|
||||||
|
# TODO: fragment large messages
|
||||||
|
packet_type = Protocol.PacketType.SINGLE
|
||||||
|
pdu = (
|
||||||
|
struct.pack(
|
||||||
|
">BH",
|
||||||
|
transaction_label << 4
|
||||||
|
| packet_type << 2
|
||||||
|
| (0 if is_command else 1) << 1
|
||||||
|
| (1 if ipid else 0),
|
||||||
|
pid,
|
||||||
|
)
|
||||||
|
+ payload
|
||||||
|
)
|
||||||
|
self.l2cap_channel.send_pdu(pdu)
|
||||||
|
|
||||||
|
def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP command: "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"pid={pid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, True, False, pid, payload)
|
||||||
|
|
||||||
|
def send_response(self, transaction_label: int, pid: int, payload: bytes):
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP response: "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"pid={pid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, False, False, pid, payload)
|
||||||
|
|
||||||
|
def send_ipid(self, transaction_label: int, pid: int) -> None:
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP ipid: " f"transaction_label={transaction_label}, " f"pid={pid}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, False, True, pid, b'')
|
||||||
|
|
||||||
|
def register_command_handler(
|
||||||
|
self, pid: int, handler: Protocol.CommandHandler
|
||||||
|
) -> None:
|
||||||
|
self.command_handlers[pid] = handler
|
||||||
|
|
||||||
|
def unregister_command_handler(
|
||||||
|
self, pid: int, handler: Protocol.CommandHandler
|
||||||
|
) -> None:
|
||||||
|
if pid not in self.command_handlers or self.command_handlers[pid] != handler:
|
||||||
|
raise ValueError("command handler not registered")
|
||||||
|
del self.command_handlers[pid]
|
||||||
|
|
||||||
|
def register_response_handler(
|
||||||
|
self, pid: int, handler: Protocol.ResponseHandler
|
||||||
|
) -> None:
|
||||||
|
self.response_handlers[pid] = handler
|
||||||
|
|
||||||
|
def unregister_response_handler(
|
||||||
|
self, pid: int, handler: Protocol.ResponseHandler
|
||||||
|
) -> None:
|
||||||
|
if pid not in self.response_handlers or self.response_handlers[pid] != handler:
|
||||||
|
raise ValueError("response handler not registered")
|
||||||
|
del self.response_handlers[pid]
|
||||||
@@ -241,7 +241,10 @@ async def find_avdtp_service_with_sdp_client(
|
|||||||
)
|
)
|
||||||
if profile_descriptor_list:
|
if profile_descriptor_list:
|
||||||
for profile_descriptor in profile_descriptor_list.value:
|
for profile_descriptor in profile_descriptor_list.value:
|
||||||
if len(profile_descriptor.value) >= 2:
|
if (
|
||||||
|
profile_descriptor.type == sdp.DataElement.SEQUENCE
|
||||||
|
and len(profile_descriptor.value) >= 2
|
||||||
|
):
|
||||||
avdtp_version_major = profile_descriptor.value[1].value >> 8
|
avdtp_version_major = profile_descriptor.value[1].value >> 8
|
||||||
avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
|
avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
|
||||||
return (avdtp_version_major, avdtp_version_minor)
|
return (avdtp_version_major, avdtp_version_minor)
|
||||||
@@ -511,7 +514,8 @@ class MessageAssembler:
|
|||||||
try:
|
try:
|
||||||
self.callback(self.transaction_label, message)
|
self.callback(self.transaction_label, message)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(color(f'!!! exception in callback: {error}'))
|
logger.exception(color(f'!!! exception in callback: {error}', 'red'))
|
||||||
|
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1916
bumble/avrcp.py
Normal file
1916
bumble/avrcp.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -97,12 +97,16 @@ class BaseError(Exception):
|
|||||||
namespace = f'{self.error_namespace}/'
|
namespace = f'{self.error_namespace}/'
|
||||||
else:
|
else:
|
||||||
namespace = ''
|
namespace = ''
|
||||||
error_text = {
|
have_name = self.error_name != ''
|
||||||
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
have_code = self.error_code is not None
|
||||||
(True, False): self.error_name,
|
if have_name and have_code:
|
||||||
(False, True): f'0x{self.error_code:X}',
|
error_text = f'{self.error_name} [0x{self.error_code:X}]'
|
||||||
(False, False): '',
|
elif have_name and not have_code:
|
||||||
}[(self.error_name != '', self.error_code is not None)]
|
error_text = self.error_name
|
||||||
|
elif not have_name and have_code:
|
||||||
|
error_text = f'0x{self.error_code:X}'
|
||||||
|
else:
|
||||||
|
error_text = '<unspecified>'
|
||||||
|
|
||||||
return f'{type(self).__name__}({namespace}{error_text})'
|
return f'{type(self).__name__}({namespace}{error_text})'
|
||||||
|
|
||||||
@@ -319,7 +323,7 @@ BT_HIDP_PROTOCOL_ID = UUID.from_16_bits(0x0011, 'HIDP')
|
|||||||
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
|
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
|
||||||
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
|
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
|
||||||
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
|
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
|
||||||
BT_AVTCP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
|
BT_AVCTP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
|
||||||
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
|
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
|
||||||
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
|
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
|
||||||
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
|
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ from typing import (
|
|||||||
Tuple,
|
Tuple,
|
||||||
Type,
|
Type,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Set,
|
|
||||||
Union,
|
Union,
|
||||||
cast,
|
cast,
|
||||||
overload,
|
overload,
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ from collections.abc import Callable, MutableMapping
|
|||||||
from typing import cast, Any, Optional
|
from typing import cast, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avctp
|
||||||
from bumble import avdtp
|
from bumble import avdtp
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import crypto
|
||||||
|
from bumble import rfcomm
|
||||||
|
from bumble import sdp
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.att import ATT_CID, ATT_PDU
|
from bumble.att import ATT_CID, ATT_PDU
|
||||||
from bumble.smp import SMP_CID, SMP_Command
|
from bumble.smp import SMP_CID, SMP_Command
|
||||||
@@ -47,9 +53,7 @@ from bumble.hci import (
|
|||||||
HCI_AclDataPacket,
|
HCI_AclDataPacket,
|
||||||
HCI_Disconnection_Complete_Event,
|
HCI_Disconnection_Complete_Event,
|
||||||
)
|
)
|
||||||
from bumble.rfcomm import RFCOMM_Frame, RFCOMM_PSM
|
|
||||||
from bumble.sdp import SDP_PDU, SDP_PSM
|
|
||||||
from bumble import crypto
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -59,11 +63,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
PSM_NAMES = {
|
PSM_NAMES = {
|
||||||
RFCOMM_PSM: 'RFCOMM',
|
rfcomm.RFCOMM_PSM: 'RFCOMM',
|
||||||
SDP_PSM: 'SDP',
|
sdp.SDP_PSM: 'SDP',
|
||||||
avdtp.AVDTP_PSM: 'AVDTP',
|
avdtp.AVDTP_PSM: 'AVDTP',
|
||||||
|
avctp.AVCTP_PSM: 'AVCTP'
|
||||||
|
# TODO: add more PSM values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketTracer:
|
class PacketTracer:
|
||||||
@@ -71,17 +78,20 @@ class PacketTracer:
|
|||||||
psms: MutableMapping[int, int]
|
psms: MutableMapping[int, int]
|
||||||
peer: Optional[PacketTracer.AclStream]
|
peer: Optional[PacketTracer.AclStream]
|
||||||
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
||||||
|
avctp_assemblers: MutableMapping[int, avctp.MessageAssembler]
|
||||||
|
|
||||||
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
|
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
|
||||||
self.analyzer = analyzer
|
self.analyzer = analyzer
|
||||||
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
||||||
|
self.avctp_assemblers = {} # AVCTP assemblers, by source_cid
|
||||||
self.psms = {} # PSM, by source_cid
|
self.psms = {} # PSM, by source_cid
|
||||||
self.peer = None
|
self.peer = None
|
||||||
|
|
||||||
# pylint: disable=too-many-nested-blocks
|
# pylint: disable=too-many-nested-blocks
|
||||||
def on_acl_pdu(self, pdu: bytes) -> None:
|
def on_acl_pdu(self, pdu: bytes) -> None:
|
||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
|
self.analyzer.emit(l2cap_pdu)
|
||||||
|
|
||||||
if l2cap_pdu.cid == ATT_CID:
|
if l2cap_pdu.cid == ATT_CID:
|
||||||
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
|
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
|
||||||
@@ -103,42 +113,51 @@ class PacketTracer:
|
|||||||
connection_response.result
|
connection_response.result
|
||||||
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
||||||
):
|
):
|
||||||
if self.peer:
|
if self.peer and (
|
||||||
if psm := self.peer.psms.get(
|
psm := self.peer.psms.get(connection_response.source_cid)
|
||||||
connection_response.source_cid
|
):
|
||||||
):
|
# Found a pending connection
|
||||||
# Found a pending connection
|
self.psms[connection_response.destination_cid] = psm
|
||||||
self.psms[connection_response.destination_cid] = psm
|
|
||||||
|
|
||||||
# For AVDTP connections, create a packet assembler for
|
|
||||||
# each direction
|
|
||||||
if psm == avdtp.AVDTP_PSM:
|
|
||||||
self.avdtp_assemblers[
|
|
||||||
connection_response.source_cid
|
|
||||||
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
|
||||||
self.peer.avdtp_assemblers[
|
|
||||||
connection_response.destination_cid
|
|
||||||
] = avdtp.MessageAssembler(
|
|
||||||
self.peer.on_avdtp_message
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# For AVDTP connections, create a packet assembler for
|
||||||
|
# each direction
|
||||||
|
if psm == avdtp.AVDTP_PSM:
|
||||||
|
self.avdtp_assemblers[
|
||||||
|
connection_response.source_cid
|
||||||
|
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
||||||
|
self.peer.avdtp_assemblers[
|
||||||
|
connection_response.destination_cid
|
||||||
|
] = avdtp.MessageAssembler(self.peer.on_avdtp_message)
|
||||||
|
elif psm == avctp.AVCTP_PSM:
|
||||||
|
self.avctp_assemblers[
|
||||||
|
connection_response.source_cid
|
||||||
|
] = avctp.MessageAssembler(self.on_avctp_message)
|
||||||
|
self.peer.avctp_assemblers[
|
||||||
|
connection_response.destination_cid
|
||||||
|
] = avctp.MessageAssembler(self.peer.on_avctp_message)
|
||||||
else:
|
else:
|
||||||
# Try to find the PSM associated with this PDU
|
# Try to find the PSM associated with this PDU
|
||||||
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
|
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
|
||||||
if psm == SDP_PSM:
|
if psm == sdp.SDP_PSM:
|
||||||
sdp_pdu = SDP_PDU.from_bytes(l2cap_pdu.payload)
|
sdp_pdu = sdp.SDP_PDU.from_bytes(l2cap_pdu.payload)
|
||||||
self.analyzer.emit(sdp_pdu)
|
self.analyzer.emit(sdp_pdu)
|
||||||
elif psm == RFCOMM_PSM:
|
elif psm == rfcomm.RFCOMM_PSM:
|
||||||
rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
rfcomm_frame = rfcomm.RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
||||||
self.analyzer.emit(rfcomm_frame)
|
self.analyzer.emit(rfcomm_frame)
|
||||||
elif psm == avdtp.AVDTP_PSM:
|
elif psm == avdtp.AVDTP_PSM:
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
||||||
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
||||||
)
|
)
|
||||||
assembler = self.avdtp_assemblers.get(l2cap_pdu.cid)
|
if avdtp_assembler := self.avdtp_assemblers.get(l2cap_pdu.cid):
|
||||||
if assembler:
|
avdtp_assembler.on_pdu(l2cap_pdu.payload)
|
||||||
assembler.on_pdu(l2cap_pdu.payload)
|
elif psm == avctp.AVCTP_PSM:
|
||||||
|
self.analyzer.emit(
|
||||||
|
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
||||||
|
f'PSM=AVCTP]: {l2cap_pdu.payload.hex()}'
|
||||||
|
)
|
||||||
|
if avctp_assembler := self.avctp_assemblers.get(l2cap_pdu.cid):
|
||||||
|
avctp_assembler.on_pdu(l2cap_pdu.payload)
|
||||||
else:
|
else:
|
||||||
psm_string = name_or_number(PSM_NAMES, psm)
|
psm_string = name_or_number(PSM_NAMES, psm)
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
@@ -155,6 +174,28 @@ class PacketTracer:
|
|||||||
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_avctp_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
):
|
||||||
|
if pid == avrcp.AVRCP_PID:
|
||||||
|
avc_frame = avc.Frame.from_bytes(payload)
|
||||||
|
details = str(avc_frame)
|
||||||
|
else:
|
||||||
|
details = payload.hex()
|
||||||
|
|
||||||
|
c_r = 'Command' if is_command else 'Response'
|
||||||
|
self.analyzer.emit(
|
||||||
|
f'{color("AVCTP", "green")} '
|
||||||
|
f'{c_r}[{transaction_label}][{name_or_number(AVCTP_PID_NAMES, pid)}] '
|
||||||
|
f'{"#" if ipid else ""}'
|
||||||
|
f'{details}'
|
||||||
|
)
|
||||||
|
|
||||||
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
self.packet_assembler.feed_packet(packet)
|
self.packet_assembler.feed_packet(packet)
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
||||||
return answer.confirm
|
return answer.confirm
|
||||||
@@ -125,7 +125,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(numeric_comparison=number))
|
event = self.add_origin(PairingEvent(numeric_comparison=number))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
||||||
return answer.confirm
|
return answer.confirm
|
||||||
@@ -140,7 +140,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
if answer.answer_variant() is None:
|
if answer.answer_variant() is None:
|
||||||
return None
|
return None
|
||||||
@@ -157,7 +157,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
if answer.answer_variant() is None:
|
if answer.answer_variant() is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
|
|||||||
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
||||||
|
|
||||||
# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
|
|
||||||
|
# Profile-specific Attribute Identifiers (cf. Assigned Numbers for Service Discovery)
|
||||||
# used by AVRCP, HFP and A2DP
|
# used by AVRCP, HFP and A2DP
|
||||||
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
||||||
|
|
||||||
@@ -115,7 +116,8 @@ SDP_ATTRIBUTE_ID_NAMES = {
|
|||||||
SDP_DOCUMENTATION_URL_ATTRIBUTE_ID: 'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
|
SDP_DOCUMENTATION_URL_ATTRIBUTE_ID: 'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
|
||||||
SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID: 'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
|
SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID: 'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
|
||||||
SDP_ICON_URL_ATTRIBUTE_ID: 'SDP_ICON_URL_ATTRIBUTE_ID',
|
SDP_ICON_URL_ATTRIBUTE_ID: 'SDP_ICON_URL_ATTRIBUTE_ID',
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID'
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID',
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID: 'SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID',
|
||||||
}
|
}
|
||||||
|
|
||||||
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
||||||
|
|||||||
@@ -17,9 +17,10 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import traceback
|
|
||||||
import collections
|
import collections
|
||||||
|
import enum
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -34,7 +35,7 @@ from typing import (
|
|||||||
Union,
|
Union,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
from functools import wraps, partial
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -131,13 +132,14 @@ class EventWatcher:
|
|||||||
Args:
|
Args:
|
||||||
emitter: EventEmitter to watch
|
emitter: EventEmitter to watch
|
||||||
event: Event name
|
event: Event name
|
||||||
handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
|
handler: (Optional) Event handler. When nothing is passed, this method
|
||||||
|
works as a decorator.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def wrapper(f: _Handler) -> _Handler:
|
def wrapper(wrapped: _Handler) -> _Handler:
|
||||||
self.handlers.append((emitter, event, f))
|
self.handlers.append((emitter, event, wrapped))
|
||||||
emitter.on(event, f)
|
emitter.on(event, wrapped)
|
||||||
return f
|
return wrapped
|
||||||
|
|
||||||
return wrapper if handler is None else wrapper(handler)
|
return wrapper if handler is None else wrapper(handler)
|
||||||
|
|
||||||
@@ -157,13 +159,14 @@ class EventWatcher:
|
|||||||
Args:
|
Args:
|
||||||
emitter: EventEmitter to watch
|
emitter: EventEmitter to watch
|
||||||
event: Event name
|
event: Event name
|
||||||
handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
|
handler: (Optional) Event handler. When nothing passed, this method works
|
||||||
|
as a decorator.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def wrapper(f: _Handler) -> _Handler:
|
def wrapper(wrapped: _Handler) -> _Handler:
|
||||||
self.handlers.append((emitter, event, f))
|
self.handlers.append((emitter, event, wrapped))
|
||||||
emitter.once(event, f)
|
emitter.once(event, wrapped)
|
||||||
return f
|
return wrapped
|
||||||
|
|
||||||
return wrapper if handler is None else wrapper(handler)
|
return wrapper if handler is None else wrapper(handler)
|
||||||
|
|
||||||
@@ -276,7 +279,7 @@ class AsyncRunner:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
coroutine = func(*args, **kwargs)
|
coroutine = func(*args, **kwargs)
|
||||||
if queue is None:
|
if queue is None:
|
||||||
@@ -410,30 +413,35 @@ class FlowControlAsyncPipe:
|
|||||||
self.check_pump()
|
self.check_pump()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
async def async_call(function, *args, **kwargs):
|
async def async_call(function, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Immediately calls the function with provided args and kwargs, wrapping it in an async function.
|
Immediately calls the function with provided args and kwargs, wrapping it in an
|
||||||
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
|
async function.
|
||||||
|
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject
|
||||||
|
a running loop.
|
||||||
|
|
||||||
result = await async_call(some_function, ...)
|
result = await async_call(some_function, ...)
|
||||||
"""
|
"""
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def wrap_async(function):
|
def wrap_async(function):
|
||||||
"""
|
"""
|
||||||
Wraps the provided function in an async function.
|
Wraps the provided function in an async function.
|
||||||
"""
|
"""
|
||||||
return partial(async_call, function)
|
return functools.partial(async_call, function)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def deprecated(msg: str):
|
def deprecated(msg: str):
|
||||||
"""
|
"""
|
||||||
Throw deprecation warning before execution.
|
Throw deprecation warning before execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrapper(function):
|
def wrapper(function):
|
||||||
@wraps(function)
|
@functools.wraps(function)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
warnings.warn(msg, DeprecationWarning)
|
warnings.warn(msg, DeprecationWarning)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
@@ -443,13 +451,14 @@ def deprecated(msg: str):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def experimental(msg: str):
|
def experimental(msg: str):
|
||||||
"""
|
"""
|
||||||
Throws a future warning before execution.
|
Throws a future warning before execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrapper(function):
|
def wrapper(function):
|
||||||
@wraps(function)
|
@functools.wraps(function)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
warnings.warn(msg, FutureWarning)
|
warnings.warn(msg, FutureWarning)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
@@ -457,3 +466,22 @@ def experimental(msg: str):
|
|||||||
return inner
|
return inner
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class OpenIntEnum(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Subclass of enum.IntEnum that can hold integer values outside the set of
|
||||||
|
predefined values. This is convenient for implementing protocols where some
|
||||||
|
integer constants may be added over time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value):
|
||||||
|
if not isinstance(value, int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
obj = int.__new__(cls, value)
|
||||||
|
obj._value_ = value
|
||||||
|
obj._name_ = f"{cls.__name__}[{value}]"
|
||||||
|
return obj
|
||||||
|
|||||||
274
examples/avrcp_as_sink.html
Normal file
274
examples/avrcp_as_sink.html
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Server Port <input id="port" type="text" value="8989"></input> <button id="connectButton" onclick="connect()">Connect</button><br>
|
||||||
|
<div id="socketState"></div>
|
||||||
|
<br>
|
||||||
|
<div id="buttons"></div><br>
|
||||||
|
<hr>
|
||||||
|
<button onclick="onGetPlayStatusButtonClicked()">Get Play Status</button><br>
|
||||||
|
<div id="getPlayStatusResponseTable"></div>
|
||||||
|
<hr>
|
||||||
|
<button onclick="onGetElementAttributesButtonClicked()">Get Element Attributes</button><br>
|
||||||
|
<div id="getElementAttributesResponseTable"></div>
|
||||||
|
<hr>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<b>VOLUME</b>:
|
||||||
|
<button onclick="onVolumeDownButtonClicked()">-</button>
|
||||||
|
<button onclick="onVolumeUpButtonClicked()">+</button>
|
||||||
|
<span id="volumeText"></span><br>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>PLAYBACK STATUS</b></td><td><span id="playbackStatusText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>POSITION</b></td><td><span id="positionText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>TRACK</b></td><td><span id="trackText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>ADDRESSED PLAYER</b></td><td><span id="addressedPlayerText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>UID COUNTER</b></td><td><span id="uidCounterText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>SUPPORTED EVENTS</b></td><td><span id="supportedEventsText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>PLAYER SETTINGS</b></td><td><div id="playerSettingsTable"></div></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<script>
|
||||||
|
const portInput = document.getElementById("port")
|
||||||
|
const connectButton = document.getElementById("connectButton")
|
||||||
|
const socketState = document.getElementById("socketState")
|
||||||
|
const volumeText = document.getElementById("volumeText")
|
||||||
|
const positionText = document.getElementById("positionText")
|
||||||
|
const trackText = document.getElementById("trackText")
|
||||||
|
const playbackStatusText = document.getElementById("playbackStatusText")
|
||||||
|
const addressedPlayerText = document.getElementById("addressedPlayerText")
|
||||||
|
const uidCounterText = document.getElementById("uidCounterText")
|
||||||
|
const supportedEventsText = document.getElementById("supportedEventsText")
|
||||||
|
const playerSettingsTable = document.getElementById("playerSettingsTable")
|
||||||
|
const getPlayStatusResponseTable = document.getElementById("getPlayStatusResponseTable")
|
||||||
|
const getElementAttributesResponseTable = document.getElementById("getElementAttributesResponseTable")
|
||||||
|
let socket
|
||||||
|
let volume = 0
|
||||||
|
|
||||||
|
const keyNames = [
|
||||||
|
"SELECT",
|
||||||
|
"UP",
|
||||||
|
"DOWN",
|
||||||
|
"LEFT",
|
||||||
|
"RIGHT",
|
||||||
|
"RIGHT_UP",
|
||||||
|
"RIGHT_DOWN",
|
||||||
|
"LEFT_UP",
|
||||||
|
"LEFT_DOWN",
|
||||||
|
"ROOT_MENU",
|
||||||
|
"SETUP_MENU",
|
||||||
|
"CONTENTS_MENU",
|
||||||
|
"FAVORITE_MENU",
|
||||||
|
"EXIT",
|
||||||
|
"NUMBER_0",
|
||||||
|
"NUMBER_1",
|
||||||
|
"NUMBER_2",
|
||||||
|
"NUMBER_3",
|
||||||
|
"NUMBER_4",
|
||||||
|
"NUMBER_5",
|
||||||
|
"NUMBER_6",
|
||||||
|
"NUMBER_7",
|
||||||
|
"NUMBER_8",
|
||||||
|
"NUMBER_9",
|
||||||
|
"DOT",
|
||||||
|
"ENTER",
|
||||||
|
"CLEAR",
|
||||||
|
"CHANNEL_UP",
|
||||||
|
"CHANNEL_DOWN",
|
||||||
|
"PREVIOUS_CHANNEL",
|
||||||
|
"SOUND_SELECT",
|
||||||
|
"INPUT_SELECT",
|
||||||
|
"DISPLAY_INFORMATION",
|
||||||
|
"HELP",
|
||||||
|
"PAGE_UP",
|
||||||
|
"PAGE_DOWN",
|
||||||
|
"POWER",
|
||||||
|
"VOLUME_UP",
|
||||||
|
"VOLUME_DOWN",
|
||||||
|
"MUTE",
|
||||||
|
"PLAY",
|
||||||
|
"STOP",
|
||||||
|
"PAUSE",
|
||||||
|
"RECORD",
|
||||||
|
"REWIND",
|
||||||
|
"FAST_FORWARD",
|
||||||
|
"EJECT",
|
||||||
|
"FORWARD",
|
||||||
|
"BACKWARD",
|
||||||
|
"ANGLE",
|
||||||
|
"SUBPICTURE",
|
||||||
|
"F1",
|
||||||
|
"F2",
|
||||||
|
"F3",
|
||||||
|
"F4",
|
||||||
|
"F5",
|
||||||
|
]
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
|
const buttons = document.getElementById("buttons")
|
||||||
|
keyNames.forEach(name => {
|
||||||
|
const button = document.createElement("BUTTON")
|
||||||
|
button.appendChild(document.createTextNode(name))
|
||||||
|
button.addEventListener("mousedown", event => {
|
||||||
|
send({type: 'send-key-down', key: name})
|
||||||
|
})
|
||||||
|
button.addEventListener("mouseup", event => {
|
||||||
|
send({type: 'send-key-up', key: name})
|
||||||
|
})
|
||||||
|
buttons.appendChild(button)
|
||||||
|
})
|
||||||
|
|
||||||
|
updateVolume(0)
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
||||||
|
socket.onopen = _ => {
|
||||||
|
socketState.innerText = 'OPEN'
|
||||||
|
connectButton.disabled = true
|
||||||
|
}
|
||||||
|
socket.onclose = _ => {
|
||||||
|
socketState.innerText = 'CLOSED'
|
||||||
|
connectButton.disabled = false
|
||||||
|
}
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
socketState.innerText = 'ERROR'
|
||||||
|
console.log(`ERROR: ${error}`)
|
||||||
|
connectButton.disabled = false
|
||||||
|
}
|
||||||
|
socket.onmessage = (message) => {
|
||||||
|
onMessage(JSON.parse(message.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message) {
|
||||||
|
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmsText(position) {
|
||||||
|
const h_1 = 1000 * 60 * 60
|
||||||
|
const h = Math.floor(position / h_1)
|
||||||
|
position -= h * h_1
|
||||||
|
const m_1 = 1000 * 60
|
||||||
|
const m = Math.floor(position / m_1)
|
||||||
|
position -= m * m_1
|
||||||
|
const s_1 = 1000
|
||||||
|
const s = Math.floor(position / s_1)
|
||||||
|
position -= s * s_1
|
||||||
|
|
||||||
|
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}:${position}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableHead(table, columns) {
|
||||||
|
let thead = table.createTHead()
|
||||||
|
let row = thead.insertRow()
|
||||||
|
for (let column of columns) {
|
||||||
|
let th = document.createElement("th")
|
||||||
|
let text = document.createTextNode(column)
|
||||||
|
th.appendChild(text)
|
||||||
|
row.appendChild(th)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTable(rows) {
|
||||||
|
const table = document.createElement("table")
|
||||||
|
|
||||||
|
if (rows.length != 0) {
|
||||||
|
columns = Object.keys(rows[0])
|
||||||
|
setTableHead(table, columns)
|
||||||
|
}
|
||||||
|
for (let element of rows) {
|
||||||
|
let row = table.insertRow()
|
||||||
|
for (key in element) {
|
||||||
|
let cell = row.insertCell()
|
||||||
|
let text = document.createTextNode(element[key])
|
||||||
|
cell.appendChild(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(message) {
|
||||||
|
console.log(message)
|
||||||
|
if (message.type == "set-volume") {
|
||||||
|
updateVolume(message.params.volume)
|
||||||
|
} else if (message.type == "supported-events") {
|
||||||
|
supportedEventsText.innerText = JSON.stringify(message.params.events)
|
||||||
|
} else if (message.type == "playback-position-changed") {
|
||||||
|
positionText.innerText = hmsText(message.params.position)
|
||||||
|
} else if (message.type == "playback-status-changed") {
|
||||||
|
playbackStatusText.innerText = message.params.status
|
||||||
|
} else if (message.type == "player-settings-changed") {
|
||||||
|
playerSettingsTable.replaceChildren(message.params.settings)
|
||||||
|
} else if (message.type == "track-changed") {
|
||||||
|
trackText.innerText = message.params.identifier
|
||||||
|
} else if (message.type == "addressed-player-changed") {
|
||||||
|
addressedPlayerText.innerText = JSON.stringify(message.params.player)
|
||||||
|
} else if (message.type == "uids-changed") {
|
||||||
|
uidCounterText.innerText = message.params.uid_counter
|
||||||
|
} else if (message.type == "get-play-status-response") {
|
||||||
|
getPlayStatusResponseTable.replaceChildren(message.params)
|
||||||
|
} else if (message.type == "get-element-attributes-response") {
|
||||||
|
getElementAttributesResponseTable.replaceChildren(createTable(message.params))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVolume(newVolume) {
|
||||||
|
volume = newVolume
|
||||||
|
volumeText.innerText = `${volume} (${Math.round(100*volume/0x7F)}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event) {
|
||||||
|
console.log(event)
|
||||||
|
send({ type: 'send-key-down', key: event.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event) {
|
||||||
|
console.log(event)
|
||||||
|
send({ type: 'send-key-up', key: event.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeUpButtonClicked() {
|
||||||
|
updateVolume(Math.min(volume + 5, 0x7F))
|
||||||
|
send({ type: 'set-volume', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeDownButtonClicked() {
|
||||||
|
updateVolume(Math.max(volume - 5, 0))
|
||||||
|
send({ type: 'set-volume', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGetPlayStatusButtonClicked() {
|
||||||
|
send({ type: 'get-play-status', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGetElementAttributesButtonClicked() {
|
||||||
|
send({ type: 'get-element-attributes' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
408
examples/run_avrcp.py
Normal file
408
examples/run_avrcp.py
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
# Copyright 2023 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
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
from bumble.core import BT_BR_EDR_TRANSPORT
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import avdtp
|
||||||
|
from bumble import a2dp
|
||||||
|
from bumble import utils
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def sdp_records():
|
||||||
|
a2dp_sink_service_record_handle = 0x00010001
|
||||||
|
avrcp_controller_service_record_handle = 0x00010002
|
||||||
|
avrcp_target_service_record_handle = 0x00010003
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
return {
|
||||||
|
a2dp_sink_service_record_handle: a2dp.make_audio_sink_service_sdp_records(
|
||||||
|
a2dp_sink_service_record_handle
|
||||||
|
),
|
||||||
|
avrcp_controller_service_record_handle: avrcp.make_controller_service_sdp_records(
|
||||||
|
avrcp_controller_service_record_handle
|
||||||
|
),
|
||||||
|
avrcp_target_service_record_handle: avrcp.make_target_service_sdp_records(
|
||||||
|
avrcp_controller_service_record_handle
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def codec_capabilities():
|
||||||
|
return avdtp.MediaCodecCapabilities(
|
||||||
|
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
|
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
||||||
|
media_codec_information=a2dp.SbcMediaCodecInformation.from_lists(
|
||||||
|
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||||
|
channel_modes=[
|
||||||
|
a2dp.SBC_MONO_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_DUAL_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_STEREO_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||||
|
],
|
||||||
|
block_lengths=[4, 8, 12, 16],
|
||||||
|
subbands=[4, 8],
|
||||||
|
allocation_methods=[
|
||||||
|
a2dp.SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||||
|
a2dp.SBC_SNR_ALLOCATION_METHOD,
|
||||||
|
],
|
||||||
|
minimum_bitpool_value=2,
|
||||||
|
maximum_bitpool_value=53,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_avdtp_connection(server):
|
||||||
|
# Add a sink endpoint to the server
|
||||||
|
sink = server.add_sink(codec_capabilities())
|
||||||
|
sink.on('rtp_packet', on_rtp_packet)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_rtp_packet(packet):
|
||||||
|
print(f'RTP: {packet}')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketServer):
|
||||||
|
async def get_supported_events():
|
||||||
|
events = await avrcp_protocol.get_supported_events()
|
||||||
|
print("SUPPORTED EVENTS:", events)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "supported-events",
|
||||||
|
"params": {"events": [event.name for event in events]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if avrcp.EventId.TRACK_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_track_changed())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYBACK_STATUS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_playback_status())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYBACK_POS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_playback_position())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYER_APPLICATION_SETTING_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_player_application_settings())
|
||||||
|
|
||||||
|
if avrcp.EventId.AVAILABLE_PLAYERS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_available_players())
|
||||||
|
|
||||||
|
if avrcp.EventId.ADDRESSED_PLAYER_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_addressed_player())
|
||||||
|
|
||||||
|
if avrcp.EventId.UIDS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_uids())
|
||||||
|
|
||||||
|
if avrcp.EventId.VOLUME_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_volume())
|
||||||
|
|
||||||
|
utils.AsyncRunner.spawn(get_supported_events())
|
||||||
|
|
||||||
|
async def monitor_track_changed():
|
||||||
|
async for identifier in avrcp_protocol.monitor_track_changed():
|
||||||
|
print("TRACK CHANGED:", identifier.hex())
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "track-changed", "params": {"identifier": identifier.hex()}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_playback_status():
|
||||||
|
async for playback_status in avrcp_protocol.monitor_playback_status():
|
||||||
|
print("PLAYBACK STATUS CHANGED:", playback_status.name)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "playback-status-changed",
|
||||||
|
"params": {"status": playback_status.name},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_playback_position():
|
||||||
|
async for playback_position in avrcp_protocol.monitor_playback_position(
|
||||||
|
playback_interval=1
|
||||||
|
):
|
||||||
|
print("PLAYBACK POSITION CHANGED:", playback_position)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "playback-position-changed",
|
||||||
|
"params": {"position": playback_position},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_player_application_settings():
|
||||||
|
async for settings in avrcp_protocol.monitor_player_application_settings():
|
||||||
|
print("PLAYER APPLICATION SETTINGS:", settings)
|
||||||
|
settings_as_dict = [
|
||||||
|
{"attribute": setting.attribute_id.name, "value": setting.value_id.name}
|
||||||
|
for setting in settings
|
||||||
|
]
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "player-settings-changed",
|
||||||
|
"params": {"settings": settings_as_dict},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_available_players():
|
||||||
|
async for _ in avrcp_protocol.monitor_available_players():
|
||||||
|
print("AVAILABLE PLAYERS CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "available-players-changed", "params": {}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_addressed_player():
|
||||||
|
async for player in avrcp_protocol.monitor_addressed_player():
|
||||||
|
print("ADDRESSED PLAYER CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "addressed-player-changed",
|
||||||
|
"params": {
|
||||||
|
"player": {
|
||||||
|
"player_id": player.player_id,
|
||||||
|
"uid_counter": player.uid_counter,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_uids():
|
||||||
|
async for uid_counter in avrcp_protocol.monitor_uids():
|
||||||
|
print("UIDS CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "uids-changed",
|
||||||
|
"params": {
|
||||||
|
"uid_counter": uid_counter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_volume():
|
||||||
|
async for volume in avrcp_protocol.monitor_volume():
|
||||||
|
print("VOLUME CHANGED:", volume)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "volume-changed", "params": {"volume": volume}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class WebSocketServer:
|
||||||
|
def __init__(
|
||||||
|
self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate
|
||||||
|
) -> None:
|
||||||
|
self.socket = None
|
||||||
|
self.delegate = None
|
||||||
|
self.avrcp_protocol = avrcp_protocol
|
||||||
|
self.avrcp_delegate = avrcp_delegate
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
# pylint: disable-next=no-member
|
||||||
|
await websockets.serve(self.serve, 'localhost', 8989) # type: ignore
|
||||||
|
|
||||||
|
async def serve(self, socket, _path) -> None:
|
||||||
|
print('### WebSocket connected')
|
||||||
|
self.socket = socket
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = await socket.recv()
|
||||||
|
print('Received: ', str(message))
|
||||||
|
|
||||||
|
parsed = json.loads(message)
|
||||||
|
message_type = parsed['type']
|
||||||
|
if message_type == 'send-key-down':
|
||||||
|
await self.on_send_key_down(parsed)
|
||||||
|
elif message_type == 'send-key-up':
|
||||||
|
await self.on_send_key_up(parsed)
|
||||||
|
elif message_type == 'set-volume':
|
||||||
|
await self.on_set_volume(parsed)
|
||||||
|
elif message_type == 'get-play-status':
|
||||||
|
await self.on_get_play_status()
|
||||||
|
elif message_type == 'get-element-attributes':
|
||||||
|
await self.on_get_element_attributes()
|
||||||
|
except websockets.exceptions.ConnectionClosedOK:
|
||||||
|
self.socket = None
|
||||||
|
break
|
||||||
|
|
||||||
|
async def on_send_key_down(self, message: dict) -> None:
|
||||||
|
key = avc.PassThroughFrame.OperationId[message["key"]]
|
||||||
|
await self.avrcp_protocol.send_key_event(key, True)
|
||||||
|
|
||||||
|
async def on_send_key_up(self, message: dict) -> None:
|
||||||
|
key = avc.PassThroughFrame.OperationId[message["key"]]
|
||||||
|
await self.avrcp_protocol.send_key_event(key, False)
|
||||||
|
|
||||||
|
async def on_set_volume(self, message: dict) -> None:
|
||||||
|
volume = message["volume"]
|
||||||
|
self.avrcp_delegate.volume = volume
|
||||||
|
self.avrcp_protocol.notify_volume_changed(volume)
|
||||||
|
|
||||||
|
async def on_get_play_status(self) -> None:
|
||||||
|
play_status = await self.avrcp_protocol.get_play_status()
|
||||||
|
self.send_message(
|
||||||
|
{
|
||||||
|
"type": "get-play-status-response",
|
||||||
|
"params": {
|
||||||
|
"song_length": play_status.song_length,
|
||||||
|
"song_position": play_status.song_position,
|
||||||
|
"play_status": play_status.play_status.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_get_element_attributes(self) -> None:
|
||||||
|
attributes = await self.avrcp_protocol.get_element_attributes(
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
avrcp.MediaAttributeId.TITLE,
|
||||||
|
avrcp.MediaAttributeId.ARTIST_NAME,
|
||||||
|
avrcp.MediaAttributeId.ALBUM_NAME,
|
||||||
|
avrcp.MediaAttributeId.TRACK_NUMBER,
|
||||||
|
avrcp.MediaAttributeId.TOTAL_NUMBER_OF_TRACKS,
|
||||||
|
avrcp.MediaAttributeId.GENRE,
|
||||||
|
avrcp.MediaAttributeId.PLAYING_TIME,
|
||||||
|
avrcp.MediaAttributeId.DEFAULT_COVER_ART,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.send_message(
|
||||||
|
{
|
||||||
|
"type": "get-element-attributes-response",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"attribute_id": attribute.attribute_id.name,
|
||||||
|
"attribute_value": attribute.attribute_value,
|
||||||
|
}
|
||||||
|
for attribute in attributes
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_message(self, message: dict) -> None:
|
||||||
|
if self.socket is None:
|
||||||
|
print("no socket, dropping message")
|
||||||
|
return
|
||||||
|
serialized = json.dumps(message)
|
||||||
|
utils.AsyncRunner.spawn(self.socket.send(serialized))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Delegate(avrcp.Delegate):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
[avrcp.EventId.VOLUME_CHANGED, avrcp.EventId.PLAYBACK_STATUS_CHANGED]
|
||||||
|
)
|
||||||
|
self.websocket_server = None
|
||||||
|
|
||||||
|
async def set_absolute_volume(self, volume: int) -> None:
|
||||||
|
await super().set_absolute_volume(volume)
|
||||||
|
if self.websocket_server is not None:
|
||||||
|
self.websocket_server.send_message(
|
||||||
|
{"type": "set-volume", "params": {"volume": volume}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(
|
||||||
|
'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
|
||||||
|
'<sbc-file> [<bt-addr>]'
|
||||||
|
)
|
||||||
|
print('example: run_avrcp_controller.py classic1.json usb:0')
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Setup the SDP to expose the sink service
|
||||||
|
device.sdp_service_records = sdp_records()
|
||||||
|
|
||||||
|
# Start the controller
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
# Create a listener to wait for AVDTP connections
|
||||||
|
listener = avdtp.Listener(avdtp.Listener.create_registrar(device))
|
||||||
|
listener.on('connection', on_avdtp_connection)
|
||||||
|
|
||||||
|
avrcp_delegate = Delegate()
|
||||||
|
avrcp_protocol = avrcp.Protocol(avrcp_delegate)
|
||||||
|
avrcp_protocol.listen(device)
|
||||||
|
|
||||||
|
websocket_server = WebSocketServer(avrcp_protocol, avrcp_delegate)
|
||||||
|
avrcp_delegate.websocket_server = websocket_server
|
||||||
|
avrcp_protocol.on(
|
||||||
|
"start", lambda: on_avrcp_start(avrcp_protocol, websocket_server)
|
||||||
|
)
|
||||||
|
await websocket_server.start()
|
||||||
|
|
||||||
|
if len(sys.argv) >= 5:
|
||||||
|
# Connect to the peer
|
||||||
|
target_address = sys.argv[4]
|
||||||
|
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')
|
||||||
|
|
||||||
|
server = await avdtp.Protocol.connect(connection)
|
||||||
|
listener.set_server(connection, server)
|
||||||
|
sink = server.add_sink(codec_capabilities())
|
||||||
|
sink.on('rtp_packet', on_rtp_packet)
|
||||||
|
|
||||||
|
await avrcp_protocol.connect(connection)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Start being discoverable and connectable
|
||||||
|
await device.set_discoverable(True)
|
||||||
|
await device.set_connectable(True)
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().create_future()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -82,15 +82,15 @@ console_scripts =
|
|||||||
build =
|
build =
|
||||||
build >= 0.7
|
build >= 0.7
|
||||||
test =
|
test =
|
||||||
pytest >= 6.2
|
pytest >= 8.0
|
||||||
pytest-asyncio >= 0.17
|
pytest-asyncio == 0.21.1
|
||||||
pytest-html >= 3.2.0
|
pytest-html >= 3.2.0
|
||||||
coverage >= 6.4
|
coverage >= 6.4
|
||||||
development =
|
development =
|
||||||
black == 22.10
|
black == 22.10
|
||||||
grpcio-tools >= 1.57.0
|
grpcio-tools >= 1.57.0
|
||||||
invoke >= 1.7.3
|
invoke >= 1.7.3
|
||||||
mypy == 1.5.0
|
mypy == 1.8.0
|
||||||
nox >= 2022
|
nox >= 2022
|
||||||
pylint == 2.15.8
|
pylint == 2.15.8
|
||||||
pyyaml >= 6.0
|
pyyaml >= 6.0
|
||||||
|
|||||||
246
tests/avrcp_test.py
Normal file
246
tests/avrcp_test.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Copyright 2023 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 struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
|
from bumble import device
|
||||||
|
from bumble import host
|
||||||
|
from bumble import controller
|
||||||
|
from bumble import link
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import avctp
|
||||||
|
from bumble.transport import common
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class TwoDevices:
|
||||||
|
def __init__(self):
|
||||||
|
self.connections = [None, None]
|
||||||
|
|
||||||
|
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
|
||||||
|
self.link = link.LocalLink()
|
||||||
|
self.controllers = [
|
||||||
|
controller.Controller('C1', link=self.link, public_address=addresses[0]),
|
||||||
|
controller.Controller('C2', link=self.link, public_address=addresses[1]),
|
||||||
|
]
|
||||||
|
self.devices = [
|
||||||
|
device.Device(
|
||||||
|
address=addresses[0],
|
||||||
|
host=host.Host(
|
||||||
|
self.controllers[0], common.AsyncPipeSink(self.controllers[0])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
device.Device(
|
||||||
|
address=addresses[1],
|
||||||
|
host=host.Host(
|
||||||
|
self.controllers[1], common.AsyncPipeSink(self.controllers[1])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.devices[0].classic_enabled = True
|
||||||
|
self.devices[1].classic_enabled = True
|
||||||
|
self.connections = [None, None]
|
||||||
|
self.protocols = [None, None]
|
||||||
|
|
||||||
|
def on_connection(self, which, connection):
|
||||||
|
self.connections[which] = connection
|
||||||
|
|
||||||
|
async def setup_connections(self):
|
||||||
|
await self.devices[0].power_on()
|
||||||
|
await self.devices[1].power_on()
|
||||||
|
|
||||||
|
self.connections = await asyncio.gather(
|
||||||
|
self.devices[0].connect(
|
||||||
|
self.devices[1].public_address, core.BT_BR_EDR_TRANSPORT
|
||||||
|
),
|
||||||
|
self.devices[1].accept(self.devices[0].public_address),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.protocols = [avrcp.Protocol(), avrcp.Protocol()]
|
||||||
|
self.protocols[0].listen(self.devices[1])
|
||||||
|
await self.protocols[1].connect(self.connections[0])
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_frame_parser():
|
||||||
|
with pytest.raises(ValueError) as error:
|
||||||
|
avc.Frame.from_bytes(bytes.fromhex("11480000"))
|
||||||
|
|
||||||
|
x = bytes.fromhex("014D0208")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 7
|
||||||
|
assert frame.opcode == 8
|
||||||
|
|
||||||
|
x = bytes.fromhex("014DFF0108")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 260
|
||||||
|
assert frame.opcode == 8
|
||||||
|
|
||||||
|
x = bytes.fromhex("0148000019581000000103")
|
||||||
|
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
|
||||||
|
assert isinstance(frame, avc.CommandFrame)
|
||||||
|
assert frame.ctype == avc.CommandFrame.CommandType.STATUS
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 0
|
||||||
|
assert frame.opcode == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_vendor_dependent_command():
|
||||||
|
x = bytes.fromhex("0148000019581000000103")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert isinstance(frame, avc.VendorDependentCommandFrame)
|
||||||
|
assert frame.company_id == 0x1958
|
||||||
|
assert frame.vendor_dependent_data == bytes.fromhex("1000000103")
|
||||||
|
|
||||||
|
frame = avc.VendorDependentCommandFrame(
|
||||||
|
avc.CommandFrame.CommandType.STATUS,
|
||||||
|
avc.Frame.SubunitType.PANEL,
|
||||||
|
0,
|
||||||
|
0x1958,
|
||||||
|
bytes.fromhex("1000000103"),
|
||||||
|
)
|
||||||
|
assert bytes(frame) == x
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_avctp_message_assembler():
|
||||||
|
received_message = []
|
||||||
|
|
||||||
|
def on_message(transaction_label, is_response, ipid, pid, payload):
|
||||||
|
received_message.append((transaction_label, is_response, ipid, pid, payload))
|
||||||
|
|
||||||
|
assembler = avctp.MessageAssembler(on_message)
|
||||||
|
|
||||||
|
payload = bytes.fromhex("01")
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
received_message = []
|
||||||
|
payload = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert len(received_message) == 0
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
received_message = []
|
||||||
|
payload = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 3, 0x11, 0x22]) + payload[0:1]
|
||||||
|
)
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b10 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[1:2]
|
||||||
|
)
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b11 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[2:3]
|
||||||
|
)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
# received_message = []
|
||||||
|
# parameter = bytes.fromhex("010203")
|
||||||
|
# assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
|
||||||
|
# assert len(received_message) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_avrcp_pdu_assembler():
|
||||||
|
received_pdus = []
|
||||||
|
|
||||||
|
def on_pdu(pdu_id, parameter):
|
||||||
|
received_pdus.append((pdu_id, parameter))
|
||||||
|
|
||||||
|
assembler = avrcp.PduAssembler(on_pdu)
|
||||||
|
|
||||||
|
parameter = bytes.fromhex("01")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, len(parameter)) + parameter)
|
||||||
|
assert len(received_pdus) == 0
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, 1) + parameter[0:1])
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b10, 1) + parameter[1:2])
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, 1) + parameter[2:3])
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
|
||||||
|
assert len(received_pdus) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_passthrough_commands():
|
||||||
|
play_pressed = avc.PassThroughCommandFrame(
|
||||||
|
avc.CommandFrame.CommandType.CONTROL,
|
||||||
|
avc.CommandFrame.SubunitType.PANEL,
|
||||||
|
0,
|
||||||
|
avc.PassThroughCommandFrame.StateFlag.PRESSED,
|
||||||
|
avc.PassThroughCommandFrame.OperationId.PLAY,
|
||||||
|
b'',
|
||||||
|
)
|
||||||
|
|
||||||
|
play_pressed_bytes = bytes(play_pressed)
|
||||||
|
parsed = avc.Frame.from_bytes(play_pressed_bytes)
|
||||||
|
assert isinstance(parsed, avc.PassThroughCommandFrame)
|
||||||
|
assert parsed.operation_id == avc.PassThroughCommandFrame.OperationId.PLAY
|
||||||
|
assert bytes(parsed) == play_pressed_bytes
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_supported_events():
|
||||||
|
two_devices = TwoDevices()
|
||||||
|
await two_devices.setup_connections()
|
||||||
|
|
||||||
|
supported_events = await two_devices.protocols[0].get_supported_events()
|
||||||
|
assert supported_events == []
|
||||||
|
|
||||||
|
delegate1 = avrcp.Delegate([avrcp.EventId.VOLUME_CHANGED])
|
||||||
|
two_devices.protocols[0].delegate = delegate1
|
||||||
|
supported_events = await two_devices.protocols[1].get_supported_events()
|
||||||
|
assert supported_events == [avrcp.EventId.VOLUME_CHANGED]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test_frame_parser()
|
||||||
|
test_vendor_dependent_command()
|
||||||
|
test_avctp_message_assembler()
|
||||||
|
test_avrcp_pdu_assembler()
|
||||||
|
test_passthrough_commands()
|
||||||
|
test_get_supported_events()
|
||||||
@@ -12,15 +12,20 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from bumble import utils
|
|
||||||
from pyee import EventEmitter
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pyee import EventEmitter
|
||||||
|
|
||||||
|
from bumble import utils
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def test_on() -> None:
|
def test_on() -> None:
|
||||||
emitter = EventEmitter()
|
emitter = EventEmitter()
|
||||||
with contextlib.closing(utils.EventWatcher()) as context:
|
with contextlib.closing(utils.EventWatcher()) as context:
|
||||||
@@ -33,6 +38,7 @@ def test_on() -> None:
|
|||||||
assert mock.call_count == 1
|
assert mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def test_on_decorator() -> None:
|
def test_on_decorator() -> None:
|
||||||
emitter = EventEmitter()
|
emitter = EventEmitter()
|
||||||
with contextlib.closing(utils.EventWatcher()) as context:
|
with contextlib.closing(utils.EventWatcher()) as context:
|
||||||
@@ -48,6 +54,7 @@ def test_on_decorator() -> None:
|
|||||||
assert mock.call_count == 1
|
assert mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def test_multiple_handlers() -> None:
|
def test_multiple_handlers() -> None:
|
||||||
emitter = EventEmitter()
|
emitter = EventEmitter()
|
||||||
with contextlib.closing(utils.EventWatcher()) as context:
|
with contextlib.closing(utils.EventWatcher()) as context:
|
||||||
@@ -64,6 +71,30 @@ def test_multiple_handlers() -> None:
|
|||||||
mock.assert_called_once_with('b')
|
mock.assert_called_once_with('b')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_open_int_enums():
|
||||||
|
class Foo(utils.OpenIntEnum):
|
||||||
|
FOO = 1
|
||||||
|
BAR = 2
|
||||||
|
BLA = 3
|
||||||
|
|
||||||
|
x = Foo(1)
|
||||||
|
assert x.name == "FOO"
|
||||||
|
assert x.value == 1
|
||||||
|
assert int(x) == 1
|
||||||
|
assert x == 1
|
||||||
|
assert x + 1 == 2
|
||||||
|
|
||||||
|
x = Foo(4)
|
||||||
|
assert x.name == "Foo[4]"
|
||||||
|
assert x.value == 4
|
||||||
|
assert int(x) == 4
|
||||||
|
assert x == 4
|
||||||
|
assert x + 1 == 5
|
||||||
|
|
||||||
|
print(list(Foo))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def run_tests():
|
def run_tests():
|
||||||
test_on()
|
test_on()
|
||||||
@@ -75,3 +106,4 @@ def run_tests():
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
run_tests()
|
run_tests()
|
||||||
|
test_open_int_enums()
|
||||||
|
|||||||
Reference in New Issue
Block a user