TX optimize had nothing to find in the demo: the demo device answered each trace from the world's links and ignored the power that the sweep set on a repeater. Now a relay that TX optimize tuned is heard less well on the link that it transmits into, on the simulator's curve with its peak at 19 dBm, and a reading under the decode floor loses the trace. The repeaters of the world read 22 dBm, so the sweep finds a lower power with a better signal. A relay that nobody tuned keeps the readings of the world. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
162 lines
6.4 KiB
Python
162 lines
6.4 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The demo world: the same history at each run, and a device that agrees with it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from meshterm.core.channels import channel_identity
|
|
from meshterm.core.models import utcnow
|
|
from meshterm.demo import history, world
|
|
from meshterm.demo.device import DemoDevice
|
|
from meshterm.demo.home import prepare
|
|
from meshterm.persistence.repository import Repository
|
|
|
|
|
|
def _seed(path: Path, now) -> Repository: # noqa: ANN001 - a datetime
|
|
repo = Repository(path)
|
|
history.seed(repo, now)
|
|
return repo
|
|
|
|
|
|
def test_each_node_has_its_own_hash() -> None:
|
|
"""No two nodes of the world share a path hash, so each hop resolves to one node."""
|
|
hashes = [node.hash for node in (world.SELF, *world.NODES)]
|
|
assert len(hashes) == len(set(hashes))
|
|
|
|
|
|
def test_the_history_is_the_same_at_each_run(tmp_path: Path) -> None:
|
|
"""Two runs give the same rows, with the times moved by the gap between the two starts."""
|
|
now = utcnow()
|
|
first = _seed(tmp_path / "a.db", now)
|
|
second = _seed(tmp_path / "b.db", now + timedelta(hours=5))
|
|
assert first.table_counts() == second.table_counts()
|
|
assert first.kind_counts() == second.kind_counts()
|
|
public = world.CHANNELS[0]
|
|
identity = channel_identity(public.name, public.secret)
|
|
|
|
def lines(repo: Repository, start) -> list[tuple[str, int]]: # noqa: ANN001 - a datetime
|
|
rows = repo.recent_chat_messages(is_channel=True, channel_id=identity, limit=500)
|
|
return [(m.text, round((start - m.created_at).total_seconds())) for m in rows]
|
|
|
|
assert lines(first, now) == lines(second, now + timedelta(hours=5))
|
|
|
|
|
|
def test_a_channel_line_has_its_heard_copies(tmp_path: Path) -> None:
|
|
"""The packets of a channel line decrypt to the line, so its paths can be found."""
|
|
from meshterm.services.message_paths import channel_arrivals
|
|
|
|
repo = _seed(tmp_path / "h.db", utcnow())
|
|
public = world.CHANNELS[0]
|
|
identity = channel_identity(public.name, public.secret)
|
|
last = repo.recent_chat_messages(is_channel=True, channel_id=identity, limit=1)[-1]
|
|
arrivals = channel_arrivals(repo, last, channel_name=public.name, secret=public.secret)
|
|
assert arrivals
|
|
|
|
|
|
def test_the_demo_device_traces_a_path() -> None:
|
|
"""A forced path through linked repeaters comes back with a reading for each link."""
|
|
|
|
async def run(): # noqa: ANN202
|
|
device = DemoDevice()
|
|
await device.connect()
|
|
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:
|
|
return result
|
|
return result
|
|
|
|
result = asyncio.run(run())
|
|
assert result.success
|
|
assert [h.node for h in result.hops] == ["d8", "2a", "d8", None]
|
|
|
|
|
|
def test_a_tuned_repeater_is_heard_best_at_its_best_power() -> None:
|
|
"""TX optimize finds a peak: far from its best power, a relay loses the target."""
|
|
from meshterm.demo.device import REMOTE_TX_BEST
|
|
|
|
async def heard(tx: int) -> list[float]:
|
|
device = DemoDevice()
|
|
await device.connect()
|
|
rosemont = next(c for c in await device.get_contacts() if c.name == "Rosemont")
|
|
await device.admin_login(rosemont, world.ADMIN_PASSWORD)
|
|
await device.set_remote_tx_power(rosemont, tx)
|
|
spec = ",".join(world.node(n).hash for n in ("Rosemont", "Mont-Royal", "Rosemont"))
|
|
results = [await device.run_trace("(path)", path=spec) for _ in range(5)]
|
|
return [result.hops[1].snr for result in results if result.success]
|
|
|
|
best = asyncio.run(heard(REMOTE_TX_BEST))
|
|
far = asyncio.run(heard(REMOTE_TX_BEST + 8))
|
|
assert best
|
|
assert not far or max(far) < min(best)
|
|
|
|
|
|
def test_prepare_writes_a_home(tmp_path: Path) -> None:
|
|
"""The demo home gets a database and the stores of the joined room and the alerts."""
|
|
home = tmp_path / "demo"
|
|
prepare(home, utcnow())
|
|
prepare(home, utcnow()) # a second start writes it again over the first
|
|
assert (home / "meshterm.db").exists()
|
|
assert (home / "rooms.json").exists()
|
|
assert (home / "watchtower.json").exists()
|
|
|
|
|
|
def test_each_line_is_sent_while_its_sender_transmits() -> None:
|
|
"""No node speaks before our node first heard it, or after it went quiet."""
|
|
from meshterm.demo import script
|
|
|
|
groups = (script.CHANNEL_LINES, script.DIRECT_LINES, script.ROOM_LINES)
|
|
for group in groups:
|
|
for lines in group.values():
|
|
for line in lines:
|
|
node = world.BY_NAME.get(line.sender)
|
|
if node is None or node is world.SELF:
|
|
continue # an author that no contact names
|
|
age_h = line.age_min / 60.0
|
|
assert node.quiet_hours is not None, line
|
|
assert node.quiet_hours <= age_h <= node.first_heard_days * 24.0, line
|
|
|
|
|
|
def test_our_node_uses_the_path_width_of_the_mesh() -> None:
|
|
"""Our node uses three bytes for each hop, so a trace uses two (the widest it encodes)."""
|
|
from meshterm.ui.trace_screen import collapse_trace_width
|
|
|
|
mode = asyncio.run(DemoDevice().get_path_hash_mode())
|
|
assert mode + 1 == world.path_width(world.SELF) == 3
|
|
assert collapse_trace_width(mode) == 2
|
|
|
|
|
|
def test_a_mentioned_node_answers(monkeypatch) -> None: # noqa: ANN001 - the pytest fixture
|
|
"""A channel message that mentions Dave gets his answer, which mentions our node."""
|
|
from meshterm.demo import script
|
|
|
|
monkeypatch.setattr(script, "REPLY_DELAY_S", 0.0)
|
|
|
|
async def run(): # noqa: ANN202
|
|
device = DemoDevice()
|
|
await device.connect()
|
|
messages = []
|
|
|
|
def on_event(event) -> None: # noqa: ANN001 - a MeshEvent
|
|
if event.message is not None:
|
|
messages.append(event.message)
|
|
|
|
unsubscribe = await device.subscribe_events(on_event)
|
|
await device.send_channel_message(0, "Hey, is @[Dave-Lachine] coming?")
|
|
await device.send_channel_message(0, "Hello, @[Félix]") # no answer in the script
|
|
for _ in range(100):
|
|
if messages:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
await asyncio.sleep(0.5)
|
|
unsubscribe()
|
|
return messages
|
|
|
|
messages = asyncio.run(run())
|
|
assert [m.text for m in messages] == [f"Dave-Lachine: {script.REPLIES['Dave-Lachine']}"]
|
|
assert messages[0].is_channel and messages[0].channel == 0
|
|
assert f"@[{world.SELF.name}]" in messages[0].text
|