Skip to content

Releases: maxg10/meshpulse

v2.9.4: node filters and saved settings for plugins

Choose a tag to compare

@maxg10 maxg10 released this 01 Nov 13:43

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() and refilter(). A plugin's
    filter runs at the end of the core's filter chain and hides a node by
    returning false. 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

Choose a tag to compare

@maxg10 maxg10 released this 30 Oct 19:52

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

Choose a tag to compare

@maxg10 maxg10 released this 26 Oct 14:32

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_id as an identity (accounts, permissions, a game
    login) should require pki to be True.
  • is_dm only means "not a broadcast". To answer only DMs sent to your own
    node, compare to_id with self.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

Choose a tag to compare

@maxg10 maxg10 released this 22 Oct 21:02

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_hash from config.json over 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 an onclick attribute
    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 whatever plugins/<id> resolved to, so an id like x/../../.. went
    far outside the plugins directory; a package with such an id did the same on
    install. Plugin ids must now be author/name and 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.json and plugins/<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 with stats.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.db instead 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.json and
    a data/ directory, but not data.db, the file get_database() creates, so
    every plugin using the documented API started from scratch after an update.
  • on_position now carries via_mqtt, speed (m/s), heading (degrees),
    precision_bits and pos_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

Choose a tag to compare

@maxg10 maxg10 released this 10 Oct 18:10

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
    in config.json enables 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_keys in stats.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_mqtt hardcoded 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, so hopLimit was simply missing.
  • install.sh reported "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

Choose a tag to compare

@maxg10 maxg10 released this 15 Sep 20:41

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): continue

Two 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

Choose a tag to compare

@maxg10 maxg10 released this 15 Sep 20:36

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

Choose a tag to compare

@maxg10 maxg10 released this 15 Sep 20:25

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

Choose a tag to compare

@maxg10 maxg10 released this 15 Sep 20:08

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. #map was 100vh tall
    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

Choose a tag to compare

@maxg10 maxg10 released this 14 Sep 13:19

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 in sessionStorage, so clicking around does not burn the anonymous
    GitHub rate limit.
  • Everything from ## Updating onwards 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.py carried its shebang on line 17, below the licence
    header, where a shebang does nothing at all, next to a #ver 2.3.0 comment
    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