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.
Files changed (78) hide show
  1. bscontroller/__init__.py +57 -0
  2. bscontroller/__main__.py +6 -0
  3. bscontroller/address.py +204 -0
  4. bscontroller/capabilities.py +16 -0
  5. bscontroller/cli.py +123 -0
  6. bscontroller/colors.py +82 -0
  7. bscontroller/config.py +180 -0
  8. bscontroller/crypto/__init__.py +23 -0
  9. bscontroller/errors.py +131 -0
  10. bscontroller/hci/__init__.py +9 -0
  11. bscontroller/hci/advertising.py +703 -0
  12. bscontroller/hci/backend.py +153 -0
  13. bscontroller/hci/capabilities.py +12 -0
  14. bscontroller/hci/connection.py +516 -0
  15. bscontroller/hci/context.py +76 -0
  16. bscontroller/hci/controller.py +385 -0
  17. bscontroller/hci/events.py +559 -0
  18. bscontroller/hci/le.py +108 -0
  19. bscontroller/hci/packets.py +5838 -0
  20. bscontroller/hci/privacy.py +130 -0
  21. bscontroller/hci/records.py +216 -0
  22. bscontroller/hci/scanning.py +427 -0
  23. bscontroller/hci/snoop.py +60 -0
  24. bscontroller/hci/transport.py +153 -0
  25. bscontroller/linklayer/__init__.py +6 -0
  26. bscontroller/linklayer/advertising.py +486 -0
  27. bscontroller/linklayer/api.py +243 -0
  28. bscontroller/linklayer/bitstream.py +63 -0
  29. bscontroller/linklayer/channel_selection.py +52 -0
  30. bscontroller/linklayer/connection.py +289 -0
  31. bscontroller/linklayer/connection_event.py +452 -0
  32. bscontroller/linklayer/connection_parameters.py +441 -0
  33. bscontroller/linklayer/control.py +656 -0
  34. bscontroller/linklayer/data_length.py +308 -0
  35. bscontroller/linklayer/data_path.py +150 -0
  36. bscontroller/linklayer/decision.py +174 -0
  37. bscontroller/linklayer/discovery.py +262 -0
  38. bscontroller/linklayer/encryption.py +444 -0
  39. bscontroller/linklayer/extended.py +842 -0
  40. bscontroller/linklayer/features.py +174 -0
  41. bscontroller/linklayer/filtering.py +120 -0
  42. bscontroller/linklayer/instant.py +24 -0
  43. bscontroller/linklayer/link.py +225 -0
  44. bscontroller/linklayer/listener.py +124 -0
  45. bscontroller/linklayer/llcp.py +473 -0
  46. bscontroller/linklayer/notifications.py +239 -0
  47. bscontroller/linklayer/parameters.py +126 -0
  48. bscontroller/linklayer/pdu.py +437 -0
  49. bscontroller/linklayer/periodic.py +121 -0
  50. bscontroller/linklayer/phy.py +234 -0
  51. bscontroller/linklayer/primary.py +475 -0
  52. bscontroller/linklayer/privacy.py +314 -0
  53. bscontroller/linklayer/procedure.py +77 -0
  54. bscontroller/linklayer/resource.py +102 -0
  55. bscontroller/linklayer/results.py +65 -0
  56. bscontroller/linklayer/roles.py +637 -0
  57. bscontroller/linklayer/scheduler.py +75 -0
  58. bscontroller/linklayer/service.py +824 -0
  59. bscontroller/linklayer/state.py +39 -0
  60. bscontroller/linklayer/timing.py +48 -0
  61. bscontroller/py.typed +0 -0
  62. bscontroller/radio/__init__.py +8 -0
  63. bscontroller/radio/babblesim.py +691 -0
  64. bscontroller/radio/base.py +168 -0
  65. bscontroller/radio/packet.py +41 -0
  66. bscontroller/runtime/__init__.py +8 -0
  67. bscontroller/runtime/event_loop.py +203 -0
  68. bscontroller/runtime/phy.py +94 -0
  69. bscontroller/runtime/session.py +99 -0
  70. bscontroller/runtime/worker.py +249 -0
  71. bscontroller/serialization.py +134 -0
  72. bscontroller-0.1.0.dist-info/METADATA +260 -0
  73. bscontroller-0.1.0.dist-info/RECORD +78 -0
  74. bscontroller-0.1.0.dist-info/WHEEL +5 -0
  75. bscontroller-0.1.0.dist-info/entry_points.txt +2 -0
  76. bscontroller-0.1.0.dist-info/licenses/LICENSE +221 -0
  77. bscontroller-0.1.0.dist-info/licenses/NOTICE +14 -0
  78. bscontroller-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
+ ]
@@ -0,0 +1,6 @@
1
+ # Copyright 2026 Xiaomi Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from .cli import main
5
+
6
+ main()
@@ -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]