swarmit 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,10 +1,11 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: swarmit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Run Your Own Robot Swarm Testbed.
5
5
  Project-URL: Homepage, https://github.com/DotBots/swarmit
6
6
  Project-URL: Bug Tracker, https://github.com/DotBots/swarmit/issues
7
7
  Author-email: Alexandre Abadie <alexandre.abadie@inria.fr>
8
+ License: BSD
8
9
  License-File: AUTHORS
9
10
  License-File: LICENSE
10
11
  Classifier: License :: OSI Approved :: BSD License
@@ -15,6 +16,7 @@ Classifier: Programming Language :: Python :: 3
15
16
  Requires-Python: >=3.7
16
17
  Requires-Dist: click==8.1.7
17
18
  Requires-Dist: cryptography==43.0.1
19
+ Requires-Dist: paho-mqtt>=2.1.0
18
20
  Requires-Dist: pydotbot==0.22.0
19
21
  Requires-Dist: pyserial==3.5
20
22
  Requires-Dist: rich==13.8.1
@@ -0,0 +1,191 @@
1
+ # Configuration file for the Sphinx documentation builder.
2
+
3
+ import glob
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ project = 'DotBot-firmware'
11
+ copyright = '2023, Inria'
12
+ author = 'Alexandre Abadie'
13
+
14
+ # -- General configuration ----------------------------------------------------
15
+ extensions = [
16
+ 'breathe',
17
+ "myst_parser",
18
+ "sphinx.ext.autodoc",
19
+ "sphinx.ext.autosummary",
20
+ "sphinx.ext.githubpages",
21
+ "sphinx.ext.graphviz",
22
+ "sphinx.ext.inheritance_diagram",
23
+ "sphinx.ext.todo",
24
+ "sphinx.ext.viewcode",
25
+ ]
26
+
27
+ language = "en"
28
+ tls_verify = False
29
+ templates_path = ['_templates']
30
+ exclude_patterns = ["_build"]
31
+ nitpick_ignore_regex = [
32
+ (r'c:.*', r'[u]*int\d{1,2}_t'), # ignore int8_t, uint8_t, ...
33
+ (r'c:.*', r'NRF_.*'), # ignore NRF_ macros
34
+ (r'c:.*', r'[s]*size_t'), # ignore size_t and ssize_t
35
+ (r'c:.*', r'[U]*INT\d{1,2}_MAX'), # ignore INT8_MAX, UINT8_MAX, ...
36
+ ]
37
+
38
+ # -- Options for breathe ------------------------------------------------------
39
+ breathe_projects = {"DotBot-firmware": "../doxygen/xml/"}
40
+ breathe_default_project = "DotBot-firmware"
41
+ breathe_show_include = False
42
+ breathe_domain_by_extension = {
43
+ "h" : "c",
44
+ }
45
+
46
+ myst_enable_extensions = ["html_image"]
47
+
48
+ # -- Options for HTML output --------------------------------------------------
49
+ html_theme = "pydata_sphinx_theme"
50
+ html_sourcelink_suffix = ""
51
+ html_static_path = ["_static"]
52
+
53
+ # Define the json_url for our version switcher.
54
+ json_url = "https://dotbot-firmware.readthedocs.io/en/latest/_static/switcher.json"
55
+ rtd_version = os.environ.get("READTHEDOCS_VERSION")
56
+ rtd_version_type = os.environ.get("READTHEDOCS_VERSION_TYPE")
57
+ rtd_git_identifier = os.environ.get("READTHEDOCS_GIT_IDENTIFIER")
58
+ # If READTHEDOCS_VERSION doesn't exist, we're not on RTD
59
+ # If it is an integer, we're in a PR build and the version isn't correct.
60
+ # If it's "latest" → change to "dev" (that's what we want the switcher to call it)
61
+ if not rtd_version or rtd_version.isdigit() or rtd_version == "latest":
62
+ rtd_version = "dev"
63
+ json_url = "_static/switcher.json"
64
+ elif rtd_version == "stable":
65
+ rtd_version = f"{rtd_git_identifier}"
66
+ elif rtd_version_type == "tag":
67
+ rtd_version = f"{rtd_git_identifier}"
68
+
69
+ html_theme_options = {
70
+ "external_links": [
71
+ {
72
+ "url": "https://github.com/DotBots/PyDotBot",
73
+ "name": "PyDotBot",
74
+ "attributes": {
75
+ "target" : "_blank",
76
+ "rel" : "noopener me",
77
+ },
78
+ },
79
+ {
80
+ "url": "https://github.com/DotBots/DotBot-hardware",
81
+ "name": "DotBot hardware",
82
+ "attributes": {
83
+ "target" : "_blank",
84
+ "rel" : "noopener me",
85
+ },
86
+ },
87
+ ],
88
+ "icon_links": [
89
+ {
90
+ "name": "GitHub",
91
+ "url": "https://github.com/DotBots/DotBot-firmware",
92
+ "icon": "fa-brands fa-github",
93
+ },
94
+ ],
95
+ "header_links_before_dropdown": 4,
96
+ "logo": {
97
+ "text": "DotBot firmware",
98
+ },
99
+ "navbar_align": "left",
100
+ "navbar_center": ["version-switcher", "navbar-nav"],
101
+ "switcher": {
102
+ "json_url": json_url,
103
+ "version_match": rtd_version,
104
+ },
105
+ "footer_start": ["copyright"],
106
+ "footer_center": ["sphinx-version"],
107
+ }
108
+
109
+ # -- Options for autosummary/autodoc output -----------------------------------
110
+ autosummary_generate = True
111
+ autodoc_typehints = "description"
112
+ autodoc_member_order = "groupwise"
113
+
114
+ # Hook for building doxygen documentation -------------------------------------
115
+
116
+ def run_doxygen(app):
117
+ """Run the doxygen make command."""
118
+ doxygen_path = "../doxygen"
119
+ try:
120
+ retcode = subprocess.call(f"make -C {doxygen_path}", shell=True)
121
+ if retcode < 0:
122
+ sys.stderr.write(f"doxygen terminated by signal {-retcode}")
123
+ except OSError as e:
124
+ sys.stderr.write(f"doxygen execution failed: {e}")
125
+
126
+ # Hook for generating linked README.md files --------------------------------------------
127
+
128
+ README_INCLUDE_TEMPLATE = """```{{include}} {path_to_readme}
129
+ :relative-images:
130
+ :relative-docs: ../../
131
+ ```
132
+ """
133
+
134
+ def generate_readme(app, prefix, dest):
135
+ projects_dir = os.path.join(app.srcdir, "../../projects/")
136
+ projects = [os.path.basename(project) for project in glob.glob(f"{projects_dir}/{prefix}*")]
137
+ output_dir = os.path.join(app.srcdir, dest)
138
+ if not os.path.exists(output_dir):
139
+ os.makedirs(output_dir, exist_ok=True)
140
+ for project in projects:
141
+ with open(os.path.join(output_dir, f"{project}.md"), "w") as f:
142
+ f.write(README_INCLUDE_TEMPLATE.format(path_to_readme=f"../../../projects/{project}/README.md"))
143
+
144
+
145
+ def generate_projects_readme(app):
146
+ for prefix, dest in [("01", "_examples"), ("03app", "_projects")]:
147
+ generate_readme(app, prefix, dest)
148
+
149
+
150
+ API_INCLUDE_TEMPLATE = """{title}
151
+ =================================
152
+
153
+ .. doxygengroup:: {module}
154
+ .. doxygenfile:: {header}
155
+
156
+ """
157
+ EXCLUDE_MODULES = [
158
+ "board_config",
159
+ "soft_ed25519",
160
+ "soft_edsign",
161
+ "soft_f25519",
162
+ "soft_fprime",
163
+ "soft_sha256",
164
+ "soft_sha512",
165
+ ]
166
+
167
+
168
+ def generate_api_files(app):
169
+ output_dir = os.path.join(app.srcdir, "_api")
170
+ if not os.path.exists(output_dir):
171
+ os.makedirs(output_dir, exist_ok=True)
172
+ for module in ["bsp", "crypto", "drv"]:
173
+ module_dir = os.path.join(app.srcdir, f"../../{module}/")
174
+ submodules = [os.path.basename(project).split(".")[0] for project in glob.glob(f"{module_dir}/*.h")]
175
+ submodules = [module for module in submodules if module not in EXCLUDE_MODULES]
176
+ for submodule in submodules:
177
+ with open(os.path.join(output_dir, f"{module}_{submodule}.rst"), "w") as f:
178
+ f.write(
179
+ API_INCLUDE_TEMPLATE.format(
180
+ title=f"{submodule.capitalize()}",
181
+ module=f"{module}_{submodule}",
182
+ header=f"{module}/{submodule}.h"
183
+ )
184
+ )
185
+
186
+
187
+ def setup(app):
188
+ """Add hook for building doxygen documentation."""
189
+ app.connect("builder-inited", run_doxygen)
190
+ app.connect("builder-inited", generate_api_files)
191
+ app.connect("builder-inited", generate_projects_readme)
@@ -1,5 +1,7 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = [
3
+ "hatchling>=1.4.1",
4
+ ]
3
5
  build-backend = "hatchling.build"
4
6
 
5
7
  [tool.hatch.build]
@@ -11,9 +13,12 @@ exclude = [
11
13
  "sample/",
12
14
  ]
13
15
 
16
+ [tool.hatch.version]
17
+ path = "testbed/swarmit/__init__.py"
18
+
14
19
  [project]
15
20
  name = "swarmit"
16
- version = "0.2.0"
21
+ dynamic = ["version"]
17
22
  authors = [
18
23
  { name="Alexandre Abadie", email="alexandre.abadie@inria.fr" },
19
24
  ]
@@ -25,10 +30,11 @@ dependencies = [
25
30
  "rich == 13.8.1",
26
31
  "structlog == 24.4.0",
27
32
  "tqdm == 4.66.5",
33
+ "paho-mqtt >= 2.1.0",
28
34
  ]
29
35
  description = "Run Your Own Robot Swarm Testbed."
30
36
  readme = "README.md"
31
- license-file = ["LICENSE"]
37
+ license = { text="BSD" }
32
38
  requires-python = ">=3.7"
33
39
  classifiers = [
34
40
  "Programming Language :: Python :: 3",
@@ -46,10 +52,14 @@ classifiers = [
46
52
  swarmit = "testbed.cli.main:main"
47
53
 
48
54
  [tool.ruff]
49
- select = ["E", "F"]
55
+ lint.select = ["E", "F"]
50
56
  line-length = 88
51
- ignore = ["E501"]
57
+ lint.ignore = ["E501"]
52
58
 
53
59
  [tool.isort]
54
60
  multi_line_output = 3 # Use Vertical Hanging Indent
55
61
  profile = "black"
62
+
63
+ [tool.black]
64
+ line-length = 79
65
+ skip-string-normalization = true
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python
2
+
3
+ import logging
4
+ import time
5
+
6
+ import click
7
+ import serial
8
+ import structlog
9
+ from dotbot.serial_interface import SerialInterfaceException, get_default_port
10
+ from rich import print
11
+ from rich.console import Console
12
+ from rich.pretty import pprint
13
+
14
+ from testbed.swarmit import __version__
15
+ from testbed.swarmit.controller import (
16
+ CHUNK_SIZE,
17
+ Controller,
18
+ ControllerSettings,
19
+ ResetLocation,
20
+ print_start_status,
21
+ print_status,
22
+ print_stop_status,
23
+ print_transfer_status,
24
+ )
25
+
26
+ SERIAL_PORT_DEFAULT = get_default_port()
27
+ BAUDRATE_DEFAULT = 1000000
28
+
29
+
30
+ @click.group(context_settings=dict(help_option_names=["-h", "--help"]))
31
+ @click.version_option(version=__version__)
32
+ @click.option(
33
+ "-p",
34
+ "--port",
35
+ default=SERIAL_PORT_DEFAULT,
36
+ help=f"Serial port to use to send the bitstream to the gateway. Default: {SERIAL_PORT_DEFAULT}.",
37
+ )
38
+ @click.option(
39
+ "-b",
40
+ "--baudrate",
41
+ default=BAUDRATE_DEFAULT,
42
+ help=f"Serial port baudrate. Default: {BAUDRATE_DEFAULT}.",
43
+ )
44
+ @click.option(
45
+ "-e",
46
+ "--edge",
47
+ is_flag=True,
48
+ default=False,
49
+ help="Use MQTT adapter to communicate with an edge gateway.",
50
+ )
51
+ @click.option(
52
+ "-d",
53
+ "--devices",
54
+ type=str,
55
+ default="",
56
+ help="Subset list of devices to interact with, separated with ,",
57
+ )
58
+ @click.pass_context
59
+ def main(ctx, port, baudrate, edge, devices):
60
+ if ctx.invoked_subcommand != "monitor":
61
+ # Disable logging if not monitoring
62
+ structlog.configure(
63
+ wrapper_class=structlog.make_filtering_bound_logger(
64
+ logging.CRITICAL
65
+ ),
66
+ )
67
+ ctx.ensure_object(dict)
68
+ ctx.obj["port"] = port
69
+ ctx.obj["baudrate"] = baudrate
70
+ ctx.obj["edge"] = edge
71
+ ctx.obj["devices"] = [e for e in devices.split(",") if e]
72
+
73
+
74
+ @main.command()
75
+ @click.option(
76
+ "-v",
77
+ "--verbose",
78
+ is_flag=True,
79
+ help="Print start result.",
80
+ )
81
+ @click.pass_context
82
+ def start(ctx, verbose):
83
+ """Start the user application."""
84
+ try:
85
+ settings = ControllerSettings(
86
+ serial_port=ctx.obj["port"],
87
+ serial_baudrate=ctx.obj["baudrate"],
88
+ mqtt_host="argus.paris.inria.fr",
89
+ mqtt_port=8883,
90
+ edge=ctx.obj["edge"],
91
+ devices=list(ctx.obj["devices"]),
92
+ )
93
+ controller = Controller(settings)
94
+ except (
95
+ SerialInterfaceException,
96
+ serial.serialutil.SerialException,
97
+ ) as exc:
98
+ console = Console()
99
+ console.print(f"[bold red]Error:[/] {exc}")
100
+ return
101
+ if controller.ready_devices:
102
+ started = controller.start()
103
+ print_start_status(
104
+ sorted(started),
105
+ sorted(set(controller.ready_devices).difference(set(started))),
106
+ )
107
+ if verbose:
108
+ print("Started devices:")
109
+ pprint(started)
110
+ print("Not started devices:")
111
+ pprint(
112
+ sorted(set(controller.ready_devices).difference(set(started)))
113
+ )
114
+ else:
115
+ print("No device to start")
116
+ controller.terminate()
117
+
118
+
119
+ @main.command()
120
+ @click.option(
121
+ "-v",
122
+ "--verbose",
123
+ is_flag=True,
124
+ help="Print start result.",
125
+ )
126
+ @click.pass_context
127
+ def stop(ctx, verbose):
128
+ """Stop the user application."""
129
+ try:
130
+ settings = ControllerSettings(
131
+ serial_port=ctx.obj["port"],
132
+ serial_baudrate=ctx.obj["baudrate"],
133
+ mqtt_host="argus.paris.inria.fr",
134
+ mqtt_port=8883,
135
+ edge=ctx.obj["edge"],
136
+ devices=list(ctx.obj["devices"]),
137
+ )
138
+ controller = Controller(settings)
139
+ except (
140
+ SerialInterfaceException,
141
+ serial.serialutil.SerialException,
142
+ ) as exc:
143
+ console = Console()
144
+ console.print(f"[bold red]Error:[/] {exc}")
145
+ return
146
+ if controller.running_devices or controller.resetting_devices:
147
+ stopped = controller.stop()
148
+ print_stop_status(
149
+ sorted(stopped),
150
+ sorted(
151
+ set(
152
+ controller.running_devices + controller.resetting_devices
153
+ ).difference(set(stopped))
154
+ ),
155
+ )
156
+ if verbose:
157
+ print("Started devices:")
158
+ pprint(stopped)
159
+ print("Not started devices:")
160
+ pprint(
161
+ sorted(
162
+ set(
163
+ controller.running_devices
164
+ + controller.resetting_devices
165
+ ).difference(set(stopped))
166
+ )
167
+ )
168
+ else:
169
+ print("No device to stop")
170
+ controller.terminate()
171
+
172
+
173
+ @main.command()
174
+ @click.argument(
175
+ "locations",
176
+ type=str,
177
+ )
178
+ @click.pass_context
179
+ def reset(ctx, locations):
180
+ """Reset robots locations.
181
+
182
+ Locations are provided as '<device_id>:<x>,<y>-<device_id>:<x>,<y>|...'
183
+ """
184
+ devices = ctx.obj["devices"]
185
+ if not devices:
186
+ print("No devices selected.")
187
+ return
188
+ locations = {
189
+ location.split(':')[0]: ResetLocation(
190
+ pos_x=int(float(location.split(':')[1].split(',')[0]) * 1e6),
191
+ pos_y=int(float(location.split(':')[1].split(',')[1]) * 1e6),
192
+ )
193
+ for location in locations.split("-")
194
+ }
195
+ if sorted(devices) and sorted(locations.keys()) != sorted(devices):
196
+ print("Selected devices and reset locations do not match.")
197
+ return
198
+ try:
199
+ settings = ControllerSettings(
200
+ serial_port=ctx.obj["port"],
201
+ serial_baudrate=ctx.obj["baudrate"],
202
+ mqtt_host="argus.paris.inria.fr",
203
+ mqtt_port=8883,
204
+ edge=ctx.obj["edge"],
205
+ devices=list(ctx.obj["devices"]),
206
+ )
207
+ controller = Controller(settings)
208
+ except (
209
+ SerialInterfaceException,
210
+ serial.serialutil.SerialException,
211
+ ) as exc:
212
+ console = Console()
213
+ console.print(f"[bold red]Error:[/] {exc}")
214
+ return
215
+ if not controller.ready_devices:
216
+ print("No device to reset.")
217
+ return
218
+ controller.reset(locations)
219
+ controller.terminate()
220
+
221
+
222
+ @main.command()
223
+ @click.option(
224
+ "-y",
225
+ "--yes",
226
+ is_flag=True,
227
+ help="Flash the firmware without prompt.",
228
+ )
229
+ @click.option(
230
+ "-s",
231
+ "--start",
232
+ is_flag=True,
233
+ help="Start the firmware once flashed.",
234
+ )
235
+ @click.option(
236
+ "-v",
237
+ "--verbose",
238
+ is_flag=True,
239
+ help="Print transfer data.",
240
+ )
241
+ @click.argument("firmware", type=click.File(mode="rb"), required=False)
242
+ @click.pass_context
243
+ def flash(ctx, yes, start, verbose, firmware):
244
+ """Flash a firmware to the robots."""
245
+ console = Console()
246
+ if firmware is None:
247
+ console.print("[bold red]Error:[/] Missing firmware file. Exiting.")
248
+ ctx.exit()
249
+
250
+ fw = bytearray(firmware.read())
251
+ settings = ControllerSettings(
252
+ serial_port=ctx.obj["port"],
253
+ serial_baudrate=ctx.obj["baudrate"],
254
+ mqtt_host="argus.paris.inria.fr",
255
+ mqtt_port=8883,
256
+ edge=ctx.obj["edge"],
257
+ devices=ctx.obj["devices"],
258
+ )
259
+ controller = Controller(settings)
260
+ if not controller.ready_devices:
261
+ console.print("[bold red]Error:[/] No ready devices found. Exiting.")
262
+ controller.terminate()
263
+ return
264
+ print(
265
+ f"Devices to flash ([bold white]{len(controller.ready_devices)}):[/]"
266
+ )
267
+ pprint(controller.ready_devices, expand_all=True)
268
+ if yes is False:
269
+ click.confirm("Do you want to continue?", default=True, abort=True)
270
+
271
+ devices = controller.settings.devices
272
+ start_data = controller.start_ota(fw)
273
+ if (devices and sorted(start_data.ids) != sorted(devices)) or (
274
+ not devices
275
+ and sorted(start_data.ids) != sorted(controller.ready_devices)
276
+ ):
277
+ console = Console()
278
+ console.print(
279
+ "[bold red]Error:[/] some acknowledgments are missing "
280
+ f'({", ".join(sorted(set(controller.ready_devices).difference(set(start_data.ids))))}). '
281
+ "Aborting."
282
+ )
283
+ raise click.Abort()
284
+ print()
285
+ print(f"Image size: [bold cyan]{len(fw)}B[/]")
286
+ print(f"Image hash: [bold cyan]{start_data.fw_hash.hex().upper()}[/]")
287
+ print(f"Radio chunks ([bold]{CHUNK_SIZE}B[/bold]): {start_data.chunks}")
288
+ start_time = time.time()
289
+ data = controller.transfer(fw)
290
+ print(f"Elapsed: [bold cyan]{time.time() - start_time:.3f}s[/bold cyan]")
291
+ print_transfer_status(data, start_data)
292
+ if verbose:
293
+ pprint(data)
294
+ if not all([value.hashes_match for value in data.values()]):
295
+ controller.terminate()
296
+ console = Console()
297
+ console.print("[bold red]Error:[/] Hashes do not match.")
298
+ raise click.Abort()
299
+
300
+ if start is True:
301
+ started = controller.start()
302
+ print_start_status(
303
+ sorted(started),
304
+ sorted(set(start_data.ids).difference(set(started))),
305
+ )
306
+ controller.terminate()
307
+
308
+
309
+ @main.command()
310
+ @click.pass_context
311
+ def monitor(ctx):
312
+ """Monitor running applications."""
313
+ try:
314
+ settings = ControllerSettings(
315
+ serial_port=ctx.obj["port"],
316
+ serial_baudrate=ctx.obj["baudrate"],
317
+ mqtt_host="argus.paris.inria.fr",
318
+ mqtt_port=8883,
319
+ edge=ctx.obj["edge"],
320
+ devices=ctx.obj["devices"],
321
+ )
322
+ controller = Controller(settings)
323
+ except (
324
+ SerialInterfaceException,
325
+ serial.serialutil.SerialException,
326
+ ) as exc:
327
+ console = Console()
328
+ console.print(f"[bold red]Error:[/] {exc}")
329
+ return {}
330
+ try:
331
+ controller.monitor()
332
+ except KeyboardInterrupt:
333
+ print("Stopping monitor.")
334
+ finally:
335
+ controller.terminate()
336
+
337
+
338
+ @main.command()
339
+ @click.pass_context
340
+ def status(ctx):
341
+ """Print current status of the robots."""
342
+ settings = ControllerSettings(
343
+ serial_port=ctx.obj["port"],
344
+ serial_baudrate=ctx.obj["baudrate"],
345
+ mqtt_host="argus.paris.inria.fr",
346
+ mqtt_port=8883,
347
+ edge=ctx.obj["edge"],
348
+ devices=ctx.obj["devices"],
349
+ )
350
+ controller = Controller(settings)
351
+ data = controller.status()
352
+ if not data:
353
+ click.echo("No devices found.")
354
+ else:
355
+ print_status(data)
356
+ controller.terminate()
357
+
358
+
359
+ @main.command()
360
+ @click.argument("message", type=str, required=True)
361
+ @click.pass_context
362
+ def message(ctx, message):
363
+ """Send a custom text message to the robots."""
364
+ settings = ControllerSettings(
365
+ serial_port=ctx.obj["port"],
366
+ serial_baudrate=ctx.obj["baudrate"],
367
+ mqtt_host="argus.paris.inria.fr",
368
+ mqtt_port=8883,
369
+ edge=ctx.obj["edge"],
370
+ devices=ctx.obj["devices"],
371
+ )
372
+ controller = Controller(settings)
373
+ controller.send_message(message)
374
+ controller.terminate()
375
+
376
+
377
+ if __name__ == "__main__":
378
+ main(obj={})
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"