bsworld 1.1.3__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.
- bsworld/__init__.py +31 -0
- bsworld/backends/__init__.py +14 -0
- bsworld/backends/babblesim.py +461 -0
- bsworld/backends/ellisys.py +66 -0
- bsworld/deps.py +100 -0
- bsworld/devices/__init__.py +32 -0
- bsworld/devices/bscontroller.py +92 -0
- bsworld/devices/controller.py +81 -0
- bsworld/devices/instance.py +152 -0
- bsworld/devices/layout.py +208 -0
- bsworld/devices/zephyr.py +71 -0
- bsworld/env.py +120 -0
- bsworld/interference.py +390 -0
- bsworld/log.py +73 -0
- bsworld/plugin.py +155 -0
- bsworld/runtime/__init__.py +14 -0
- bsworld/runtime/pipe.py +185 -0
- bsworld/runtime/proc.py +227 -0
- bsworld/runtime/scheduler/__init__.py +34 -0
- bsworld/runtime/scheduler/babblesim.py +186 -0
- bsworld/runtime/shell.py +83 -0
- bsworld/runtime/wait.py +85 -0
- bsworld/topology.py +308 -0
- bsworld-1.1.3.dist-info/METADATA +183 -0
- bsworld-1.1.3.dist-info/RECORD +29 -0
- bsworld-1.1.3.dist-info/WHEEL +5 -0
- bsworld-1.1.3.dist-info/entry_points.txt +2 -0
- bsworld-1.1.3.dist-info/licenses/LICENSE +201 -0
- bsworld-1.1.3.dist-info/top_level.txt +1 -0
bsworld/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
"""Deterministic simulation test framework for Bluetooth LE Applications."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# bsworld loads bscontroller from its source in this repository.
|
|
24
|
+
_BSCONTROLLER_SRC = Path(__file__).parents[2] / "components" / "bscontroller" / "src"
|
|
25
|
+
if _BSCONTROLLER_SRC.is_dir():
|
|
26
|
+
sys.path.insert(0, str(_BSCONTROLLER_SRC))
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = version("bsworld")
|
|
30
|
+
except PackageNotFoundError:
|
|
31
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
"""BabbleSim backend: PHY, independent Controllers, firmware and interference.
|
|
16
|
+
|
|
17
|
+
Application command factories receive an AppSlot and select their native
|
|
18
|
+
arguments for joining the PHY. Processes are registered once; Host and complete
|
|
19
|
+
firmware slots are derived from their final attachments when the layout freezes.
|
|
20
|
+
|
|
21
|
+
Bring-up shape:
|
|
22
|
+
1. verify configured BabbleSim artifacts.
|
|
23
|
+
2. prepare: kill stragglers for this sim_id, remove stale HCI
|
|
24
|
+
socket files.
|
|
25
|
+
3. plan_devices: freeze one DeviceLayout covering Virtual Controllers,
|
|
26
|
+
Host processes, complete firmware, the framework clock, then interference.
|
|
27
|
+
4. spawn phy (-D = num_devs).
|
|
28
|
+
5. spawn Virtual Controllers and interference devices. Firmware processes
|
|
29
|
+
are started by topology.
|
|
30
|
+
6. connect the in-process pytest clock after external participants start.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import TYPE_CHECKING
|
|
41
|
+
|
|
42
|
+
from ..deps import check_babblesim_ready
|
|
43
|
+
from ..devices.bscontroller import BSController
|
|
44
|
+
from ..devices.controller import Controller
|
|
45
|
+
from ..devices.layout import DeviceLayout, build_device_layout
|
|
46
|
+
from ..env import Env
|
|
47
|
+
from ..interference import (
|
|
48
|
+
BurstInterference,
|
|
49
|
+
ChannelAttenuation,
|
|
50
|
+
InterferenceSource,
|
|
51
|
+
PlaybackInterference,
|
|
52
|
+
StaticAttenuation,
|
|
53
|
+
TimedAttenuation,
|
|
54
|
+
WlanInterference,
|
|
55
|
+
)
|
|
56
|
+
from ..log import log, warn
|
|
57
|
+
from ..runtime.proc import ProcessManager
|
|
58
|
+
from ..runtime.scheduler import BsimClock
|
|
59
|
+
from ..runtime.wait import wait_for_path, wait_for_paths, wait_for_paths_async
|
|
60
|
+
from .ellisys import convert_tx_csv
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from ..devices.instance import ProcessInstance
|
|
64
|
+
|
|
65
|
+
_RESIDUAL_PATTERNS = (
|
|
66
|
+
"bs_2G4_phy_v1",
|
|
67
|
+
"bt_virt_controller",
|
|
68
|
+
"bs_device_2G4_",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_PHY_ID = "2G4"
|
|
72
|
+
_DEFAULT_MODEM = "BLE_simple"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class BabbleSimBackend:
|
|
77
|
+
env: Env
|
|
78
|
+
sim_id: str
|
|
79
|
+
controllers: list[Controller] = field(default_factory=list)
|
|
80
|
+
channel_attenuation: ChannelAttenuation | None = None
|
|
81
|
+
interference_sources: list[InterferenceSource] = field(default_factory=list)
|
|
82
|
+
_processes: list[ProcessInstance] = field(
|
|
83
|
+
default_factory=list, init=False, repr=False
|
|
84
|
+
)
|
|
85
|
+
_layout: DeviceLayout | None = field(default=None, init=False, repr=False)
|
|
86
|
+
_started: bool = False
|
|
87
|
+
clock: BsimClock | None = None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def processes(self) -> tuple[ProcessInstance, ...]:
|
|
91
|
+
"""Registered processes, read-only; register through register_process()."""
|
|
92
|
+
return tuple(self._processes)
|
|
93
|
+
|
|
94
|
+
def _ensure_layout_mutable(self) -> None:
|
|
95
|
+
"""Reject registrations once PHY identities have been published.
|
|
96
|
+
|
|
97
|
+
start() freezes the layout first, so this also covers a started backend.
|
|
98
|
+
"""
|
|
99
|
+
if self._layout is not None:
|
|
100
|
+
raise RuntimeError("cannot change participants after the layout is frozen")
|
|
101
|
+
|
|
102
|
+
def add_controller(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
controller_type: type[Controller] = BSController,
|
|
106
|
+
public_address: str | None = None,
|
|
107
|
+
snoop_dir: Path | None = None,
|
|
108
|
+
**options,
|
|
109
|
+
) -> Controller:
|
|
110
|
+
self._ensure_layout_mutable()
|
|
111
|
+
dev_id = len(self.controllers)
|
|
112
|
+
ctl = controller_type(
|
|
113
|
+
controller_dev_id=dev_id,
|
|
114
|
+
sim_id=self.sim_id,
|
|
115
|
+
phy_id=_PHY_ID,
|
|
116
|
+
socket_dir=self.env.sim_dir(self.sim_id),
|
|
117
|
+
public_address=public_address,
|
|
118
|
+
**options,
|
|
119
|
+
)
|
|
120
|
+
# Decided once, here: every Controller knows where its capture goes
|
|
121
|
+
# before it starts, so no implementation needs a fallback path.
|
|
122
|
+
ctl.hci_snoop_file = (
|
|
123
|
+
(snoop_dir or self.env.log_dir / "controllers") / f"hci{dev_id}.snoop"
|
|
124
|
+
).resolve()
|
|
125
|
+
self.controllers.append(ctl)
|
|
126
|
+
return ctl
|
|
127
|
+
|
|
128
|
+
def validate_attachment(
|
|
129
|
+
self, process: ProcessInstance, controller: Controller
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Check that a Controller can be attached, without changing any state."""
|
|
132
|
+
self._ensure_layout_mutable()
|
|
133
|
+
if not any(candidate is controller for candidate in self.controllers):
|
|
134
|
+
raise ValueError("Controller does not belong to this BabbleSim backend")
|
|
135
|
+
if any(
|
|
136
|
+
other is not process and other.controller is controller
|
|
137
|
+
for other in self._processes
|
|
138
|
+
):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"a Virtual Controller can only be attached to one Application process"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def register_process(self, process: ProcessInstance) -> None:
|
|
144
|
+
"""Record a process once; its final attachment is read when ids are frozen."""
|
|
145
|
+
if any(existing is process for existing in self._processes):
|
|
146
|
+
raise ValueError("process is already registered with this backend")
|
|
147
|
+
if process.controller is None:
|
|
148
|
+
self._ensure_layout_mutable()
|
|
149
|
+
else:
|
|
150
|
+
self.validate_attachment(process, process.controller)
|
|
151
|
+
self._processes.append(process)
|
|
152
|
+
|
|
153
|
+
def add_interference_source(self, source: InterferenceSource) -> int:
|
|
154
|
+
"""Record an interference device and return its index."""
|
|
155
|
+
self._ensure_layout_mutable()
|
|
156
|
+
self.interference_sources.append(source)
|
|
157
|
+
return len(self.interference_sources) - 1
|
|
158
|
+
|
|
159
|
+
def set_snoop_dir(self, controller: Controller, snoop_dir: Path) -> None:
|
|
160
|
+
"""Relocate a Controller's capture before it starts."""
|
|
161
|
+
if self._started:
|
|
162
|
+
raise RuntimeError("cannot move an HCI capture after BabbleSim has started")
|
|
163
|
+
if not any(candidate is controller for candidate in self.controllers):
|
|
164
|
+
raise ValueError("Controller does not belong to this BabbleSim backend")
|
|
165
|
+
controller.hci_snoop_file = (
|
|
166
|
+
snoop_dir / f"hci{controller.controller_dev_id}.snoop"
|
|
167
|
+
).resolve()
|
|
168
|
+
|
|
169
|
+
def plan_devices(self) -> DeviceLayout:
|
|
170
|
+
"""Freeze the device id assignment for the registered topology.
|
|
171
|
+
|
|
172
|
+
Device ids are derived from the registered participants, so the first
|
|
173
|
+
caller after registration fixes the layout for the whole simulation.
|
|
174
|
+
"""
|
|
175
|
+
if self._layout is None:
|
|
176
|
+
self._layout = build_device_layout(
|
|
177
|
+
phy_id=_PHY_ID,
|
|
178
|
+
sim_id=self.sim_id,
|
|
179
|
+
controllers=self.controllers,
|
|
180
|
+
processes=self._processes,
|
|
181
|
+
interference_count=len(self.interference_sources),
|
|
182
|
+
)
|
|
183
|
+
return self._layout
|
|
184
|
+
|
|
185
|
+
def check_deps_ready(self) -> None:
|
|
186
|
+
check_babblesim_ready(self.env)
|
|
187
|
+
|
|
188
|
+
def prepare(self) -> None:
|
|
189
|
+
log(f"preparing bsim sim_id={self.sim_id}")
|
|
190
|
+
sim_dir = self.env.sim_dir(self.sim_id)
|
|
191
|
+
sim_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
for pat in _RESIDUAL_PATTERNS:
|
|
193
|
+
subprocess.run(
|
|
194
|
+
["pkill", "-9", "-f", f"{pat}.*-s={self.sim_id}"],
|
|
195
|
+
check=False,
|
|
196
|
+
stdout=subprocess.DEVNULL,
|
|
197
|
+
stderr=subprocess.DEVNULL,
|
|
198
|
+
)
|
|
199
|
+
subprocess.run(
|
|
200
|
+
["pkill", "-9", "-f", "--", f"--sim-bsim-sid={self.sim_id}"],
|
|
201
|
+
check=False,
|
|
202
|
+
stdout=subprocess.DEVNULL,
|
|
203
|
+
stderr=subprocess.DEVNULL,
|
|
204
|
+
)
|
|
205
|
+
for ctl in self.controllers:
|
|
206
|
+
if ctl.hci_sock.exists():
|
|
207
|
+
log(f"removing stale {ctl.hci_sock}")
|
|
208
|
+
ctl.hci_sock.unlink()
|
|
209
|
+
|
|
210
|
+
async def start(
|
|
211
|
+
self,
|
|
212
|
+
pm: ProcessManager,
|
|
213
|
+
*,
|
|
214
|
+
log_dir: Path | None = None,
|
|
215
|
+
ready_timeout: float = 30.0,
|
|
216
|
+
) -> None:
|
|
217
|
+
log_dir = log_dir or self.env.log_dir
|
|
218
|
+
bsim_log_dir = log_dir / "babblesim"
|
|
219
|
+
if self._started:
|
|
220
|
+
return
|
|
221
|
+
if not self.controllers and not self._processes:
|
|
222
|
+
raise RuntimeError(
|
|
223
|
+
"a BabbleSim simulation requires at least one Virtual Controller "
|
|
224
|
+
"or complete firmware"
|
|
225
|
+
)
|
|
226
|
+
self.check_deps_ready()
|
|
227
|
+
self.prepare()
|
|
228
|
+
|
|
229
|
+
layout = self.plan_devices()
|
|
230
|
+
num_devs = layout.device_count
|
|
231
|
+
|
|
232
|
+
# Spawn phy.
|
|
233
|
+
phy_bin = self.env.bsim_bin_dir / f"bs_{_PHY_ID}_phy_v1"
|
|
234
|
+
phy_argv: list = [
|
|
235
|
+
phy_bin,
|
|
236
|
+
f"-s={self.sim_id}",
|
|
237
|
+
f"-D={num_devs}",
|
|
238
|
+
f"-defmodem={_DEFAULT_MODEM}",
|
|
239
|
+
]
|
|
240
|
+
if self.channel_attenuation is not None:
|
|
241
|
+
ca = self.channel_attenuation
|
|
242
|
+
matrix_file = ca.matrix_file
|
|
243
|
+
if ca.static_paths or ca.timed_paths:
|
|
244
|
+
matrix_file = _write_attenuation_matrix(
|
|
245
|
+
static_paths=ca.static_paths,
|
|
246
|
+
timed_paths=ca.timed_paths,
|
|
247
|
+
out_dir=bsim_log_dir,
|
|
248
|
+
)
|
|
249
|
+
phy_argv += [
|
|
250
|
+
"-channel=multiatt",
|
|
251
|
+
"-argschannel",
|
|
252
|
+
f"-at={ca.default_attenuation_db}",
|
|
253
|
+
]
|
|
254
|
+
if ca.extra_attenuation_db:
|
|
255
|
+
phy_argv.append(f"-atextra={ca.extra_attenuation_db}")
|
|
256
|
+
if matrix_file is not None:
|
|
257
|
+
phy_argv.append(f"-file={matrix_file}")
|
|
258
|
+
# switch back to main phy args so subsequent flags (-dump etc.) are
|
|
259
|
+
# not consumed by the channel plugin
|
|
260
|
+
phy_argv.append("-argsmain")
|
|
261
|
+
phy_argv.extend(["-dump", "-dump_imm"])
|
|
262
|
+
phy_process = pm.spawn(
|
|
263
|
+
"bs_2G4_phy_v1",
|
|
264
|
+
phy_argv,
|
|
265
|
+
cwd=self.env.bsim_bin_dir,
|
|
266
|
+
log_dir=bsim_log_dir,
|
|
267
|
+
)
|
|
268
|
+
sim_dir = self.env.sim_dir(self.sim_id)
|
|
269
|
+
wait_for_path(sim_dir / "2G4.phy.lock", timeout=ready_timeout)
|
|
270
|
+
log("phy ready")
|
|
271
|
+
|
|
272
|
+
self.clock = BsimClock(
|
|
273
|
+
sim_dir=sim_dir,
|
|
274
|
+
phy_id=_PHY_ID,
|
|
275
|
+
dev_id=layout.clock_dev_id,
|
|
276
|
+
phy_alive=phy_process.alive,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
for ctl in self.controllers:
|
|
280
|
+
await ctl.start(pm, self.env, log_dir=log_dir)
|
|
281
|
+
|
|
282
|
+
for i, src in enumerate(self.interference_sources):
|
|
283
|
+
dev_id = layout.interference_dev_id(i)
|
|
284
|
+
argv = _build_interf_argv(
|
|
285
|
+
src, sim_id=self.sim_id, dev_id=dev_id, bin_dir=self.env.bsim_bin_dir
|
|
286
|
+
)
|
|
287
|
+
pm.spawn(
|
|
288
|
+
f"interf_{dev_id}",
|
|
289
|
+
argv,
|
|
290
|
+
cwd=self.env.bsim_bin_dir,
|
|
291
|
+
log_dir=bsim_log_dir,
|
|
292
|
+
)
|
|
293
|
+
self._started = True
|
|
294
|
+
|
|
295
|
+
async def stop_controllers(self) -> None:
|
|
296
|
+
"""Release Controller runtimes before terminating the shared PHY."""
|
|
297
|
+
results = await asyncio.gather(
|
|
298
|
+
*(ctl.stop() for ctl in self.controllers), return_exceptions=True
|
|
299
|
+
)
|
|
300
|
+
for result in results:
|
|
301
|
+
if isinstance(result, BaseException):
|
|
302
|
+
raise result
|
|
303
|
+
|
|
304
|
+
def device_lock_paths(self) -> list[Path]:
|
|
305
|
+
return self.plan_devices().lock_paths(self.env.sim_dir(self.sim_id))
|
|
306
|
+
|
|
307
|
+
def wait_for_devices_registered(self, *, timeout: float = 30.0) -> None:
|
|
308
|
+
locks = self.device_lock_paths()
|
|
309
|
+
log(f"waiting for {len(locks)} BabbleSim device lock(s)")
|
|
310
|
+
if self.clock is not None:
|
|
311
|
+
self.clock.connect(timeout=timeout)
|
|
312
|
+
wait_for_paths(locks, timeout=timeout)
|
|
313
|
+
log("all devices registered to phy")
|
|
314
|
+
|
|
315
|
+
async def wait_for_hci_sockets(self, *, timeout: float = 30.0) -> None:
|
|
316
|
+
socks = [ctl.hci_sock for ctl in self.controllers]
|
|
317
|
+
if not socks:
|
|
318
|
+
return
|
|
319
|
+
log(f"waiting for {len(socks)} hci socket(s)")
|
|
320
|
+
await wait_for_paths_async(socks, timeout=timeout)
|
|
321
|
+
log("hci socket(s) bound")
|
|
322
|
+
|
|
323
|
+
def finish(self, *, log_dir: Path | None = None) -> None:
|
|
324
|
+
"""Release the clock and archive artifacts, including after a failed start."""
|
|
325
|
+
if self.clock is not None:
|
|
326
|
+
try:
|
|
327
|
+
self.clock.close()
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
warn(f"simulation clock close failed: {exc}")
|
|
330
|
+
finally:
|
|
331
|
+
self.clock = None
|
|
332
|
+
try:
|
|
333
|
+
self.convert_ellisys(log_dir=log_dir)
|
|
334
|
+
finally:
|
|
335
|
+
self.cleanup_sim_artifacts()
|
|
336
|
+
|
|
337
|
+
def convert_ellisys(self, *, log_dir: Path | None = None) -> None:
|
|
338
|
+
if self.env.bsim_dir is None:
|
|
339
|
+
return
|
|
340
|
+
results_dir = self.env.bsim_dir / "results" / self.sim_id
|
|
341
|
+
tx_files = sorted(results_dir.glob("d_2G4*.Tx.csv"))
|
|
342
|
+
if not tx_files:
|
|
343
|
+
warn(f"no BabbleSim Tx CSV dumps found under {results_dir}")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
out_dir = (log_dir or self.env.log_dir) / "babblesim"
|
|
347
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
out_path = out_dir / "ellisys.bttrp"
|
|
349
|
+
log(f"converting BabbleSim dump to Ellisys: {out_path}")
|
|
350
|
+
try:
|
|
351
|
+
with out_path.open("w") as out:
|
|
352
|
+
convert_tx_csv(tx_files, out)
|
|
353
|
+
except (OSError, ValueError) as exc:
|
|
354
|
+
warn(f"Ellisys conversion failed for {self.sim_id}: {exc}")
|
|
355
|
+
|
|
356
|
+
def cleanup_sim_artifacts(self) -> None:
|
|
357
|
+
sim_state_dir = self.env.sim_dir(self.sim_id)
|
|
358
|
+
if sim_state_dir.exists():
|
|
359
|
+
log(f"removing sim state {sim_state_dir}")
|
|
360
|
+
shutil.rmtree(sim_state_dir, ignore_errors=True)
|
|
361
|
+
|
|
362
|
+
results_dir = (
|
|
363
|
+
self.env.bsim_dir / "results" / self.sim_id if self.env.bsim_dir else None
|
|
364
|
+
)
|
|
365
|
+
if results_dir is not None and results_dir.exists():
|
|
366
|
+
log(f"removing bsim results {results_dir}")
|
|
367
|
+
shutil.rmtree(results_dir, ignore_errors=True)
|
|
368
|
+
|
|
369
|
+
for ctl in self.controllers:
|
|
370
|
+
ctl.hci_sock.unlink(missing_ok=True)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _write_attenuation_matrix(
|
|
374
|
+
*,
|
|
375
|
+
static_paths: list[StaticAttenuation],
|
|
376
|
+
timed_paths: list[TimedAttenuation],
|
|
377
|
+
out_dir: Path,
|
|
378
|
+
) -> Path:
|
|
379
|
+
"""Write per-path static/timed attenuation files + matrix_file into out_dir.
|
|
380
|
+
|
|
381
|
+
Files are placed in the test log directory so they survive cleanup and are
|
|
382
|
+
available for post-mortem inspection alongside bsworld.log and HCI snoops.
|
|
383
|
+
Returns the path to the generated matrix file ready for -file=<path>.
|
|
384
|
+
"""
|
|
385
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
matrix_lines = [
|
|
387
|
+
f"{path.tx} {path.rx} : {path.attenuation_db}" for path in static_paths
|
|
388
|
+
]
|
|
389
|
+
for i, tp in enumerate(timed_paths):
|
|
390
|
+
timed_file = out_dir / f"timed_att_{tp.tx}_{tp.rx}_{i}.txt"
|
|
391
|
+
with open(timed_file, "w") as fh:
|
|
392
|
+
for t, att in zip(tp.times_us, tp.attenuation_db):
|
|
393
|
+
fh.write(f"{t} {att}\n")
|
|
394
|
+
matrix_lines.append(f'{tp.tx} {tp.rx} : "{timed_file}"')
|
|
395
|
+
|
|
396
|
+
matrix_file = out_dir / "att_matrix.txt"
|
|
397
|
+
with open(matrix_file, "w") as fh:
|
|
398
|
+
fh.write("\n".join(matrix_lines) + "\n")
|
|
399
|
+
return matrix_file
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _build_interf_argv(
|
|
403
|
+
src: InterferenceSource,
|
|
404
|
+
*,
|
|
405
|
+
sim_id: str,
|
|
406
|
+
dev_id: int,
|
|
407
|
+
bin_dir: Path,
|
|
408
|
+
) -> list:
|
|
409
|
+
common = [f"-s={sim_id}", f"-d={dev_id}"]
|
|
410
|
+
|
|
411
|
+
if isinstance(src, BurstInterference):
|
|
412
|
+
argv: list = [bin_dir / "bs_device_2G4_burst_interf"] + common
|
|
413
|
+
argv += [
|
|
414
|
+
f"-centerfreq={src.center_freq_mhz}",
|
|
415
|
+
f"-power={src.power_dbm}",
|
|
416
|
+
f"-type={src.interf_type}",
|
|
417
|
+
]
|
|
418
|
+
if src.rand_seed is not None:
|
|
419
|
+
argv.append(f"-rs={src.rand_seed}")
|
|
420
|
+
_append_windows(argv, src.windows)
|
|
421
|
+
return argv
|
|
422
|
+
|
|
423
|
+
if isinstance(src, WlanInterference):
|
|
424
|
+
argv = [bin_dir / "bs_device_2G4_WLAN_actmod"] + common
|
|
425
|
+
if src.channel is not None:
|
|
426
|
+
argv.append(f"-channel={src.channel}")
|
|
427
|
+
else:
|
|
428
|
+
argv.append(f"-centerfreq={src.center_freq_mhz}")
|
|
429
|
+
argv += [
|
|
430
|
+
f"-power={src.power_dbm}",
|
|
431
|
+
f"-mod={src.mod_type}",
|
|
432
|
+
f"-ConfigSet={src.config_set}",
|
|
433
|
+
]
|
|
434
|
+
if src.rand_seed is not None:
|
|
435
|
+
argv.append(f"-rs={src.rand_seed}")
|
|
436
|
+
_append_windows(argv, src.windows)
|
|
437
|
+
return argv
|
|
438
|
+
|
|
439
|
+
if isinstance(src, PlaybackInterference):
|
|
440
|
+
argv = [bin_dir / "bs_device_2G4_playbackv2"] + common
|
|
441
|
+
argv.append(f"-inputf={src.input_file}")
|
|
442
|
+
if not src.tx:
|
|
443
|
+
argv.append("-txoff")
|
|
444
|
+
if not src.rx:
|
|
445
|
+
argv.append("-rxoff")
|
|
446
|
+
if not src.rssi:
|
|
447
|
+
argv.append("-rssioff")
|
|
448
|
+
if not src.cca:
|
|
449
|
+
argv.append("-ccaoff")
|
|
450
|
+
return argv
|
|
451
|
+
|
|
452
|
+
raise TypeError(f"unknown interference source type: {type(src)}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _append_windows(argv: list, windows: list[tuple[int, int]]) -> None:
|
|
456
|
+
if not windows:
|
|
457
|
+
return
|
|
458
|
+
argv.append("-timestart")
|
|
459
|
+
argv.extend(str(start) for start, _ in windows)
|
|
460
|
+
argv.append("-timeend")
|
|
461
|
+
argv.extend(str(end) for _, end in windows)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Copyright 2018 Oticon A/S
|
|
2
|
+
# Copyright 2026 Xiaomi Corporation
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Convert BabbleSim Tx CSV dumps to the Ellisys Bluetooth import format.
|
|
6
|
+
|
|
7
|
+
Port of ext_2G4_phy_v1/dump_post_process/convert_results_to_ellisysv2.sh.
|
|
8
|
+
Keep its header, fixed RSSI, Coded segment handling and numeric/lexical sort
|
|
9
|
+
order so existing traces remain identical, including duplicate records.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TextIO
|
|
17
|
+
|
|
18
|
+
_HEADER = (
|
|
19
|
+
"FileFormat:Bluetooth\n version=1.0\n\nItemFormat:LE version=1.1\n"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def convert_tx_csv(tx_files: Iterable[Path], output: TextIO) -> None:
|
|
24
|
+
"""Merge dumps in input order and write a time-sorted .bttrp trace."""
|
|
25
|
+
records: list[tuple[int, str]] = []
|
|
26
|
+
timestamp = "0"
|
|
27
|
+
address = ""
|
|
28
|
+
channel = 0
|
|
29
|
+
rssi = 0
|
|
30
|
+
ci = ""
|
|
31
|
+
for path in tx_files:
|
|
32
|
+
with path.open() as source:
|
|
33
|
+
next(source, None) # Each device dump has its own CSV header.
|
|
34
|
+
for line in source:
|
|
35
|
+
fields = line.removesuffix("\n").split(",")
|
|
36
|
+
if len(fields) != 10 or not fields[9]:
|
|
37
|
+
continue
|
|
38
|
+
packet = fields[9]
|
|
39
|
+
modulation = int(fields[4])
|
|
40
|
+
if modulation == 80 and int(fields[8]) > 4:
|
|
41
|
+
# FEC2 inherits the FEC1 timestamp, address, channel and CI.
|
|
42
|
+
phy = f"Coded coding=Coded{8 if ci == '00' else 2}"
|
|
43
|
+
else:
|
|
44
|
+
# Match awk's double arithmetic and %.0f timestamp format.
|
|
45
|
+
timestamp = f"{float(fields[0]) * 1000:.0f}"
|
|
46
|
+
channel = int((float(fields[2]) - 2) / 2)
|
|
47
|
+
address = fields[3]
|
|
48
|
+
rssi = -40
|
|
49
|
+
if len(packet) >= 2:
|
|
50
|
+
ci = packet[:2]
|
|
51
|
+
if modulation == 16:
|
|
52
|
+
phy = "1Mbps"
|
|
53
|
+
elif modulation in (32, 33):
|
|
54
|
+
phy = "2Mbps"
|
|
55
|
+
else:
|
|
56
|
+
continue
|
|
57
|
+
record = (
|
|
58
|
+
f'{timestamp} aa={address} rssi={rssi} rfchannel={channel} '
|
|
59
|
+
f'phy={phy} rawdata="{packet.rstrip()}"'
|
|
60
|
+
)
|
|
61
|
+
records.append((int(timestamp), record))
|
|
62
|
+
|
|
63
|
+
output.write(_HEADER)
|
|
64
|
+
# GNU sort -g breaks numeric ties by comparing the entire line.
|
|
65
|
+
for _, record in sorted(records):
|
|
66
|
+
output.write(f"Item time={record}\n")
|
bsworld/deps.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright 2026 Xiaomi Corporation
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
"""Validate the externally built artifacts a simulation depends on.
|
|
16
|
+
|
|
17
|
+
Nothing here compiles: BabbleSim, the Virtual Controller and every Host are
|
|
18
|
+
built outside pytest and selected through environment variables. These checks
|
|
19
|
+
run before a topology starts so a missing artifact is reported once, with the
|
|
20
|
+
setup guide, instead of surfacing as a failed process spawn.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .env import Env
|
|
29
|
+
|
|
30
|
+
_BABBLESIM_OUTPUTS = (
|
|
31
|
+
"bin/bs_2G4_phy_v1",
|
|
32
|
+
"bin/bs_device_2G4_burst_interf",
|
|
33
|
+
"bin/bs_device_2G4_WLAN_actmod",
|
|
34
|
+
"lib/lib_2G4Channel_NtNcable.so",
|
|
35
|
+
"lib/lib_2G4Channel_multiatt.so",
|
|
36
|
+
"lib/lib_2G4Modem_BLE_simple.so",
|
|
37
|
+
# Real AES for the nRF HW models (-RealEncryption=1).
|
|
38
|
+
"lib/libCryptov1.so",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_CONTROLLER_HINT = (
|
|
42
|
+
"Build the Virtual Controller and export BSWORLD_BT_VIRT_CONTROLLER_BIN "
|
|
43
|
+
"as the path to zephyr.exe. See docs/controllers.md."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BSWorldSetupError(RuntimeError):
|
|
48
|
+
"""A required artifact is missing or not configured.
|
|
49
|
+
|
|
50
|
+
Raised before a topology starts, so pytest reports it as an ordinary
|
|
51
|
+
failure against the test that needed the artifact.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_babblesim_ready(env: Env) -> None:
|
|
56
|
+
"""Validate the selected BabbleSim runtime outputs."""
|
|
57
|
+
if env.bsim_dir is None:
|
|
58
|
+
raise BSWorldSetupError(
|
|
59
|
+
(
|
|
60
|
+
"Set BSIM_OUT_PATH to the BabbleSim output directory containing bin/ "
|
|
61
|
+
"and lib/. See README.md."
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
missing = [
|
|
65
|
+
str(env.bsim_dir / rel)
|
|
66
|
+
for rel in _BABBLESIM_OUTPUTS
|
|
67
|
+
if not (env.bsim_dir / rel).is_file()
|
|
68
|
+
or (rel.startswith("bin/") and not os.access(env.bsim_dir / rel, os.X_OK))
|
|
69
|
+
]
|
|
70
|
+
if missing:
|
|
71
|
+
raise BSWorldSetupError(
|
|
72
|
+
"BabbleSim build outputs are missing.\nMissing:\n "
|
|
73
|
+
+ "\n ".join(missing)
|
|
74
|
+
+ "\nFollow the repo-based BabbleSim setup in README.md#build-and-test, "
|
|
75
|
+
"then set BSIM_OUT_PATH to the build output directory "
|
|
76
|
+
f"containing bin/ and lib/ (currently {env.bsim_dir})."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_controller_ready(env: Env) -> None:
|
|
81
|
+
"""Validate the native Virtual Controller executable."""
|
|
82
|
+
binary = env.hci_virt_bin
|
|
83
|
+
if binary is None or not binary.is_file() or not os.access(binary, os.X_OK):
|
|
84
|
+
raise BSWorldSetupError(
|
|
85
|
+
f"Virtual Controller executable unavailable: {binary}\n{_CONTROLLER_HINT}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def controller_bin(env: Env) -> Path:
|
|
90
|
+
"""Return the validated Virtual Controller launcher path."""
|
|
91
|
+
check_controller_ready(env)
|
|
92
|
+
return env.hci_virt_bin
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"BSWorldSetupError",
|
|
97
|
+
"check_babblesim_ready",
|
|
98
|
+
"check_controller_ready",
|
|
99
|
+
"controller_bin",
|
|
100
|
+
]
|