Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
93ce68c
fix(repeater): use the openhop_core 1.1 handler API for login, CLI an…
berikobama Oct 27, 2026
d8eb6ff
fix(repeater): make logout local instead of sending a CLI "logout"
berikobama Oct 27, 2026
6133712
fix: use the Ed25519 identity key as the node's own public key
berikobama Oct 27, 2026
21f75e2
fix(ui): rebuild the admin repeater list whenever its repeaters change
berikobama Oct 27, 2026
60b67d8
feat: discover nearby repeaters and ask them for their names
berikobama Oct 27, 2026
d61ab35
test: keep the test suite out of the real app state directories
berikobama Oct 27, 2026
cea4e6d
fix(logging): keep the log in the state directory that XDG_STATE_HOME…
berikobama Oct 27, 2026
4deab07
fix(security): keep the identity seed, database and logs owner-only
berikobama Oct 27, 2026
687fc6e
fix(logging): keep message text, positions and control characters out…
berikobama Oct 27, 2026
3c75e58
fix(privacy): send the position in telemetry replies only when it is …
berikobama Oct 27, 2026
5cc2eb6
fix(privacy): leave anonymous requests to openhop_core's handler
berikobama Oct 27, 2026
d3e00d5
fix(privacy): rate-limit discovery answers and honour the node-type f…
berikobama Oct 27, 2026
4255193
fix(ui): show toast text literally on libadwaita 1.2
berikobama Oct 27, 2026
918d39d
fix(ui): make Export Logs work on GTK 4.8
berikobama Oct 27, 2026
f5bbc6f
fix(ui): close Add Channel and Region Scope dialogs when Enter confir…
berikobama Oct 27, 2026
0e19dd3
fix(ui): keep the main window within a 1024x576 screen
berikobama Oct 27, 2026
360f5b3
fix(ui): keep the open channel and unread counts updating
berikobama Oct 27, 2026
e8cb316
fix(settings): allow a fixed position to be cleared or set to 0
berikobama Oct 27, 2026
bd7350b
fix(analyzer): keep collecting packets while paused, and stop inventi…
berikobama Oct 27, 2026
62822e6
fix: deliver bursts of events to the UI without dropping the oldest
berikobama Oct 27, 2026
a93e4b5
fix(client): never leave a radio call waiting forever
berikobama Oct 27, 2026
a7022e9
fix(client): serialise connect/disconnect and clean up a failed connect
berikobama Oct 27, 2026
dacd2b5
fix(db): run schema upgrades in one transaction
berikobama Oct 27, 2026
388e36f
fix(client): keep processing a batch of events when one of them fails
berikobama Oct 27, 2026
af4fc1e
fix(cli): use the saved settings for listen, send and advert
berikobama Oct 27, 2026
1af9c4f
fix(admin): show the logged-out state after a disconnect
berikobama Oct 27, 2026
62ca82c
fix(admin): enable the command line only for admin logins
berikobama Oct 27, 2026
49a8331
fix(admin): ask before rebooting a repeater
berikobama Oct 27, 2026
cc8aa1b
fix(security): build peers only from adverts openhop_core has verified
berikobama Oct 27, 2026
adb4711
fix(security): never let another key take over a contact's name
berikobama Oct 27, 2026
c8d9438
fix(routing): flood DMs until a route is known, and stop taking route…
berikobama Oct 27, 2026
12e341b
fix: give DM threads their own channel ids
berikobama Oct 27, 2026
48a00bd
fix(messages): send without freezing the UI, retry DMs, and report de…
berikobama Oct 27, 2026
d2ba4c1
fix(messages): stop dropping a repeated DM sent minutes later
berikobama Oct 27, 2026
47b1d2c
fix(messages): pair each decoded message with its own packet
berikobama Oct 27, 2026
3fcf99e
fix(messages): show the real hop count instead of "255 hops"
berikobama Oct 27, 2026
75536ac
fix(security): bound the peer list
berikobama Oct 27, 2026
4fc3544
fix(radio): add the EU/UK Narrow preset and use MeshCore 1.16 preambl…
berikobama Oct 27, 2026
da28aac
fix(radio): pass MeshCore's sync word to the radio explicitly
berikobama Oct 27, 2026
3e2c91c
fix(presets): power the AIOv2 LoRa module from GPIO 16, not the GPS rail
berikobama Oct 27, 2026
d61624f
fix(settings): fall back to the default for an unreadable settings row
berikobama Oct 27, 2026
10f499d
fix(ui): URL-encode the node name in the contact QR code
berikobama Oct 27, 2026
59dafb0
fix(messages): show late CLI replies in Admin and credit room-post au…
berikobama Oct 28, 2026
57c8512
fix(messages): keep message history per channel
berikobama Oct 28, 2026
aafcb9e
fix(channels): remove a private channel's key with the channel, after…
berikobama Oct 28, 2026
c312982
fix(peers): keep details, focus and selection when the peer list refr…
berikobama Oct 28, 2026
fd8e561
fix(ui): reach channel actions from the keyboard
berikobama Oct 28, 2026
aac8cd5
fix(ui): stop leaking a popover per channel right-click
berikobama Oct 28, 2026
a7c85be
fix(ui): make node badges usable from the keyboard
berikobama Oct 28, 2026
b63190a
fix(admin): say "still waiting" instead of ignoring requests while busy
berikobama Oct 28, 2026
e89a711
fix(admin): let Up/Down leave the command line when there is no history
berikobama Oct 28, 2026
dfd804d
fix(admin): open the console after an admin login
berikobama Oct 28, 2026
7ed9dd3
fix(map): update markers when peers move or are renamed
berikobama Oct 28, 2026
d6c2220
fix(gps): report a gpsd position only while there is a current fix
berikobama Oct 28, 2026
b1b60ca
fix(gps): don't power the GPS rail off when the serial fallback stops
berikobama Oct 28, 2026
8d23f2a
fix(cli): let import-channel take the key without the command line
berikobama Oct 28, 2026
80d4be1
fix(client): close the database connection of a replaced session
berikobama Oct 28, 2026
bfd50f2
fix(analyzer): store each received packet once
berikobama Oct 28, 2026
d0bae8c
fix(routing): don't restore routes from stored advert paths at startup
berikobama Oct 28, 2026
c69342b
fix(cli): let `send` reach the peers the app knows
berikobama Oct 28, 2026
c386a77
fix(client): record app-generated events once in the event history
berikobama Oct 28, 2026
dd741c2
fix(cli): end `listen --duration` on time even when nothing is received
berikobama Oct 28, 2026
8894882
fix(requests): skip a contact with an unusable key instead of droppin…
berikobama Oct 28, 2026
fab3433
fix(requests): don't answer replayed requests or transit copies
berikobama Oct 28, 2026
c90bb53
feat(map): add a switch to stop downloading map tiles
berikobama Oct 28, 2026
485fc3c
fix(admin): replace the "telemetry" quick command, which repeaters do…
berikobama Oct 28, 2026
1ca7fd4
fix(ui): stop network paths drawing over the public key
berikobama Oct 28, 2026
b9c2bae
fix(map): show this device's fixed position on the map even when not …
berikobama Oct 28, 2026
601d6e2
fix(map): open where the map was left, or at this device, not on the USA
berikobama Oct 28, 2026
ef446ef
feat(repeater): fetch status, owner info, neighbours and access list
berikobama Oct 28, 2026
cd3ae14
feat(repeater): describe remote repeater settings and read/write them…
berikobama Oct 28, 2026
cd4296c
feat(admin): manage a repeater from tabs instead of only the console
berikobama Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ All commands below assume you are inside `nix develop`.
| `MESHCORE_GPS_DEVICE` | GPS serial port; overrides auto-detection and gpsd (also in Settings > GPS Device) |
| `MESHCORE_GPIO_CHIP` | `/dev/gpiochipN` number (default: 0; also in Settings > Hardware) |
| `MESHCORE_USE_GPIOD_BACKEND=1` | Poll for IRQ edges instead of using kernel edge interrupts |
| `MESHCORE_EN_PINS` | Comma-separated GPIO pins driven HIGH at init to power the radio (HG AIOv2: `27`) |
| `MESHCORE_EN_PINS` | Comma-separated GPIO pins driven HIGH at init to power the radio (HG AIOv2: `16`) |

Every hardware variable above overrides the matching value in Settings >
Hardware. `_HARDWARE_ENV_OVERRIDES` in `meshcore/config.py` holds the whole
Expand All @@ -183,7 +183,8 @@ configured chip and lists what the host actually has:
`HARDWARE_PRESETS` in `meshcore/settings.py` owns the per-board pinout,
including `en_pins`: the LoRa power-enable line belongs to the board, so every
preset states it, and a switch between boards clears a stale pin. The
`hg-aiov2` preset is the uConsole pinout plus enable pin 27.
`hg-aiov2` preset is the uConsole pinout plus LoRa enable pin 16 (27 is the
AIOv2's GPS rail).

`gpio_chip` is deliberately *not* in any preset. It follows the SoC and the
kernel (CM4 vs CM5), not the radio board, so a preset must never clobber it.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ command line. Notable radio bring-up flags:
- `MESHCORE_GPIO_CHIP=15` — which `/dev/gpiochipN` carries the 40-pin header.
It is 0 on CM4, but 15 on CM5 and Pi 5 kernels, where the header hangs off
the RP1. `doctor` lists the chips your host actually has.
- `MESHCORE_EN_PINS=27` — GPIO pins driven HIGH at init to power the radio.
The HackerGadgets AIOv2 enable pin is 27; the **uConsole HG AIOv2** board
preset sets this for you.
- `MESHCORE_EN_PINS=16` — GPIO pins driven HIGH at init to power the radio.
The HackerGadgets AIOv2 LoRa enable pin is 16 (27 is its GPS rail); the
**uConsole HG AIOv2** board preset sets this for you.
- `MESHCORE_USE_GPIOD_BACKEND=1` — poll for IRQ edges instead of asking the
kernel for edge interrupts. Try this if the radio connects but receives
nothing, which happens on kernels that reject the edge request.
61 changes: 61 additions & 0 deletions src/meshcore_console/core/map_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Where the map opens: the last view, else this device, else the known peers."""

from __future__ import annotations

import math

View = tuple[float, float, float] # latitude, longitude, zoom level

DEVICE_ZOOM = 12.0 # streets around this device
WORLD: View = (20.0, 0.0, 2.0)
MAX_ZOOM = 19.0


def encode_view(lat: float, lon: float, zoom: float) -> str:
return f"{lat:.6f},{lon:.6f},{zoom:.2f}"


def decode_view(raw: str | None) -> View | None:
"""A stored view, or None if missing or not a sane view."""
try:
lat, lon, zoom = (float(part) for part in (raw or "").split(","))
except ValueError:
return None
if not (-90 <= lat <= 90 and -180 <= lon <= 180 and 0 <= zoom <= MAX_ZOOM):
return None
return lat, lon, zoom


def fit_view(points: list[tuple[float, float]], width_px: int = 600, height_px: int = 400) -> View:
"""Centre and zoom showing every point on a map about this size
(256-pixel tiles, Web Mercator; ignores the antimeridian)."""
if len(points) == 1:
return points[0][0], points[0][1], DEVICE_ZOOM
lats = [p[0] for p in points]
lons = [p[1] for p in points]
lat, lon = (min(lats) + max(lats)) / 2, (min(lons) + max(lons)) / 2
lat_span = max(max(lats) - min(lats), 1e-4)
lon_span = max(max(lons) - min(lons), 1e-4)
zoom_lon = math.log2(width_px * 360 / (256 * lon_span))
zoom_lat = math.log2(height_px * 360 * math.cos(math.radians(lat)) / (256 * lat_span))
return lat, lon, float(max(2, min(14, math.floor(min(zoom_lon, zoom_lat)))))


def initial_view(
*,
gps_fix: tuple[float, float] | None,
saved: View | None,
device: tuple[float, float] | None,
peers: list[tuple[float, float]],
) -> tuple[View, str]:
"""The opening view and where it came from: "gps", "saved", "device"
(the fixed position from Settings), "peers" or "world"."""
if gps_fix is not None:
return (gps_fix[0], gps_fix[1], DEVICE_ZOOM), "gps"
if saved is not None:
return saved, "saved"
if device is not None:
return (device[0], device[1], DEVICE_ZOOM), "device"
if peers:
return fit_view(peers), "peers"
return WORLD, "world"
14 changes: 14 additions & 0 deletions src/meshcore_console/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
from datetime import UTC, datetime


# DM threads live in their own channel-id namespace. They used to share it with
# group channels (both were the lowercased name), so a DM from "Test" landed in
# #test and the reply was broadcast there, and "Message" on a node named
# "Public" posted to the Public channel.
DM_PREFIX = "dm:"


def dm_channel_id(peer_name: str) -> str:
"""The channel id of the DM thread with ``peer_name``."""
return DM_PREFIX + peer_name.lower()


@dataclass(slots=True)
class DeviceStatus:
node_id: str
Expand Down Expand Up @@ -40,6 +52,8 @@ class Message:
path_hops: list[str] = field(default_factory=list)
snr: float | None = None
rssi: int | None = None
# Outgoing DMs: True once ACKed, False if no ACK came; None otherwise (not stored).
delivered: bool | None = None


@dataclass(slots=True)
Expand Down
34 changes: 34 additions & 0 deletions src/meshcore_console/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,22 @@ def list_messages_for_channel(self, channel_id: str, limit: int = 50) -> list[Me

def send_message(self, peer_id: str, body: str) -> Message: ...

def is_private_channel(self, channel_id: str) -> bool:
"""True for a group channel joined with its own (non-derivable) key."""
...

def ensure_dm_channel(self, peer_name: str) -> Channel:
"""The DM thread with ``peer_name`` (channel id ``dm:<name>``), created if needed."""
...

def send_advert(
self, name: str | None = None, *, route_type: str = "flood"
) -> SendResultDict: ...

def discover_nodes(self) -> list[dict]:
"""Zero-hop discover of nearby repeaters/sensors; blocks for the answer window."""
...

def poll_events(self, limit: int = 50) -> list[MeshEventDict]: ...

def list_stored_packets(self, limit: int = 100) -> list[MeshEventDict]:
Expand All @@ -73,6 +85,12 @@ def update_settings(self, settings: MeshcoreSettings) -> None: ...

def get_device_location(self) -> tuple[float, float] | None: ...

def get_map_view(self) -> tuple[float, float, float] | None:
"""The map's last view (latitude, longitude, zoom), if one was saved."""
...

def save_map_view(self, lat: float, lon: float, zoom: float) -> None: ...

def is_mock_mode(self) -> bool:
"""Return True if running in mock mode."""
...
Expand Down Expand Up @@ -124,6 +142,22 @@ def logout_from_repeater(self, peer_name: str) -> None: ...

def send_repeater_command(self, peer_name: str, command: str) -> dict: ...

def get_repeater_status(self, peer_name: str) -> dict | None:
"""Repeater counters (battery, uptime, airtime, noise floor, packets); None on no reply."""
...

def get_repeater_owner_info(self, peer_name: str) -> dict | None:
"""{"version", "name", "owner_info"} from the repeater; None on no reply."""
...

def get_repeater_neighbours(self, peer_name: str) -> list[dict] | None:
"""[{"pubkey" (6-byte prefix), "name" or None, "secs_ago", "snr"}], newest first."""
...

def get_repeater_access_list(self, peer_name: str) -> list[dict] | None:
"""[{"key" (6-byte prefix), "role", "name" or None}]; admin login only."""
...

def get_repeater_login_state(self, peer_name: str) -> RepeaterLoginState | None: ...

def list_logged_in_repeaters(self) -> list[str]: ...
Expand Down
4 changes: 4 additions & 0 deletions src/meshcore_console/main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from __future__ import annotations

import argparse
import os
import asyncio
import importlib.metadata
import traceback


def main() -> int:
# Everything the app writes is private: identity seed, database (channel
# secrets, repeater passwords, messages), logs and log exports.
os.umask(0o077)
parser = argparse.ArgumentParser(
prog="meshcore-console",
description="MeshCore console — GUI and headless CLI",
Expand Down
Loading