The demo's message paths look like a real mesh
Measured against a real Montreal database: most messages arrive twice, through the two repeaters a companion hears, on paths that share their start and split near the end, with 3-byte hashes. The demo did five or six short copies through five neighbours. Our node now hears only two new rooftop repeaters, Villeray and Rosemont (the terrain hides the big ones), companions lose 7 dB at street level, and each sender has its own hash width (mostly 3 bytes). Now 91% of messages arrive twice and 83% of paths use 3-byte hashes. Noor moves to Côte-des-Neiges to stay in reach, and the traces leave through Villeray or Rosemont. The capture finds the Message paths message by its text (a key press can be lost while the transcript draws) and selects the second path, since the first is often a straight line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No files matched your search
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||