Skip to content

Repository files navigation

MeshRF

MeshRF is a cross-platform Meshtastic SDR transceiver for Windows, Linux and macOS.

MeshRF's usual mode uses an SDR (HackRF One or RTL-SDR) with no LoRa modem chip at all, performing LoRa demodulation and modulation in software on the host CPU. It decodes Meshtastic frames, decrypts channel payloads, parses protobufs, and provides a desktop UI for channels, nodes, map, telemetry, and messaging.

An SX1262 hardware modem can also be used for either direction, on its own or alongside an SDR — over a CH341 USB stick on any platform, or on the host's own SPI bus on Linux (uConsole AIO V2, Raspberry Pi HATs). See SX1262 hardware modems.

Current release line: v2.4.0

image

Status

  • Receive path is operational end-to-end: SDR IQ -> DSP -> LoRa demod -> Meshtastic frame decode -> decrypt -> parse -> UI. An SX1262 modem can take the place of everything left of the frame decode, handing up finished frames instead of IQ.
  • Transmit path is operational for channel broadcast, direct messages, and control/management packets, either modulated in software onto a HackRF or handed to an SX1262 modem.
  • The app is actively maintained with frequent updates focused on map scale, messaging UX, telemetry/routing controls, and observability.
  • Windows, Linux and macOS all build the native core and the app from source, x64 everywhere and arm64 on Linux and macOS. Windows is the most exercised; Linux and macOS builds are produced by CI and have had far less time on real radio hardware. The linux-arm64 artifact and the SPI radio path it exists for have not been run on hardware at all yet.

Key Capabilities

Radio and Signal Processing

  • Runtime-selectable radio backend: HackRF One or RTL-SDR for receive, HackRF One for transmit, or an SX1262 hardware modem for either direction.
  • Independent RX and TX device selection, so an SDR receiver can be paired with a hardware transmitter. See SX1262 hardware modems.
  • Software LoRa demod/mod with Meshtastic-oriented preset support.
  • Optional receive conditioning features (including DC blocking).
  • Live spectrum and waterfall with packet-linked snapshot support (SDR receive only — a hardware modem produces no IQ), with each demodulated channel marked across it.
  • Several presets received at once off one capture, each a mesh this station is a full node on. See Listening on several presets.

Listening on several presets

A capture wide enough to cover them can carry more than one Meshtastic channel. With Presets… (beside the frequency box) switched on, the receiver also listens for every other preset whose default-slot channel fits inside the capture, and this station becomes a full node on each of those meshes rather than a monitor of them: their nodes go in the node list, their messages into tabs, and it acknowledges and relays for them.

Off by default, and off it changes nothing: the receiver takes exactly the single-channel path it always did.

Each channel gets a chain of its own — mixed down to baseband, decimated, and demodulated on a thread of its own — so what this costs is CPU per preset rather than anything on the air. Presets whose default slots coincide share one chain.

What decides which presets fit is the sample rate, since that is how much spectrum the capture covers. A HackRF runs its baseband filter below the selected rate (1.75 MHz at 2.4 MS/s, 15 MHz at 16), so the window is narrower than the rate suggests. The Presets window lists every preset in the region with its default slot and frequency, and for one that does not fit it names the rate that would reach it. The capture need not be centred on the primary: it slides to take in the most presets, which is what lets a 10 MS/s window hold both a MediumFast primary at 913.125 MHz and LongFast at 906.875 when centring on the primary would need 16. The offset can also be set by hand.

Only presets on their default slots are listened for — where a node with an unrenamed primary channel lands. A mesh on a renamed slot is reachable only as the primary. Listening for every preset on every slot would need a channelizer rather than a chain per channel.

What the primary keeps to itself:

Auto reports Primary only
Scripts The primary, unless a script's mesh: says otherwise
MQTT uplink and downlink Primary only — traffic from another preset's mesh is not this gateway's to publish
Spectrum, waterfall, packet snapshot The capture, and the primary's channel
Channel utilisation The primary's channel, which is what device metrics report on

Everything else follows the mesh a packet came from. An acknowledgement, a relay, an introduction and a reply to a request all go back out on the listener that heard it; a message to a node goes out on the settings that node was last heard on. Each node records that, and the node list shows and filters by it — the preset name, or Custom when it was heard on a primary running custom SF/BW/CR.

Channels are per preset. A channel only means anything on the mesh it belongs to, so each listener has a channel list of its own, seeded with that preset's default channel the first time it is listened on. Decrypt tries the arriving listener's list alone, and a message sent from a tab goes out on the preset that owns it. Existing channels stay in the primary's list.

An SX1262 is a hardware modem and receives one channel at a time, so the feature is unavailable while one is the receiver.

SX1262 hardware modems

MeshRF normally modulates and demodulates LoRa in software using an SDR. It can instead hand framed bytes to an SX1262, which does preamble, sync, FEC and chirping itself. The radio is selectable for RX, TX or both.

An SX1262 is reached over one of two buses, chosen by the board you pick:

Bus Boards Platforms
CH341 USB-SPI bridge Elecrow MeshStick, NullHop/muzi MeshToad V3 Windows, Linux, macOS
The host's own SPI bus uConsole AIO V2, Raspberry Pi HATs Linux only

Everything above the PHY — decrypt, protobuf, routing, MQTT, the whole UI — is identical either way, because both paths emit the same frame events the software demodulator does.

The intended setup is SDR receive + SX1262 transmit. It keeps everything that makes MeshRF worth using — the spectrum, waterfall, packet spectrogram and IQ capture — while fixing the weak leg: HackRF TX is ~10 dBm of unfiltered wideband output, where these modems put out 22 or 30 dBm through a matched front end. Because the modem is a device of its own — a second USB device, or a radio on the SPI bus while the SDR is on USB — RX is never paused for a burst; the waterfall stays live throughout, and the receiver hears the transmission.

USB sticks

Receiving on the stick as well makes MeshRF a complete node for someone who owns a LoRa stick and no SDR — one stick serves both directions half-duplex, the way real Meshtastic hardware does. The cost is everything an SDR gives you: a hardware modem produces decoded frames, never IQ, so the spectrum, waterfall, packet snapshot and IQ capture all go away and the display says so. In exchange the RSSI and SNR are the radio's own measurements rather than estimates off an IQ stream, sensitivity is ~20 dB better, and the CPU is idle. Every layer above the PHY — decrypt, protobuf, routing, MQTT, the whole UI — is unchanged, because the hardware path emits the same frame events the software demodulator does.

Supported boards, selected in the TX toolbar next to the power control:

Board Radio Antenna-port power
Elecrow MeshStick bare SX1262 -9 .. 22 dBm
NullHop / muzi MeshToad V3 SX1262 + E22P-915M30S -1 .. 30 dBm

Both enumerate as 1a86:5512 with an identical pin map, and neither reports a USB product string to tell them apart, so the board picker starts empty and nothing will transmit until you choose. That gate exists because a wrong guess is silent in the worse direction: a MeshToad driven as a MeshStick radiates about 8 dB more than the UI reports, with no warning about the current draw. The reverse — a MeshStick set to MeshToad — is harmless, because the requested power is clamped to the SX1262's own +22 dBm ceiling either way. No board selection can overdrive the radio; the risk is purely mislabelling.

The choice only selects the power model: the MeshToad's external PA adds roughly 8 dB, so the dBm shown in the UI is what leaves the antenna, not what is programmed into the chip. Above 22 dBm a MeshToad can draw ~900 mA, more than a USB 2.0 port is obliged to supply; the UI warns, and a powered hub is the fix.

Driver requirements:

  • Windows — install the WCH CH341PAR package. MeshRF loads CH341DLLA64.DLL at runtime, the same way it loads hackrf.dll, so it works against the driver binding meshtasticd users already have. No Zadig re-bind, and nothing to configure.
  • Linux / macOS — libusb, the same path meshtasticd uses. On Linux the ch341 kernel module has to be blacklisted or detached, and the device needs udev permissions.

Only one process can own a stick at a time, so MeshRF and a local meshtasticd cannot share one. Selecting any other RX/TX device releases it immediately.

With more than one stick attached, a Stick picker appears offering each one's EEPROM serial — the only thing that distinguishes them, since they all share 1a86:5512 and report no product string. With a single stick the picker stays hidden and the first device found is used.

SPI boards (Linux)

On a single-board computer the radio is usually soldered to the host's own SPI bus rather than hanging off USB. MeshRF drives those through /dev/spidevB.D and the GPIO character device — the same wiring meshtasticd uses, so a board with a meshtasticd config already has its pin map written down.

Board Radio Antenna-port power
uConsole AIO V2 bare SX1262 on SPI1 -9 .. 22 dBm
Custom SPI board whatever you declare whatever you declare

Only one preset ships, and deliberately: a pin map can be read off a config file, but a power model cannot. Nothing on an SPI bus reports whether a power amplifier sits after the chip, and meshtasticd's configs do not record one either — they cap chip power instead. Assuming a board is bare when it has an E22-style front end is wrong in the direction that over-radiates: the UI would say 22 dBm while the antenna saw 30. The uConsole AIO V2 is listed because it is genuinely a bare SX1262 with nothing after it. Every other board goes through Custom SPI board, where the front end is yours to declare.

Requirements:

  • SPI enabled (dtparam=spi=on, or raspi-config), so /dev/spidev* exists.
  • Read/write on the spidev node and the GPIO chip — the spi and gpio groups on Raspberry Pi OS. No root needed.
  • Nothing else holding the radio. meshtasticd claims the same GPIO lines, and MeshRF will say so by name rather than failing vaguely.

uConsole AIO V2 additionally gates its peripherals behind GPIOs that are off at boot: LoRa on 16, SDR on 7, GPS on 27, the internal USB hub on 23. Until the AIO's own enable has been set, neither the radio nor the RTL-SDR exists as far as MeshRF is concerned.

Custom SPI board takes its wiring and power model from settings.json under CustomSpi. Line numbers are GPIO chip offsets, which on a Raspberry Pi are the BCM numbers meshtasticd quotes. Cs: -1 leaves chip select to the SPI controller, which is the usual wiring; give it a line number only if your board routed CS to an ordinary GPIO. RxEn: -1 is right for any board whose DIO2 runs the RF switch.

"CustomSpi": {
  "SpiDev": "spidev0.0",
  "GpioChip": "gpiochip0",
  "SpeedHz": 2000000,
  "Cs": -1, "Busy": 20, "Reset": 24, "Dio1": 16, "RxEn": 12,
  "Dio2AsRfSwitch": true, "Dio3Tcxo": true, "TcxoVoltage": 2,
  "MaxChipDbm": 22, "PaGainDb": 8, "MinOutDbm": -1, "MaxOutDbm": 30
}

Pin maps for common HATs, transcribed from bin/config.d in meshtastic/firmware. The power fields are not included — fill those in from your own module's datasheet, per the reasoning above.

Board SpiDev Busy Reset Dio1 RxEn
MeshAdv Mini E22-900M22S spidev0.0 20 24 16 12
Nebra SX1262 Pi HAT spidev0.0 4 18 22 25
PiTastic / ZebraHat 1W spidev0.0 27 17 22 -1
RAK6421 13300 (slot 1) spidev0.0 24 16 22 -1

Boards with a separate TXen line (MeshAdv-Pi 900M30S, and others that switch transmit and receive with two pins) are not supported: the driver drives one RF-switch line, not two.

Meshtastic Protocol Support

  • Channel decode/decrypt with PSK handling.
  • Channel and direct messaging workflows.
  • PKC direct messaging with X25519 key exchange and AES-256-CCM message protection for DM payloads.
  • Routing ACK/NACK-based delivery state.
  • Reply-linked messages and per-message emoji reactions.
  • Waypoint send/receive support, including circular and bounding-box geofences with enter/exit alerts.
  • Traceroute and request-position / node-info exchanges.
  • MQTT bridge with per-channel uplink/downlink, mirroring firmware's MQTT module: same default server/credentials/root topic (mqtt.meshtastic.org / meshdev / large4cats / msh), encrypted ServiceEnvelope publishing (both the channel-encrypted and, when disabled, the plaintext-decoded wire forms), self-originated packets uplinked the same as received ones, SNR-independent gating rules (ok_to_mqtt, default-server port suppression, PKI-aware). Off by default; configured from the MQTT toolbar button, with Uplink/Downlink toggles per channel in that channel's Settings. Optional periodic MapReport publishing (name, role, hardware, firmware version, region/preset, fuzzed location) to the broker's map topic, matching firmware's map-reporting feature. Optional parallel JSON publish/subscribe (firmware json_enabled): human-readable per-port JSON alongside every uplink, plus "sendtext"/"sendposition" remote-command downlink on a channel named "mqtt".
  • Self-reported firmware version/edition (Identity settings) surfaced to MQTT map reports, defaulting to the same baseline as stock firmware (2.8.0 / VANILLA).

Nodes, Telemetry, and Mapping

  • SQLite-backed channel, node, message, and waypoint persistence.
  • Device metrics and environment metrics display.
  • Channel utilization and TX airtime surfaced in the UI.
  • OpenStreetMap-based map view with clustering and location history support.
  • Home location from manual map selection or USB serial GPS source, with a smart-position filter so a receiver reporting every second only moves the marker when it has actually moved.
  • Filtering for nodes, telemetry presence, ignore state, and position-history presence.
  • Configurable map node label modes.
  • Basemaps including topographic and none, and place search by name.

RF Planning

Terrain-aware link and coverage prediction, calibrated against what the radio has actually heard. Elevation comes from Terrarium tiles on AWS Open Data and building footprints from OpenStreetMap through Overpass; neither needs a key.

The propagation model follows MeshLab RF by Haruki Toreda (MIT), adapted for a live client that has measurements of its own rather than a simulator that must go and collect them. The knife-edge diffraction, Fresnel clearance, link budget and building-attenuation figures are all its work.

  • Link profile between this station — or any point on the map — and a positioned node: terrain cross-section, the first Fresnel zone, single knife-edge diffraction loss (ITU-R P.526), and the LoRa link budget for the modem in use. Where the node is a direct neighbour its measured SNR is shown against the predicted one, and the gap is the clutter the terrain model does not carry.
  • Path-loss calibration from that gap: a log-distance model fitted by least squares to every direct neighbour heard over the air, with terrain and building loss taken out first. The exponent says how fast signal falls off at this site; the offset absorbs what the mesh never reports, such as peer transmit power. Outliers show as residuals and can be dropped from the fit, and a fit that could not measure an exponent says so rather than pretending — including the quiet case where the readings strengthen with reported distance, which means the ranges are invented rather than that the model is subtle. Direct neighbours are recognised from the best path they have actually been heard over, tied to the geometry it was heard at, so one relayed duplicate no longer drops a node out of the fit.
  • Coverage as a compass sweep of how far the station reaches, drawn either as per-bearing verdicts or as a heatmap shaded by the odds of a packet decoding — which also shows the coverage islands past an obstruction that a single ring edge will not claim. Bounded by the range the calibration has evidence for, rather than extrapolated past it.
  • Horizon panorama: the 360° skyline from the antenna, drawn ridge behind ridge and shaded by how far away the ground making each one is, with every positioned node plotted where it would appear against it. Drag it to turn and scroll to zoom in on one part of the sky. Geometry only, and the answer to how much mast a hidden neighbour would take.
  • Survey recording writes every directly-heard packet to a CSV with the station's position at the time, which turns a client with a GPS into the survey instrument the fit needs. Readings are binned per peer per range and averaged, so a driven survey measures one neighbour at many ranges instead of at one. Surveys import and export as CSV, since recording happens wherever the GPS is and the fit is read back at the station; importing merges, so several drives accumulate.
  • Building attenuation charges a path for the footprints it crosses, in the profile, the sweep and the calibration alike. Off by default, since it puts the app on a shared public service. A lookup that does not happen says which of rate limiting, an overloaded server, a timeout, no network, or an unexpired backoff was responsible, rather than collapsing all five into "could not be reached". Footprints are fetched within 2.5 km — a measured limit, since a 6 km box over a city runs to 95 MB and minutes — and a box too heavy for the service is halved and re-asked rather than abandoned.
  • A chosen point on the map runs any of it from somewhere other than this station, which turns "what do I reach" into "what would a node here reach". Set it by right-clicking bare ground, or pick an existing node — on the map or in the Nodes table — to ask about where one already is. A node can be either end of a link profile, drawn to it or from it, and so can a bare place, which covers siting two nodes at once when neither exists yet. There is one point however it was set, so either replaces the other. A sweep from elsewhere uses your own antenna and modem settings, since the mesh does not report a peer's.

UI and Workflow

  • Cross-platform Avalonia desktop app (.NET 8, Windows/Linux/macOS) with MVVM architecture.
  • Channel/DM tabs with persisted history.
  • RTTTL notification tones for messages, geofence crossings and alert bells, each with its own duration or Off, behind a Notifications button. Volume is shared; muting is available per channel and per conversation.
  • Alert bell button beside the compose box. It shows a bell in the message and adds Meshtastic's ASCII_BELL on the way out, so a receiving node sounds its external notification. Incoming alerts are marked on the bubble, which is the only way to see one from a client that sends the character alone.
  • Improved auto-scroll and large-node-count map performance tuning.
  • Drag-to-reorder for secondary channel tabs and DM tabs.
  • Emoji picker built from the colour emoji font's actual glyph coverage, so it offers every emoji the system can draw and nothing it can't.
  • Raw decoded-packet JSON feed with export, for analysis and replay.

Automation Scripts

MeshRF can answer messages and transmit on a schedule, driven by YAML scripts in %APPDATA%\MeshRF\scripts (one file per script). The shape follows Home Assistant's automations — a list of triggers, conditions that all have to hold, and a sequence of actions — with a closed vocabulary and no expression language.

enabled: true
alias: Answer !ping with a signal report

trigger:
  - command: ping

condition:
  - scope: direct
  - snr_above: -12

action:
  - reply: "pong — {snr} dB over {hops} hops"

limits:
  cooldown: 60s
  max_per_hour: 6
  • Triggers: command, text (regex), new_node, reaction, every, at, quick_send (adds a named button to the Quick send bar and runs when pressed; its to: asks for a destination, or names a channel or node).
  • Conditions: scope, channel / not_channel, from / not_from, snr_above, hops_below, between, favorite, has_key.
  • Actions: reply, send, react, position, nodeinfo, traceroute, http, waypoint, require, delay, log, ring.
  • Reach: send: and waypoint: take hops: (0-7) to override the app-wide hop limit for one message. hops: 0 is never repeated by any node, so it costs one airtime slot rather than one per relay in range — the right answer for anything that only means something to whoever can already hear you.
  • Meshes: a station listening to several presets at once can point a script at any of them with mesh:. On a trigger it says which meshes the script answers; on a send: or a waypoint: it says which it speaks on, one copy per mesh. One name or a list, {primary} for this station's own mesh whatever preset it is on, or any for every mesh it is listening to. Leaving it out means the primary for a trigger and the mesh the trigger arrived on for an action, so a script written before the key behaves exactly as it did.
trigger:
  - command: ping
    mesh: any            # answer whichever mesh asked

action:
  - reply: "pong — {snr} dB over {hops} hops"
  - send:
      channel: Alerts    # every mesh listed has to have this channel
      mesh: ["{primary}", LongFast]
      text: "!ping from {from.long}"

A mesh: on a send: or waypoint: goes with channel:, never with to: — a message addressed to a node follows that node to the mesh it was last heard on. A named mesh nothing is listening to, or one without the channel named, is left out with a line in the log.

A waypoint: action drops a marker, optionally with a geofence and enter/exit alerts. A require: action stops the sequence unless a value holds — which is how a script acts on what an http: call returned, since conditions are settled before any action runs:

trigger:
  - every: 10m

action:
  - http:
      url: "https://api.example.com/lightning?p={my.lat},{my.lon}&radius=30mi"
      credential: [api-id, api-secret]   # one name, or several
      optional: true                      # an empty answer is normal here
      json:                               # several values, one response
        lat: response[0].loc.lat
        lon: response[0].loc.long
  - require:
      value: "{http.lat}"
      not_empty: true
  - waypoint:
      lat: "{http.lat}"
      lon: "{http.lon}"
      name: "Lightning"
      radius: 30mi
      expires: 1h
      notify_on_enter: true

{my.lat} and {my.lon} carry this node's home location, so a script asking a location-shaped question needs no coordinates pasted into it.

Working starting points live in samples/scripts/ — a signal report, a ChatGPT bridge, a lightning waypoint and a wildfire waypoint. All ship disabled; copy one into the scripts folder, fill in the credential it names, and turn it on.

A feed sync is the other half: instead of answering something that happened, it keeps a set of waypoints in step with a REST feed. It polls, places a marker for each record it has not seen, resends one whose watched fields changed, and retires one that has gone — a record leaving a list is not an event, so only something holding the previous list can notice it. require: narrows a feed to the records worth a marker, and failing it counts as gone rather than as unseen, so a record that stops qualifying clears itself off everyone's map:

sync:
  every: 15m
  url: "https://api.watchduty.org/api/v1/geo_events/?geo_event_types=*"
  items: ""              # the response is the array itself
  id: id                 # identity, so a resend replaces
  active: is_active
  lat: lat
  lon: lng
  within: 30mi
  require:
    - value: "{item.data.is_prescribed}"
      not_equals: true   # a planned burn is not "fire near you"
  watch: [data.acreage, data.containment]
  waypoint:
    name: "Fire: {item.name}"
    icon: "🔥"
    radius: 10mi

A script can call a REST API and broadcast the answer. Fetching and sending are two steps, so the result can be shaped into a sentence, combined from more than one endpoint, or sent somewhere other than back to the asker:

trigger:
  - command: wx

action:
  - http:
      url: "https://api.example.com/v1/current?q={args}"
      credential: weather      # names a key stored in the app, not here
      json: current.temp_c     # dotted path into the JSON response
      save_as: temp            # becomes {http.temp}
  - reply: "{args}: {http.temp}°C"

API keys are optional, and are stored under the Scripts window's Credentials button rather than in the script — protected at rest, attachable as a bearer token, a named header or a query parameter. A script names a credential and can never read its value, so it cannot broadcast it, and keys are never written to the log. Placeholders inside a url: are percent-encoded and inside a JSON body: are JSON-escaped, so a received message cannot rewrite the request. Responses are capped, flattened to one line and clamped to the payload size. A failed fetch skips the rest of the script rather than broadcasting a half-formed sentence. Dry run still performs GET (a read changes nothing) but skips POST/PUT.

  • Scripts window (the Scripts button): lists every script in execution order with an enable toggle, and an embedded editor that refuses to save a script it cannot parse, reporting the line and column and suggesting the key you probably meant. Help documents the full vocabulary.

Airtime is shared, so the safety rails are on by default and are not all settable from a script file: scripts never answer your own node or an ignored one, a message a script sent can never trigger another script, each script has a cooldown and an hourly cap, and a global budget of 30 transmissions/hour applies across every script together. The master switch is off until you turn it on, and Dry run evaluates and logs everything without transmitting.

Architecture

MeshRF.App.Avalonia  (.NET 8 Avalonia — Windows/Linux/macOS)
  - UI, map, waterfall, view models, app settings
  - P/Invoke into native bridge library

MeshRF.Core  (.NET 8 class library)
  - Native interop bindings
  - Meshtastic frame decode/encode helpers
  - Crypto helpers and key handling
  - SQLite stores (channels, nodes, messages, waypoints)

MeshRF.Native (C++20, built with CMake)
  - SDR HAL (HackRF, RTL-SDR) — IQ in, IQ out
  - Packet-radio HAL (SX126x over CH341 USB-SPI or Linux spidev) — framed
    bytes, no IQ
  - DSP + LoRa modem pipeline
  - Spectrum/waterfall and native packet plumbing

Requirements

Common to every platform:

  • CMake 3.25+
  • .NET 8 SDK
  • Radio hardware: a HackRF One or RTL-SDR dongle, and/or an SX1262 modem (see SX1262 hardware modems). A modem alone is enough for both directions; an SDR alone can receive, and needs a HackRF to transmit.

Windows 10/11 x64

  • Visual Studio 2022 or newer with "Desktop development with C++" and ".NET desktop development". The windows-x64 preset pins no generator, so CMake uses the newest Visual Studio it finds.
  • SDR drivers as needed (typically via Zadig/WinUSB).
  • For an SX1262 USB stick, the WCH CH341PAR driver package (not Zadig).

Linux x64 / arm64

sudo apt-get install -y ninja-build cmake libhackrf-dev librtlsdr-dev \
                        libusb-1.0-0-dev libudev-dev \
                        autoconf autoconf-archive automake libtool

arm64 additionally needs VCPKG_FORCE_SYSTEM_BINARIES=1, which the linux-arm64 preset sets for the configure step. Export it in your shell too if you are bootstrapping vcpkg yourself: it ships no prebuilt tools for that architecture and has to build its own with the system compiler.

macOS (arm64 or x64)

brew install ninja cmake hackrf librtlsdr libusb autoconf autoconf-archive automake libtool

Linux and macOS also need VCPKG_ROOT pointing at a vcpkg checkout.

Notes:

  • On Windows, native SDR dependencies are built from source submodules (third_party/hackrf, third_party/rtlsdr) and the resulting runtime DLLs are copied next to app outputs. On Linux and macOS they come from system packages and are loaded at runtime via dlopen, so the submodules are not built there.
  • On Windows, CMake auto-provisions a repo-local .vcpkg when no toolchain is supplied. It is cloned in full on purpose: vcpkg.json pins a builtin-baseline commit that a shallow clone cannot resolve.
  • The autotools packages above are for vcpkg's own libusb port, which configures from source.
  • Meshtastic protobuf schemas are linked via git submodule at third_party/meshtastic_protobufs, tracking master on upstream meshtastic/protobufs with no local modifications. Every field MeshRF uses — geofence, ATAK and the rest — is official Meshtastic.
  • Default development flow expects native RelWithDebInfo for practical SDR throughput.

Submodules

If you use VS Code Build Native / Build & Run tasks, submodules are initialized automatically by the Init Submodules task.

For CLI/manual workflows, initialize linked dependencies after clone:

git submodule update --init --recursive

Update Meshtastic protobuf schemas later (pulls the latest commit on upstream master, per .gitmodules):

git submodule update --remote -- third_party/meshtastic_protobufs

Build

Quick Start (VS Code)

From a fresh clone, run task Build & Run.

This task chain will:

  • initialize submodules,
  • configure and build native components,
  • deploy native bridge/runtime DLLs into app output,
  • build managed app,
  • run the app.

Native (CMake)

# Windows
cmake --preset windows-x64
cmake --build build/windows-x64 --config RelWithDebInfo -j
# Linux. Ninja is single-config, so the build type is set at configure time.
cmake --preset linux-x64 -D CMAKE_BUILD_TYPE=Release
cmake --build build/linux-x64 -j

# Linux arm64 (Raspberry Pi, uConsole) — native, not cross-compiled
cmake --preset linux-arm64 -D CMAKE_BUILD_TYPE=Release
cmake --build build/linux-arm64 -j

# macOS (use macos-x64 on Intel)
cmake --preset macos-arm64 -D CMAKE_BUILD_TYPE=Release
cmake --build build/macos-arm64 -j

Managed App

dotnet build app/MeshRF.App.Avalonia/MeshRF.App.Avalonia.csproj -c Debug --nologo

The app project copies the native bridge (MeshRF.Native.dll, libMeshRF.Native.so or libMeshRF.Native.dylib) — and on Windows the SDR runtime DLLs — from the platform's build/<preset>/bin/ directory into the managed output folder after build.

Run

dotnet run --project app/MeshRF.App.Avalonia/MeshRF.App.Avalonia.csproj -c Debug --no-build

VS Code tasks are included for configure/build/test/run workflows.

Testing

Native Tests

ctest --test-dir build/windows-x64 --output-on-failure -C RelWithDebInfo

Managed Tests

dotnet test tests/managed/MeshRF.Tests.csproj --nologo

Release Packaging

scripts/build-release.ps1 runs under PowerShell 7 on all three platforms. It detects the host, picks the matching CMake preset, RID and archive format, and builds a self-contained single-file release into dist/:

Host Artifact
Windows MeshRF-v<version>-win-x64.zip
Linux x64 MeshRF-v<version>-linux-x64.tar.gz
Linux arm64 MeshRF-v<version>-linux-arm64.tar.gz
macOS MeshRF-v<version>-osx-arm64.zip (or -osx-x64)
# Package for the host platform
pwsh scripts/build-release.ps1

# Override the version, and optionally tag it
pwsh scripts/build-release.ps1 -Version 2.0.1
pwsh scripts/build-release.ps1 -Tag

The version comes from the app project's VersionPrefix, falling back to Directory.Build.props.

The bundle includes the published app, the native bridge (plus the SDR runtime DLLs on Windows), LICENSE, README.md, and on Linux a .desktop entry and icon.

Native libraries cannot be cross-compiled, so each platform's artifact must be built on that platform — or in CI. .github/workflows/release.yml builds all three on their own runners and drafts a GitHub release when a v* tag is pushed; it checks the tag against VersionPrefix first and fails loudly on a mismatch.

Repository Layout

Path Purpose
app/MeshRF.App.Avalonia/ Cross-platform desktop application (Windows/Linux/macOS)
app/MeshRF.Core/ Managed protocol/interop/storage library
native/core/ C++ SDR/DSP/LoRa core, plus the SX126x packet radio
native/bridge/ C ABI bridge DLL for P/Invoke
tests/managed/ Managed unit tests
tests/native/ Native unit tests
scripts/ Utility and release scripts
third_party/meshtastic_protobufs/ Meshtastic protobuf schema submodule
third_party/hackrf/ HackRF source submodule (built during native build)
third_party/rtlsdr/ RTL-SDR source submodule (built during native build)

Licensing

This project is licensed under GPL-3.0-or-later. See LICENSE.

Upstream references influencing licensing and implementation include:

  • gr-lora_sdr
  • meshtastic/protobufs (linked as third_party/meshtastic_protobufs)
  • meshtastic/firmware
  • MeshLab RF by Haruki Toreda (MIT), whose propagation model the RF planning tools follow
  • Overpass, for the OpenStreetMap building footprints the obstruction model uses
  • Nominatim and OpenTopoMap (CC-BY-SA), for place search and the topographic basemap
  • OpenFreeMap and OpenMapTiles, for the vector dark basemap
  • Esri ArcGIS Online, for the Street and Satellite basemaps. Unlike everything else here these tiles are not open data: they are reachable without a key but are not offered under a licence that grants this use, so treat those two basemaps as convenience rather than as something the project is entitled to. Every other source is free and openly licensed.

Disclaimer

MeshRF is an independent project and is not affiliated with or endorsed by the Meshtastic project.

Releases

Packages

Contributors

Languages