MeshCore

← What it does

Every capability, side by side

Every capability in both clients — 527 of them — each one checked against the source rather than a changelog. Where they differ, the row says whether that is a limit of the platform, a deliberate choice, or simply not built yet.

Radio 71 Chat 69 Contacts and channels 30 Map 30 Nodes and telemetry 40 Repeater management 41 Firmware and flashing 43 Profiles and sync 112 Your data 25 The app 66

Radio 71

Connecting to a radio

Feature Desktop Mobile
Connect over USB serial
Platform limit

Platform. Android has no serial API for the radio link; the phone's USB-C port is used only for flashing.

yes not applicable on this platform
Connect over BLE
Notes

Nordic UART, one notify per frame — no </> envelope, because the firmware sets the MTU to a whole frame and running a frame decoder over that data discards every byte. Filed as Platform in this document until 2026-09-02, and it was a Gap; meshcore/ui/ble_transport.py is a port of android/src/blelink.cpp against the same Qt API, and the phone was the working reference for it. The desktop half has never met a radio — see the caveat above.

yes yes
Enumerate all serial ports, unfiltered
Notes

No VID/PID whitelist; a port is a candidate until the handshake says otherwise.

yes not applicable on this platform
Identify a radio by protocol handshake, not by USB ID
Notes

transport/discovery.py probes each port.

yes not applicable on this platform
Identify a radio by advertised GATT service UUID
Notes

Matches on the NUS UUID, not on the device name. The desktop sorts the advertisers to the top of its picker rather than filtering down to them.

yes yes
Scan for nearby radios
Notes

The same 12s LE discovery on both, from the same constant.

yes yes
List devices that do not advertise the service
Notes

Shown anyway, so a mis-flashed node is still reachable — a peripheral only lists its service UUIDs when the advertisement has room. The desktop drops just the nameless non-advertisers, which have nothing to show and nothing to suggest they are radios.

yes yes
Manual device picker
Notes

The desktop now has two, deliberately: "Choose port…" enumerates instantly and "Connect over Bluetooth…" scans for twelve seconds, and folding them into one list would make the fast case wait for the slow one. Mobile: "Radios nearby".

yes yes
Remember the last device across restarts
Notes

The desktop now remembers a pair — which transport, and which device on it (ui:last_transport, ui:last_ble) — because a Bluetooth address left in last_port would be handed to a port probe on the next run. last_port is still read when nothing says which transport was used, so an archive written before BLE existed is not handed a blank slate. Mobile keys on the Bluetooth address.

yes yes
Auto-connect to the remembered device at startup yes yes
Dial a remembered radio directly, with no scan in front of it
Notes

Twelve seconds of discovery per attempt is what makes a retry ladder unaffordable; on the phone it is also the thing Android suppresses once the screen is off, which is the only place reconnect matters. Finding a radio in the first place stays the picker's job on both.

yes yes
Retry auto-connect as new scan results arrive
Deliberate choice

Choice on the desktop: its only scan runs inside the picker, which is a manual act, and a countdown running underneath a list somebody is reading would connect to something they had not picked. The phone retries on every device found and again when the scan finishes.

no yes
"Forget this radio"
Not built yet

Gap, and a smaller one than it was: forget_endpoint is written and tested in meshcore/app/connect.py, and nothing calls it. The remembered radio is still only ever overwritten, never cleared.

no yes
Automatic reconnect with backoff after a drop
Not built yet

Gap closed on the phone, which previously looked for the remembered radio at startup and never again. The first five steps are the same ladder on both — 2/5/10/15/30s — deliberately, because the cases they cover are the same: a radio rebooting, a link glitching, somebody walking through a doorway.

yes yes
One reconnect policy shared by every transport
Notes

meshcore/transport/reconnect.py: pure stdlib, free of Qt, owned by neither transport, so the caller owns the timer and this owns only the decision. Serial and BLE ask the same object what to do next. A second backoff growing beside the first is how two links on one machine give up at different times for different reasons and neither matches what the status bar says.

yes not applicable on this platform
Stop after a capped number of attempts rather than retrying for ever
Notes

A behaviour change on the desktop, and worth knowing: serial previously retried without limit. Ten attempts on both. The phone's ladder then stretches to 60/120/300s, for two reasons that are true there and not on a mains-powered laptop — a failed BLE dial to a radio that is not there occupies the radio for about thirty seconds before Android answers, and a phone has a battery.

yes yes
Say so when it has given up, and offer the retry
Notes

Both settle on the same sentence. The desktop's is a status-bar QLabel, so nothing about the line itself is clickable and the retry is really the Connect button or the tray entry — the wording ("tap to retry") slightly oversells what is there. The phone's header line is tappable on every page, the Radio page grows a card explaining what happened, and a notice goes to the shade when the app is not in front.

yes yes
Reset the ladder when a link comes up
Notes

A drop an hour later is a fresh fault, not a continuation of the last one — and treating a flapping link like an absent one would make the first look like the second within a quarter of an hour.

yes yes
Manual reconnect action
Not built yet

Gap closed on the phone. The desktop uses it to re-handshake after an identity import; on the phone it is the give-up retry, and bringing the app to the front counts as one, because nothing else would explain the shade line vanishing when it is tapped.

yes yes
Manual disconnect
Notes

Distinguished from every accidental drop on the phone by exactly this call: asked-for takes the backoff and the foreground service down with it.

yes yes
Connection status text in the main chrome yes yes
Coloured connection-state indicator
Notes

The phone now shows four states, not two — connected, handshaking, working on it, given up — with a slow pulse while it is trying, because "trying" and "stopped" are the difference between something to do and nothing to do. The desktop still shows state only on the tray icon.

partial yes
Per-port "probing…" progress during a scan
Notes

Desktop probes candidates one at a time; a BLE scan is a single operation.

yes not applicable on this platform
Connect off the UI thread
Notes

Opening a serial port blocks for seconds, so the desktop probes on a worker QThread. Both BLE paths run on the UI thread and must: QLowEnergyController is not thread-safe, and Qt Bluetooth is asynchronous by construction, so there is nothing to marshal from and nothing that blocks.

yes yes
Wait out the board reset that opening a serial port causes
Platform limit

Platform. Toggling DTR/RTS reboots the board; 2s settle.

yes not applicable on this platform
Explain a serial permission failure with the fix
Notes

Names the dialout group and the exact usermod command.

yes not applicable on this platform
Request Android runtime BLE permissions
Platform limit

Platform. Split scan/connect on 12+, fine-location below.

not applicable on this platform yes
Work around Qt reporting a granted permission as undetermined
Notes

QTBUG-112164.

not applicable on this platform yes
Deep-link to the OS app-settings page when permission is permanently denied
Platform limit

Platform. Android's "ask once" model leaves no other route.

not applicable on this platform yes
Detect that the Bluetooth adapter is switched off
Not built yet

Gap closed on both, and on the phone it was worse than a missing message: Qt raises neither disconnected nor errorOccurred when Android's adapter goes off under a live link, so the app went on reporting a connection and a green dot for as long as it was left. QBluetoothLocalDevice is now watched directly and off is a disconnection like any other. The desktop checks the same thing before every scan and every connect, and separates "switched off" — a two-click fix — from "no adapter at all", which is not.

yes yes
Offer to switch the adapter back on
Notes

The desktop's picker offers it and then re-checks rather than assuming it worked, because an rfkill block is not the app's to lift.

yes no
Retry from the top when the adapter comes back on
Notes

Every attempt made while Bluetooth was off failed for a reason that has just stopped being true, so the budget resets and the next attempt is now — including from having already given up, which is the common case: somebody switches it off for a flight and on at the other end.

not applicable on this platform yes
Reachable from the headless CLI
Deliberate choice

Choice, recorded in docs/BETA.md §7. The BLE transport is written against Qt Bluetooth, which arrives with the optional gui extra, and importing it into meshcore-cli would make a GUI toolkit a hard dependency of a headless tool; the alternative is a second BLE implementation to keep in step with the phone's for ever. --help says so, rather than leaving somebody to hunt for a --ble flag that is not there.

serial only not applicable on this platform
Fixed baud rate, not user-configurable
Notes

115200, deliberately not a setting.

yes not applicable on this platform

Identifying the radio once connected

Feature Desktop Mobile
Device query at handshake
Notes

Both issue it; the order relative to app-start differs and does not matter.

yes yes
Self query at handshake (name, key, radio params) yes yes
Reach "ready" only once both replies are in yes yes
Show the firmware version and version code yes yes
Warn when the firmware is newer than the client knows
Not built yet

Gap. Desktop compares against KNOWN_FIRMWARE_VER_CODE; the phone only displays the string.

yes no
Show the firmware build date
Not built yet

Gap. The field is decoded by the shared core and then dropped on the phone.

yes no
Show the model / manufacturer
Not built yet

Gap. Same — decoded, never surfaced.

yes no
Show your own public key
Notes

The desktop now carries the active profile's key in the rail chip's tooltip, and the full key in each switcher entry's; there is still no labelled field anywhere.

partial yes
Show contact/channel capacity
Notes

The phone uses max_contacts for its "contacts full" warning but shows no capacity field.

yes partial
Show your own node name yes yes
Show your own advertised position yes yes
Read the device battery on connect yes yes
Show device storage use
Not built yet

Gap. The same reply carries both; the desktop discards the storage half.

no yes
Read the device clock and compare it before setting it
Not built yet

Gap. Desktop tolerates 30s of skew and refuses to move a clock backwards.

yes no
Sync the clock automatically on every connect
Not built yet

Gap. On the phone it is a manual button.

yes no
Retry in-flight commands on a timeout tick
Notes

Same 250ms tick and retry discipline; the C++ core is a deliberate port.

yes yes
Idle keep-alive to detect a dead link
Not built yet

Gap on both.

no no
Finish sends that were in flight when the app was last closed yes yes

Radio settings

Feature Desktop Mobile
Setting
Notes

Notes

Desktop Mobile
Node name yes yes
Advertised latitude and longitude
Not built yet

Gap. The phone's selfLat/selfLon are read-only properties.

yes no
Frequency yes yes
Bandwidth yes yes
Spreading factor
Notes

Desktop clamps with a spin box; the phone parses free text.

yes yes
Coding rate yes yes
TX power
Notes

Spin box vs slider, both bounded by the reported maximum.

yes yes
Confirmation before changing a radio parameter
Notes

Getting these wrong takes the node off the air.

yes yes
Frequency or region preset (e.g. "EU868")
Not built yet

Gap closed on the desktop, through the country (§20) rather than as a band picker of its own: the preset is a consequence of where you are, and asking twice would let the two answers disagree. The phone still takes raw numbers only.

yes no
Default region / flood scope
Notes

On the desktop it lives with Channels rather than in Settings.

yes yes
Auto-add: drop the oldest contact to make room yes yes
Summary of what the radio adds by itself yes yes
Client-repeat toggle
Not built yet

Gap; desktop hides it unless the firmware supports it.

yes no
Path-hash-size mode
Not built yet

Gap, same version gating.

yes no
Advert interval
Not built yet

Gap on both; only a manual "send advert".

no no
Set the clock from the host
Notes

Automatic and drift-aware on the desktop, a manual button on the phone.

yes yes
BLE pairing PIN
Notes

The OS pairing dialog handles it; the firmware command is not exposed.

not applicable on this platform no
Send advert, flood or zero-hop
Notes

A dialog on the desktop, two buttons on the phone.

yes yes

Chat 69

Chat: what a message looks like

Feature Desktop Mobile
Own messages aligned right and tinted, incoming left and neutral yes yes
Squared "notch" corner on the bubble nearest the speaker
Notes

Cosmetic; extra paint code the QML delegate never gained.

yes no
Consecutive messages from one sender drawn as a single turn
Not built yet

Gap. A QListView delegate can look at the previous row cheaply; the QML delegate holds no cross-row state.

yes no
Avatar on incoming bubbles
Not built yet

Gap, same cause. The phone keeps avatars in the conversation list only.

yes no
Sender name as its own line above the bubble, in channels
Deliberate choice

Choice. The phone folds it inline as "sender: text".

yes partial
Day or date separators in the scrollback
Not built yet

Gap on both.

no no
Timestamp on every message, host receive time, HH:MM
Notes

Both deliberately ignore the sender's clock — UI.md §5.

yes yes
Full date and seconds available per message
Notes

The phone's long-press "Message details" dialog; the desktop meta line is always abbreviated.

no yes
SNR shown in dB with an explicit sign, incoming only yes yes
Hop count, or "direct", on incoming messages
Notes

Same inverted-0xFF convention on both.

yes yes
"Show path" link opening the route on the map
Notes

Not a URL on either — the route is a local row and the map is local.

yes yes
Delivery mark on outgoing messages
Notes

Tick when acknowledged, "sending N/3" before that, failed after.

yes yes
Failure shown as a glyph vs a word
Deliberate choice

Choice. renders as a tofu box in Android's default font.

`✕ failed` `failed`
Failed bubble filled red vs red border only
Notes

The phone only recolours the border. Not deliberate, just less paint code.

yes partial
Marker bar on a message that mentions you yes yes
"as <identity>" on an outgoing message sent under another identity
Not built yet

Gap. The saved-configuration identity of §15, not the archive profile of §16. Both clients can apply one; the phone's message row has no sent_as field, so it cannot say which identity sent it.

yes no
"via <region>" per outgoing message
Not built yet

Gap. The phone shows the region once in the channel subtitle, never per message.

yes no
Channel subtitle naming slot, region and "no delivery receipts" yes yes
Contact's public-key prefix in the conversation header
Not built yet

Gap.

yes no
Sender's key prefix on an individual message no no

Chat: composing and sending

Feature Desktop Mobile
Live byte counter against the real limit
Notes

Bytes, not characters, and both escalate colour at 85%.

yes yes
Limit shrinks by the node-name prefix on a channel
Notes

Literally the same shared C++ function on the phone.

yes yes
Counter accounts for a mention growing on the wire
Notes

@name becomes @[name], two bytes longer.

yes yes
Enter sends, Shift+Enter newlines
Not built yet

Gap. On the phone only the send button sends.

yes no
Composer grows with the message
Not built yet

Gap on the desktop, which is a fixed 58px box that scrolls internally.

no yes
In-app emoji picker
Platform limit

Platform. Android's own keyboard supplies one; a desktop text field does not. The phone only fixes the font fallback so emoji render.

yes no
Attachments
Notes

Protocol. Text frames carry no attachment payload.

no no
Message templates or quick replies
Not built yet

Gap on both.

no no
Drafts kept per conversation
Not built yet

Gap on both.

no no
Send blocked while disconnected, with an explanation
Notes

The phone disables the button; if the path is reached anyway it returns silently.

yes partial
Message archived optimistically before the radio replies yes yes

Delivery, retries and failures

Feature Desktop Mobile
ACK wait derived from the radio's own estimate plus a grace
Notes

Same 3s grace and 3 attempts on both; the C++ side is a deliberate port.

yes yes
Automatic retry on no ACK yes yes
Second attempt drops the cached route and floods
Notes

The cure for a stale path that goes nowhere.

yes yes
Channel sends tick immediately, no ACK wait
Notes

Broadcast has no receipts; counting attempts would never finish.

yes yes
Duplicate ACK is a no-op
Notes

Guarded in SQL on both.

yes yes
Sends resumed after an app restart, with a staleness cutoff
Notes

600s on both.

yes yes
Manual resend of a failed message
Not built yet

Gap on both — only the automatic retries exist.

no no
Offer to re-add a contact the radio no longer has, and resend
Not built yet

Gap. A common recovery after a repeater reflash, desktop only.

yes no

Mentions

Feature Desktop Mobile
@name typed, @[name] on the wire
Notes

The form every other MeshCore client writes and parses.

yes yes
Longest-name match, immune to a shorter name being a prefix
Notes

Hand-ported; "@Bob" does not match inside "@Bobby".

yes yes
Highlight a message that mentions you yes yes
Autocomplete popup while typing yes yes
Completion by arrow keys, Tab or Enter
Not built yet

Gap; less pressing on a touch keyboard.

yes no
Name sources for autocomplete
Not built yet

Gap. The phone will not complete or highlight a channel name or a channel-only participant.

contacts, channels, channel senders, self contacts and self
Notification escalated for a mention
Not built yet

Gap. The phone has the mention test and uses it for the in-thread marker, but never wired it into the notification title or urgency.

yes no

Conversations, favourites, filtering and search

Feature Desktop Mobile
Last-message preview in the row yes yes
"heard X ago" fallback when there are no messages yet
Not built yet

Gap; the phone shows "no messages yet" instead.

yes no
Relative timestamp of the last activity
Notes

Desktop is relative ("3m", "2h"); the phone shows a clock, weekday or date.

yes yes
"You: " prefix on your own last message no yes
Unread badge per row, capped at 99+ yes yes
Title bolded while unread yes yes
Avatar colour stable across a rename
Notes

Bug on mobile. The desktop hashes the public key precisely so a rename keeps the colour; the phone hashes name.length, so a rename changes the colour and equal-length names collide.

yes no
Presence dot on the avatar
Not built yet

Gap. The desktop's present/quiet/stale classification was never ported.

yes no
Favourites promoted to the top yes yes
Favourites under a collapsible section header
Deliberate choice

Choice. The phone keeps a flat list with a star.

yes no
Pin/unpin a favourite
Notes

Right-click on the desktop, long-press on the phone.

yes yes
Mute a conversation
Not built yet

Gap on both.

no no
Archive or delete a conversation
Notes

The list is derived from messages and contacts, not a deletable object.

no no
Mark read without opening
Not built yet

Gap.

yes no
Mark back to unread
Not built yet

Gap on both.

no no
Room servers listed with people
Notes

Divergence. The desktop deliberately files a room server with people — "you log in and talk to it". The phone treats anything of advert type ≥ 2 as infrastructure, so a room server appears only under Repeaters and its unread is left out of the chat badge.

yes no
Show/hide People yes yes
Show/hide Channels yes yes
The hide toggle shows how much unread it is hiding
Not built yet

Gap.

yes no
Search by name yes yes
Search also matches the public key
Not built yet

Gap on the desktop.

no yes
Separate search boxes for chat and for repeaters
Notes

Both learned the same way: one box wired to two lists silently emptied the chat.

yes yes
Filter to favourites only, or unread only
Not built yet

Gap on both.

no no

Contacts and channels 30

Contacts, paths, channels and regions

Feature Desktop Mobile
Details view with the full public key
Notes

The phone shows the key in the Nodes action sheet, not from an open chat.

yes partial
Contact position shown as text
Not built yet

Gap; on the phone position exists only on the map.

yes no
Advert count for a node yes yes
Signal history chart (SNR over time)
Not built yet

Gap. A bar per reading rather than a line, because these are separate receptions hours apart and joining them would imply a value in between. Adverts and messages are combined — a repeater nobody talks to is heard only through its adverts, a person mostly through their messages — and the two sources are drawn in different colours.

yes no
SNR attributed to an advert from the raw packet that carried it
Not built yet

Gap, and the reason the chart above was empty for repeaters: neither advert push carries a signal, so the value exists only on the LogRxData frame. An advert names its own sender in the first 32 bytes of its payload, so it can be attributed exactly rather than matched by arrival time. The phone parses the same raw frames for message routes but has no advert_key — it is not in the shared C++ core.

yes no
Inbound path resolved to a named hop chain
Not built yet

Gap.

yes no
Add a contact from an advert already heard yes yes
Add a contact by typing a key with nothing heard
Notes

Both only add from something already on file.

no no
Remove a contact from the radio
Not built yet

Gap on the desktop, which has the protocol call but never wires it to anything.

no yes
Rename a saved contact locally
Notes

The name follows the radio's contact record on both.

no no
Share / export / import a contact
Notes

The desktop defines the three protocol calls and never uses them; they are not in the C++ core at all.

no no
Forget a contact's route so the next send floods yes yes
Fetch the inbound path from the radio
Not built yet

Gap.

yes no
Trace a route hop by hop for live SNR
Not built yet

Gap.

yes no
Set a path manually, or force flood per contact
Notes

The firmware has no such command.

no no
List channel slots with index, name and region yes yes
Channel slot count read from the radio
Not built yet

Gap. The phone's "add a channel" only looks at slots 0–3; channels above that still sync and edit.

yes no
Rename a channel yes yes
A rename cannot silently change the key
Notes

Desktop hazard. In its default hashtag mode the desktop re-derives the key from the new name, which breaks the channel for everyone already on it. The phone only derives a key when the slot has none.

no yes
Choose the key kind: public, hashtag, random or manual
Not built yet

Gap. The phone always derives from the name.

yes no
Recognise the well-known public channel by its key
Not built yet

Gap; the constant is not in the C++ core.

yes no
Delete a channel / clear a slot
Not built yet

Gap.

yes no
Per-channel region, independent of the radio default
Notes

The phone's Store::upsertChannel now carries region through its ON CONFLICT branch. Until it did, a region set on a slot that already existed was shown as saved and silently dropped — and a channel with the wrong flood scope is not merely unread, every repeater drops it (PROTOCOL.md 10a), so what looked configured transmitted into nothing. is_favourite is still deliberately left out of that update, because the caller that writes a channel from a radio read builds a fresh record and never fills it in.

yes yes
Send through the channel's own region, restoring the default after
Notes

Same swap-and-restore design, written twice.

yes yes
Read and set the radio's own default region yes yes
Warn that an empty region means unscoped yes yes
Warn that region names are hashed exactly as typed
Notes

dk, #dk and DK are three different scopes.

yes yes
Discover repeaters (zero-hop broadcast, 12s window)
Notes

Same window and de-duplication on both.

yes yes
Discover scopes (discover, then ask each what it carries) yes yes
Clear the discovery log no yes

Map 30

The map

Feature Desktop Mobile
QtLocation OSM tiles yes yes
Offline tile cache
Notes

Cache directory set before the plugin is built, as QtLocation requires.

yes yes
Provider-repository lookup disabled so it works offline from first launch yes yes
Ships a TLS backend so tiles can be fetched at all
Platform limit

Platform. Qt for Android has none; OpenSSL is bundled, arm64 only.

not applicable on this platform yes
Pre-cache an area before going offline
Not built yet

Gap. On the phone the cache only fills with what has been looked at.

yes no
Count of cached tiles shown yes yes
Every node with a fix is drawn, whatever the list filters say
Notes

Same reasoning on both: a filtered map hides where people are.

yes yes
Marker colour meaning
Notes

Divergence, not obviously deliberate.

contact vs stranger node role
Marker name labels on the pin
Not built yet

Gap; on the phone the name appears only when a pin is tapped.

yes no
Own position drawn distinctly yes yes
Own position comes from the radio's advertised fix, never phone GPS
Deliberate choice

Choice on both.

yes yes
Enter your own lat/lon by hand
Not built yet

Gap. The protocol call is shared and the phone already uses it when applying a profile; no settings field exposes it.

yes no
Uncertainty circle around a pin
Notes

Desktop draws 500m to be honest about advertised precision.

yes no
Position history track per node
Not built yet

Gap, and structural: the shared positions table is never read or written by the mobile store, so no track can exist.

yes no
Reject a 0,0 fix as "no position" yes yes
Show how old a fix is
Not built yet

Gap on both.

no no
Pan, pinch-zoom, double-tap zoom yes yes
Mouse-wheel zoom
Platform limit

Platform.

yes not applicable on this platform
On-screen zoom buttons yes yes
Fit-all button
Not built yet

Gap. The phone only auto-frames a route or a highlighted set.

yes no
Opens centred on your own node
Notes

The desktop always opens on the same fixed coordinate.

no yes
Remember the last centre and zoom
Not built yet

Gap on both.

no no
Tap a marker to reach that conversation
Notes

One click on the desktop; the phone interposes a card, which suits a fat finger.

yes yes
Draw the route a message arrived over yes yes
Route legs trimmed to stop at the marker rings
Notes

Near-identical algorithm, kept in step deliberately.

yes yes
Say how many hops could not be placed yes yes
Long-press the route text to copy it no yes
Jump to a node's pin from a contact or repeater row
Not built yet

Gap.

yes no
Search on the map
Not built yet

Gap on both.

no no
Degrade gracefully if QtLocation fails to load
Notes

The desktop cannot assume which Qt is installed; the phone ships its own.

yes no

Nodes and telemetry 40

Nodes and repeaters lists

Feature Desktop Mobile
"Every node ever heard" view, contact or not yes yes
Name, key prefix, type, last heard, advert count
Notes

Desktop uses sortable columns; the phone folds signal and hops into one subtitle.

yes yes
Full key on hover
Platform limit

Platform; a touch UI has no hover.

yes no
Search the nodes list
Notes

Both match the name and the public key. The desktop matches the whole 64-character key rather than the shortened form in the column.

yes yes
Filter to nodes that are not yet contacts yes yes
Sort by clicking a column header
Platform limit

Platform; a ListView has no header row. Order is fixed to last-heard.

yes no
Add a discovered node as a contact yes yes
Open a conversation from the list yes yes
Mark or unmark a node as "mine" from the nodes list
Not built yet

Gap on the desktop, which only offers this from the Repeaters view.

no yes
Forget a route from the nodes list no yes
Remove a contact from the nodes list no yes
Stale styling in the nodes list
Not built yet

Gap on both.

no no
Separate destination for repeaters and sensors
Notes

Desktop has one Repeaters screen; the phone splits all-repeaters from my-repeaters.

yes yes
Route / hop count in the repeaters list
Not built yet

Gap.

yes no
Advert count in the repeaters list
Not built yet

Gap.

yes no
Unread badge in the repeaters list no yes
Search the repeaters list yes yes
Mark as mine from the repeaters list
Notes

Mobile bug. The phone's sheet hardcodes setMine(key, true), so it can mark but never unmark from this screen.

yes partial
"Show on map" from the repeaters list
Not built yet

Gap.

yes no
Banner warning that the radio's contact table is full
Not built yet

Gap on the desktop, which shows capacity only as a static number in Settings.

no yes
"My repeaters" list of infrastructure you run yes yes
Battery gauge with colour thresholds
Notes

Identical thresholds, drawn twice.

yes yes
Battery percent, falling back to volts yes yes
Temperature beside the battery yes yes
Firmware version, from cache, never triggering a read yes yes
Stale-repeater highlighting and count (over 6h unheard)
Not built yet

Gap — arguably the point of the screen.

yes no
Search "my repeaters"
Not built yet

Gap.

yes no
Battery sweep: log in, ask, move on
Notes

Same algorithm; both skip nodes with no saved password and say so.

yes yes
Sweep progress indicator yes yes
Per-row actions beyond "Manage"
Not built yet

Gap. Trace route, show on map, forget route and unmark are all unreachable from the phone's my-repeaters screen.

yes no

Telemetry

Feature Desktop Mobile
Decode CayenneLPP telemetry replies
Notes

Desktop has its own decoder; the phone uses the shared C++ core.

yes yes
Battery percent estimated from voltage
Notes

Byte-identical curve on both.

yes yes
Percent suppressed when the voltage is not a single cell yes yes
Request telemetry for one node on demand
Notes

Needs no admin login — a guest may ask.

yes yes
Store the reading with the time it was read yes yes
Show how old a reading is
Not built yet

Gap. The phone stores the timestamp and never displays it.

yes no
Own battery voltage yes yes
Own storage use
Not built yet

Gap on the desktop, which discards half of the same reply.

no yes
Packet counters, airtime, noise floor, uptime, queue depth yes yes
Per-contact SNR history
Not built yet

Gap. store.signal_history unions adverts, direct messages and channel messages whose sender name is unambiguous; SignalBars draws it. See §8.

yes no

Repeater management 41

Repeater administration

Feature Desktop Mobile
Log in to a repeater with a password
Notes

Role comes back from the node; neither client chooses it.

yes yes
Remember the password locally
Notes

Same storage key, so a restored archive carries it to the other client.

yes yes
Log in automatically when a password is saved
Not built yet

Gap.

yes no
Forget a rejected password automatically
Not built yet

Gap — the phone retries a known-bad password forever.

yes no
Explicit "forget password" button
Notes

The desktop ties it to the "remember" checkbox instead.

no yes
Show the logged-in role, and warn when it cannot run commands yes yes
Log out, and log out on closing the screen yes yes
Administer several repeaters at once
Platform limit

Platform. Desktop opens a non-modal window each; the phone has one page.

yes no
Offer to re-add the contact when the node no longer knows you
Not built yet

Gap.

yes no
All 32 config fields present in the form
Notes

Generated from one source.

yes yes
Read one group of settings at a time
Notes

One get per key — the node answers one command at a time.

yes yes
Read every group in one action
Not built yet

Gap.

yes no
Show cached values with their age before any round trip yes yes
Track edits and send only what changed, after one confirmation
Not built yet

Gap. The phone sends set the instant a field's button is tapped.

yes no
Send the four radio parameters as one set radio
Notes

Mobile bug. The generated table says all four go together and the firmware rejects a partial set; the phone sends them individually. The correct adminSetRadio exists in the bridge and nothing calls it.

yes no
Clamp numeric fields to their range in the client
Notes

The phone shows the range as a hint and relies on the node to refuse.

yes no
Choice fields as a picker, bools as a toggle, passwords masked yes yes
Read the neighbour list
Notes

Same parse, same handling of an ambiguous key prefix.

yes yes
Neighbours on a map
Notes

Desktop embeds a second map in the admin window; the phone hands off to its one shared map. Both say how many could not be placed.

yes yes
Neighbours viewable without logging in yes yes
Read a repeater's region tree yes yes
Toggle flood allow/deny, set home, add a region yes yes
Remove a region
Not built yet

Gap. The phone has adminRemoveRegion in the bridge and no button wired to it.

yes no
Save the region table to flash
Notes

Not implicit on either — the firmware keeps it in RAM until told.

yes yes
Read and set the default/wildcard scope
Not built yet

Gap.

yes no
Copy a whole region tree from another repeater
Notes

Diff-only, depth-first, with a trailing save. Ported deliberately.

yes yes
Cancel a region copy in flight
Notes

Both say plainly that what was already sent stays on the node.

yes yes
Free-text CLI console yes yes
Console log with sent / reply / error colouring
Notes

The phone folds everything that is not sent-or-reply into one "note" kind.

yes partial
Command history recall
Not built yet

Gap; less pressing without arrow keys.

yes no
Quick read-only query buttons
Notes

Desktop has 12, the phone 11.

yes yes
Timeout and retry on *every* command, including typed ones
Not built yet

Gap. The phone queues group reads, region copies and logins, but a typed console command is fired and forgotten.

yes partial
Send advert, flood or zero-hop yes yes
Reboot, with confirmation yes yes
Sync the node's clock yes yes
Clear stats
Not built yet

Gap.

yes no
Factory reset as a button
Notes

Console-only on both, deliberately.

no no
Remote firmware update of a repeater
Notes

Not built on either.

no no
Overview page: board, version, route, last heard, key
Not built yet

Gap. On the phone these are console queries whose output is not laid out or kept.

yes no
Cache ver for the repeater list yes yes
Cache board for the repeater list
Not built yet

Gap, and admitted in the phone's own comment.

yes no

Firmware and flashing 43

Firmware library

Feature Desktop Mobile
Board and role catalogue (114 boards, 14 roles)
Notes

qt/firmware_catalog.h is generated from the Python catalogue.

yes yes
Catalogue is advisory — an unknown board is still flashable
Deliberate choice

Choice on both.

yes yes
Longest-match board parsing from the filename
Notes

Heltec_t114_without_display beats Heltec_t114.

yes yes
Local library with a shared index.json
Notes

Same format deliberately, so one library serves both after a restore.

yes yes
Rescan the directory and rebuild a missing index yes yes
Import a local .bin / .uf2 / .hex / .zip, de-duplicated by hash
Notes

Desktop uses a file dialog; the phone must use Android's document picker.

yes yes
Download firmware from the network
Deliberate choice

Choice on both. Neither client has any network code for this; the whole design assumes no internet.

no no
Show the SHA-256 digest
Notes

Both compute and store it; only the desktop shows it.

yes no
Edit guessed metadata after import yes yes
Flag an image whose metadata was guessed, not reviewed yes yes
Export an image back out of the library
Platform limit

Platform. App-private storage on Android is otherwise unreachable.

no yes
Verify a signature or publisher
Not built yet

Gap on both. Neither authenticates firmware provenance.

no no
Record when an image was last flashed, and to what
Notes

Same JSON keys.

yes yes
Library included in a backup yes yes
Warn when the connected radio's protocol is newer than the client
Not built yet

Gap.

yes no
"A newer version is available" for the connected radio
Not built yet

Gap on both.

no no

Flashing

Feature Desktop Mobile
Flash an ESP32 by driving esptool as a subprocess
Platform limit

Platform. There is no Python to shell out to on a phone.

yes not applicable on this platform
Flash an ESP32 by implementing the ROM bootloader protocol
Notes

Written from esptool's own source; held to its byte layout by cpp/tests/test_esp32boot.cpp.

no yes
Upload a stub loader
Platform limit

Platform. A stub means shipping a compiled blob per chip family.

yes no
Flash block size
Notes

Consequence of the stub: the ROM loader's own limit is smaller, so the phone is slower.

16 kB+ 1 kB
Whole-chip erase
Platform limit

Platform. ERASE_FLASH is stub-only, so the phone physically cannot wipe a node's identity.

yes no
Guard the radio's identity before an erase, offering to save a profile
Notes

Nothing to guard against without erase.

yes not applicable on this platform
Identify the chip on demand yes yes
Refuse to write when the image's chip does not match the board
Not built yet

Gap on the desktop, which relies on the operator reading the filename.

no yes
Write baud rate
Notes

The phone has no stub to manage a rate change, so it syncs conservatively.

460800 115200, raised after handshake
Verify the write by MD5 readback
Notes

Automatic inside esptool on the desktop; explicit and non-fatal on the phone.

yes yes
Cancel a flash in progress
Not built yet

Gap. The phone's write loop has no stop hook.

yes no
Live progress percentage and a streamed log
Notes

Desktop scrapes esptool's output; the phone counts its own bytes.

yes yes
Disconnect the radio session before flashing
Platform limit

Platform. The desktop shares one serial port; the phone's BLE and USB are separate.

yes not applicable on this platform
Flash a UF2 board by copying to its bootloader drive yes yes
Identify a bootloader drive by INFO_UF2.TXT, never by name
Notes

Same rule, ported deliberately.

yes yes
Drive access
Platform limit

Platform. Scoped storage forbids a raw path.

mounted path Storage Access Framework
Check the UF2's family and board against the drive before writing
Not built yet

Gap on the desktop. The phone has a full verdict engine with its own tests.

no yes
Re-check the drive at the moment of writing, not just when picked
Notes

Guards against the cable being moved in between.

no yes
Treat the drive vanishing mid-write as success
Notes

Correct UF2 semantics on both.

yes yes
USB serial: CDC-ACM
Notes

The desktop uses the kernel's driver; the phone hand-rolls one.

yes yes
USB serial: CP210x
Notes

Vendor requests implemented in Java on the phone.

yes yes
USB serial: CH340, FTDI, PL2303
Platform limit

Platform. Android has no generic USB-serial driver. Each is listed with its bridge named and refused, rather than opened and left to fail at sync.

yes no
List an unsupported device with a reason instead of hiding it
Notes

The desktop does not filter at all, so the question does not arise.

not applicable on this platform yes
Android USB permission prompt per device
Platform limit

Platform.

not applicable on this platform yes
Reset into the bootloader over DTR/RTS
Notes

esptool does it on the desktop; the phone reimplements it in Java per bridge type.

yes yes
Application-only vs merged image, with the right offset yes yes
Tell the user to reconnect afterwards rather than auto-reconnecting
Notes

Same wording.

yes yes

Profiles and sync 112

Profiles and identity

Feature Desktop Mobile
Save the radio's current state as a named profile
Notes

Same JSON format, readable by either client.

yes yes
Include the identity in a profile, opt-in per save yes yes
Profile written owner-read-only on disk yes yes
Apply a profile with independent choices of what to write
Notes

The phone offers three fixed bundles instead of free checkboxes.

yes partial
Type "REPLACE" to confirm writing an identity
Not built yet

Gap. The phone has a warning and a danger-styled button, but no typed gate.

yes no
Delete a profile, warning if it holds the only copy of a key
Notes

The phone deletes on one tap with no confirmation at all.

yes partial
Import or export a single profile file
Not built yet

Gap. On the phone a profile can only travel inside a full backup.

yes no
Mint a brand-new identity in the client
Not built yet

Gap, and deliberate for now: cpp/README.md puts ed25519 keygen last, on the grounds that the firmware and the platform both already have it.

yes no
Import a private key onto the radio yes yes
Export the radio's private key yes yes
Show your own public key
Notes

Desktop: in the profile chip and switcher tooltips only, never as a field.

partial yes
QR code of your identity
Not built yet

Gap on both.

no no
Change your node name yes yes
Warn that replacing an identity is irreversible yes yes

Profiles: which radio's world you are looking at

Feature Desktop Mobile
Every observation stamped with the radio it was heard through
Notes

One migration, written once as MIGRATIONS[9] in meshcore/store/db.py and generated from there into schema/migrations/010.sql and the APK's resources. adverts, contacts, channels, messages, node_paths, positions, repeater_state and the scoped settings all carry profile_key.

yes yes
Primary keys widened where one radio's view is not unique
Notes

contacts, channels and repeater_state are rebuilt: a public key, a channel slot number and (public_key, key) are each unique only within one radio.

yes yes
Dedupe index leads with the profile
Notes

idx_messages_dedupe is UNIQUE on five columns that are identical when two radios hear the same channel message. Without the scope in front, the second insert is silently rejected and the loss looks like a bug somewhere else.

yes yes
Safety copy of the archive before the schema moves
Notes

store.db.pre-v10-<epoch>, beside the archive, never deleted automatically. Desktop takes it with SQLite's backup API; the phone checkpoints the WAL first, because a plain copy in WAL mode omits exactly the messages that arrived most recently.

yes yes
The migration key is never hardcoded
Notes

Each device stamps its rows with the key it already holds, so the migration cannot be applied to the wrong archive.

yes yes
Registry of profiles: key, name, pending flag, last used
Notes

Keyed by the radio's own public key rather than a local number, so two archives can be compared later without two devices' "profile 5" colliding.

yes yes
Provisional pending: profile for an archive with rows but no usable key
Notes

64 hex characters becomes the profile key; anything else becomes pending:<random> and the rows are stamped with that. Nothing ever guesses a key or stamps rows with a value that looks real.

yes yes
A fresh archive with no rows and no key gets no profile at all
Notes

It acquires one when a radio first says who it is.

yes yes
Bind a provisional or unscoped archive to the radio that turns up
Notes

One transaction per scoped table, and the provisional profile is deleted as its rows leave.

yes yes
Binding moves settings one key at a time, not in one UPDATE
Notes

The unscoped key is '', which is also where last_port, ui:* and the attached-radio pointers live permanently; a blanket update would carry this machine's /dev/ttyUSB0 into a profile that a later phase would hand to another device.

yes yes
Binding onto a profile that already holds rows is refused
Notes

Folding two populated scopes needs a merge rule — which last_seen wins, what happens to a slot both radios use — and that is a later phase. The answer for a radio that already has a profile is to switch to it.

yes yes
Read markers and the default region follow the profile
Notes

One classification function per client, and tests/test_profile_scope.py holds the two to each other and to the migration.

yes yes
An unclassified setting is treated as device-local
Notes

The safe direction to be wrong in: it stays put rather than following a switch. A test forces the decision to be made — but it walks the Python package only, so a phone-only key such as android:keep_connected is device-local by default and unexamined.

yes yes
The scope is held by the store, never passed to a query
Notes

Sixty-odd SQL sites on each side; one missed predicate would show one radio's contacts inside another's chat list, silently. A source-inspection test reads both store/db.py and qt/store.cpp back and fails on any statement naming a scoped table without naming profile_key.

yes yes
Profile switcher
Notes

Desktop: a chip at the head of the rail, because the answer scopes every destination below it. Mobile: a Profile page under More.

yes yes
The key shown beside each profile, not only the name
Notes

A name is a label — it can be renamed and two radios can share one. Desktop puts it in the chip and menu tooltips, the phone in the row itself.

yes yes
Active profile visible in the chrome without opening the switcher
Notes

The desktop chip names the profile on every page. The phone's toolbar names the attached radio, which is a different question the moment one profile is being read while another radio is connected.

yes partial
Switcher marks which profile is the radio attached now yes yes
Switcher marks a profile not yet bound to a radio yes yes
Switching rebuilds every view, and empties the open conversation
Notes

The scrollback belongs to the profile that was active when it was loaded; leaving it on screen under a new name is the exact confusion scoping exists to prevent.

yes yes
A radio that is not the active profile is an offer, never an action
Notes

Switching for the user moves them out of what they were reading; re-pointing the profile at the new key would relabel one radio's whole history as another's.

yes yes
The offer gates the whole post-handshake sequence, not just the UI
Notes

Region read, contact sync, channel read and the message drain all write into the active scope, so all four stop until it is answered. Losing a minute of a sixteen-deep queue is a far smaller loss than sixteen messages filed under a radio that never heard them.

yes yes
Answering the offer resumes the sequence that was stopped
Notes

Controller.use_profile and Radio::useProfile pick up where the handshake stopped.

yes yes
Declining leaves the radio connected and unsynced, and says so
Notes

Desktop puts it in the status line, the phone in the header status. Quietly resuming the sync into the wrong scope is the failure both are avoiding.

yes yes
The offer is put in front of the user rather than left to be found
Notes

Desktop opens a modal named after what each button does. The phone brings the Profile page to the front, because a stopped sync with nothing on screen to explain it is indistinguishable from a broken app.

yes yes
Add the attached radio as a profile from the switcher
Notes

The desktop menu offers it whenever the attached radio has no profile. On the phone a new radio becomes a profile only through the connect-time offer.

yes partial
The composer names the identity a send would go out under
Notes

The desktop says "Sends via " whenever it can send. The phone shows the line only when it cannot, so the ordinary case is unlabelled.

yes partial
Sending refused when the attached radio is not the active profile
Notes

Reading is always allowed — the archive is offline — but a message typed under one profile and transmitted by another radio cannot be taken back. The desktop disables the button and repeats the refusal in Composer._emit, because Enter never goes through the button. The phone gates the QML button only; Radio::sendText does not re-check.

yes partial
Any profile readable with nothing connected
Notes

The archive is offline. Only transmitting needs a radio.

yes yes
Map, nodes, repeaters and chat all drawn from the active profile
Notes

The map's own-node marker included: it takes the active profile in preference to settings.self_public_key, so a route still starts at the radio that heard it.

yes yes
Profiles from a restored archive picked up without a restart
Notes

Both stores re-read the active profile as the file is replaced, and both rebuild their lists. The desktop leaves the rail chip and the composer line naming the previous profile until the next event refreshes them.

partial yes
Rename a profile
Notes

The desktop store has rename_profile and nothing calls it; the phone has no method at all. The label follows the radio's own name.

no no
Delete a profile, and everything observed through it
Notes

Store.delete_profile exists and is tested — for received messages the archive is the only copy, so it is not something to wire up without a confirmation designed for it. Nothing on the phone.

no no
Merge two profiles
Notes

Not what §18 does. The merge rules fold two archives together and are explicit that a row never moves between profiles; folding one radio's history into another's is a different act, needs an answer to which last_seen wins and what happens to a slot both radios use, and inventing one silently is what phase 1 exists not to do.

no no

Merged "all profiles" views

Feature Desktop Mobile
An "all profiles" switch
Notes

Desktop: a checkable entry in the rail's profile menu, because that is where the question of scope already lives. Mobile: a toggle on More → Profile. Off by default on both, and stored device-locally under ui:all_profiles — how you are looking is not a property of the archive.

yes yes
Hidden or disabled while there is only one profile
Notes

The desktop disables it and says why ("Only one profile — nothing to merge"); the phone hides it. Both also refuse at the read — _merge_scopes and mergeScopes return nothing below two profiles — so a setting left over from a deleted profile cannot produce a "merged" view of one thing.

yes yes
The map merges yes yes
The nodes list merges yes yes
The repeaters list merges
Notes

Both filter the merged set the same way: infrastructure advert types, held as a contact by some radio.

yes yes
Conversations never merge
Notes

Chat, the conversation list, unread counts and the nav badges stay scoped whatever the switch says. tests/test_merged_views.py asserts it directly on the desktop, down to the chat views not even having the setter; on the phone it is structural — the conversation reads are the ordinary scoped ones — and there is no test.

yes yes
Deduped by public key
Notes

A node both radios heard is one pin and one row.

yes yes
Merged name, advert type, is-contact, is-mine
Notes

Name and type from whichever radio heard it last, because that is the radio that heard it renamed; contact and mine are true if any radio says so. MergedNode in meshcore/store/models.py and in qt/store.cpp are the same rules written twice, function for function.

yes yes
last_heard the newest, advert_count the sum
Notes

Both are answers about your radios collectively rather than numbers attributed to one of them.

yes yes
SNR, hop count and route kept per radio, never merged
Notes

They live in the sightings. Averaging two radios' SNR would invent a reading nothing took.

yes yes
Per-profile provenance line
Notes

"Horsens Havn, 3 hops · ADT-DK8700, direct" — a Heard by column on the desktop's tables, a third line on the phone's rows.

yes yes
Provenance replaces SNR and route in the merged table
Deliberate choice

Choice on the phone: it adds the third line and keeps the active profile's own SNR and hop count beside it, falling back to the newest sighting when the active radio never heard the node at all. Wider rows, and one number on screen that the column header no longer accounts for.

yes no
Long-form provenance, per radio, on demand
Not built yet

Gap. The desktop's cell carries a tooltip giving each radio's SNR, last-heard and advert count separately; the phone has no equivalent surface.

yes no
Provenance follows the column it sits under
Notes

"Route" means two different things and both clients had already chosen: the repeaters list shows the outbound route from each radio's contact record, because the question that list answers is whether you can reach the thing, while nodes and the map show the inbound path of the last advert. provenance() and provenanceText() each take an outbound flag for exactly that.

yes yes
Provenance on the map
Notes

Each client built one of the two surfaces SYNC.md §5b.1 names. The desktop draws a dimmer second line under the pin's own name label; the phone's pins carry no label at all, and its provenance is on the card a tap opens — which the desktop does not have, because a click there goes straight to the conversation.

partial partial
Merged position: the newest fix wins
Notes

Mobile bug. MergedNode::gpsLat walks the sightings and returns the first one holding a fix — and the sightings are ordered active-profile-first, then by name, never by time. So the phone shows the active radio's position however stale it is, and the alphabetically-first other radio's when the active one has none. The comment directly above the loop says the newest wins. The loop does not.

yes no
Merged position track
Not built yet

Gap, and structural. Two radios watching one node moving saw the same journey and each filled gaps in the other's view of it; the desktop stacks every profile's track and re-sorts by time. The phone has no track of any kind — its map draws contacts' gps_lat/gps_lon, and the positions table is never read there at all (§19).

yes no
Merged repeater rows still carry unread and a preview
Notes

The phone's repeaters list is built from the conversation list rather than from contacts, so the merge is split down the middle: the world half of each row merges, the conversation half comes from the active profile alone, and a repeater only the other radio has ever seen gets no badge and no preview. Tapping it opens a chat, an empty one, in the active profile. That is the truth rather than a bug. The desktop's list is built from contacts and has no such seam.

not applicable on this platform partial
The merged view says that it is merged
Notes

The desktop appends "across all profiles" to the summary line on all three views. The phone does it on Nodes and the Map and not on Repeaters.

yes partial
"My repeaters" deliberately left scoped
Notes

Argued rather than overlooked (SYNC.md §5b.5). is_mine is a per-profile flag, and the page's question is "is my infrastructure still up", answered by last_seen from the radio that watches it. Merging would produce a page saying a node is fine because the other radio heard it, which is the opposite of what somebody opens it to find out.

yes yes
Battery and firmware resolved across profiles on that scoped page
Not built yet

Gap, and §5b.5's leftover. Two radios that have both read a repeater hold two repeater_state rows. Store.repeater_reading takes the newest batt: or info: reading whatever profile it sits in and names the radio that took it — "Read by Horsens Havn, not by the radio in front of you" — while cfg: and auth: stay in the active profile, because a configuration value is what one radio read back in its own admin session and a saved password is a credential this radio holds. Store::repeaterState on the phone answers from the active profile alone: never wrong, sometimes the older of two answers.

yes no
No cross-profile SQL anywhere
Notes

Every merged read runs the already-scoped query once per profile and stacks the answers (Store._scoped_to, store::Store::ScopeFor). Provenance falls out of the stacking rather than being reconstructed after a flattening, every statement underneath still carries its profile_key predicate, and two profiles cost two queries on a table of a few thousand rows.

yes yes
The scope scanner requires a *bound* predicate, not a mention
Notes

A merged query that selects profile_key for provenance mentions the column all day and constrains nothing, so "it's a merged view" would have been a phrase big enough to hide a real leak behind. One pre-existing statement was caught by the tighter rule and is on the allowlist with its reason.

yes yes
An allowlist naming every function allowed to re-scope the store
Notes

Because the SQL scanner cannot see a merged read at all, and wrapping messages() in _scoped_to is exactly as easy as wrapping discovered_nodes(). MERGES_ON_PURPOSE names four functions, one at a time with a reason, and fails on a dead entry. The phone's half is a scan of qt/store.cpp for uses of ScopeFor, with a canary assertion so the test cannot silently prove nothing.

yes yes

Sync: merging two archives, and moving one over the network

Feature Desktop Mobile
A row identity that survives leaving its file
Notes

(profile_key, origin, seq). A row's id is a per-file AUTOINCREMENT and cannot travel — archive A's message 500 and archive B's are unrelated, and B cannot adopt A's ids without colliding with its own. Indexed as the range scan a resumable log wants.

yes yes
origin is the writing *device*, not the profile's owner
Notes

Connecting the phone's radio to the desktop — to flash it, or because BLE is being awkward — makes both machines write into that radio's profile. Keyed on the profile, both count from their own highest row and a merge folds unrelated rows together in silence. Keyed on the device they are distinguishable and the union is simply correct. Single-writer survives as an optimisation, never as something correctness rests on.

yes yes
A device id that never travels as a setting
Notes

A lazily-minted uuid, classified device-local in both stores, because two archives sharing one would allocate colliding sequence numbers. It is announced in a sync hello — which is how two devices that turn out to be copies of one archive are refused rather than merged.

yes yes
Pre-v11 rows attributed deterministically
Notes

legacy:<profile_key>, with seq taken from id. It has to be deterministic across copies: two copies of one v10 archive must migrate to the same row identities, or restoring last Monday's backup would insert every row a second time.

yes yes
Content as the fallback identity
Notes

The price of that determinism is that two devices which both wrote to one profile before v11 genuinely share a legacy origin, with overlapping sequence numbers. So identity is checked first — an indexed lookup, and the whole answer in every normal merge — and only the colliding rows are then compared by payload. A row with no payload-equal counterpart here is one this archive does not have, whatever its label claims, and it is re-homed onto a free sequence number rather than dropped. rehomed counts them; before this they were the rows the unique index rejected in silence.

yes yes
Tombstones, so a deletion is not undone by the next merge
Notes

Absence is also what "I have not heard of it yet" looks like, so without them the union puts every deleted row back and the next exchange undoes every deletion the user has ever made — silently, and repeatedly. The desktop writes them from its only two deleting paths, delete_contact and repeater_forget (which is also how a saved password is forgotten), and honours them. The phone honours what it receives and writes none, because it deletes nothing locally: removing a contact there tells the radio and leaves the archive's row alone.

yes partial
A row re-created after its deletion outranks the tombstone
Notes

A contact the radio has re-advertised is newer information than its removal, and first_seen — when this archive met it — is what says so.

yes yes
Deleting a profile deliberately leaves no tombstone
Notes

It takes a whole radio's history with it, and for received messages the archive is the only copy. A tombstone would make that destruction contagious: delete on one device, and the next merge deletes on the other. It stays a local act, and a merge will happily bring the profile back. The safe direction to be wrong in. (_tombstone's own docstring still says a profile is tombstoned. The code is right and the comment is stale.)

yes yes
Last-writer-wins, per field and on a short list
Notes

contacts.is_favourite, contacts.is_mine, channels.is_favourite, channels.region, the scoped settings and a repeater_state reading — each with its own stamp. Per row rather than per field is what produces "I renamed it on the phone and a stale favourite flag clobbered the rename": one timestamp cannot say that the favourite is old and the name is new.

yes yes
Everything the radio said moves as one block
Notes

Name, type, flags, route and gps are one observation, already stamped lastmod by the radio. Splitting them field by field would assemble a contact record no radio ever held. first_seen takes the earlier and last_seen the later, because between two radios both are true.

yes yes
Ties resolved on the value
Notes

Which makes every rule a maximum over a total order — commutative and idempotent, so the order merges run in cannot matter. It also settles the one tie that actually occurs: every row migrated from v10 carries stamp 0, and 1 > 0 means a favourite set years ago beats an archive that never knew about it.

yes yes
Read markers translated, not copied
Notes

A marker's value is a messages.id, the per-file counter that cannot travel; the other archive's "read up to 412" says nothing about this file's row 412. So the id resolves to the row it names, that row's (origin, seq) finds the same message here, and the local id is what gets compared and stored. A marker that cannot be resolved leaves the local one alone — wrong in the safe direction, because the badge reappears rather than a message being silently marked read.

yes yes
The phone writes what a merge needs, even where it cannot merge
Notes

origin and seq on the rows it creates, favourite_at, mine_at and settings.updated_at on the edits it makes, so a row created there is identifiable wherever it ends up.

yes yes
Restore merges rather than replaces
Notes

Changed on both. Restore used to overwrite the database wholesale; both dialogs now offer Merge and Replace over the same rules a sync runs. The desktop makes Merge the default button and reports what it folded in; the phone offers two equally-weighted buttons.

yes yes
Restore merges from the headless CLI too
Not built yet

Gap. meshcore-cli restore still replaces, with no flag to say otherwise, while the GUI it shares a store with now defaults to merging.

no not applicable on this platform
A merge cannot move a row between profiles
Notes

The merge is the one operation whose subject is the file rather than the profile on screen — a backup holds every profile, and restoring only the active one's half of it would be a quiet way to lose the rest — so ten of its statements are exempt from the scope scanner, named one at a time with what each does. The functional half is a test that writes a second profile's backup into a store showing the first and asserts every row lands under the profile_key it arrived with.

yes yes
Exchange archives with a paired device over the local network
Notes

TCP 45737. Two roles and not two programs: either device may dial, and the exchange is symmetric.

yes yes
Pair by typing a code shown on the other device
Notes

Both clients can show a code and type one. Ten characters of Crockford's base32, normalised before use — uppercased, dashes dropped, O read as zero and I/L as one — because somebody copying a code off a screen writes O for zero every time, and refusing it teaches them nothing.

yes yes
One attempt, three minutes, fifty bits
Notes

Said plainly rather than dressed up: this is not a PAKE, and an attacker who reaches the pairing listener and gets one confirmation out of it can attack the code offline. So the defence is arranged differently — a wrong code closes the listener, the window closes itself, and 200,000 PBKDF2 iterations make that one offline attempt cost about half a second a guess.

yes yes
Both certificates pinned, compared as exact DER afterwards
Notes

No CA and no hostname check: a peer is an address the user typed and the certificate that was pinned, which is a much smaller set than "something that verifies". Load-bearing rather than belt-and-braces, because saved repeater passwords are in scope for the merge and therefore cross this socket.

yes yes
Mint the certificate the client uses
Notes

Desktop through cryptography; the phone through the OpenSSL that Qt's TLS backend already loads, because Qt can use a certificate and cannot make one.

yes yes
QR code instead of typing the code
Not built yet

Gap on both. Nothing in the format depends on how the code got across.

no no
Only the delta is sent for the append-only tables
Notes

The receiver sends a high-water mark per (profile, origin, table) and the sender selects above it — the seek (profile_key, origin, seq) was designed to make possible. An origin the receiver has never seen is absent from the map, reads as zero, and that whole history follows, which is how a row relayed through a third device arrives without the two ends ever meeting. Nothing rewrites origin, so re-exchange stays idempotent.

yes yes
The small tables sent whole, every time
Notes

Profiles, contacts, channels, repeater state, settings and tombstones: a few hundred rows against forty-odd thousand positions, so a watermark for them would be complexity bought with nothing — and their merge rule is a maximum over a total order, so re-sending is idempotent by construction.

yes yes
Device-local settings filtered out before they leave
Notes

At the sender and at the receiving merge, deliberately twice. A listen port that followed the archive would have every paired device fighting over one socket.

yes yes
Nothing applied until the whole stream has arrived
Notes

Rows land in a scratch archive and are folded in with one call to the same merge a restore uses, in one transaction. A connection that dies halfway leaves the archive exactly as it was and the cursor exactly where it was, so the next attempt simply repeats.

yes yes
Sync does not get a second implementation of the merge
Notes

Received rows go through meshcore/store/merge.py and qt/merge.cpp — the same code a restore uses. Two would disagree, and the one that only ran over a socket is the one nobody could test.

yes yes
The wire format pinned by conformance vectors
Notes

conformance/sync.json covers the framing, the pairing derivation, the code alphabet and normalisation, and the value codec — everything that is arithmetic over bytes — replayed by both ports. It is the corpus's first authored group: every other one describes a radio's protocol and is authoritative because the hardware says so, while this one describes a protocol this project wrote, so disagreeing with a vector means the code and the specification have parted company and one of them has to give.

yes yes
The cross-client exchange exercised end to end
Notes

tests/test_qt_sync.py builds the phone's sync code for the host and runs it against the desktop's service over loopback, because the corpus cannot pin a TLS handshake, a pinned certificate or a delta computed from a peer's cursors — and a phone in a drawer is not a test that runs on demand.

yes yes
Peers and the sync identity kept outside the archive
Notes

identity.pem and peers.json, 0600, packed into no backup. A peer list that merged would turn "A paired with B" and "B paired with C" into "A trusts C", which is authorisation nobody granted.

yes yes
Manual "sync now", per peer
Notes

And that is the whole trigger.

yes yes
An automatic trigger, or discovery of a peer on the network
Not built yet

Gap on both, and the one being closed as this is written: another agent is building discovery and the automatic trigger now, so nothing in this section describes them. Until they land, a peer is a host and port somebody typed, and nothing re-resolves one whose address has changed.

no no
Say which network the device is on
Notes

Both show the local IPv4 address rather than the network's name.

partial partial
Say plainly when there is no network, rather than showing a blank
Notes

The phone's wording names Wi-Fi; the desktop's is the flatter "Not on a network."

yes yes
Tell "listening" from "nothing paired" from "not accepting"
Notes

Three states that are easy to conflate, said apart on both.

yes yes
"Last synced" per peer, rather than "reachable"
Notes

A fact rather than a guess. The phone's peer record even carries a reachable field and deliberately never exposes it to the UI.

yes yes
Unpair a peer
Notes

The desktop confirms first; the phone unpairs on one tap.

yes partial
Rename a peer
Not built yet

Gap on both: a peer's name is whatever it called itself at pairing, for ever.

no no
Name this device, as its peers will see it
Not built yet

Gap. setPeerSyncName exists on the phone and no QML calls it, so a phone appears to its peers under its radio's name — or under the literal string "This phone".

yes no
Change the listen port
Not built yet

Gap on both. Each side has the setter and neither has a widget.

no no

Your data 25

The archive, backup and restore

Feature Desktop Mobile
SQLite archive as the record of the mesh
Notes

The radio's own storage is smaller and destructive; the app is the only full copy.

yes yes
contacts, channels, messages, adverts, repeater_state, settings yes yes
positions (position history)
Not built yet

Gap. The mobile store never references the table.

yes no
node_paths (observed routes)
Not built yet

Gap; matches trace/path discovery not being ported to the C++ core.

yes no
sent_as_key, sent_as_name, sent_region, flooded columns written
Not built yet

Gap. Present in the shared schema, never populated by the phone.

yes no
WAL journal, synchronous=FULL
Notes

Same reasoning: the radio deletes a message once you have read it.

yes yes
Archive file restricted to the owner
Notes

Android's per-app sandbox already does this.

yes not applicable on this platform
Forward-only migrations tracked in schema_version
Notes

The .sql files are generated from the Python source and embedded in the APK as resources. At v11, which is where §18's row identity and tombstones arrived.

yes yes
Migrate an existing database, not only create a fresh one
Notes

Verified on both — a natural thing to assume otherwise.

yes yes
Refuse a database newer than the build understands yes yes
Automatic pruning or retention of old messages
Deliberate choice

Choice on both: the archive is the only copy.

no no
Backup to a single .mcbackup container
Notes

Same format deliberately, so a phone backup opens on the desktop.

yes yes
Consistency across the WAL when backing up
Notes

Desktop uses SQLite's backup API; the phone checkpoints then copies.

yes yes
Choose which parts to include
Notes

The phone always takes everything.

yes no
Merge tiles, profiles and firmware on restore yes yes
Merge the database too, rather than replacing it
Notes

New on both, and the rules are §18's. Replace is still offered; it is no longer the only answer.

yes yes
Keep a safety copy of the database before restoring yes yes
Restore a bare pre-container .db
Not built yet

Gap.

yes no
Refuse an archive newer than the build understands yes yes
Storage usage shown in the UI yes yes
"Delete everything" / reset the app
Not built yet

Gap on both.

no no
Scheduled or automatic backup
Not built yet

Gap on both.

no no
Database encrypted at rest
Not built yet

Gap on both; it relies on file permissions and the app sandbox.

no no
App lock (PIN or biometric)
Not built yet

Gap on both.

no no
Android's own backup kept away from the archive
Notes

Was the open decision here; it is taken. allowBackup is false and both rule files exclude everything, because the two are not alternatives: from Android 12 allowBackup="false" switches off cloud backup and leaves device-to-device transfer running, so dataExtractionRules answers that half and fullBackupContent covers Android 11 and earlier. The archive holds every message the phone has seen, saved repeater passwords, and — where an identity was captured into a profile — node private keys. D2D was the closer call and is excluded anyway: the app already has two deliberate ways to move an archive, and both keep the property that makes them safe, which is that the archive only moves when somebody moves it. The cost is written down rather than hidden — restoring a new phone from an old one brings MeshCore back empty. Confirmed on the handset with dumpsys package.

not applicable on this platform yes

The app 66

First run, where you are, and which build this is

Feature Desktop Mobile
First-run wizard
Notes

Five steps: where you are, find a radio, radio settings, your name, done. Gap on the phone, which drops a new install straight into an empty Chat.

yes no
The wizard owns no settings of its own
Notes

Every one of its signals lands on a path that already existed. See above.

yes not applicable on this platform
Knows a fresh install from a used one
Notes

A marker file beside the archive and a setting, because they fail differently: a fresh install that has merged a backup has the setting and not the marker, and should still be walked through. An archive that already holds messages, contacts or a remembered port and has neither is quietly adopted as set up rather than interrogated.

yes not applicable on this platform
Skippable, and re-runnable afterwards
Notes

Skip is offered on every step and closing the window counts as one. Settings › General carries "Run setup again…" and a line saying which of finished, skipped or adopted actually happened.

yes not applicable on this platform
Country selection
Notes

One device-local setting. Not profile-scoped, deliberately: it is where the operator is sitting, not a property of a radio, and two radios in one country is the normal case — scoping it would ask the same question once per radio and let the two answers disagree.

yes no
Country selects the band, and its four radio parameters
Notes

Frequency, bandwidth, spreading factor and coding rate.

yes no
The preset is offered, never applied
Notes

meshcore/country.py has no apply of any kind. Choosing a country fills nothing in until a button is pressed, and even then the numbers land in the spin boxes and wait for the ordinary "Change radio parameters?" confirmation.

yes not applicable on this platform
Country gives a transmit-power ceiling, as a warning
Notes

A question and not a clamp: an operator with a licence and a reason may well mean to go higher, and what they must not do is go higher without being told they have. The note that comes with it is the only place the table says its number is conducted power at the radio rather than radiated power at the antenna, so the two are one label and never two — split them and the claim becomes a lie by omission.

yes no
Country gives local links
Notes

Alongside two project links, which are shown whatever the country.

yes no
Countries in the table
Notes

Denmark, one band, EU868. The mechanism is general and the data is not, on purpose: an unverified band must not ship, and a test enforces the rule rather than trusting it.

1 not applicable on this platform
The country table generated for the other client
Notes

qt/country_table.h is generated from meshcore/country.py and held fresh by a test, exactly as the firmware catalogue and the repeater config table are — and nothing in the APK includes it yet. Data plumbing laid ahead of a UI, which is worth knowing before somebody reads the generated file as evidence the phone has the feature.

yes not applicable on this platform
About: the app's own version
Not built yet

Gap closed on the desktop, which until now could not tell a user which build they were running. Taken from meshcore/__init__.py, the same single source the backup manifest stamps — not a third place a version lives.

yes yes
About: the archive's schema version
Not built yet

Gap. "Which build" and "which archive format" are different questions the moment two devices can disagree, and the desktop says both — and says so more loudly when the file and the build disagree.

yes no
About: the connected radio's firmware
Notes

Desktop adds the build date and the manufacturer, which it already decoded and the phone still drops (§2).

yes yes
About: copy the lot for a bug report
Not built yet

Gap. One line, one button.

yes no
Where About lives own Settings tab a card inside Radio settings

Diagnostics

Feature Desktop Mobile
Packet counters, sent and received yes yes
TX/RX airtime, flagged when nothing has been transmitted yes yes
Noise floor and last signal yes yes
Uptime and outbound queue depth yes yes
"Transmit and measure" verdict
Notes

The only way to answer "is this radio actually transmitting" without a second one.

yes yes
Default flood scope read from the device, flagged when scoped
Not built yet

Gap.

yes no
Dedicated link-state card
Notes

The desktop uses its status bar.

no yes
Raw frame or hex view in the GUI
Not built yet

Gap on both.

no no
Raw frame dump anywhere
Platform limit

Platform. meshcore-cli monitor --raw --decode; a phone has no headless entry point.

yes (CLI) no
Connection statistics (frames, bytes, discards) yes (CLI) no
Log file, log level, or in-app log viewer
Not built yet

Gap on both. The phone's only trace is adb logcat.

no no
Headless CLI: probe, monitor, contacts, send, advert, backup, restore
Platform limit

Platform.

yes not applicable on this platform

Notifications

Feature Desktop Mobile
System notification on a new message
Notes

D-Bus on the desktop, the Android shade on the phone. Not mobile-only, as is easy to assume.

yes yes
One notification per conversation, replaced rather than stacked yes yes
Suppressed for the conversation you are looking at yes yes
Cleared once the conversation is read yes yes
Choose when to notify (always / unfocused / never)
Not built yet

Gap; the phone has one fixed policy.

yes no
Escalated for a mention
Not built yet

Gap.

yes no
Notification when the app stops trying to reconnect
Notes

The phone posts it on the foreground service's own low-importance channel, so it cannot make a sound and silencing one silences both — and only when the app is not the thing on screen, because a notification for something you are looking at is noise. The desktop's give-up goes to the status bar and nowhere else.

no yes
Fallback when no notification daemon exists
Notes

Flashes the taskbar, and says so in Settings. Degrading to silence is fine; pretending to have notified is not.

yes not applicable on this platform
Runtime notification permission
Platform limit

Platform, Android 13+.

not applicable on this platform yes
Sound or vibration configurable in the app
Notes

Both defer to the OS.

no no
Do-not-disturb inside the app
Not built yet

Gap on both.

no no
Unread badge on the OS launcher or taskbar icon
Not built yet

Gap on both; both badge their own nav instead.

no no

App shell, theme and platform integration

Feature Desktop Mobile
Navigation
Platform limit

Platform convention.

rail bottom bar + More
Destinations
Notes

The phone shows three and overflows the rest.

Chat, Repeaters, Nodes, Map, Manage repeaters, Settings Chat, Map, Settings, then Radio, Repeaters, Nodes, My repeaters
Unread badge on a nav destination
Notes

General on the desktop; the phone hardcodes the Chat slot.

yes yes
Resizable panes
Platform limit

Platform.

yes not applicable on this platform
Remember window geometry and splitter sizes
Not built yet

Gap on the desktop — re-laid out on every launch.

no not applicable on this platform
Toggleable details panel
Notes

Off until asked for, and withdrawn entirely on a channel: a channel has no node behind it — no key, route, signal or position — so the toggle goes with the panel rather than sitting there doing nothing.

yes no
Right-click context menus
Notes

Long-press is the touch equivalent, and the phone has it.

yes not applicable on this platform
Keyboard shortcuts
Platform limit

Platform.

yes not applicable on this platform
Menu bar
Deliberate choice

Choice: everything is reachable from the UI.

hidden not applicable on this platform
Back navigation into and out of a sub-page
Notes

The phone returns to wherever admin was opened from.

not applicable on this platform yes
Hardware Back button handled explicitly
Not built yet

Gap; it falls through to the default Activity behaviour.

not applicable on this platform no
Light / dark / follow system
Not built yet

Gap. The phone always follows the OS.

yes no
React to a system theme change without a restart
Not built yet

Gap on both — each reads the setting once at startup.

no no
Accent colour or font size customisation
Not built yet

Gap on both.

no no
Icons drawn in code rather than shipped as assets
Notes

Same choice, twice: nothing to package, and they recolour with the palette.

yes yes
System tray, minimise/close to tray, start minimised
Platform limit

Platform.

yes not applicable on this platform
Autostart at login
Platform limit

Platform.

yes not applicable on this platform
Single-instance guard
Notes

The phone gets it free from launchMode="singleTop"; the desktop has none.

no yes
Foreground service to hold BLE open in the background
Platform limit

Platform — a desktop app is never suspended. Type connectedDevice, in the app's own process, with a setting (default on). The cause was never the process being killed — it survives fine — but Qt stopping its event dispatcher in onStop, so frames arrived and nothing woke to read them. MainActivity undoes the suspend while the service is up. Android 12's background-start rule can refuse the start when the link comes up with the app already backgrounded; that is swallowed, and the next connection made with the app on screen starts it. Its lifetime is now the reconnect's intent rather than the link's — see the row below.

not applicable on this platform yes
Reconnect the BLE link from the background
Not built yet

Gap closed, and it was the binding one. The service used to stop the moment a link dropped — deliberately, because nothing was working towards getting it back and an ongoing notification promising otherwise would have been a lie. Something is now, so it stays up, and its text says which of the two states it is in rather than claiming the better one. That the service stays is what makes the retry possible at all: the ladder's timers are Qt timers, and taking the service down between attempts takes the attempts with it. What remains impossible, and must, is the third state — foreground, no link, nothing trying — so giving up clears the intent before it stops the service. Measured on the handset for one drop-and-recover cycle; the long end of the ladder and the give-up were not.

not applicable on this platform yes
Keep the screen on while in use no no
Strings wrapped for translation
Notes

The phone is translation-ready; the desktop has no i18n mechanism at all. Neither ships a translated build.

no yes
Locale-aware 12/24-hour time
Not built yet

Gap on both; hardcoded 24-hour.

no no
App version shown in the UI
Not built yet

Gap closed on the desktop — Settings › About, §20.

yes yes
Update check
Not built yet

Gap on both.

no no
ABIs built
Notes

Fine for any recent phone; the bundled OpenSSL is arm64 too.

not applicable on this platform arm64-v8a only

This table is generated from docs/FEATURES.md in the client repository, which is where it is maintained. The document is the source; this page is a rendering of it. If you were after the short version, it is on What it does.