Repository navigation
Releases: maxg10/meshpulse
Release list
v2.9.4: node filters and saved settings for plugins
A release for plugins: they can now decide which nodes the map shows, and keep
their own settings on your MeshPulse. The first plugin to use both is
Circles, in the
plugin store now.
Circles, and what it needs from the core
Circles keeps your own circles of nodes (family, your own gear, nodes you keep
an eye on): rings in their colour on the map, a panel with when each was last
heard, and one switch, Only my circles, that hides every other node. Lines,
Live Pulse, Weather Overlay and Trails keep working for the nodes that stay.
For plugin authors, that takes two new calls:
api.nodes.addFilter(fn),removeFilter()andrefilter(). A plugin's
filter runs at the end of the core's filter chain and hides a node by
returningfalse. Markers, lines, the No GPS list, Mesh Info and every other
plugin follow it. Your own node always stays, and a filter that throws hides
nothing.api.config.save(updates, cb): a map plugin stores its settings on the
host, through the same protected save the Config page uses, so the admin
password applies and every browser sees the same values. A setting declared
with"type": "hidden"stays off the Config page, for data a plugin edits in
its own panel.
Details and examples in the
plugin developer guide.
Also in the store: Live Pulse 1.0.1 (nodes the map's filters hide do not
pulse) and Weather Overlay 1.0.4 (impossible sensor readings hidden).
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.9.3: collapsible plugin panels, live packets for plugins
Two things you will see on the map, and one for plugin authors.
Collapsible plugin panels
With a few plugins on, their panels covered half the map. Every plugin panel now
has a ▾ button in the bar at its top (next to the ⠿ grip). A collapsed panel
shrinks to a slim bar with the plugin's name; ▸ opens it again.
- It only hides the panel: the plugin keeps running.
- Your choice is remembered in the browser, next to where you dragged the panels.
Reset layout puts panels back in place and leaves collapsed ones collapsed. - Works in the Map Layers drawer on phones too, with a thumb-sized button.
- Every plugin gets it, with no update needed.
Live traffic for plugins, and Live Pulse
The core now hands map plugins every packet your radio hears, as it happens:
api.packets.onPacket(cb), with the sender, port, SNR, RSSI, hops and relay.
Until now only the most recent packet of each second reached the browser, and map
plugins could not see them at all.
The first plugin to use it is Live Pulse,
in the plugin store now: a pulse on the node that just transmitted and a glowing
arc along the part of its path your radio can actually see, on a dark map.
Fix
With no browser connected, old packet events piled up and the next page to open
got them as if they were live. That queue is now emptied every second, and an
event older than 5 s is never sent.
Details for plugin authors in the
plugin developer guide.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.9.2: plugins can tell a PKI-signed DM
A small release for plugin authors: a plugin can now tell whether a DM really
comes from the node it says it comes from.
on_message gets pki
Since firmware 2.5, Meshtastic encrypts and signs a direct message with the
sender's PKI key. on_message now reports that as pki: True. Only then is
from_id more than a claim: on a channel, anyone who knows the PSK can send a
packet as any node.
- Plugins that use
from_idas an identity (accounts, permissions, a game
login) should requirepkito beTrue. is_dmonly means "not a broadcast". To answer only DMs sent to your own
node, compareto_idwithself.get_tracker_info()['node_id'].- The field is additive: nothing else about messages changes. Use
message.get('pki')if your plugin also runs on older cores.
Details and an example in the
plugin developer guide.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.9.1: admin password, security fixes, working restore
A security and reliability release. Recommended for everyone, and especially
if people you do not fully trust can reach your MeshPulse: several of the fixes
below close holes that anyone on the network, or anyone who could name a node,
was able to use.
Optional admin password
MeshPulse trusts its LAN, so until now anyone who could open the map could also
install a plugin (which runs Python on your Pi), restore a backup, read the
channel keys, arm remote admin and transmit as your node.
Set a password in Config > 🔒 Security and the map, Stats and Messages stay
open to everyone, while everything that changes the system, reads its secrets or
goes out over the air asks for it first: plugins, settings, channels (including
reading the channel keys), backup and restore, clearing stats, favourites,
remote admin, sending messages, traceroute and position requests.
- Only a PBKDF2-SHA256 hash is stored, in
config.json(owner-only). The
password never appears in the log. - A browser that logs in stays unlocked for 14 days. Changing the password or
Sign out every browser ends every session, open ones included. - Five wrong passwords from one address within 10 minutes lock it out for 5
minutes. - Forgot it? Remove
admin_password_hashfromconfig.jsonover SSH and
restart the service. - Without a password nothing changes. It does not encrypt traffic: MeshPulse
serves plain HTTP and WebSocket, so use a VPN or an authenticating reverse
proxy with TLS before exposing it to the internet.
Security fixes
- A node name could run JavaScript in your browser. Names come from the air,
and the DM, traceroute and Admin buttons put them into anonclickattribute
escaping only apostrophes. A 30-character long name was enough to run code in
every open map. Those arguments are now JSON-encoded and HTML-escaped, and the
DM list, favourites, channel name and plugin messages escape what they show. - Uninstalling a plugin could delete your home directory.
uninstall_plugin
removed whateverplugins/<id>resolved to, so an id likex/../../..went
far outside the plugins directory; a package with such an id did the same on
install. Plugin ids must now beauthor/nameand stay inside the plugins
directory. - A crafted backup could write any file, including code. Restore now writes
only what a backup contains:config.json,nodes.json,stats.db,
plugins/enabled.jsonandplugins/<author>/<name>/config.json. - The web server handed out
stats.db, with the remote admin audit and
client addresses, to anyone on the network. It is owner-only now, like
config.json. - The WebSocket server buffered up to 10 MB per message and connection. It
now stops anything over 128 KB before reading it. - Leaflet and Chart.js load with Subresource Integrity, and Chart.js is pinned to
4.6.1 instead of whatever version the CDN serves today.
Backup and restore that work
- Restoring a full backup never worked. A WebSocket message is 64 KB at most
and anything bigger was dropped without a word, so a backup withstats.db
(or a larger plugin package installed from a file) simply never arrived. The
Config page now uploads files in 40 KB pieces with a progress display, and the
Pi writes them to disk instead of memory: up to 512 MB for a backup and 32 MB
for a plugin, checked against free disk space first. - A full backup now takes a consistent snapshot of
stats.dbinstead of copying
a live file that could catch a write half done.
Faster on SD cards
stats.db runs in WAL mode. Logging a packet on a Raspberry Pi 2 SD card went
from 28.9 ms to 5.3 ms (median) and from 66 ms to 6.5 ms (p95), and that is
time spent holding the lock the rest of MeshPulse waits on. You will see
stats.db-wal and stats.db-shm next to stats.db: that is normal.
Plugins
- Updating a plugin deleted its database. The upgrade kept
config.jsonand
adata/directory, but notdata.db, the fileget_database()creates, so
every plugin using the documented API started from scratch after an update. on_positionnow carriesvia_mqtt,speed(m/s),heading(degrees),
precision_bitsandpos_time. The guide promised speed and heading for a
long time; the core never sent them. Trails uses them for real GPS speed.
Everything above was tested live on a Raspberry Pi 2 before shipping. Full list
in docs/changelog.md.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.9.0: honest traffic stats and remote admin
Two things in this release. Stats used to answer one question badly: how
much traffic is there? It counted four kinds of packet, kept three days of them,
and called the result "total packets". On a real mesh (Pimesh1, Luboń) the node's
own radio decoded about 16,000 unique frames a day while Stats showed about 6,000.
2.9.0 makes that number honest and keeps it long enough to see a trend.
And remote admin: read and change another node's settings over the mesh,
straight from its popup on the map.
Traffic History
A new card at the top of Stats, with 24h / 7d / 30d / 1y ranges:
- Logged vs heard. Bars show what MeshPulse decoded and stored (RF and MQTT
apart); the line shows what the radio actually received, from the node's own
firmware counters with duplicates removed. The gap between them is traffic
MeshPulse cannot decode. - Channel utilisation, average and peak, duplicate and bad-CRC rates, and
the packet type mix, with range totals on top and a CSV export. - Hours with no data stay gaps instead of turning into quiet hours, and days with
fewer than 24 recorded hours are drawn lighter and starred, so a short bar is
not mistaken for a quiet day.
History that survives
Every complete hour is folded into a small rollup table that is never pruned
(about 9,000 rows a year). The first start after the update backfills it from
whatever raw data is still there. Raw packet history is now kept 30 days
instead of 3, configurable as stats_retention_days in config.json (1 to 365).
Every packet counts
Until now only NODEINFO, POSITION, TELEMETRY and TEXT reached the stats database.
Every other port the radio hands over is now counted too, including packets on
channels this node has no key for (ENCRYPTED) and ports the Meshtastic library
does not know (PORT_<n>). Expect your Stats totals to jump: the mesh did
not get busier, the counter got honest.
Remote admin
A 🛠 Admin button in a Meshtastic node's popup (and 🛠 in the No GPS list) opens
a window that talks to that node over the mesh, through the radio MeshPulse is
already connected to. No port juggling, no CLI.
- Read 15 sections: owner, firmware, device, LoRa, position, power, network,
display, Bluetooth, security, MQTT, telemetry, neighbor info, store & forward,
range test. A console shows every step and its round trip, typically 1 to 2 s
per section on a direct neighbour. - Write: ✏️ Edit turns a section into a form built from the firmware's own
protobuf definitions. Review shows old → new for every changed field, Apply
sends it, and 20 s later MeshPulse reads the section back and marks each field
✓ or ✗ (so you see it when the firmware quietly clamps a value). Reboot is a
two-click button. - Off by default. Writes are armed from Config > 🛠 Remote Admin for 15,
30, 60 or 120 minutes and switch off by themselves;remote_admin_write: true
inconfig.jsonenables them permanently. Every write, reboot, arm and disarm
is recorded with the client address and listed on that tab. - Guard rails. Fields that can cut a node off the mesh are locked, with the
reason shown: LoRa radio parameters, Security, Network, Bluetooth, MQTT, device
role, licensed mode, power saving. - Requirements. The target node must list this node's public key as an
Admin Key (Security settings), and both must run firmware with PKC (2.5+).
MeshPulse now keeps every public key it hears (node_keysinstats.db) and
hands a key back to its own radio when the radio has dropped it from a full
NodeDB, so admin keeps working on a busy mesh.
Fixes
- TELEMETRY and TEXT were logged with
via_mqtthardcoded to false and no hops or
SNR, so MQTT telemetry counted as radio traffic and could raise "too frequent
telemetry" anomalies against nodes that are not even in range. - A packet that arrived with its hop limit used up lost its hop count: protobuf
leaves zero-valued fields out, sohopLimitwas simply missing. install.shreported "web server NOT FOUND" when run over ssh, because lighttpd
lives in/usr/sbin.
Performance
Measured on a Raspberry Pi 2 before shipping. With 30 days of traffic, one query
of the Stats summary took 4.95 s, every minute, holding the lock that packet
logging waits on; a new index brings it to 0.47 s. Pruning now deletes in small
chunks, so lowering the retention cannot freeze packet logging. The history card
reads only the rollup: 18 to 40 ms per request on the same Pi.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.8.3 — api.links stops inventing direct links
api.links, added in 2.8.0, decided that a node was heard directly by this
mapper's radio if its hops field read zero. That is the Meshtastic test, and
only the Meshtastic test.
The core's own version of the same question — a few hundred lines away in the
backend — has always been more careful:
hops = node.get('hops') if net == 'MT' else node.get('hops_away')
if hops != 0: continue
if net == 'MT' and node.get('via_mqtt', False): continueTwo things the edge list was missing. Meshcore reports out_path_len, where
zero means an established outbound path rather than zero radio hops — the exact
distinction 2.7.2 introduced Network reach for — so a Meshcore contact could
enter a plugin's graph as a direct RF neighbour of the local node. And a node
heard via MQTT was heard by somebody else's radio, not this one.
Both are excluded now, the developer guide states that the edge list is
Meshtastic-only, and the four cases are covered: a Meshtastic node at zero hops
is a link; the same node via MQTT is not; a Meshcore contact reading zero is not;
a Meshtastic node two hops away is not.
Meshcore nodes are unaffected everywhere else — they stay on the map and in
api.nodes. They are simply no longer claimed to be RF neighbours of anything.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.8.2 — getTracker() answers for the first time
One fix, found the way these things usually are: a plugin drew a perfectly
reasonable conclusion from a wrong answer.
api.nodes.getTracker() is how a plugin asks which node is this mapper's own
radio. It read window._trackerNodeId — a global that nothing in the codebase
ever assigns. So it had always returned null, to every plugin, since the day
it was written. The tracker's id arrives on the connection status message and
has been sitting in trackerInfo the whole time.
It reads trackerInfo now, with the global kept as an override.
Why it matters beyond the obvious: a plugin that asks a question and gets a wrong
answer does not fail loudly. The Lifelines plugin asked which node was yours, was
told "none", fell back to the busiest node on the graph, and reported perfectly
correct distances measured from the wrong place — with a label claiming it was
your node. Nothing crashed. The numbers were just quietly about someone else.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.8.1 — put the zoom buttons back where they belong
Two fixes, one of them mine from yesterday.
The zoom buttons hid under the search bar on phones
2.8.0 removed a margin-top: 44px on .leaflet-top that double-counted the
desktop top bar — the map already carries that offset, so the map controls were
sitting 44px lower than anyone intended and the plugin panels overlapped them.
That margin was also, entirely by accident, what kept the zoom control clear of
the search field on a phone. Removing it dropped the zoom buttons to 80px, which
is exactly where the search bar is. The clearance is now written down on purpose
in the mobile rules, with the arithmetic in a comment, instead of being a side
effect of a bug nobody had noticed.
A failed plugin enable left the button spinning
If enabling a plugin failed, the red error appeared — and the button kept saying
Enabling…, disabled, for the rest of the session, because the plugin list was
only re-rendered on success. It re-renders either way now, so the plugin goes
back to showing what it actually is: not enabled.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.8.0 — the map is yours to arrange
Two releases ago the app could not say who made it. This one is about the other
half of that conversation: the map is yours to arrange, and the plugins can
finally see the mesh as a network rather than a pile of nodes.
Panels go where you put them
Every plugin panel now has a grip. Drag it and the panel floats wherever you
left it, clamped so at least 48px of it stays on screen — a panel you cannot
reach is worse than one in the wrong place. Double-click the grip and it drops
back into the column. Positions are remembered per panel.
On a phone the same grip does something different on purpose. There is nowhere
to float to on a 390px screen, and a panel dragged off the edge is one you
cannot get back, so in the Map Layers drawer the grip reorders the list —
and that order is what the desktop column uses too. Set it once, it holds
everywhere. A Reset layout button appears as soon as anything has moved.
Mouse, finger and pen all go through one pointer stream, and the grip claims
that stream outright, so dragging a panel no longer pans the map underneath it
or scrolls the drawer out from under your thumb.
The panels were sitting on the zoom buttons
They had been, on every desktop width, for a long time. The host was pinned at
top: 125px, under a comment explaining that the zoom control occupies
"~54-114px". It occupies 98-163px: the 44px top bar was subtracted twice, once
by #map and again by .leaflet-top inside it. The first panel overlapped the
zoom buttons by 38px and the comment had never been true.
The double offset is gone, and the position is no longer a number anyone has to
maintain. The control stack is measured at runtime and published as a CSS
variable, re-measured on resize and whenever the corner changes size — so a
plugin that adds its own control there pushes the panels down by itself.
Plugins can see the mesh, not just the nodes
api.links.getAll() hands a plugin the edge list the core has always had:
neighbour reports between any two nodes, plus everything this mapper's own radio
hears at zero hops. Undirected, each pair once, ids ordered so a pair always
looks the same, with the reported SNR. api.links.onUpdate() fires when a
neighbour report changes the picture.
Two things the developer guide now says out loud, because they will bite
whoever builds on this: NEIGHBORINFO is off by default in the firmware, so
on many meshes this list starts empty — say so in your UI rather than drawing an
empty graph. And RF is not always symmetric; the edge list flattens that.
Two new plugins in the store
- Lifelines — finds the single points of failure: the nodes that, if they
went off the air, would cut other nodes off from the mesh. Click one and the
map rings it in amber and everything that depends on it in red. Frontend only. - Ground Truth — compares every link and directly-heard node against its own
past few weeks and reports the ones that quietly lost signal. Water in a
connector does not announce itself; it shows up as four decibels that were
never coming back. Reads the mapper's own history, read-only.
Smaller things
- Release notes in the version dialog are readable now: heading hashes, bold
markers and backticks are stripped instead of being shown as punctuation. - The map page no longer scrolls vertically by 44px.
#mapwas100vhtall
underneath a 44px bar, which is 44px more than the window has.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh
v2.7.3 — the app finally says who made it
An installation of MeshPulse was oddly anonymous. Unless an update happened to
be pending, nothing in the app said what it was, who wrote it, which licence it
carries or where the project lives — so a Pi someone else had set up left its
user with no way back to any of that. This release fixes it, and fixes what
looking closely at the top bar turned up.
The top bar says where you are
The wordmark is a link. 📶 MeshPulse opens meshpulse.app.
The version opens the release notes. Clicking v2.7.3 opens a dialog with
the notes for the version that is actually running — not the newest one — and
underneath them an About block: version, GPL-3.0, copyright, and links to the
site, the plugin store and the repository.
The dialog is not new code. showUpdateBanner() already built one and threw it
away in every case except a pending update; it is now a standalone
showReleaseModal() that the banner and the version chip both call.
The details that matter on a mapper in a shed:
- Notes come from the release tagged with the running version and are cached per
version insessionStorage, so clicking around does not burn the anonymous
GitHub rate limit. - Everything from
## Updatingonwards is cut. Install instructions belong on
GitHub, not in a "what's new" box. - With no internet the dialog still opens and still shows the About block. An
offline mapper is the normal case, not an error. - The notes are Markdown shown as plain text, so heading hashes are stripped and
list dashes become bullets. No Markdown renderer was added and nothing reaches
the page unescaped.
Phones
The bar used to hide the whole version string to make room — which would also
have hidden the new dialog's only entry point. Now only the page name is
dropped: v2.7.3 stays, and its tap area is larger than the text.
Stats stops pushing the page sideways
On a 320px screen the Stats page dragged the document 53px to the right. It was
not the box-sizing bug it resembled: a grid item's automatic minimum size is its
content's min-content width, so a Chart.js canvas reporting 300px stopped even a
single 1fr track from shrinking, and the Most Active Nodes table is 343px wide
on its own. Grid children may now shrink, canvases are capped at the card width,
and a card scrolls content that genuinely is wider than the screen.
Smaller things
- The telemetry notice in Config still said "Meshtastic Mapper" — the last
string left from the rename, sitting in the one place where a user reads about
privacy. backend/meshpulse.pycarried its shebang on line 17, below the licence
header, where a shebang does nothing at all, next to a#ver 2.3.0comment
from another era.
Updating
cd ~/meshpulse && git pull origin main && ./install.sh && sudo systemctl restart meshpulse
Docker
docker compose pull && docker compose up -d
Fresh Install
git clone https://github.com/maxg10/meshpulse.git
cd meshpulse
./install.sh