meshmap 0.1.0__py3-none-any.whl

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.
meshmap/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """meshmap - A tool to scan and map meshcore network graphs."""
2
+
3
+ from meshmap import crypto, decoder, models
4
+ from meshmap.scanner import MeshScanner
5
+ from meshmap.sniffer import PacketSniffer
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "MeshScanner",
11
+ "PacketSniffer",
12
+ "models",
13
+ "decoder",
14
+ "crypto",
15
+ ]
@@ -0,0 +1,128 @@
1
+ """Command-line interface for meshmap using Click."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from .contacts import all_contacts
10
+ from .discover_repeaters import discover_repeaters
11
+ from .explore import explore
12
+ from .export_key import export_key
13
+ from .get_neighbours import get_neighbours_cmd
14
+ from .guest_login import guest_login
15
+ from .rf_discover import rf_discovery
16
+ from .scan import scan, scan_zero_hop
17
+ from .sniff import sniff
18
+
19
+
20
+ def _load_sniff_keys(keyfiles: tuple) -> list[dict[str, str]]:
21
+ """Validate and load private key files for packet decryption."""
22
+ private_keys = []
23
+ for keyfile in keyfiles:
24
+ try:
25
+ key_hex = Path(keyfile).read_text().strip()
26
+ except Exception as exc:
27
+ raise click.BadParameter(f"Cannot read key file {keyfile}: {exc}") from exc
28
+
29
+ if len(key_hex) != 128 or not all(c in "0123456789abcdefABCDEF" for c in key_hex):
30
+ raise click.BadParameter(
31
+ f"{keyfile}: expected 128 hex characters (64-byte Ed25519 key), "
32
+ f"got {len(key_hex)} characters"
33
+ )
34
+ private_keys.append({"hex": key_hex, "file": str(keyfile)})
35
+ return private_keys
36
+
37
+
38
+ @click.group(invoke_without_command=True)
39
+ @click.option(
40
+ "--serial-port",
41
+ "-p",
42
+ required=True,
43
+ type=str,
44
+ help="Serial port to connect to (e.g., /dev/ttyUSB0 or COM3)",
45
+ )
46
+ @click.option(
47
+ "--debug/--no-debug", "-d", default=False, help="Enable low-level debug logging from meshcore"
48
+ )
49
+ @click.option(
50
+ "--verbose/--no-verbose", "-v", default=False, help="Print progress messages to stderr"
51
+ )
52
+ @click.option("--baudrate", "-b", default=115200, type=int, help="Serial port baud rate")
53
+ @click.option(
54
+ "--sniff",
55
+ "sniff_active",
56
+ is_flag=True,
57
+ default=False,
58
+ help="Print RF packets in real-time while the command runs.",
59
+ )
60
+ @click.option(
61
+ "--sniff-key",
62
+ "sniff_keyfiles",
63
+ multiple=True,
64
+ type=click.Path(exists=True, path_type=Path),
65
+ help="Private key file for packet decryption (can be repeated).",
66
+ )
67
+ @click.pass_context
68
+ def cli(
69
+ ctx,
70
+ serial_port: str,
71
+ debug: bool,
72
+ verbose: bool,
73
+ baudrate: int,
74
+ sniff_active: bool,
75
+ sniff_keyfiles: tuple,
76
+ ) -> None:
77
+ """Scan and map meshcore network graphs.
78
+
79
+ By default, scans for 0-hop nodes. Use subcommands for other operations.
80
+ """
81
+ ctx.ensure_object(dict)
82
+ ctx.obj["serial_port"] = serial_port
83
+ ctx.obj["debug"] = debug
84
+ ctx.obj["verbose"] = verbose
85
+ ctx.obj["baudrate"] = baudrate
86
+ ctx.obj["sniff_active"] = sniff_active
87
+ ctx.obj["sniff_keys"] = _load_sniff_keys(sniff_keyfiles) if sniff_keyfiles else []
88
+
89
+ if ctx.invoked_subcommand is None:
90
+ asyncio.run(
91
+ scan_zero_hop(
92
+ serial_port,
93
+ debug,
94
+ baudrate,
95
+ verbose=False,
96
+ sniff_active=sniff_active,
97
+ sniff_keys=ctx.obj["sniff_keys"],
98
+ )
99
+ )
100
+
101
+
102
+ cli.add_command(scan)
103
+ cli.add_command(all_contacts, name="contacts")
104
+ cli.add_command(discover_repeaters, name="discover-repeaters")
105
+ cli.add_command(explore)
106
+ cli.add_command(get_neighbours_cmd, name="get-neighbours")
107
+ cli.add_command(guest_login, name="guest-login")
108
+ cli.add_command(rf_discovery, name="rf-discover")
109
+ cli.add_command(sniff)
110
+ cli.add_command(export_key, name="export-key")
111
+
112
+
113
+ def main() -> int:
114
+ """Main entry point for the meshmap CLI."""
115
+ try:
116
+ cli(obj={})
117
+ return 0
118
+ except SystemExit as e:
119
+ return e.code if isinstance(e.code, int) else 0
120
+ except Exception:
121
+ import traceback
122
+
123
+ traceback.print_exc()
124
+ return 1
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())
@@ -0,0 +1,117 @@
1
+ """Contacts command - show all known contacts."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ import click
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from meshmap.scanner import MeshScanner
15
+
16
+
17
+ @click.command()
18
+ @click.pass_context
19
+ @click.option(
20
+ "--output",
21
+ "-o",
22
+ type=click.Choice(["table", "json", "yaml"], case_sensitive=False),
23
+ default="table",
24
+ show_default=True,
25
+ help="Output format.",
26
+ )
27
+ def all_contacts(ctx, output: str):
28
+ """Show all contacts (including unknown paths)."""
29
+ asyncio.run(
30
+ show_all_contacts(
31
+ ctx.obj["serial_port"],
32
+ ctx.obj["debug"],
33
+ ctx.obj["baudrate"],
34
+ output,
35
+ ctx.obj.get("sniff_active", False),
36
+ ctx.obj.get("sniff_keys", []),
37
+ )
38
+ )
39
+
40
+
41
+ async def show_all_contacts(
42
+ serial_port: str,
43
+ debug: bool,
44
+ baudrate: int,
45
+ output: str,
46
+ sniff_active: bool = False,
47
+ sniff_keys: list | None = None,
48
+ ) -> None:
49
+ """Show all contacts."""
50
+ try:
51
+ scanner = MeshScanner(serial_port, debug=debug)
52
+ await scanner.connect()
53
+
54
+ if sniff_active:
55
+ await scanner.start_sniff(sniff_keys or [])
56
+
57
+ contacts = await scanner.get_all_contacts()
58
+
59
+ if output == "json":
60
+ click.echo(json.dumps(contacts, indent=2, sort_keys=True))
61
+ elif output == "yaml":
62
+ click.echo(yaml.dump(contacts, allow_unicode=True, sort_keys=True), nl=False)
63
+ else:
64
+ _print_contacts_table(contacts)
65
+
66
+ await scanner.disconnect()
67
+
68
+ except Exception as e:
69
+ click.echo(f"Error: {e}", err=True)
70
+ sys.exit(1)
71
+
72
+
73
+ def _print_contacts_table(contacts: dict[str, Any]) -> None:
74
+ """Render contacts as a rich table."""
75
+ console = Console()
76
+
77
+ table = Table(title=f"Contacts ({len(contacts)} total)", show_lines=False)
78
+ table.add_column("Name", style="bold cyan", no_wrap=True)
79
+ table.add_column("Public Key", style="dim")
80
+ table.add_column("Type", justify="center")
81
+ table.add_column("Path Len", justify="right")
82
+ table.add_column("Lat", justify="right")
83
+ table.add_column("Lon", justify="right")
84
+ table.add_column("Last Advert", style="dim")
85
+
86
+ def _path_sort_key(kv: tuple) -> int:
87
+ v = kv[1].get("out_path_len")
88
+ if v is None or v == -1:
89
+ return 1000
90
+ return v
91
+
92
+ sorted_contacts = sorted(contacts.items(), key=_path_sort_key)
93
+
94
+ for pubkey, contact in sorted_contacts:
95
+ name = contact.get("adv_name") or ""
96
+ ctype = contact.get("type", 0)
97
+ type_label = {1: "contact", 2: "repeater", 3: "chatroom"}.get(ctype, str(ctype))
98
+ path_len = contact.get("out_path_len")
99
+ path_str = "flood" if path_len == -1 else (str(path_len) if path_len is not None else "")
100
+ lat = contact.get("adv_lat")
101
+ lon = contact.get("adv_lon")
102
+ lat_str = f"{lat:.5f}" if lat is not None else ""
103
+ lon_str = f"{lon:.5f}" if lon is not None else ""
104
+ ts = contact.get("last_advert")
105
+ last_advert = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S") if ts else ""
106
+
107
+ table.add_row(
108
+ name,
109
+ f"{pubkey[:16]}…",
110
+ type_label,
111
+ path_str,
112
+ lat_str,
113
+ lon_str,
114
+ str(last_advert),
115
+ )
116
+
117
+ console.print(table)
@@ -0,0 +1,137 @@
1
+ """discover-repeaters command - find 0-hop repeaters via active node discovery."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ import click
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from meshmap.models import snr_color
16
+ from meshmap.scanner import MeshScanner
17
+
18
+
19
+ @click.command()
20
+ @click.pass_context
21
+ @click.option(
22
+ "--output",
23
+ "-o",
24
+ type=click.Choice(["table", "json", "yaml"], case_sensitive=False),
25
+ default="table",
26
+ show_default=True,
27
+ help="Output format.",
28
+ )
29
+ @click.option(
30
+ "--wait",
31
+ "-w",
32
+ default=10,
33
+ show_default=True,
34
+ type=int,
35
+ help="Seconds to listen for discovery responses.",
36
+ )
37
+ def discover_repeaters(ctx, output: str, wait: int):
38
+ """Discover repeaters reachable at 0 hops (directly connected).
39
+
40
+ Sends a NODE_DISCOVER_REQ control packet and collects responses from
41
+ nearby repeaters for WAIT seconds.
42
+ """
43
+ asyncio.run(
44
+ _discover_repeaters(
45
+ ctx.obj["serial_port"],
46
+ ctx.obj["debug"],
47
+ ctx.obj["baudrate"],
48
+ output,
49
+ wait,
50
+ ctx.obj.get("sniff_active", False),
51
+ ctx.obj.get("sniff_keys", []),
52
+ )
53
+ )
54
+
55
+
56
+ async def _discover_repeaters(
57
+ serial_port: str,
58
+ debug: bool,
59
+ baudrate: int,
60
+ output: str,
61
+ wait: int,
62
+ sniff_active: bool = False,
63
+ sniff_keys: list | None = None,
64
+ ) -> None:
65
+ try:
66
+ scanner = MeshScanner(serial_port, debug=debug)
67
+ await scanner.connect()
68
+
69
+ # Load contacts first so the sniffer attaches instantly (no blocking
70
+ # ensure_contacts call inside attach()) and is active before we send
71
+ # the discovery request.
72
+ await scanner.get_all_contacts()
73
+
74
+ if sniff_active:
75
+ await scanner.start_sniff(sniff_keys or [])
76
+
77
+ repeaters = await scanner.discover_zero_hop_repeaters(wait_time=wait)
78
+
79
+ if output == "json":
80
+ click.echo(json.dumps(repeaters, indent=2))
81
+ elif output == "yaml":
82
+ click.echo(yaml.dump(repeaters, allow_unicode=True), nl=False)
83
+ else:
84
+ _print_table(repeaters)
85
+
86
+ await scanner.disconnect()
87
+
88
+ except KeyboardInterrupt:
89
+ click.echo("\nInterrupted.")
90
+ sys.exit(130)
91
+ except Exception as e:
92
+ click.echo(f"Error: {e}", err=True)
93
+ if debug:
94
+ import traceback
95
+
96
+ traceback.print_exc()
97
+ sys.exit(1)
98
+
99
+
100
+ def _print_table(repeaters: list[dict[str, Any]]) -> None:
101
+ console = Console()
102
+ table = Table(title=f"0-hop repeaters ({len(repeaters)} found)", show_lines=False)
103
+ table.add_column("Name", style="bold cyan", no_wrap=True)
104
+ table.add_column("Public Key", style="dim")
105
+ table.add_column("SNR", justify="right", no_wrap=True)
106
+ table.add_column("Lat", justify="right")
107
+ table.add_column("Lon", justify="right")
108
+ table.add_column("Last Advert", style="dim")
109
+
110
+ repeaters_sorted = sorted(
111
+ repeaters,
112
+ key=lambda r: r.get("snr") or float("-inf"),
113
+ reverse=True,
114
+ )
115
+
116
+ for node in repeaters_sorted:
117
+ lat = node.get("lat")
118
+ lon = node.get("lon")
119
+ ts = node.get("last_advert")
120
+ last_advert = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S") if ts else ""
121
+ snr = node.get("snr")
122
+ if snr is not None:
123
+ sc = snr_color(snr)
124
+ snr_cell = Text(f"{snr:+.1f} dB", style=sc)
125
+ else:
126
+ snr_cell = Text("—", style="dim")
127
+
128
+ table.add_row(
129
+ node.get("name") or "",
130
+ f"{node['public_key'][:16]}…",
131
+ snr_cell,
132
+ f"{lat:.5f}" if lat is not None else "",
133
+ f"{lon:.5f}" if lon is not None else "",
134
+ last_advert,
135
+ )
136
+
137
+ console.print(table)