Local hashtag-channel monitor for MeshCore mesh radio networks.
Runs as a separate service alongside meshcore-gui. Monitors hashtag channels of which the name (and therefore the SHA-256-derived key) is known, without requiring those channels to occupy a slot on the connected MeshCore device.
meshcore-gui writes every received LoRa packet to an append-only
JSONL file under ~/.meshcore-gui/archive/_dev_*_rxlog.jsonl.
meshcore-watchlist tails this file with a byte-offset cursor,
attempts to decrypt each GroupText packet using locally-derived
hashtag keys, and presents the decoded messages in its own UI and
REST API.
Output format (Message, RxLogEntry, REST API responses) is identical
to meshcore-gui, so downstream consumers such as domca.nl work
without changes.
Requires meshcore-gui v1.22.1 or newer (provides the JSONL stream).
sudo ./install_script/install.sh --port 8083Optional flags:
--user USER Run-as user (default: current user)
--install-dir DIR Install location (default: /opt/meshcore-watchlist)
The UI becomes available at http://localhost:<port>/.
sudo ./install_script/uninstall.shStops and disables the service, removes the systemd unit and the install
directory, but keeps your watchlist and archive in
~/.meshcore-watchlist/ so a reinstall picks them up again.
To also wipe user data and start completely fresh:
sudo ./install_script/uninstall.sh --purgeOther flags: --user USER, --install-dir DIR (must match what was used
at install time), --yes to skip the confirmation prompt.
The service exposes the same read-only REST API as meshcore-gui under
/api/v1/, with identical JSON shapes. Downstream consumers such as
domca.nl can therefore pull from this service using exactly the
polling logic they already use for meshcore-gui — only the host/port
needs to change. CORS is enabled (* by default; override with
MESHCORE_WATCHLIST_CORS_ORIGINS). There is no authentication, same
as meshcore-gui.
The service binds to 0.0.0.0:<port>, so the API is reachable from
other hosts on the LAN. Restrict with a firewall if undesired.
Base URL: http://<host>:<port>/api/v1
| Endpoint | Method | Description |
|---|---|---|
/channels |
GET | Channels currently being monitored (the watchlist) |
/channels |
POST | Add a hashtag channel (used by tools/channel_injector) |
/messages |
GET | Decoded public + hashtag messages, paginated |
/stats |
GET | Aggregated counts over the last 72 hours |
/nodes |
GET | Always [] — watchlist has no contact list of its own |
/rescan/by-name |
POST | Submit a per-channel rescan over an explicit date window |
Returns the channels this instance monitors. Watchlist entries are
hashtag channels by construction, so is_private is always false.
[
{ "idx": 1, "name": "#mc-radar", "is_private": false },
{ "idx": 2, "name": "#nl-mesh", "is_private": false }
]Paginated list of decoded public + hashtag messages.
| Param | Type | Default | Range |
|---|---|---|---|
limit |
int | 100 | 1–500 |
offset |
int | 0 | ≥ 0 |
Response:
{
"total": 1234,
"limit": 100,
"offset": 0,
"items": [
{
"id": 1,
"channel_idx": 1,
"channel_name": "#mc-radar",
"sender": "PE1ABC",
"sender_pubkey": "abc123…",
"text": "Hallo allemaal",
"timestamp": "2026-04-27T07:44:41+00:00",
"hops": 2,
"path_hashes": ["a1b2", "c3d4"],
"path_names": ["pe1xyz-rep", "pe1xyz-home"]
}
]
}Field names and types match meshcore-gui's /api/v1/messages exactly.
Counts and aggregates over the last 72 hours of public + hashtag
traffic. The fields active_clients, active_repeaters and
active_room_servers are always 0 for the watchlist (no contact list
or radio of its own); they remain in the response for shape
compatibility with meshcore-gui.
The intent is that channels and messages are pulled out of this service
and processed elsewhere — e.g. for ingestion into domca.nl — in
exactly the same manner as the existing meshcore-gui ↔ domca.nl
flow. Because the response shapes are identical, no changes to the
downstream code are needed; configure it with this service's URL as an
additional source.
Practical examples with curl:
HOST=raspberrypi5nas.local
PORT=8083
# All monitored channels
curl -s "http://$HOST:$PORT/api/v1/channels" | jq
# Latest 100 messages
curl -s "http://$HOST:$PORT/api/v1/messages?limit=100" | jq
# Page through older messages
curl -s "http://$HOST:$PORT/api/v1/messages?limit=100&offset=100" | jqWhen persisting messages downstream, dedupe on a content key such as
(timestamp, sender_pubkey, text) rather than on id — id is a
positional index within a single response, not a stable primary key.
Adds a hashtag channel to the watchlist. Additive endpoint introduced
in 0.3.0 so out-of-process clients (notably tools/channel_injector)
can grow the watchlist without violating the "WatchlistStore is the
only mutator" invariant: the daemon still owns the store, the client
merely asks it to add a name.
| Param | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Channel name. URL-encode # as %23. |
Status codes:
| Code | Meaning |
|---|---|
| 201 | Channel added. |
| 200 | Already on the watchlist (or Public, which is system-managed). |
| 400 | Empty / control-character / over-32-byte UTF-8 name. |
Channel names are limited to 32 UTF-8 bytes per the MeshCore Companion
Protocol; see ADR-007 for the rationale. Length is in bytes, not
codepoints (#café is 6 bytes, not 5).
curl -X POST "http://localhost:8083/api/v1/channels?name=%23weather"
# → 201 {"name": "#weather", "added": true}tools/channel_injector is a small standalone script that fetches one
or more upstream channel listings (JSON over HTTP) and seeds any
missing hashtag channels into the running daemon, then triggers a
per-channel rescan over the last 7 days. It is intended to run
periodically from cron, in the daemon's own venv. No extra
dependencies.
See tools/channel_injector/README.md
for the full reference and install_script/channel_injector.cron.example
for a sample crontab entry. Quick start:
/opt/meshcore-watchlist/.venv/bin/python -m tools.channel_injector \
--source-url https://example.org/channels.jsonThe watchlist is stored in ~/.meshcore-watchlist/watchlist.json and
managed via the Watchlist tab in the UI. Each entry is a hashtag
channel name (e.g. #mc-radar). The idx is the position in the
file.
~/.meshcore-watchlist/
├── watchlist.json # CRUD via UI
├── state.json # tailer cursors per source file
└── archive/ # decoded messages + raw rx-log
Same as meshcore-gui.