bscontroller 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bscontroller/__init__.py +57 -0
- bscontroller/__main__.py +6 -0
- bscontroller/address.py +204 -0
- bscontroller/capabilities.py +16 -0
- bscontroller/cli.py +123 -0
- bscontroller/colors.py +82 -0
- bscontroller/config.py +180 -0
- bscontroller/crypto/__init__.py +23 -0
- bscontroller/errors.py +131 -0
- bscontroller/hci/__init__.py +9 -0
- bscontroller/hci/advertising.py +703 -0
- bscontroller/hci/backend.py +153 -0
- bscontroller/hci/capabilities.py +12 -0
- bscontroller/hci/connection.py +516 -0
- bscontroller/hci/context.py +76 -0
- bscontroller/hci/controller.py +385 -0
- bscontroller/hci/events.py +559 -0
- bscontroller/hci/le.py +108 -0
- bscontroller/hci/packets.py +5838 -0
- bscontroller/hci/privacy.py +130 -0
- bscontroller/hci/records.py +216 -0
- bscontroller/hci/scanning.py +427 -0
- bscontroller/hci/snoop.py +60 -0
- bscontroller/hci/transport.py +153 -0
- bscontroller/linklayer/__init__.py +6 -0
- bscontroller/linklayer/advertising.py +486 -0
- bscontroller/linklayer/api.py +243 -0
- bscontroller/linklayer/bitstream.py +63 -0
- bscontroller/linklayer/channel_selection.py +52 -0
- bscontroller/linklayer/connection.py +289 -0
- bscontroller/linklayer/connection_event.py +452 -0
- bscontroller/linklayer/connection_parameters.py +441 -0
- bscontroller/linklayer/control.py +656 -0
- bscontroller/linklayer/data_length.py +308 -0
- bscontroller/linklayer/data_path.py +150 -0
- bscontroller/linklayer/decision.py +174 -0
- bscontroller/linklayer/discovery.py +262 -0
- bscontroller/linklayer/encryption.py +444 -0
- bscontroller/linklayer/extended.py +842 -0
- bscontroller/linklayer/features.py +174 -0
- bscontroller/linklayer/filtering.py +120 -0
- bscontroller/linklayer/instant.py +24 -0
- bscontroller/linklayer/link.py +225 -0
- bscontroller/linklayer/listener.py +124 -0
- bscontroller/linklayer/llcp.py +473 -0
- bscontroller/linklayer/notifications.py +239 -0
- bscontroller/linklayer/parameters.py +126 -0
- bscontroller/linklayer/pdu.py +437 -0
- bscontroller/linklayer/periodic.py +121 -0
- bscontroller/linklayer/phy.py +234 -0
- bscontroller/linklayer/primary.py +475 -0
- bscontroller/linklayer/privacy.py +314 -0
- bscontroller/linklayer/procedure.py +77 -0
- bscontroller/linklayer/resource.py +102 -0
- bscontroller/linklayer/results.py +65 -0
- bscontroller/linklayer/roles.py +637 -0
- bscontroller/linklayer/scheduler.py +75 -0
- bscontroller/linklayer/service.py +824 -0
- bscontroller/linklayer/state.py +39 -0
- bscontroller/linklayer/timing.py +48 -0
- bscontroller/py.typed +0 -0
- bscontroller/radio/__init__.py +8 -0
- bscontroller/radio/babblesim.py +691 -0
- bscontroller/radio/base.py +168 -0
- bscontroller/radio/packet.py +41 -0
- bscontroller/runtime/__init__.py +8 -0
- bscontroller/runtime/event_loop.py +203 -0
- bscontroller/runtime/phy.py +94 -0
- bscontroller/runtime/session.py +99 -0
- bscontroller/runtime/worker.py +249 -0
- bscontroller/serialization.py +134 -0
- bscontroller-0.1.0.dist-info/METADATA +260 -0
- bscontroller-0.1.0.dist-info/RECORD +78 -0
- bscontroller-0.1.0.dist-info/WHEEL +5 -0
- bscontroller-0.1.0.dist-info/entry_points.txt +2 -0
- bscontroller-0.1.0.dist-info/licenses/LICENSE +221 -0
- bscontroller-0.1.0.dist-info/licenses/NOTICE +14 -0
- bscontroller-0.1.0.dist-info/top_level.txt +1 -0
bscontroller/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Standalone Bluetooth LE radio Controller and synchronized runtime."""
|
|
5
|
+
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from . import hci as hci
|
|
12
|
+
from .config import ControllerConfig as ControllerConfig
|
|
13
|
+
from .radio.base import Phy as Phy
|
|
14
|
+
from .radio.base import Radio as Radio
|
|
15
|
+
from .radio.base import Received as Received
|
|
16
|
+
from .runtime.event_loop import RadioEventLoop as RadioEventLoop
|
|
17
|
+
from .runtime.session import BabbleSimSession as RadioSession
|
|
18
|
+
from .runtime.worker import RadioControllerWorker as RadioController
|
|
19
|
+
|
|
20
|
+
# Importing a protocol module must not construct the HCI/runtime dependency tree.
|
|
21
|
+
# Keep the public convenience exports, loading their implementation when requested.
|
|
22
|
+
_EXPORTS = {
|
|
23
|
+
'hci': ('.hci', None),
|
|
24
|
+
'ControllerConfig': ('.config', 'ControllerConfig'),
|
|
25
|
+
'Phy': ('.radio.base', 'Phy'),
|
|
26
|
+
'Radio': ('.radio.base', 'Radio'),
|
|
27
|
+
'Received': ('.radio.base', 'Received'),
|
|
28
|
+
'RadioEventLoop': ('.runtime.event_loop', 'RadioEventLoop'),
|
|
29
|
+
'RadioSession': ('.runtime.session', 'BabbleSimSession'),
|
|
30
|
+
'RadioController': ('.runtime.worker', 'RadioControllerWorker'),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __getattr__(name: str):
|
|
35
|
+
if name not in _EXPORTS:
|
|
36
|
+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
|
|
37
|
+
module, attribute = _EXPORTS[name]
|
|
38
|
+
imported = import_module(module, __name__)
|
|
39
|
+
value = getattr(imported, attribute) if attribute else imported
|
|
40
|
+
globals()[name] = value
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
__version__ = version('bscontroller')
|
|
46
|
+
except PackageNotFoundError:
|
|
47
|
+
__version__ = '0+unknown'
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
'Phy',
|
|
51
|
+
'Radio',
|
|
52
|
+
'RadioController',
|
|
53
|
+
'RadioEventLoop',
|
|
54
|
+
'RadioSession',
|
|
55
|
+
'Received',
|
|
56
|
+
'hci',
|
|
57
|
+
]
|
bscontroller/__main__.py
ADDED
bscontroller/address.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Copyright 2021-2025 Google LLC
|
|
2
|
+
# Copyright 2026 Xiaomi Corporation
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Bluetooth device addresses (Core 6.3, Vol 6, Part B, 1.3).
|
|
6
|
+
|
|
7
|
+
Address type tags are also encoded at the HCI service boundary.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, ClassVar, Self
|
|
13
|
+
|
|
14
|
+
from bscontroller.errors import InvalidArgumentError
|
|
15
|
+
from bscontroller.serialization import SpecableEnum
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OwnAddressType(SpecableEnum):
|
|
19
|
+
"""Own-address selection (Core Vol 4, Part E, 7.8.5/10/12/53/64/66)."""
|
|
20
|
+
|
|
21
|
+
PUBLIC = 0x00
|
|
22
|
+
RANDOM = 0x01
|
|
23
|
+
RESOLVABLE_OR_PUBLIC = 0x02
|
|
24
|
+
RESOLVABLE_OR_RANDOM = 0x03
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def uses_random_fallback(self) -> bool:
|
|
28
|
+
return self in (self.RANDOM, self.RESOLVABLE_OR_RANDOM)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def uses_resolvable_address(self) -> bool:
|
|
32
|
+
return self in (self.RESOLVABLE_OR_PUBLIC, self.RESOLVABLE_OR_RANDOM)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PrivacyMode(SpecableEnum):
|
|
36
|
+
"""Peer identity acceptance policy (Core Vol 6, Part B, 4.7)."""
|
|
37
|
+
|
|
38
|
+
NETWORK_PRIVACY_MODE = 0x00
|
|
39
|
+
DEVICE_PRIVACY_MODE = 0x01
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Vol 6, Part B, 1.3.2.2: an RPA contains a 24-bit hash and 24-bit prand.
|
|
43
|
+
RPA_HASH_SIZE = 3
|
|
44
|
+
RPA_PRAND_SIZE = 3
|
|
45
|
+
RPA_PRAND_RANDOM_BITS = 22
|
|
46
|
+
RPA_PRAND_TYPE_TAG = 0b01
|
|
47
|
+
RANDOM_ADDRESS_TYPE_SHIFT = 6
|
|
48
|
+
# Vol 6, Part B, 1.3.2, Table 1.2: address[47:46] = 0b11 marks a static address.
|
|
49
|
+
STATIC_ADDRESS_TYPE_TAG = 0b11
|
|
50
|
+
RPA_PRAND_RESOLVABLE_MASK = RPA_PRAND_TYPE_TAG << RPA_PRAND_RANDOM_BITS
|
|
51
|
+
IRK_SIZE = 16
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AddressType(SpecableEnum):
|
|
55
|
+
PUBLIC_DEVICE = 0x00
|
|
56
|
+
RANDOM_DEVICE = 0x01
|
|
57
|
+
PUBLIC_IDENTITY = 0x02
|
|
58
|
+
RANDOM_IDENTITY = 0x03
|
|
59
|
+
# (Directed Only) Address is RPA, but controller cannot resolve.
|
|
60
|
+
UNABLE_TO_RESOLVE = 0xFE
|
|
61
|
+
# (Extended Only) No address.
|
|
62
|
+
ANONYMOUS = 0xFF
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Address:
|
|
66
|
+
'''
|
|
67
|
+
Bluetooth Address (see Bluetooth spec Vol 6, Part B - 1.3 DEVICE ADDRESS)
|
|
68
|
+
NOTE: the address bytes are stored in little-endian byte order here, so
|
|
69
|
+
address[0] is the LSB of the address, address[5] is the MSB.
|
|
70
|
+
'''
|
|
71
|
+
|
|
72
|
+
PUBLIC_DEVICE_ADDRESS = AddressType.PUBLIC_DEVICE
|
|
73
|
+
RANDOM_DEVICE_ADDRESS = AddressType.RANDOM_DEVICE
|
|
74
|
+
PUBLIC_IDENTITY_ADDRESS = AddressType.PUBLIC_IDENTITY
|
|
75
|
+
RANDOM_IDENTITY_ADDRESS = AddressType.RANDOM_IDENTITY
|
|
76
|
+
|
|
77
|
+
# Type declarations
|
|
78
|
+
NIL: ClassVar[Address]
|
|
79
|
+
ANY: ClassVar[Address]
|
|
80
|
+
ANY_RANDOM: ClassVar[Address]
|
|
81
|
+
|
|
82
|
+
# pylint: disable-next=unnecessary-lambda
|
|
83
|
+
ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def address_type_name(cls: type[Self], address_type: int) -> str:
|
|
87
|
+
return AddressType(address_type).name
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def parse_address(cls: type[Self], data: bytes, offset: int) -> tuple[int, Self]:
|
|
91
|
+
# Fix the type to a default value. This is used for parsing type-less Classic
|
|
92
|
+
# addresses
|
|
93
|
+
return cls.parse_address_with_type(data, offset, Address.PUBLIC_DEVICE_ADDRESS)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def parse_random_address(
|
|
97
|
+
cls: type[Self], data: bytes, offset: int
|
|
98
|
+
) -> tuple[int, Self]:
|
|
99
|
+
return cls.parse_address_with_type(data, offset, Address.RANDOM_DEVICE_ADDRESS)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def parse_address_with_type(
|
|
103
|
+
cls: type[Self], data: bytes, offset: int, address_type: AddressType
|
|
104
|
+
) -> tuple[int, Self]:
|
|
105
|
+
return offset + 6, cls(data[offset : offset + 6], address_type)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def parse_address_preceded_by_type(
|
|
109
|
+
cls: type[Self], data: bytes, offset: int
|
|
110
|
+
) -> tuple[int, Self]:
|
|
111
|
+
address_type = AddressType(data[offset - 1])
|
|
112
|
+
return cls.parse_address_with_type(data, offset, address_type)
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
address: bytes | str,
|
|
117
|
+
address_type: AddressType = RANDOM_DEVICE_ADDRESS,
|
|
118
|
+
) -> None:
|
|
119
|
+
'''
|
|
120
|
+
Initialize an instance. `address` may be a byte array in little-endian
|
|
121
|
+
format, or a hex string in big-endian format (with optional ':'
|
|
122
|
+
separators between the bytes).
|
|
123
|
+
If the address is a string suffixed with '/P', `address_type` is ignored and
|
|
124
|
+
the type is set to PUBLIC_DEVICE_ADDRESS.
|
|
125
|
+
'''
|
|
126
|
+
if isinstance(address, bytes):
|
|
127
|
+
self.address_bytes = address
|
|
128
|
+
else:
|
|
129
|
+
# Check if there's a '/P' type specifier
|
|
130
|
+
if address.endswith('P'):
|
|
131
|
+
address_type = Address.PUBLIC_DEVICE_ADDRESS
|
|
132
|
+
address = address[:-2]
|
|
133
|
+
|
|
134
|
+
if len(address) == 12 + 5:
|
|
135
|
+
# Form with ':' separators
|
|
136
|
+
address = address.replace(':', '')
|
|
137
|
+
|
|
138
|
+
self.address_bytes = bytes(reversed(bytes.fromhex(address)))
|
|
139
|
+
|
|
140
|
+
if len(self.address_bytes) != 6:
|
|
141
|
+
raise InvalidArgumentError('invalid address length')
|
|
142
|
+
|
|
143
|
+
self.address_type = address_type
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def is_public(self) -> bool:
|
|
147
|
+
return self.address_type in (
|
|
148
|
+
self.PUBLIC_DEVICE_ADDRESS,
|
|
149
|
+
self.PUBLIC_IDENTITY_ADDRESS,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_random(self) -> bool:
|
|
154
|
+
return not self.is_public
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def is_resolvable(self) -> bool:
|
|
158
|
+
return self.address_type == self.RANDOM_DEVICE_ADDRESS and (
|
|
159
|
+
self.address_bytes[-1] >> RANDOM_ADDRESS_TYPE_SHIFT == RPA_PRAND_TYPE_TAG
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def is_static(self) -> bool:
|
|
164
|
+
return self.address_type == self.RANDOM_DEVICE_ADDRESS and (
|
|
165
|
+
self.address_bytes[-1] >> RANDOM_ADDRESS_TYPE_SHIFT
|
|
166
|
+
== STATIC_ADDRESS_TYPE_TAG
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def to_string(self, with_type_qualifier: bool = True) -> str:
|
|
170
|
+
'''
|
|
171
|
+
String representation of the address, MSB first, with an optional type
|
|
172
|
+
qualifier.
|
|
173
|
+
'''
|
|
174
|
+
result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
|
|
175
|
+
if not with_type_qualifier or not self.is_public:
|
|
176
|
+
return result
|
|
177
|
+
return result + '/P'
|
|
178
|
+
|
|
179
|
+
def __bytes__(self) -> bytes:
|
|
180
|
+
return self.address_bytes
|
|
181
|
+
|
|
182
|
+
def __hash__(self) -> int:
|
|
183
|
+
return hash(self.address_bytes)
|
|
184
|
+
|
|
185
|
+
def __eq__(self, other: Any) -> bool:
|
|
186
|
+
return (
|
|
187
|
+
isinstance(other, Address)
|
|
188
|
+
and self.address_bytes == other.address_bytes
|
|
189
|
+
and self.is_public == other.is_public
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def __str__(self) -> str:
|
|
193
|
+
return self.to_string()
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
return (
|
|
197
|
+
f'Address({self.to_string(False)}/'
|
|
198
|
+
f'{self.address_type_name(self.address_type)})'
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
Address.NIL = Address(b"\xff" * 6, Address.PUBLIC_DEVICE_ADDRESS)
|
|
203
|
+
Address.ANY = Address(bytes(6), Address.PUBLIC_DEVICE_ADDRESS)
|
|
204
|
+
Address.ANY_RANDOM = Address(bytes(6), Address.RANDOM_DEVICE_ADDRESS)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Implementation capabilities fixed by this Controller (PHYs, codec limits).
|
|
5
|
+
|
|
6
|
+
Values the specification lets an implementation choose, such as buffer and list
|
|
7
|
+
sizes, are configurable through :class:`bscontroller.config.ControllerConfig`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
CONTROLLER_SUPPORTED_PHY_MASK = 0b111
|
|
11
|
+
|
|
12
|
+
CONTROLLER_PRIMARY_SCAN_PHY_MASK = 0b101
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Maximum combined extended advertising data retained by this Controller.
|
|
16
|
+
CONTROLLER_ADV_DATA_SIZE_MAX = 1650
|
bscontroller/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Independent native simulation and external-Host entry points."""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import signal
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import nullcontext
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import RadioController, RadioEventLoop, RadioSession, __version__
|
|
15
|
+
from .config import ControllerConfig
|
|
16
|
+
from .runtime import PhyProcess
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def serve(args):
|
|
20
|
+
controller = RadioController(
|
|
21
|
+
args.name,
|
|
22
|
+
args.address,
|
|
23
|
+
hci_snoop_file=args.btsnoop_file,
|
|
24
|
+
hci_socket=args.hci_socket,
|
|
25
|
+
config=args.config,
|
|
26
|
+
)
|
|
27
|
+
async with RadioSession(
|
|
28
|
+
args.sim_id, [(args.dev_id, controller)], clock_dev_id=args.clock_id
|
|
29
|
+
):
|
|
30
|
+
print(f'HCI listening on {args.hci_socket}', flush=True)
|
|
31
|
+
await controller.terminated
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def run(args):
|
|
35
|
+
loop = asyncio.get_running_loop()
|
|
36
|
+
task = asyncio.current_task()
|
|
37
|
+
loop.add_signal_handler(signal.SIGTERM, task.cancel)
|
|
38
|
+
try:
|
|
39
|
+
await serve(args)
|
|
40
|
+
finally:
|
|
41
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main():
|
|
45
|
+
parser = argparse.ArgumentParser(description='BSController Bluetooth LE Controller')
|
|
46
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
47
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
48
|
+
commands.add_parser(
|
|
49
|
+
'config', help='print a JSON configuration with every default value'
|
|
50
|
+
)
|
|
51
|
+
for name, help_text in [('serve', 'serve H4 HCI over a Unix socket')]:
|
|
52
|
+
command = commands.add_parser(name, help=help_text)
|
|
53
|
+
command.add_argument(
|
|
54
|
+
'--bsim-dir',
|
|
55
|
+
type=Path,
|
|
56
|
+
help='BabbleSim build output (default: BSIM_OUT_PATH)',
|
|
57
|
+
)
|
|
58
|
+
command.add_argument('--sim-id', default=None)
|
|
59
|
+
command.add_argument('--log-dir', type=Path)
|
|
60
|
+
server = commands.choices['serve']
|
|
61
|
+
server.add_argument('--hci-socket', type=Path, required=True)
|
|
62
|
+
server.add_argument(
|
|
63
|
+
'--btsnoop-file',
|
|
64
|
+
type=Path,
|
|
65
|
+
help='write HCI BTSnoop using the Controller simulation clock',
|
|
66
|
+
)
|
|
67
|
+
server.add_argument('--address', default='F0:F1:F2:F3:F4:01')
|
|
68
|
+
server.add_argument(
|
|
69
|
+
'--join', action='store_true', help='join a PHY managed by the caller'
|
|
70
|
+
)
|
|
71
|
+
server.add_argument(
|
|
72
|
+
'--config',
|
|
73
|
+
type=Path,
|
|
74
|
+
help='JSON Controller configuration (see "bscontroller config")',
|
|
75
|
+
)
|
|
76
|
+
server.add_argument(
|
|
77
|
+
'--name',
|
|
78
|
+
default='controller',
|
|
79
|
+
help='select this entry under "controllers" in --config',
|
|
80
|
+
)
|
|
81
|
+
server.add_argument('--dev-id', type=int, default=0)
|
|
82
|
+
server.add_argument('--clock-id', type=int, default=1)
|
|
83
|
+
args = parser.parse_args()
|
|
84
|
+
if args.command == 'config':
|
|
85
|
+
print(ControllerConfig.template())
|
|
86
|
+
return
|
|
87
|
+
if args.command == 'serve':
|
|
88
|
+
if args.dev_id < 0 or args.clock_id < 0 or args.dev_id == args.clock_id:
|
|
89
|
+
parser.error(
|
|
90
|
+
'Controller and clock IDs must be distinct nonnegative integers'
|
|
91
|
+
)
|
|
92
|
+
if args.join and args.sim_id is None:
|
|
93
|
+
parser.error('--join requires --sim-id and matching PHY device identities')
|
|
94
|
+
if not args.join and (args.dev_id, args.clock_id) != (0, 1):
|
|
95
|
+
parser.error('managed PHY mode uses --dev-id 0 and --clock-id 1')
|
|
96
|
+
args.sim_id = args.sim_id or ('bscontroller_' + uuid.uuid4().hex[:12])
|
|
97
|
+
args.log_dir = args.log_dir or Path('/tmp') / args.sim_id
|
|
98
|
+
logging.basicConfig(level=logging.WARNING)
|
|
99
|
+
if args.config is not None and not args.config.is_file():
|
|
100
|
+
parser.error(f'configuration file not found: {args.config}')
|
|
101
|
+
try:
|
|
102
|
+
owner = (
|
|
103
|
+
nullcontext()
|
|
104
|
+
if args.command == 'serve' and args.join
|
|
105
|
+
else PhyProcess(
|
|
106
|
+
args.sim_id,
|
|
107
|
+
2,
|
|
108
|
+
directory=args.bsim_dir,
|
|
109
|
+
log_dir=args.log_dir,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
with owner, asyncio.Runner(loop_factory=RadioEventLoop) as runner:
|
|
113
|
+
runner.run(run(args))
|
|
114
|
+
except asyncio.CancelledError:
|
|
115
|
+
return
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
parser.exit(130)
|
|
118
|
+
except (ValueError, FileExistsError, RuntimeError, TimeoutError, OSError) as error:
|
|
119
|
+
parser.exit(1, f'bscontroller: {error}\n')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == '__main__':
|
|
123
|
+
main()
|
bscontroller/colors.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Adapted for the independent BSController package.
|
|
2
|
+
# Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com>
|
|
3
|
+
#
|
|
4
|
+
# Permission to use, copy, modify, and distribute this software for any
|
|
5
|
+
# purpose with or without fee is hereby granted, provided that the above
|
|
6
|
+
# copyright notice and this permission notice appear in all copies.
|
|
7
|
+
#
|
|
8
|
+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
9
|
+
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
10
|
+
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
11
|
+
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
12
|
+
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
13
|
+
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
14
|
+
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ColorError(ValueError):
|
|
18
|
+
"""Error raised when a color spec is invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ANSI color names. There is also a "default"
|
|
22
|
+
COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
|
|
23
|
+
|
|
24
|
+
# ANSI style names
|
|
25
|
+
STYLES = (
|
|
26
|
+
'none',
|
|
27
|
+
'bold',
|
|
28
|
+
'faint',
|
|
29
|
+
'italic',
|
|
30
|
+
'underline',
|
|
31
|
+
'blink',
|
|
32
|
+
'blink2',
|
|
33
|
+
'negative',
|
|
34
|
+
'concealed',
|
|
35
|
+
'crossed',
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ColorSpec = str | int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _join(*values: ColorSpec) -> str:
|
|
43
|
+
return ';'.join(str(v) for v in values)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _color_code(spec: ColorSpec, base: int) -> str:
|
|
47
|
+
if isinstance(spec, str):
|
|
48
|
+
spec = spec.strip().lower()
|
|
49
|
+
|
|
50
|
+
if spec == 'default':
|
|
51
|
+
return _join(base + 9)
|
|
52
|
+
elif spec in COLORS:
|
|
53
|
+
return _join(base + COLORS.index(spec))
|
|
54
|
+
elif isinstance(spec, int) and 0 <= spec <= 255:
|
|
55
|
+
return _join(base + 8, 5, spec)
|
|
56
|
+
else:
|
|
57
|
+
raise ColorError(f'Invalid color spec "{spec}"')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def color(
|
|
61
|
+
s: str,
|
|
62
|
+
fg: ColorSpec | None = None,
|
|
63
|
+
bg: ColorSpec | None = None,
|
|
64
|
+
style: str | None = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
codes: list[ColorSpec] = []
|
|
67
|
+
|
|
68
|
+
if fg:
|
|
69
|
+
codes.append(_color_code(fg, 30))
|
|
70
|
+
if bg:
|
|
71
|
+
codes.append(_color_code(bg, 40))
|
|
72
|
+
if style:
|
|
73
|
+
for style_part in style.split('+'):
|
|
74
|
+
if style_part in STYLES:
|
|
75
|
+
codes.append(STYLES.index(style_part))
|
|
76
|
+
else:
|
|
77
|
+
raise ColorError(f'Invalid style "{style_part}"')
|
|
78
|
+
|
|
79
|
+
if codes:
|
|
80
|
+
return f'\x1b[{_join(*codes)}m{s}\x1b[0m'
|
|
81
|
+
else:
|
|
82
|
+
return s
|
bscontroller/config.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Controller-defined parameters within their Core specification ranges.
|
|
5
|
+
|
|
6
|
+
Precedence: built-in defaults, ``defaults``, ``controllers.<name>``, keyword
|
|
7
|
+
arguments. JSON keys are camelCase or snake_case. Invalid or unknown entries are
|
|
8
|
+
skipped with a warning; direct construction with an invalid value raises.
|
|
9
|
+
|
|
10
|
+
Example file::
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"defaults": {"aclPacketCount": 16},
|
|
14
|
+
"controllers": {"hci0": {"maxConnections": 2}}
|
|
15
|
+
}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from dataclasses import asdict, dataclass, field, fields, replace
|
|
24
|
+
from os import PathLike
|
|
25
|
+
from typing import Any, Union
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _option(default: int, low: int, high: int) -> Any:
|
|
31
|
+
"""A field with its inclusive specification range."""
|
|
32
|
+
return field(default=default, metadata={'range': (low, high)})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ControllerConfig:
|
|
37
|
+
"""Controller-defined values; comments cite Core Vol 4, Part E."""
|
|
38
|
+
|
|
39
|
+
# 7.4.1 Read Local Version Information. Default Core 6.1 includes
|
|
40
|
+
# decision filtering and the v2 randomized RPA timeout command.
|
|
41
|
+
hci_version: int = _option(0x0F, 0x06, 0x10)
|
|
42
|
+
hci_revision: int = _option(0, 0, 0xFFFF)
|
|
43
|
+
lmp_version: int = _option(0x0F, 0x06, 0x10)
|
|
44
|
+
lmp_subversion: int = _option(0, 0, 0xFFFF)
|
|
45
|
+
manufacturer: int = _option(0xFFFF, 0, 0xFFFF)
|
|
46
|
+
# 7.8.2 LE Read Buffer Size.
|
|
47
|
+
acl_packet_count: int = _option(8, 1, 0xFF)
|
|
48
|
+
acl_packet_length: int = _option(251, 0x1B, 0xFFFF)
|
|
49
|
+
# 7.8.46 LE Read Maximum Data Length.
|
|
50
|
+
max_tx_octets: int = _option(251, 0x1B, 0xFB)
|
|
51
|
+
max_tx_time: int = _option(17040, 0x148, 0x4290)
|
|
52
|
+
max_rx_octets: int = _option(251, 0x1B, 0xFB)
|
|
53
|
+
max_rx_time: int = _option(17040, 0x148, 0x4290)
|
|
54
|
+
# 7.8.58 / 7.8.57 advertising sets and data.
|
|
55
|
+
advertising_set_count: int = _option(16, 1, 0xF0)
|
|
56
|
+
max_advertising_data_length: int = _option(1650, 0x1F, 0x672)
|
|
57
|
+
# 7.8.14 / 7.8.73 list sizes.
|
|
58
|
+
filter_accept_list_size: int = _option(16, 1, 0xFF)
|
|
59
|
+
# 7.8.41 LE Read Resolving List Size.
|
|
60
|
+
resolving_list_size: int = _option(16, 1, 0xFF)
|
|
61
|
+
periodic_advertiser_list_size: int = _option(8, 1, 0xFF)
|
|
62
|
+
# Concurrency limits; 5.3.1 bounds them by the 0x0000-0x0EFF handle space.
|
|
63
|
+
max_connections: int = _option(64, 1, 0x0EFF)
|
|
64
|
+
max_periodic_syncs: int = _option(16, 1, 0x0EFF)
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
for item in fields(self):
|
|
68
|
+
value = getattr(self, item.name)
|
|
69
|
+
if not _valid(item.name, value):
|
|
70
|
+
low, high = RANGES[item.name]
|
|
71
|
+
raise ValueError(f'{item.name}={value!r} is outside {low}-{high}')
|
|
72
|
+
|
|
73
|
+
def merged(
|
|
74
|
+
self, values: dict[str, Any] | None, source: str = 'config'
|
|
75
|
+
) -> ControllerConfig:
|
|
76
|
+
"""Apply ``values``; invalid entries keep the current value."""
|
|
77
|
+
changes = {}
|
|
78
|
+
for key, value in (values or {}).items():
|
|
79
|
+
name = _NAMES.get(key) if isinstance(key, str) else None
|
|
80
|
+
if name is None:
|
|
81
|
+
logger.warning('%s: unknown Controller option %r ignored', source, key)
|
|
82
|
+
elif not _valid(name, value):
|
|
83
|
+
low, high = RANGES[name]
|
|
84
|
+
logger.warning(
|
|
85
|
+
'%s: %s=%r is outside %d-%d; keeping %r',
|
|
86
|
+
source,
|
|
87
|
+
key,
|
|
88
|
+
value,
|
|
89
|
+
low,
|
|
90
|
+
high,
|
|
91
|
+
changes.get(name, getattr(self, name)),
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
changes[name] = int(value)
|
|
95
|
+
return replace(self, **changes)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_mapping(
|
|
99
|
+
cls, document: Any, name: str | None = None, **overrides
|
|
100
|
+
) -> ControllerConfig:
|
|
101
|
+
"""Build a configuration from a ``{defaults, controllers}`` document."""
|
|
102
|
+
if document is None:
|
|
103
|
+
document = {}
|
|
104
|
+
elif not isinstance(document, dict):
|
|
105
|
+
logger.warning('Controller configuration must be a JSON object')
|
|
106
|
+
document = {}
|
|
107
|
+
for key in document.keys() - {'defaults', 'controllers'}:
|
|
108
|
+
logger.warning('unknown Controller configuration section %r', key)
|
|
109
|
+
config = cls().merged(_section(document, 'defaults'), 'defaults')
|
|
110
|
+
if name is not None:
|
|
111
|
+
entry = _section(_section(document, 'controllers'), name)
|
|
112
|
+
config = config.merged(entry, f'controllers.{name}')
|
|
113
|
+
return config.merged(overrides, 'arguments')
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def load(
|
|
117
|
+
cls, path: str | PathLike[str], name: str | None = None, **overrides
|
|
118
|
+
) -> ControllerConfig:
|
|
119
|
+
"""Read a JSON file. A missing or unreadable file raises ``OSError``;
|
|
120
|
+
malformed JSON raises ``ValueError``.
|
|
121
|
+
"""
|
|
122
|
+
with open(path, encoding='utf-8') as source:
|
|
123
|
+
document = json.load(source)
|
|
124
|
+
return cls.from_mapping(document, name, **overrides)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def resolve(cls, source: ConfigSource, name: str | None) -> ControllerConfig:
|
|
128
|
+
"""The configuration for the Controller called ``name``.
|
|
129
|
+
|
|
130
|
+
A ``ControllerConfig`` is used as given. A ``{defaults, controllers}``
|
|
131
|
+
mapping, or the path of such a JSON file, applies ``defaults`` and then
|
|
132
|
+
the entry for ``name``. ``None`` gives the built-in defaults.
|
|
133
|
+
"""
|
|
134
|
+
if source is None:
|
|
135
|
+
return cls()
|
|
136
|
+
if isinstance(source, ControllerConfig):
|
|
137
|
+
return source
|
|
138
|
+
if isinstance(source, Mapping):
|
|
139
|
+
return cls.from_mapping(dict(source), name)
|
|
140
|
+
return cls.load(source, name)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def template(cls) -> str:
|
|
144
|
+
"""A complete JSON document with every default, for editing."""
|
|
145
|
+
defaults = {_camel(key): value for key, value in asdict(cls()).items()}
|
|
146
|
+
return json.dumps({'defaults': defaults, 'controllers': {}}, indent=2)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
ConfigSource = Union[ControllerConfig, Mapping[str, Any], str, PathLike[str], None]
|
|
150
|
+
"""Anything ``ControllerConfig.resolve`` accepts."""
|
|
151
|
+
|
|
152
|
+
RANGES: dict[str, tuple[int, int]] = {
|
|
153
|
+
item.name: item.metadata['range'] for item in fields(ControllerConfig)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _valid(name: str, value: object) -> bool:
|
|
158
|
+
"""Integers (including int enums) within range; bool and floats are not."""
|
|
159
|
+
low, high = RANGES[name]
|
|
160
|
+
return (
|
|
161
|
+
isinstance(value, int) and not isinstance(value, bool) and low <= value <= high
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _camel(name: str) -> str:
|
|
166
|
+
head, *rest = name.split('_')
|
|
167
|
+
return head + ''.join(part.title() for part in rest)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_NAMES = {name: name for name in RANGES} | {_camel(name): name for name in RANGES}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _section(document: dict, key: str) -> dict:
|
|
174
|
+
value = document.get(key)
|
|
175
|
+
if value is None:
|
|
176
|
+
return {}
|
|
177
|
+
if not isinstance(value, dict):
|
|
178
|
+
logger.warning('Controller configuration %r must be an object', key)
|
|
179
|
+
return {}
|
|
180
|
+
return value
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Copyright 2021-2025 Google LLC
|
|
2
|
+
# Copyright 2026 Xiaomi Corporation
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""AES-128 primitive used by the LE Controller.
|
|
6
|
+
|
|
7
|
+
Core 6.3, Vol 4, Part E, 7.8.22 and Vol 6, Part E.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives import ciphers
|
|
11
|
+
from cryptography.hazmat.primitives.ciphers import algorithms, modes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def e(key: bytes, data: bytes) -> bytes:
|
|
15
|
+
'''
|
|
16
|
+
AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
|
|
17
|
+
|
|
18
|
+
See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
|
|
19
|
+
'''
|
|
20
|
+
|
|
21
|
+
cipher = ciphers.Cipher(algorithms.AES(key[::-1]), modes.ECB())
|
|
22
|
+
encryptor = cipher.encryptor()
|
|
23
|
+
return encryptor.update(data[::-1])[::-1]
|