meshbench 0.0.5__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.
@@ -0,0 +1,61 @@
1
+ /meshbench
2
+ /dist/
3
+ *.exe
4
+
5
+ # Build caches for MeshCore firmware refs (ADR-0009) — reproducible, large
6
+ /firmware-cache/
7
+
8
+ # DEM tiles; large and reproducible from the tile server
9
+ /dem-cache/
10
+
11
+ # Captures and IQ recordings — can be enormous (1 MB/s per receiver)
12
+ /captures/
13
+ *.pcapng
14
+ *.sigmf-data
15
+ *.cf32
16
+
17
+ # Python build artifacts, from the client under pkg/client-python and from
18
+ # anything that imports it. Reproducible from the source beside them, and here
19
+ # rather than beside the client because a test run leaves them in the working
20
+ # tree and the next `git add -A` on any branch picks them up - which is exactly
21
+ # how eleven of them got committed once.
22
+ __pycache__/
23
+ *.py[cod]
24
+ *.egg-info/
25
+ .pytest_cache/
26
+ .ruff_cache/
27
+ /pkg/client-python/build/
28
+ /pkg/client-python/dist/
29
+ /.venv/
30
+ /venv/
31
+
32
+ /.idea/
33
+ /.vscode/
34
+ *.swp
35
+ # Local Claude Code state, but not the skills: those describe how to drive this
36
+ # project and are worth sharing. Anything personal or machine-specific stays
37
+ # out.
38
+ .claude/*
39
+ !.claude/skills/
40
+ .claude/settings.local.json
41
+ tools/armfw/build/
42
+
43
+ # Tool binaries built at the repo root by `go build ./tools/...`. They shadow
44
+ # the packages they are built from, so tab-completing the name gets the binary.
45
+ # Every `package main` under tools/ produces one, named after its directory; the
46
+ # list is the whole set, because a missing one sits untracked in `git status`
47
+ # until a `git add -A` sweeps it in - which is how eleven Python artifacts got
48
+ # committed once, and how these four kept reappearing.
49
+ /clientgen
50
+ /envgen
51
+ /goldencap
52
+ /licgen
53
+ /mockup
54
+ /render
55
+
56
+ # built test binaries
57
+ *.test
58
+
59
+ # Written by `go test -coverprofile` for a Sonar scan; regenerated on demand.
60
+ coverage.out
61
+ .carto-key
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.5
2
+ Name: meshbench
3
+ Version: 0.0.5
4
+ Summary: Drive a MeshBench workbench: RF-accurate MeshCore simulation, from Python
5
+ Project-URL: Homepage, https://github.com/MeshBench/meshbench
6
+ Project-URL: Issues, https://github.com/MeshBench/meshbench/issues
7
+ Author: MeshBench
8
+ License-Expression: GPL-3.0-or-later
9
+ Keywords: lora,mesh,meshcore,rf,simulation
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications :: Ham Radio
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # meshbench
22
+
23
+ Drive a [MeshBench](https://github.com/MeshBench/meshbench) workbench from
24
+ Python: real MeshCore firmware over a sample-accurate LoRa channel, scripted.
25
+
26
+ ```python
27
+ from datetime import timedelta
28
+
29
+ from meshbench import Workbench
30
+
31
+ with Workbench.headless(fixture="fife-strict", seed=9001) as wb:
32
+ wb.sim.run(timedelta(minutes=5))
33
+ print(wb.provenance())
34
+ print(wb.events.total(), "events")
35
+ ```
36
+
37
+ `headless` needs no display, no GPU and no toolkit. `Workbench.attach()`
38
+ connects to a workbench somebody is already looking at and never closes it.
39
+
40
+ Where more than one is running, `meshbench.sessions()` says which: the address
41
+ to connect on, the process, when it started, and what it has open. Pass a row
42
+ straight to `attach()`. A session that was killed rather than closed is not
43
+ listed, because the check is a dial of the address and not a look at a socket
44
+ file or a pid.
45
+
46
+ ## Installing
47
+
48
+ ```
49
+ pip install meshbench
50
+ ```
51
+
52
+ You also need the `meshbench` binary on `PATH` — the package drives it, it
53
+ does not contain it.
54
+
55
+ ## What it looks like
56
+
57
+ ```python
58
+ wb.project.new(place="Fife")
59
+ wb.nodes.place_many(
60
+ [
61
+ {"name": "R1", "kind": Kind.SIMPLE_REPEATER, "lat": 56.20, "lon": -3.20},
62
+ {"name": "C1", "kind": Kind.COMPANION, "lat": 56.19, "lon": -3.17},
63
+ ]
64
+ )
65
+ wb.sim.start()
66
+ wb.firmware.wait_started() # a sensible default, or pass a timedelta
67
+
68
+ node = wb.nodes["C1"]
69
+ node.firmware = wb.firmware.find("companion-v1.17.0")
70
+ print(node.console.ask("get region"))
71
+ ```
72
+
73
+ `wb.call(verb, params)` is the whole API underneath, and stays public: anything
74
+ this package has not shaped is one line away rather than a blocker.
75
+
76
+ ## Testing firmware with it
77
+
78
+ The package registers a pytest plugin, so there is no `conftest.py` to copy:
79
+
80
+ ```python
81
+ def test_the_flood_reaches_glenrothes(meshbench):
82
+ meshbench.project.open("fixtures/fixture-fife-strict.json")
83
+ meshbench.sim.run(timedelta(minutes=5))
84
+ assert meshbench.events.total() > 0
85
+ ```
86
+
87
+ One workbench for the whole run — starting firmware on a real mesh is minutes,
88
+ not milliseconds — with the scenario cleared between tests so reuse does not
89
+ leak. `--meshbench-socket` attaches to one you are already running instead.
90
+
91
+ **A failing test prints the provenance**, whether or not it asked. Somebody
92
+ reading a failed assertion about a mesh is deciding whether their firmware
93
+ change broke something, and they need to know what the run assumed first.
94
+
95
+ ## Two things that will bite otherwise
96
+
97
+ **Simulated time is not your time.** Every duration here is a
98
+ `datetime.timedelta` - Python already has a duration type, so this package does
99
+ not invent one - but two different clocks are measured in them:
100
+
101
+ - **the mesh's**, for `sim.run(...)`, `schedule.add(at=, every=)` and
102
+ `sim.wait_until(at=)`
103
+ - **yours**, for every `timeout` and for `sim.run(wait=)`
104
+
105
+ `sim.run(timedelta(minutes=5), wait=timedelta(minutes=60))` is five minutes of
106
+ the mesh's clock, and up to an hour of yours waiting for it. On 155 emulated
107
+ nodes that gap is the normal case, which is why they are separate arguments.
108
+ Every wait has a defensible default, so `wait_started()` on its own is fine.
109
+
110
+ **A node answers on its next loop.** Its loop only runs when the engine steps,
111
+ so reading a console straight after writing to it reads the moment *before* the
112
+ command was sent. Use `console.ask()`, which gives the mesh its own time first.
113
+
114
+ ## Honesty
115
+
116
+ Every result comes out of a simulator that is **kinder than the air**: no
117
+ multipath, no body loss, no oscillator error. The measured biases are nearly
118
+ all in one direction, which is what makes a result usable — treat it as a best
119
+ case. `wb.provenance()` says what a given run assumed, and it is meant to be
120
+ printed above any number you publish.
121
+
122
+ ## Licence
123
+
124
+ GPL-3.0-or-later, with the rest of MeshBench.
@@ -0,0 +1,104 @@
1
+ # meshbench
2
+
3
+ Drive a [MeshBench](https://github.com/MeshBench/meshbench) workbench from
4
+ Python: real MeshCore firmware over a sample-accurate LoRa channel, scripted.
5
+
6
+ ```python
7
+ from datetime import timedelta
8
+
9
+ from meshbench import Workbench
10
+
11
+ with Workbench.headless(fixture="fife-strict", seed=9001) as wb:
12
+ wb.sim.run(timedelta(minutes=5))
13
+ print(wb.provenance())
14
+ print(wb.events.total(), "events")
15
+ ```
16
+
17
+ `headless` needs no display, no GPU and no toolkit. `Workbench.attach()`
18
+ connects to a workbench somebody is already looking at and never closes it.
19
+
20
+ Where more than one is running, `meshbench.sessions()` says which: the address
21
+ to connect on, the process, when it started, and what it has open. Pass a row
22
+ straight to `attach()`. A session that was killed rather than closed is not
23
+ listed, because the check is a dial of the address and not a look at a socket
24
+ file or a pid.
25
+
26
+ ## Installing
27
+
28
+ ```
29
+ pip install meshbench
30
+ ```
31
+
32
+ You also need the `meshbench` binary on `PATH` — the package drives it, it
33
+ does not contain it.
34
+
35
+ ## What it looks like
36
+
37
+ ```python
38
+ wb.project.new(place="Fife")
39
+ wb.nodes.place_many(
40
+ [
41
+ {"name": "R1", "kind": Kind.SIMPLE_REPEATER, "lat": 56.20, "lon": -3.20},
42
+ {"name": "C1", "kind": Kind.COMPANION, "lat": 56.19, "lon": -3.17},
43
+ ]
44
+ )
45
+ wb.sim.start()
46
+ wb.firmware.wait_started() # a sensible default, or pass a timedelta
47
+
48
+ node = wb.nodes["C1"]
49
+ node.firmware = wb.firmware.find("companion-v1.17.0")
50
+ print(node.console.ask("get region"))
51
+ ```
52
+
53
+ `wb.call(verb, params)` is the whole API underneath, and stays public: anything
54
+ this package has not shaped is one line away rather than a blocker.
55
+
56
+ ## Testing firmware with it
57
+
58
+ The package registers a pytest plugin, so there is no `conftest.py` to copy:
59
+
60
+ ```python
61
+ def test_the_flood_reaches_glenrothes(meshbench):
62
+ meshbench.project.open("fixtures/fixture-fife-strict.json")
63
+ meshbench.sim.run(timedelta(minutes=5))
64
+ assert meshbench.events.total() > 0
65
+ ```
66
+
67
+ One workbench for the whole run — starting firmware on a real mesh is minutes,
68
+ not milliseconds — with the scenario cleared between tests so reuse does not
69
+ leak. `--meshbench-socket` attaches to one you are already running instead.
70
+
71
+ **A failing test prints the provenance**, whether or not it asked. Somebody
72
+ reading a failed assertion about a mesh is deciding whether their firmware
73
+ change broke something, and they need to know what the run assumed first.
74
+
75
+ ## Two things that will bite otherwise
76
+
77
+ **Simulated time is not your time.** Every duration here is a
78
+ `datetime.timedelta` - Python already has a duration type, so this package does
79
+ not invent one - but two different clocks are measured in them:
80
+
81
+ - **the mesh's**, for `sim.run(...)`, `schedule.add(at=, every=)` and
82
+ `sim.wait_until(at=)`
83
+ - **yours**, for every `timeout` and for `sim.run(wait=)`
84
+
85
+ `sim.run(timedelta(minutes=5), wait=timedelta(minutes=60))` is five minutes of
86
+ the mesh's clock, and up to an hour of yours waiting for it. On 155 emulated
87
+ nodes that gap is the normal case, which is why they are separate arguments.
88
+ Every wait has a defensible default, so `wait_started()` on its own is fine.
89
+
90
+ **A node answers on its next loop.** Its loop only runs when the engine steps,
91
+ so reading a console straight after writing to it reads the moment *before* the
92
+ command was sent. Use `console.ask()`, which gives the mesh its own time first.
93
+
94
+ ## Honesty
95
+
96
+ Every result comes out of a simulator that is **kinder than the air**: no
97
+ multipath, no body loss, no oscillator error. The measured biases are nearly
98
+ all in one direction, which is what makes a result usable — treat it as a best
99
+ case. `wb.provenance()` says what a given run assumed, and it is meant to be
100
+ printed above any number you publish.
101
+
102
+ ## Licence
103
+
104
+ GPL-3.0-or-later, with the rest of MeshBench.
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Example 1 from #209: a blank setup, one companion, and its screen on show.
3
+
4
+ ./01_blank_setup_with_a_board.py
5
+
6
+ Costs: a minute or two. wadamesh is imported, not downloaded, so it must
7
+ already be in the library or reachable through WADAMESH_IMAGE (see below).
8
+ Needs a display. It opens the node's own window on the Hardware tab at the
9
+ end, which is the point of it.
10
+
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ from datetime import timedelta
16
+
17
+ from meshbench import Board, Kind, NotFound, Role, Workbench
18
+
19
+ WADAMESH = "wadamesh"
20
+ # wadamesh is imported, not in the download catalogue: a built image to
21
+ # import if it is not already in the library. Point this at one.
22
+ WADAMESH_IMAGE = os.environ.get("WADAMESH_IMAGE")
23
+ BOARD = Board.LILYGO_TDECK
24
+
25
+
26
+ def main() -> None:
27
+ with Workbench.launch() as wb:
28
+ wb.project.new(place="Fife")
29
+
30
+ deck = wb.nodes.place(
31
+ "Deck",
32
+ kind=Kind.COMPANION,
33
+ lat=56.19,
34
+ lon=-3.17,
35
+ board=BOARD,
36
+ )
37
+
38
+ # Whatever the catalogue has, so this does not go stale against a
39
+ # version number typed here.
40
+ wb.firmware.scan()
41
+ try:
42
+ build = wb.firmware.find(WADAMESH, board=BOARD)
43
+ except NotFound:
44
+ # wadamesh is imported, not downloaded - import a built image.
45
+ if not WADAMESH_IMAGE:
46
+ sys.exit(
47
+ f"{WADAMESH} is not in the library; set WADAMESH_IMAGE "
48
+ "to a built image, or import one in the workbench first"
49
+ )
50
+ build = wb.firmware.import_(
51
+ WADAMESH_IMAGE,
52
+ Role.COMPANION_RADIO_USB,
53
+ board=BOARD,
54
+ label=WADAMESH,
55
+ )
56
+
57
+ # Applied: stop, provision, start. On a board that means an emulator,
58
+ # which is why the wait below is generous.
59
+ deck.firmware = build
60
+
61
+ wb.sim.start()
62
+ deck.wait_running(timedelta(minutes=5))
63
+
64
+ # The Hardware tab is where the board draws its own screen, which is
65
+ # the whole reason for making this node a T-Deck.
66
+ tab = wb.window(deck, tab="Hardware")
67
+
68
+ print(f"{deck.name} is up on {build}; its window is open on {tab}")
69
+ print(wb.provenance())
70
+ # Held open for somebody looking at it, and only then. Piped or run
71
+ # from CI there is nobody to press enter, and input() raises EOFError
72
+ # there - so an example that had done everything right ended in a
73
+ # traceback and a non-zero-looking failure.
74
+ if sys.stdin.isatty():
75
+ input("press enter to close the workbench ")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """Example 2 from #209: a fixture trimmed to two, both on a build from a
3
+ MeshCore checkout - and re-runnable without clearing anything down.
4
+
5
+ ./02_two_nodes_on_a_local_build.py ~/src/MeshCore
6
+
7
+ The interesting half is the second run. It attaches to the workbench the first
8
+ one left, stops the clock, rebuilds, repoints the nodes and starts again -
9
+ rather than opening a fresh session and paying for the fixture twice.
10
+
11
+ Costs: minutes, mostly firmware. Real firmware on two nodes, not fifty-eight,
12
+ because trimming is what this example is about.
13
+ """
14
+
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from datetime import timedelta
19
+
20
+ from meshbench import BINARY_ENV, Build, Kind, Role, Workbench
21
+
22
+ # Outskirts of Glasgow, and Glenrothes.
23
+ KEEP = {
24
+ "Glasgow-Outskirts": (55.8720, -4.3300),
25
+ "Glenrothes": (56.1980, -3.1780),
26
+ }
27
+
28
+
29
+ def build_from_checkout(checkout: str, wb: Workbench) -> dict[str, Build]:
30
+ """Build MeshCore and import what came out, one build per role.
31
+
32
+ Both roles from one invocation, deliberately. A locally built repeater
33
+ compiled against a stale shim once answered console output with 0x06 where
34
+ the host expects 0x07: it connected, misbehaved and exited. Two arms of a
35
+ comparison speaking different wire protocols measure the shim, not the
36
+ firmware - so if either arm is built by hand, both are, the same way, at
37
+ the same moment.
38
+ """
39
+ # The same binary the client is driving, not whatever is on PATH: a
40
+ # checkout usually has one built and not installed, and building with one
41
+ # while talking to another is how two arms end up on different code.
42
+ exe = os.environ.get(BINARY_ENV) or "meshbench"
43
+
44
+ out: dict[Role, Build] = {}
45
+ for role in (Role.SIMPLE_REPEATER, Role.COMPANION_RADIO):
46
+ # Named here rather than left to default to the git branch, because
47
+ # the build has to be found again afterwards and a name you chose is
48
+ # the only one you can look up.
49
+ name = f"local-{role}"
50
+ made = subprocess.run(
51
+ [exe, "dev", "-from", checkout, "-role", role, "-name", name],
52
+ capture_output=True,
53
+ text=True,
54
+ check=False,
55
+ )
56
+ if made.returncode != 0:
57
+ sys.exit(
58
+ f"building the {role}: {made.stderr.strip() or made.stdout.strip()}"
59
+ )
60
+ # meshbench dev puts the build in the cache; the library sees it.
61
+ wb.firmware.scan()
62
+ out[role] = wb.firmware.find(name)
63
+ return out
64
+
65
+
66
+ def main() -> None:
67
+ if len(sys.argv) < 2:
68
+ sys.exit("usage: 02_two_nodes_on_a_local_build.py <path to MeshCore>")
69
+ checkout = sys.argv[1]
70
+
71
+ with Workbench.attach_or_launch() as wb:
72
+ # Whether the mesh is already the one this example is about, not
73
+ # whether the session is empty. A launched workbench is never empty:
74
+ # it opens its own default fixture, which is 311 nodes - so "is it
75
+ # empty" was always false, the trim below never ran, and this put a
76
+ # local build on a national network and reported "311 nodes" as
77
+ # though that had been the plan.
78
+ already = {n.name for n in wb.nodes.list()} == set(KEEP)
79
+
80
+ # Stop the clock before anything else. A no-op on a fresh session, and
81
+ # the thing that makes the second run safe on a live one.
82
+ wb.sim.pause()
83
+
84
+ if not already:
85
+ wb.project.open("fife-strict")
86
+ # Put them where they belong first, then delete the rest. keep is
87
+ # all-or-none by design, so naming a node that is not there yet
88
+ # refuses and removes nothing - and one of these two is never in
89
+ # the fixture, so the trim refused on every run that reached it.
90
+ for name, (lat, lon) in KEEP.items():
91
+ if name in wb.nodes:
92
+ wb.nodes[name].move(lat, lon)
93
+ else:
94
+ wb.nodes.place(name, Kind.COMPANION, lat, lon)
95
+ wb.nodes.keep(*KEEP)
96
+ wb.wait_idle(timedelta(minutes=10))
97
+
98
+ builds = build_from_checkout(checkout, wb)
99
+
100
+ # Repoint every node, applied - which stops, provisions and starts it.
101
+ for node in wb.nodes:
102
+ role = (
103
+ Role.COMPANION_RADIO
104
+ if node.info.kind == Kind.COMPANION
105
+ else Role.SIMPLE_REPEATER
106
+ )
107
+ node.firmware = builds[role]
108
+
109
+ wb.sim.start()
110
+ wb.firmware.wait_started(timedelta(minutes=10))
111
+ print(f"{len(wb.nodes)} nodes on a build from {checkout}")
112
+ print(wb.provenance())
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Example 3 from #209: two repeaters, two companions, one of them a T-Deck,
3
+ and a message to the public channel every twenty seconds.
4
+
5
+ ./03_small_mesh_with_traffic.py
6
+
7
+ Needs a display: it opens the workbench so you can watch the traffic move.
8
+
9
+ Costs: about ten minutes of simulated time, and a few of yours.
10
+
11
+ The repeating traffic needed no new verb: schedule.add has taken every_ms all
12
+ along and nothing said so, which to somebody writing a script is the same as
13
+ it not existing.
14
+ """
15
+
16
+ from datetime import timedelta
17
+
18
+ from meshbench import Board, Class, Kind, Workbench
19
+
20
+ MESH = [
21
+ {"name": "R1", "kind": Kind.SIMPLE_REPEATER, "lat": 56.20, "lon": -3.20},
22
+ {"name": "R2", "kind": Kind.SIMPLE_REPEATER, "lat": 56.12, "lon": -3.02},
23
+ {"name": "C1", "kind": Kind.COMPANION, "lat": 56.19, "lon": -3.17},
24
+ {"name": "C2", "kind": Kind.COMPANION, "lat": 56.09, "lon": -3.10},
25
+ ]
26
+
27
+
28
+ def main() -> None:
29
+ with Workbench.launch() as wb:
30
+ wb.project.new(place="Fife")
31
+ wb.nodes.place_many(MESH)
32
+ wb.wait_idle(timedelta(minutes=10))
33
+
34
+ # C1 is the T-Deck. The board goes on before the firmware is pinned,
35
+ # because a host image is not a board image and setting the board
36
+ # clears a pin that was made for different hardware.
37
+ wb.nodes["C1"].board = Board.LILYGO_TDECK
38
+
39
+ # Whatever this machine holds for each role that needs one, rather
40
+ # than a version typed here that goes stale.
41
+ wb.firmware.use_what_is_here()
42
+
43
+ # Every twenty seconds, from the plain companion to the public channel.
44
+ # Simulated seconds - the mesh's own clock, not yours.
45
+ wb.schedule.add(
46
+ "C2",
47
+ "public hello",
48
+ at=timedelta(seconds=5),
49
+ every=timedelta(seconds=20),
50
+ )
51
+
52
+ wb.sim.start()
53
+ wb.firmware.wait_started(timedelta(minutes=10))
54
+ wb.sim.run(timedelta(minutes=10), wait=timedelta(minutes=60))
55
+
56
+ received = [e for e in wb.events.recent(1000) if e.class_ == Class.RECEIVED]
57
+ print(wb.provenance())
58
+ print(f"{wb.events.total()} events, {len(received)} receptions in the tail")
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Example 4: the one CI runs.
3
+
4
+ ./04_headless_regression.py [fixture] [junit.xml]
5
+
6
+ No display, no GPU, no toolkit. Opens a fixture, runs it, checks its
7
+ assertions, writes JUnit, and exits non-zero if the mesh stopped delivering.
8
+ This is the shape a MeshCore pull request would use.
9
+
10
+ Costs: as long as the fixture asks for. fife-strict at five simulated minutes
11
+ is a couple of minutes of wall clock with no firmware, and considerably more
12
+ with it.
13
+ """
14
+
15
+ import sys
16
+ from datetime import timedelta
17
+
18
+ from meshbench import Workbench
19
+
20
+ SEED = 9001
21
+
22
+
23
+ def main() -> int:
24
+ fixture = sys.argv[1] if len(sys.argv) > 1 else "fife-strict"
25
+ junit = sys.argv[2] if len(sys.argv) > 2 else ""
26
+
27
+ with Workbench.headless(fixture=fixture, seed=SEED) as wb:
28
+ # Bring the mesh up before running the clock. sim.run only advances
29
+ # time; without this the firmware never starts, nothing transmits, and
30
+ # the run reports every assertion failed on a tree with nothing wrong
31
+ # with it - which is the worst thing a regression check can do.
32
+ wb.sim.start()
33
+ wb.firmware.wait_started()
34
+
35
+ wb.sim.run(timedelta(minutes=5), wait=timedelta(minutes=60))
36
+
37
+ report = wb.assertions.check()
38
+
39
+ # The report prints the caveats above the numbers itself, because this
40
+ # is the output somebody pastes into a pull request and the caveats are
41
+ # the half that gets dropped.
42
+ print(report)
43
+ print(f"{wb.events.total()} events")
44
+
45
+ if junit:
46
+ report.write_junit(junit)
47
+
48
+ if report.total == 0:
49
+ # Not a pass. A fixture with no assertions can report but cannot
50
+ # pass or fail, and a green tick that checked nothing is the worst
51
+ # outcome available here.
52
+ return 2
53
+ return 0 if report.ok else 1
54
+
55
+
56
+ if __name__ == "__main__":
57
+ sys.exit(main())
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """Two builds, two nodes, one scenario - the A/B #192 was filed from.
3
+
4
+ ./05_two_builds_in_one_scenario.py <stock version> <path to a local build>
5
+
6
+ The most common real use of this API, and the reason the node window grew a
7
+ firmware control: comparing a stock build against one with a single changed
8
+ constant, on the same mesh, at the same seed.
9
+
10
+ Needs a display: it opens the workbench so you can watch both arms run.
11
+
12
+ Costs: firmware on a whole fixture, so minutes.
13
+ """
14
+
15
+ import sys
16
+ from datetime import timedelta
17
+
18
+ from meshbench import Role, Workbench
19
+
20
+ SEED = 9001
21
+
22
+
23
+ def main() -> None:
24
+ if len(sys.argv) < 3:
25
+ sys.exit(
26
+ "usage: 05_two_builds_in_one_scenario.py <stock version> <local build path>"
27
+ )
28
+ stock_version, local_path = sys.argv[1], sys.argv[2]
29
+
30
+ with Workbench.launch(fixture="fife-strict", seed=SEED) as wb:
31
+ stock = wb.firmware.find(stock_version)
32
+ # The application name the verbs are keyed on, not the catalogue's
33
+ # shorter "repeater": a build imported under a role no node has is a
34
+ # build nothing will ever run.
35
+ changed = wb.firmware.import_(local_path, Role.SIMPLE_REPEATER)
36
+
37
+ # Two nodes far enough apart to be independently interesting, one on
38
+ # each build. Applied, which restarts each of them.
39
+ a, b = list(wb.nodes)[0], list(wb.nodes)[1]
40
+ a.firmware = stock
41
+ b.firmware = changed
42
+
43
+ wb.sim.start()
44
+ wb.firmware.wait_started(timedelta(minutes=15))
45
+ wb.sim.run(timedelta(minutes=5), wait=timedelta(minutes=60))
46
+
47
+ # Per node, because the whole point is which of the two behaved
48
+ # differently - a total would hide it.
49
+ print(wb.provenance())
50
+ for node, build in ((a, stock), (b, changed)):
51
+ s = node.stat
52
+ print(f"{node.name:24} {str(build):32} sent {s.sent:4} heard {s.heard:4}")
53
+
54
+ # One run of one seed is one draw. A difference here is a hypothesis,
55
+ # not a result: run it across seeds with experiment.* before believing
56
+ # anything.
57
+ print("\none seed, one draw - vary the seed before calling this a difference")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()