diff --git a/docs/screenshots/cardputer-zero/chat.png b/docs/screenshots/cardputer-zero/chat.png index fbbcb60..7ad8d68 100644 Binary files a/docs/screenshots/cardputer-zero/chat.png and b/docs/screenshots/cardputer-zero/chat.png differ diff --git a/docs/screenshots/cardputer-zero/map.png b/docs/screenshots/cardputer-zero/map.png index 0d8a220..719586c 100644 Binary files a/docs/screenshots/cardputer-zero/map.png and b/docs/screenshots/cardputer-zero/map.png differ diff --git a/docs/screenshots/cardputer-zero/paths.png b/docs/screenshots/cardputer-zero/paths.png index 5ef8d43..1f3eded 100644 Binary files a/docs/screenshots/cardputer-zero/paths.png and b/docs/screenshots/cardputer-zero/paths.png differ diff --git a/docs/screenshots/picocalc-lyra/chat.png b/docs/screenshots/picocalc-lyra/chat.png index 30d56f0..751c1f2 100644 Binary files a/docs/screenshots/picocalc-lyra/chat.png and b/docs/screenshots/picocalc-lyra/chat.png differ diff --git a/docs/screenshots/picocalc-lyra/map.png b/docs/screenshots/picocalc-lyra/map.png index 893f119..532585f 100644 Binary files a/docs/screenshots/picocalc-lyra/map.png and b/docs/screenshots/picocalc-lyra/map.png differ diff --git a/docs/screenshots/picocalc-lyra/menu.png b/docs/screenshots/picocalc-lyra/menu.png index aac3695..cc42486 100644 Binary files a/docs/screenshots/picocalc-lyra/menu.png and b/docs/screenshots/picocalc-lyra/menu.png differ diff --git a/docs/screenshots/picocalc-lyra/paths.png b/docs/screenshots/picocalc-lyra/paths.png index 4169179..1ff89e9 100644 Binary files a/docs/screenshots/picocalc-lyra/paths.png and b/docs/screenshots/picocalc-lyra/paths.png differ diff --git a/docs/screenshots/terminal/chat.png b/docs/screenshots/terminal/chat.png index 9b3d761..c3dcab3 100644 Binary files a/docs/screenshots/terminal/chat.png and b/docs/screenshots/terminal/chat.png differ diff --git a/docs/screenshots/terminal/map.png b/docs/screenshots/terminal/map.png index 87e2ad5..7fde3f5 100644 Binary files a/docs/screenshots/terminal/map.png and b/docs/screenshots/terminal/map.png differ diff --git a/docs/screenshots/terminal/menu.png b/docs/screenshots/terminal/menu.png index 2c26b5b..b2d2178 100644 Binary files a/docs/screenshots/terminal/menu.png and b/docs/screenshots/terminal/menu.png differ diff --git a/docs/screenshots/terminal/paths.png b/docs/screenshots/terminal/paths.png index 9327656..78c27d4 100644 Binary files a/docs/screenshots/terminal/paths.png and b/docs/screenshots/terminal/paths.png differ diff --git a/meshterm/demo/script.py b/meshterm/demo/script.py index dfea0ae..88fb32a 100644 --- a/meshterm/demo/script.py +++ b/meshterm/demo/script.py @@ -113,7 +113,7 @@ CHANNEL_LINES: dict[str, tuple[Line, ...]] = { "Jean-Seb", "And the repeater at the chalet hears everyone, even through the trees 😄", ), - Line(at(0, "07:48"), "Noor", "First test from Saint-Laurent, can anyone hear me?"), + Line(at(0, "07:48"), "Noor", "First test from Côte-des-Neiges, can anyone hear me?"), Line(at(0, "07:52"), "Marie-Ève", "@[Noor] got you through Mont-Royal, +6 dB. Welcome!"), Line( at(0, "10:05"), "Mathilde", "Hi everyone! First time on MeshCore, I'm in Saint-Henri 👋" @@ -127,7 +127,7 @@ CHANNEL_LINES: dict[str, tuple[Line, ...]] = { Line( at(0, "10:15"), ME, - "Your advert reaches me in one hop through Mont-Royal, SNR +8. Trace path shows it all", + "Your advert reaches me through Mont-Royal, then Rosemont. Trace path shows it all", ), Line( at(0, "10:40"), @@ -324,26 +324,59 @@ LIVE_CHANNEL: tuple[tuple[float, str, Line], ...] = ( #: the link model, tries again a few times when the trace does not come back (as a person #: does), and stores each try. TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = ( - (at(6, "21:30"), "Longueuil", ("Mont-Royal", "Longueuil", "Mont-Royal")), - (at(5, "20:10"), "Pointe-Claire", ("Mont-Royal", "Pointe-Claire", "Mont-Royal")), + ( + at(6, "21:30"), + "Longueuil", + ("Rosemont", "Stade-Olympique", "Longueuil", "Stade-Olympique", "Rosemont"), + ), + ( + at(5, "20:10"), + "Pointe-Claire", + ("Villeray", "Mont-Royal", "Pointe-Claire", "Mont-Royal", "Villeray"), + ), ( at(5, "20:20"), None, - ("Mont-Royal", "Pointe-Claire", "Île-Bizard", "Pointe-Claire", "Mont-Royal"), + ( + "Villeray", + "Mont-Royal", + "Pointe-Claire", + "Île-Bizard", + "Pointe-Claire", + "Mont-Royal", + "Villeray", + ), ), - (at(4, "21:00"), "Laval-Chomedey", ("Ahuntsic", "Laval-Chomedey", "Ahuntsic")), - (at(3, "22:15"), None, ("Mont-Royal", "Mont-St-Hilaire", "Mont-Royal")), - (at(2, "20:40"), None, ("Oratoire", "Lachine", "Oratoire")), + ( + at(4, "21:00"), + "Laval-Chomedey", + ("Villeray", "Ahuntsic", "Laval-Chomedey", "Ahuntsic", "Villeray"), + ), + (at(3, "22:15"), None, ("Rosemont", "Mont-Royal", "Mont-St-Hilaire", "Mont-Royal", "Rosemont")), + (at(2, "20:40"), None, ("Villeray", "Oratoire", "Lachine", "Oratoire", "Villeray")), ( at(2, "21:50"), None, - ("Mont-Royal", "Mont-St-Bruno", "Mont-St-Hilaire", "Mont-St-Bruno", "Mont-Royal"), + ( + "Rosemont", + "Stade-Olympique", + "Mont-St-Bruno", + "Mont-St-Hilaire", + "Mont-St-Bruno", + "Stade-Olympique", + "Rosemont", + ), + ), + ( + at(1, "20:30"), + None, + ("Villeray", "Mont-Royal", "Laval-Chomedey", "Ahuntsic", "Stade-Olympique", "Rosemont"), ), - (at(1, "20:30"), None, ("Mont-Royal", "Laval-Chomedey", "Ahuntsic", "Stade-Olympique")), ( at(1, "21:10"), None, ( + "Rosemont", "Centre-Ville", "Verdun-Quai", "Brossard", @@ -359,12 +392,14 @@ TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = ( "Lachine", "Pointe-Claire", "Mont-Royal", + "Villeray", ), ), ( at(1, "21:40"), None, ( + "Villeray", "Oratoire", "Lachine", "Pointe-Claire", @@ -373,10 +408,11 @@ TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = ( "Mont-St-Bruno", "Mont-St-Hilaire", "Stade-Olympique", + "Rosemont", ), ), - (at(0, "09:05"), "Brossard", ("Mont-Royal", "Brossard", "Mont-Royal")), - (at(0, "10:20"), None, ("Mont-Royal", "Longueuil", "Stade-Olympique")), + (at(0, "09:05"), "Brossard", ("Rosemont", "Mont-Royal", "Brossard", "Mont-Royal", "Rosemont")), + (at(0, "10:20"), None, ("Rosemont", "Longueuil", "Stade-Olympique", "Villeray")), ) #: The Watchtower: the nodes that our node watches, and the alerts that it raised and the diff --git a/meshterm/demo/traffic.py b/meshterm/demo/traffic.py index f41d9ae..5aee980 100644 --- a/meshterm/demo/traffic.py +++ b/meshterm/demo/traffic.py @@ -51,6 +51,7 @@ from .world import ( flood, link_snr, loss_chance, + path_width, reading, ) @@ -255,8 +256,16 @@ def heard_flood( if all(_relays_region(relay, region) for relay in copy.relays) ] route = "TC_FLOOD" if region else "FLOOD" + width = path_width(origin) out = [ - _packet(origin if advert else None, start_s, copy, {"route_typename": route, **body}, rng) + _packet( + origin if advert else None, + start_s, + copy, + {"route_typename": route, **body}, + rng, + width=width, + ) for copy in copies ] if advert and copies: @@ -266,11 +275,17 @@ def heard_flood( def _packet( - origin: Node | None, start_s: float, copy: Copy, body: dict, rng: random.Random + origin: Node | None, + start_s: float, + copy: Copy, + body: dict, + rng: random.Random, + *, + width: int = 1, ) -> Heard: - """One ``packet`` observation for one copy of a transmission.""" - path = ",".join(relay.hash for relay in copy.relays) - raw = {**body, "path_len": len(copy.relays), "path_hash_size": 1} + """One ``packet`` observation for one copy of a transmission, with ``width``-byte hashes.""" + path = ",".join(relay.public_key[: 2 * width] for relay in copy.relays) + raw = {**body, "path_len": len(copy.relays), "path_hash_size": width} share = origin is not None and origin.shares_location return Heard( start_s + copy.delay_s, @@ -348,7 +363,8 @@ def heard_direct( continue remaining = route[index:] copy = Copy(remaining, reading(snr, rng), elapsed) - out.append(_packet(None, start_s, copy, {"route_typename": "DIRECT", **body}, rng)) + direct = {"route_typename": "DIRECT", **body} + out.append(_packet(None, start_s, copy, direct, rng, width=path_width(src))) return out diff --git a/meshterm/demo/world.py b/meshterm/demo/world.py index 5043858..967fcf0 100644 --- a/meshterm/demo/world.py +++ b/meshterm/demo/world.py @@ -110,7 +110,9 @@ class Node: _R, _C, _ROOM, _S = NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR #: Our node: the companion that the demo device is. It stands in La Petite-Patrie, near the -#: Jean-Talon market, with an antenna on the roof of a triplex. +#: Jean-Talon market, with an antenna on the roof of a triplex. The buildings around it +#: hide the big repeaters (refer to :data:`_OBSTRUCTED`). It hears two small repeaters on +#: the roofs nearby, Villeray and Rosemont, as a companion in the city usually does. SELF = Node("Alex Base", 0x91, _C, 45.5350, -73.6030, 2.0, first_heard_days=400.0) # fmt: off @@ -132,6 +134,8 @@ NODES: tuple[Node, ...] = ( Node("Brossard", 0x18, _R, 45.4570, -73.4660, 6.0, first_heard_days=70.0), Node("Mont-St-Bruno", 0xE1, _R, 45.5530, -73.3260, 13.0, first_heard_days=280.0), Node("Mont-St-Hilaire", 0x61, _R, 45.5490, -73.1570, 15.0, first_heard_days=45.0), + Node("Villeray", 0x70, _R, 45.5470, -73.6170, 4.0, first_heard_days=250.0), + Node("Rosemont", 0xD8, _R, 45.5420, -73.5850, 4.0, first_heard_days=170.0), # The room servers. Node("MTL BBS", 0x3B, _ROOM, 45.5225, -73.5840, 4.0, first_heard_days=330.0), Node("Café Mesh", 0x64, _ROOM, 45.5250, -73.5990, 3.0, first_heard_days=60.0, @@ -154,7 +158,7 @@ NODES: tuple[Node, ...] = ( Node("Kim-NDG", 0xBF, _C, 45.4735, -73.6145, 1.0, first_heard_days=260.0), Node("Nico R.", 0x94, _C, 45.5110, -73.5695, 0.0, shares_location=False, first_heard_days=90.0, talk=1.3), - Node("Noor", 0x50, _C, 45.5075, -73.6880, 1.0, first_heard_days=75.0), + Node("Noor", 0x50, _C, 45.4960, -73.6300, 1.0, first_heard_days=75.0), Node("Chloé L.", 0xC9, _C, 45.5170, -73.6060, 1.0, first_heard_days=220.0), Node("Sam Verdun", 0x68, _C, 45.4610, -73.5690, 1.0, first_heard_days=240.0), Node("Steve K.", 0x22, _C, 45.5340, -73.5050, 1.0, first_heard_days=40.0), @@ -214,6 +218,35 @@ _DECADE_DB = 25.0 _SOFT_CAP_DB = 10.0 +#: The extra loss (dB) of a link from or to a companion of the people of the mesh. Such a +#: node is indoors, or in a pocket, at street level, so the buildings around it take more +#: than the distance explains. A link between two companions takes it two times. Thus a +#: companion reaches the repeaters near it, and another companion only when the two are +#: close, as on a real mesh. Our node has its antenna on a roof, and the room servers and +#: the sensors are outdoors, so they do not take it. +_GROUND_DB = 7.0 + +#: Links that the terrain blocks more than the model knows, by the names of their two +#: ends, in dB of extra loss. The buildings around our node hide the big repeaters from +#: it. Thus each flood reaches our node through the two small repeaters near it, usually +#: one copy from each. This is what a real companion in Montréal hears: two copies of most +#: messages, on paths that share their start and divide near the end. +_OBSTRUCTED: dict[frozenset[str], float] = { + frozenset((SELF.name, name)): 14.0 + for name in ("Mont-Royal", "Oratoire", "Centre-Ville", "Stade-Olympique", "Ahuntsic") +} + + +def _ground_loss(a: Node, b: Node) -> float: + """The loss that the street level adds to a link (refer to :data:`_GROUND_DB`).""" + return _GROUND_DB * (_indoors(a) + _indoors(b)) + + +def _indoors(node: Node) -> bool: + """Whether ``node`` is a companion at street level: each one except our node.""" + return node.node_type == NODE_TYPE_CHAT and node is not SELF + + def _skew(a: Node, b: Node) -> float: """A fixed offset (dB) for the link from ``a`` to ``b``: the terrain that no model knows. @@ -253,6 +286,7 @@ def link_snr(a: Node, b: Node) -> float | None: return None km = max(haversine_km(a.lat, a.lon, b.lat, b.lon), 0.05) snr = _ONE_KM_DB - _DECADE_DB * math.log10(km) + a.site_db + b.site_db + _skew(a, b) + snr -= _ground_loss(a, b) + _OBSTRUCTED.get(frozenset((a.name, b.name)), 0.0) if snr > _SOFT_CAP_DB: snr = _SOFT_CAP_DB + (snr - _SOFT_CAP_DB) * 0.25 return round(snr, 2) if snr >= FLOOR_DB else None @@ -336,6 +370,30 @@ def _link_cost(snr: float) -> float: return 1.0 + 4.0 * loss_chance(snr) +#: The nodes whose floods do not use three-byte hashes, and the width that they use. Our +#: node keeps the default of its device, one byte. +_NARROW_PATHS: dict[str, int] = { + SELF.name: 1, + "Steve K.": 2, + "Raphaël": 2, + "Olivier.T": 1, + "Tomás": 1, + "T1000-E": 1, + "Wio Tracker": 1, + "Mobile Node": 1, +} + + +def path_width(origin: Node) -> int: + """The bytes of each hash in the path of a flood from ``origin``. + + The node that starts a flood sets the width, and each relay adds its hash at that + width. Most nodes of the mesh of Montréal use three bytes, a few use two, and some + older ones still use one (:data:`_NARROW_PATHS`). + """ + return _NARROW_PATHS.get(origin.name, 3) + + @dataclass(frozen=True, slots=True) class Copy: """One copy of a flood that our node hears. @@ -506,7 +564,15 @@ REGION_TABLES: dict[str, list[tuple[str, str, bool]]] = {} _ISLAND = [("qc", "*", True), ("mtl", "qc", True), ("mtl-west", "mtl", True)] _NORTH = [("qc", "*", True), ("mtl", "qc", True), ("laval", "qc", True)] _SOUTH = [("qc", "*", True), ("mtl", "qc", False), ("south-shore", "qc", True)] -for _name in ("Oratoire", "Centre-Ville", "Stade-Olympique", "Verdun-Quai", "Ahuntsic"): +for _name in ( + "Oratoire", + "Centre-Ville", + "Stade-Olympique", + "Verdun-Quai", + "Ahuntsic", + "Villeray", + "Rosemont", +): REGION_TABLES[_name] = _ISLAND for _name in ("Lachine", "Pointe-Claire", "Île-Bizard"): REGION_TABLES[_name] = [*_ISLAND, ("west-island", "mtl-west", True)] diff --git a/scripts/screenshots.py b/scripts/screenshots.py index c151c3e..7f2b4a5 100644 --- a/scripts/screenshots.py +++ b/scripts/screenshots.py @@ -80,10 +80,13 @@ NOW = datetime(2026, 10, 15, 11, 42, tzinfo=timezone(timedelta(hours=-4))) #: system. TZ = "EST5EDT" -#: How many times the capture presses ↑ in the chat to select the message whose paths it -#: shows: the line of Gabriel in Beloeil, which arrives on five paths through -#: Mont-St-Hilaire. -PATHS_MESSAGE = 2 +#: The message whose paths the capture shows: the channel to type in the picker, how many +#: times to press ↑ from the newest message, and a part of its text near the start, which +#: the quote at the top of the dialog shows on each platform. The capture checks the quote, +#: and presses ↑ again until it finds the message, because a key press can be lost while +#: the transcript draws. In the dialog it then selects the second path: the first one is +#: often a straight line. +PATHS_MESSAGE = ("public", 2, "Hello from Beloeil") def main(argv: list[str]) -> int: @@ -177,6 +180,15 @@ class Capture: screen = self.terminal.text() raise TimeoutError(f"{text!r} did not appear. The screen:\n{screen}") + def quote(self) -> str: + """The quote of the message in the paths dialog: the line above its "heard" line.""" + with self.lock: + lines = self.terminal.text().splitlines() + for index, line in enumerate(lines): + if index and "heard" in line and "time" in line: + return lines[index - 1] + return "" + def grid(self) -> list[list[tuple[str, object]]]: """A copy of the cells of the screen.""" with self.lock: @@ -269,10 +281,35 @@ def _tour(capture: Capture, type_text: Callable[[str], None], save: Callable[[st capture.settle() save("chat") # The paths of a message. - press("up", count=PATHS_MESSAGE) + channel, ups, snippet = PATHS_MESSAGE + if channel != "public": + press(text="w", ctrl=True) + capture.settle(quiet=0.8) + press("escape") + typed("chat") + press("enter") + capture.wait_for("Public") + capture.settle() + typed(channel) + press("enter") + capture.settle(quiet=1.5) + for _ in range(ups): + press("up") + time.sleep(0.15) capture.settle(quiet=0.6) - press(text="p", ctrl=True) - capture.settle(quiet=2.0) + for _ in range(8): + press(text="p", ctrl=True) + capture.settle(quiet=2.0) + if snippet in capture.quote(): + break + press("escape") + capture.settle(quiet=0.6) + press("up") + capture.settle(quiet=0.6) + else: + raise TimeoutError(f"no message with {snippet!r} in {channel}") + press("down") + capture.settle(quiet=1.0) save("paths") # The map, from the main menu. press("escape") diff --git a/tests/test_demo.py b/tests/test_demo.py index 13f533a..89c76b4 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -62,7 +62,7 @@ def test_the_demo_device_traces_a_path() -> None: async def run(): # noqa: ANN202 device = DemoDevice() await device.connect() - spec = ",".join(world.node(n).hash for n in ("Mont-Royal", "Oratoire", "Mont-Royal")) + spec = ",".join(world.node(n).hash for n in ("Rosemont", "Mont-Royal", "Rosemont")) for _ in range(10): result = await device.run_trace("(path)", path=spec) if result.success: @@ -71,7 +71,7 @@ def test_the_demo_device_traces_a_path() -> None: result = asyncio.run(run()) assert result.success - assert [h.node for h in result.hops] == ["2a", "4f", "2a", None] + assert [h.node for h in result.hops] == ["d8", "2a", "d8", None] def test_prepare_writes_a_home(tmp_path: Path) -> None: