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 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
+ ]