Skip to content

Meshtastic app: full LoRa transmit and receive on HackRF - #3306

Open
verhogladov wants to merge 2 commits into
portapack-mayhem:nextfrom
verhogladov:meshtastic-lora
Open

Meshtastic app: full LoRa transmit and receive on HackRF#3306
verhogladov wants to merge 2 commits into
portapack-mayhem:nextfrom
verhogladov:meshtastic-lora

Conversation

@verhogladov

@verhogladov verhogladov commented Aug 28, 2026

Copy link
Copy Markdown

I have built this code and tested it on physical PortaPack hardware.

Testing used a stock Meshtastic node, a Heltec V4 on unmodified Meshtastic firmware,
as the outside source of truth. Seven of the nine modem presets are confirmed in both
directions: that node decoded our text, and we displayed its text.

Written with Claude as a pair-programmer, as AGENT.md encourages.
The causes of the bugs were found by measurement on live hardware, not by reading: raw
symbol bins dumped to the console and analysed off-device, a dropped-buffer counter, a
step-by-step map of memory use. I take full responsibility for every line and can
answer detailed questions on any of it.

What this is

A Meshtastic app for the PortaPack: chat, encrypted channels, a node list, telemetry,
positions, a map, and mesh routing.

The part that matters: this is not a wrapper around a radio module. The HackRF has
nothing in it that understands LoRa: it hands over a stream of samples and takes one
back. So the physical layer is written out in full: preamble detection, time and
frequency synchronisation, dechirping against a reference, the Fourier transform, Gray
coding, interleaving, Hamming, whitening. All of it in both directions, on the M4,
inside 54 kB of buffers and a 1.024 ms deadline per buffer.

Two pieces of the interface are unusual enough to name here, since both show up in the
diff and both have a reason in the sections below: the app carries its own on-screen
keyboard, and it can read a glyph table off the SD card so the chat shows the alphabet
the person on the other end actually types in.

What works

preset receive transmit
LONG_FAST (SF11/250) yes yes
LONG_MODERATE (SF11/125) yes yes
MEDIUM_SLOW (SF10/250) yes yes
MEDIUM_FAST (SF9/250) yes yes
SHORT_SLOW (SF8/250) yes yes
SHORT_FAST (SF7/250) yes yes
SHORT_TURBO (SF7/500) yes yes
LONG_SLOW (SF12/125) no yes

Also confirmed on air, against the same stock node: encrypted custom channels both
ways, telemetry and position reception, clock synchronisation from a peer, settings
surviving a hard reset, traceroute asked and answered in both directions, and the
three requests a phone app makes of a node - exchange user info, request position,
request telemetry - answered. The user info reply carries our public key, which is the
only way it travels, and without it there is no encrypted direct message either way.

A stock node may not answer a telemetry request of its own accord. Measured against a
Heltec V4: the request arrives and is acknowledged and nothing comes back, while the
same node's identical request to us is answered. The request we send is byte for byte
what the official client sends, so this looks like the far end's choice rather than a
fault at either end.

VERY_LONG_SLOW is not offered. Current Meshtastic firmware no longer implements it: a
node told to use it comes back reporting LongFast's parameters. Its row is kept in the
table so the other presets keep their stored indices.

What does not

SF12 receive. The algorithm is proven offline against recorded air:
tools/lora_bench/sf12_off.py decodes a whole frame from a capture. It is switched off
on the device for want of time, not correctness: at 4096 samples per symbol one
demodulation takes a full symbol, and resolving the half-symbol ambiguity needs three of
them back to back, by which point the sample window has moved 3.5 symbols past a header
that tolerates 1.75. Fixing it means resolving the ambiguity from the already
demodulated down-chirp, or an integer FFT. The limit is stated in the code with the
arithmetic beside it.

Custom-channel passphrases. The way a passphrase becomes a key here is this app's
own. The primary channel uses Meshtastic's well-known key and interoperates out of the
box; a custom channel talks to a stock node only if you enter that node's raw 32-hex key
instead of a passphrase.

Resources

  • Flash: 5820 bytes free after merging with next, which is 0.6 percent. This is the
    binding constraint on everything below, and the section after next is about it.
  • M0 memory: the app takes 16.7 kB of core memory, 3 kB of it the node database
    (10 nodes at 216 bytes). The limit follows from what is left and is stated in the
    header, with the arithmetic that produced it.
  • M4 memory: a 54 kB ring shared with the existing receive path. They never run at
    the same time, so nothing was added.
  • Baseband images: PLOR 27 kB (receive) and PLOT 9.5 kB (transmit), both in flash.

The cost this branch imposes on other apps

This is the part I would look at first if I were reviewing it.

The two signal-processing images this app needs are 36.5 kB together, and the flash
was already full. To make room, eleven images that next keeps in flash are moved to
the SD card by this branch:

afsktx, ais, aprsrx, btletx, mic_tx, rds, replay, sonde, subghzd,
weather, wideband_spectrum

That covers APRS receive and transmit, AIS, BLE transmit, the microphone transmitter,
RDS, Replay, the radiosonde receiver, the SubGHz decoder, Weather and Looking Glass.
Each of them needs its image present as /BASEBAND/<TAG>.img or the app reports
NoImg and does not start. The build writes each one a second time under its tag,
which is the name the loader looks for, and both the .ppfw.tar and the SD card
archive built by the release workflow now carry that directory, so a normal install
puts them where they belong. The package grows from 2.9 MB to 3.5 MB. That was not
true of the first version of this branch, which worked only because the card in front
of me had been prepared by hand.

Loading them is done by m4_init_from_sd in firmware/application/core_control.cpp,
which reads the image from the card when the tag is not in flash. The file on disk is
byte-identical to a flash chunk, so it is the same decompress-and-run path. That
mechanism is new here, and it is the one piece of this branch that changes behaviour
for people who never open the Meshtastic app.

What that costs, stated plainly, because it is not free. An image in flash is loaded
unconditionally: the tag is found in memory that is already mapped and decompressed
straight from there. An image on the card needs two things it did not need before,
and both depend on how much memory is left rather than on anything about the app:

  • a contiguous block the size of the compressed image, 7 kB to 21 kB;
  • about half a kilobyte of stack for FatFs.

The stack half is handled. The read runs on its own 1 kB thread, the same size and
the same call capture_thread and replay_thread already use to touch the card, so
the caller's depth no longer matters. Before that it did: measured on the device, the
4 kB process stack has around 300 bytes left at its high-water mark after boot and
under a hundred once a few screens have been opened, so reading on the caller's stack
worked for a shallow app and hard-faulted the deep ones.

The memory half is not handled, and I do not think it can be without changing the
decompressor. Core memory on the M0 is handed out and never returned, so a device
that has been used for a while has less of it; when the block cannot be found the app
reports NoImg, which is the same thing that happens when the file is missing. It is
a clear failure rather than a crash, but it is a failure that flash never had.

Measured on the device, that puts a ceiling on the mechanism. A clean boot leaves
about 24 kB of core; opening one menu to reach an app costs about 8 kB of it. So an
image has to fit in roughly 15 kB to be loadable in practice. Nine of the eleven do.
Two do not:

image size loadable
weather 21.4 kB no
subghzd 20.0 kB no

Those two are not marginal, they are out of reach: the memory is already spent by the
time you have navigated to them.

Rearranging which images go to the card does not fix it. Bringing those two back
needs 41 kB freed elsewhere, and the only images in flash small enough to take their
place are ADS-B receive, OOK transmit, FSK transmit and Capture, which together come
to 39 kB and still fall short. That trade makes four more apps depend on free memory
in order to rescue two, and Capture is not an app I would want to make conditional.

I have not tried to hide this behind a smaller number. Nine of eleven work and two do
not, and the two that do not are a direct consequence of this branch.

So the honest summary of the trade is not "flash space for a file on the card". It is
"an unconditional load for one that depends on free memory", for eleven apps that did
not ask for it. That is the part I would want a maintainer to weigh.

I am not attached to this arrangement. It is what fits today, not what I think the
project should do. If the maintainers would rather see fewer images moved, a
different set, or a packaging step that puts them on the card, say which and I will
rework it. If the answer is that a firmware app may not cost other apps anything at
all, then this app cannot ship as an internal app in its present size, and that is
worth knowing before either of us spends more time on it.

Memory, and one change outside this app

Worth stating plainly, because it is the one thing here that can stop the app dead.

Core memory on the M0 is handed out and never returned: a freed block joins a free list
and is reused only for a request of a similar size. The app takes about 16.7 kB at
startup, and the on-screen keyboard wants 4848 bytes in one contiguous piece the
first time it opens in a session. Between those, roughly two kilobytes are left. The
failure is not "memory ran out" but "the first large contiguous request failed".

This PR therefore also touches two shared firmware files, 26 lines, more than half
of them comment. firmware/common/chibios_cpp.cpp records the size of a failed
allocation, and firmware/application/debug.cpp prints it on the panic screen:

want 4848 free 3624

Out of Memory on its own says nothing, and the panic screen is all that is left
afterwards. A four-figure request means the budget is too tight; a two-figure one means
the heap is fragmented; they call for opposite fixes. Only the store happens in the
shared file, because formatting it there cost an external application 900 bytes and
broke its 32 kB ceiling twice. It earned its place immediately: it showed the problem was the
budget, not a leak.

Fixed on our side: replaying the saved chat used to read through a heap string sized
from a setting, 12.8 kB in one piece at its maximum, plus a vector holding every line
again. It now reads a fixed 1 kB tail, the same cost at any setting.

Two things that grow with use are bounded explicitly, and both default to the safe side:

setting where default cost
chat scrollback Settings → Chat → Lines 20 lines ~40 B a line
store-and-forward buffer Settings → Advanced → buffer 8 messages ~50 B a message plus its text

The node list is fixed at ten for the same reason and is not a setting: it lives in
memory permanently rather than on demand. It was twelve until the map view needed 3736
bytes in one piece and could not get them; two entries are 432 bytes, and they turn a
map that refuses to open into one that does. A handheld that can name ten neighbours
and show them on a map beats one that can name twelve and cannot.

The margin is still thin, and neither the code nor the guide hides it.

Tests in the repository

All of these run on an ordinary computer, no hardware needed:

  • tools/lora_bench/test_mesh_regions.cpp: 6429 combinations of region × preset ×
    slot against an independently written implementation of Meshtastic's formula, plus
    reference points measured on hardware.
  • tools/lora_bench/test_lora_framing.cpp: 192 end-to-end frame round-trips across
    every spreading factor, bandwidth and coding rate, and a frame recorded off the air
    from a stock node.
  • tools/lora_bench/test_lora_ref.cpp: the chunked reference-chirp generator against
    one computed whole.
  • tools/lora_bench/gen_font.py: walks each alphabet the glyph table claims to cover
    and refuses to write a file that falls short.
  • tools/lora_bench/test_mesh_router.cpp: dedup, relay mutation, channel-hash
    filtering, packet id seeding, the traceroute request on the wire, the route journal,
    and the pair that stores a real telemetry reading and then sends a request over it.
  • tools/lora_bench/sweep_one.py: a scripted on-air run of one preset, both directions.

The second one deserves a note, because it answers "aren't you just checking yourself
against yourself?". A round-trip proves only self-consistency. The coding-rate bug would
have sailed straight through it, because both ends read the same wrong table. That is why a
real frame from a stock node is in there: it is the one part that can disagree with us.

The last one deserves a note too, for the opposite reason. A telemetry request is an
empty copy of the variant it asks for, so it decodes into a perfectly valid measurement
of all zeroes, and this app used to store it: a node broadcasting 101 percent and
4.36 V read as 0 percent and 0.0 V on its own card from the moment it asked us for
ours. The test stores a real reading and then sends the request over it. Without the
fix it fails, which is the only reason to trust it.

Anticipated objections

"New apps are supposed to be external"

It does not fit, for two independent reasons.

An external app is capped at 32 kB. The largest existing ones sit right against it
(epirb_rx is 32492 bytes), and the application layer here is 10 800 lines. While
working on this PR, an edit in a shared firmware file pushed unrelated external apps
over that ceiling twice. The limit is live and tight.

Beyond that, the external format has no provision for a baseband image of its own, and
two are needed. The receive image is 27 kB and cannot be loaded from the card even in
principle: loading requires holding the compressed image whole in the M0 heap, which is
43 kB with most of it already spoken for. This is recorded in
firmware/baseband/CMakeLists.txt beside the target.

Happy to discuss a middle path if the project has plans for external apps with their own
baseband.

"That is a lot of std::, and the rules ask to avoid it"

Granted. The app uses std::string, std::vector and std::function the way the other
built-in apps do. They are absent where it matters: the demodulator runs on static
buffers, and complex multiplication is written by hand, because the standard operator
compiles to a library call and cost 424 dropped buffers per second.

Happy to cut back where you point.

"Why not error-correct the payload with Hamming?"

Because it measurably makes things worse. Hamming(8,4) corrects one error; with two, the
syndrome points at an innocent data bit. From the same recorded symbols: uncorrected, the
broadcast address came out FF FF FF FF; corrected, FF FF FF 7F. Correction is kept
for the header, where the stake is higher.

"Why does the app read a font off the SD card?"

Because the firmware font is ASCII and Latin-1, the flash is full, and the chat shows
text people send. The file is a table of codepoint ranges rather than a built-in script,
so which alphabet a device shows belongs to whoever holds the card. It is allocated only
if the file exists, capped at 256 glyphs, and taken through chHeapAlloc rather than
new, because new panics the firmware when the heap is short and an optional extra has
no business doing that. With no file, the chat says so instead of pretending.

"Why is it called Mesh and not Meshtastic?"

Their trademark policy does not allow the mark in the branding of a third-party product,
so the app carries its own name and icon, and a disclaimer on the settings screen. The
name stays in the code, where it identifies the protocol being implemented rather than
this product.

@verhogladov
verhogladov force-pushed the meshtastic-lora branch 4 times, most recently from 5e4d6a0 to 7b45bd3 Compare August 28, 2026 18:30
@verhogladov

Copy link
Copy Markdown
Author
SCR_0002 SCR_0003 IMG_3416 IMG_3417

@verhogladov

Copy link
Copy Markdown
Author

Attaching the build package and screenshots up front, since I see those are asked for
before review starts.

portapack-mayhem_OCI.ppfw.tar.gz is built from this branch. One thing to know before
flashing it: this branch moves eleven baseband images out of the ROM onto the SD card,
so the package now carries a BASEBAND folder as well as FIRMWARE and APPS. All
three need to end up on the card or the apps that own those images report NoImg. The
pull request description explains why they had to move and what it costs.

The screenshots are from a PortaPack H4M, and from the Meshtastic phone app talking to
a stock Heltec V4 in the same mesh: our node appears in the stock app's node list, and
text goes both ways.

Happy to answer anything, and to rework the baseband arrangement if you would rather
see a different set moved or none at all.

portapack-mayhem_OCI.ppfw.tar.gz

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a full Meshtastic implementation for PortaPack that performs LoRa PHY TX/RX on the HackRF (no external LoRa radio), plus a substantial host-side bench (“lora_bench”) for validation and on-air automation. It also adds supporting firmware changes to make room in flash (moving some baseband images to SD), improve out-of-memory diagnostics, and reduce optional feature RAM pressure.

Changes:

  • Add a Meshtastic app + LoRa baseband TX/RX images, with new M0↔M4 messages and baseband API hooks.
  • Add tools/lora_bench/ host tests and hardware-driven scripts to validate framing/crypto/regions/TX synthesis.
  • Add SD-card fallback loading for baseband images moved out of SPI flash, plus small UX/memory tweaks (QRCode scaling, GeoMap tile-cache lazy allocation, OOM size reporting).

Reviewed changes

Copilot reviewed 85 out of 89 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tools/lora_bench/watch_rx.py Script to monitor Heltec RX output and report decoded frames.
tools/lora_bench/tx_heltec.py Builds/encodes a Meshtastic packet and emits IQ for Heltec interop testing.
tools/lora_bench/tx_chain_test.cpp Host-side reference for TX encode chain (chips output).
tools/lora_bench/test_tx_lengths.py Regression test for firmware TX encoder across payload lengths.
tools/lora_bench/test_synth_staircase.py Compares firmware chirp synthesis vs smooth reference; explores fixes.
tools/lora_bench/test_mesh_telemetry.cpp Host test for Meshtastic telemetry codec.
tools/lora_bench/test_mesh_regions.cpp Host test for region/preset/slot frequency model + channel hash rules.
tools/lora_bench/test_mesh_partial.cpp Host test for truncated-payload decode behavior.
tools/lora_bench/test_mesh_crypto.cpp Host test for AES-CTR convention and round-trip.
tools/lora_bench/test_lora_rssi.cpp Host test validating RSSI approximation vs log10.
tools/lora_bench/test_lora_ref.cpp Host test verifying chunked reference chirp generation matches full-pass.
tools/lora_bench/sync_test.py Front-end sync/preamble grid test to validate sync word reading.
tools/lora_bench/sweep_one.py Automates bidirectional on-air testing for a single preset.
tools/lora_bench/shot.py Captures PortaPack screen via console to PNG (RGB888 hex rows).
tools/lora_bench/sf12_off.py Offline SF12 decode prototype against recorded air captures.
tools/lora_bench/selftest_demod.py Isolates/deterministically validates demod primitive.
tools/lora_bench/screenshot.py Alternate screen capture via screenframeshort.
tools/lora_bench/README.md Documentation for the bench tests and scripts.
tools/lora_bench/preset_set.py UI-driven preset switching automation with readback verification.
tools/lora_bench/ports.py Serial port discovery and env override support for hardware scripts.
tools/lora_bench/ocr.py Screen-to-text using firmware font glyph tables.
tools/lora_bench/mc.py Send one console command to PortaPack and print response.
tools/lora_bench/Makefile Builds/runs host-side tests against firmware sources.
tools/lora_bench/lora_inspect.py Capture inspection utility (bursts, occupied BW, spectrogram).
tools/lora_bench/lora_gold.py Reference decoder port of gr-lora_sdr chain (“authoritative” decode).
tools/lora_bench/lora_encode.py Reference encoder inverse of the verified decode chain.
tools/lora_bench/lora_decode.py Golden CSS decoder/oracle for capture inspection and symbol dumping.
tools/lora_bench/lora_crc.py CRC-based brute forcing/verification helper for decode-chain cracking.
tools/lora_bench/lora_crack.py Brute-force chain cracking tool for offline analysis.
tools/lora_bench/lf_sf11_stream.py Streaming SF11 decoder prototype matching firmware constraints.
tools/lora_bench/lf_sf11_stream_finealign.py Streaming SF11 fine-alignment variant buffering bins not samples.
tools/lora_bench/lf_sf11_ref.py Reference SFD-based sync recipe to port to firmware.
tools/lora_bench/lf_process.py Extracts/decimates LongFast bursts and dumps iq16 slices.
tools/lora_bench/hreset.py Host-side HackRF reset helper for exiting HackRF mode.
tools/lora_bench/hlisten.py Uses meshtastic python to print received packets/text from Heltec.
tools/lora_bench/heltec_rx_monitor/heltec_rx_monitor.ino Arduino sketch turning Heltec into raw LoRa RX monitor.
tools/lora_bench/hackrf_io.py ctypes-based HackRF capture/replay using SDR++ bundled libs.
tools/lora_bench/flash.py ctypes-based SPI flash tool (read/verify/write firmware).
tools/lora_bench/diag.py Sync-word discriminator + sharpness calibration script.
tools/lora_bench/capture.sh Captures IQ while commanding Heltec to TX known texts.
tools/lora_bench/.gitignore Ignores captures/artifacts and built host tests.
firmware/common/spi_image.hpp Adds LoRa baseband image tags.
firmware/common/message.hpp Adds LoRa M0↔M4 message IDs and message classes.
firmware/common/dsp_fir_taps.hpp Adds FIR taps for LoRa BW125 decimation stage.
firmware/common/chibios_cpp.cpp Records failed allocation size in global g_oom_size.
firmware/baseband/proc_lora_tx.hpp Introduces LoRa TX processor interface and TX frame structure.
firmware/baseband/CMakeLists.txt Adds LoRa RX/TX basebands; emits .img chunks; moves several images to SD.
firmware/application/ui/ui_qrcode.cpp Makes QR code scale/centering depend on widget size.
firmware/application/ui/ui_geomap.hpp Lazy-allocates tile cache; adds marker-link drawing API.
firmware/application/ui/ui_geomap.cpp Implements marker-link drawing and lazy tile-cache allocation.
firmware/application/ui_navigation.cpp Registers Meshtastic (“Mesh”) app in navigation list.
firmware/application/event_m0.hpp Adds getter for display sleep state.
firmware/application/debug.cpp Prints OOM request size + free core bytes on panic screen.
firmware/application/core_control.cpp Adds SD-card fallback baseband loader and refactors M4 init from chunks.
firmware/application/CMakeLists.txt Adds Meshtastic app sources and mesh modules to build.
firmware/application/bitmap.hpp Adds Meshtastic icon bitmap.
firmware/application/baseband_api.hpp Adds LoRa config/packet baseband API.
firmware/application/baseband_api.cpp Implements LoRa config/packet baseband API functions.
firmware/application/apps/mesh/mesh_pki.hpp Declares PKI primitives for Meshtastic direct messages.
firmware/application/apps/mesh/mesh_nodedb.hpp Adds node database structures and memory-bounded limits.
firmware/application/apps/mesh/mesh_nodedb.cpp Implements node DB operations, eviction, and route log.
firmware/application/apps/mesh/mesh_crypto.hpp Adds Meshtastic AES-CTR crypto interface + default PSK constants.
firmware/application/apps/mesh/docs/streaming_rx_demod.md Design note on streaming demodulator and buffering constraints.
firmware/application/app_settings.hpp Adds explicit SettingsManager::save() API.
firmware/application/app_settings.cpp Implements SettingsManager::save() (persist immediately).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +605 to +610
void send_lora_packet(const uint8_t* data, size_t len) {
LoRaPacketMessage message{};
message.length = static_cast<uint8_t>(std::min(len, LoRaPacketMessage::MAX_DATA));
std::memcpy(message.data, data, message.length);
send_message(&message);
}
Comment thread firmware/application/ui/ui_qrcode.cpp Outdated
Comment on lines +72 to +82
int scale = (r.width() - 12) / qrcode.size;
if (scale < 2) scale = 2;
const int quiet = scale * 2;
const int side = qrcode.size * scale + quiet * 2;

// Centre the code in the space it was given: the modules only come in whole pixels,
// so the drawn square is rarely the exact width of the widget and used to sit hard
// against the left edge.
const int ox = (r.width() > side) ? (r.width() - side) / 2 : 0;
const int oy = (r.height() > side) ? (r.height() - side) / 2 : 0;
display.fill_rectangle(Rect(r.left() + ox, r.top() + oy, side, side), Color::white());
Comment on lines +783 to +794
bool BMPFileCache::ensure_slots() {
if (slots_) return true;
constexpr size_t need = sizeof(std::array<Slot, SlotsCount>);
if (chCoreStatus() < need + 1024u) return false;
slots_ = new std::array<Slot, SlotsCount>{};
return slots_ != nullptr;
}

void BMPFileCache::release() {
delete slots_;
slots_ = nullptr;
}
Comment thread firmware/application/core_control.cpp Outdated
Comment on lines +94 to +118
// File carries FatFs' 512-byte sector buffer. On the heap even here: this thread's
// kilobyte is meant for the FatFs call frames, not for a 560-byte local.
auto f = std::make_unique<File>();
if (f->open(std::filesystem::path{path})) return 0; // Optional<Error> truthy = failed
const uint64_t fsize = f->size();
if (fsize < 13 || fsize > 48u * 1024u) return 0; // header(12)+data; M4 region is 32 KiB

// chHeapAlloc rather than new: this firmware's operator new panics with "Out of
// Memory" when the heap is short, and a baseband that will not fit is meant to be
// a NoImg - the same thing that happens when the file is missing - not a dead
// device. Core memory is handed out and never returned, so whether ten kilobytes
// are available here depends on which apps have already run this session; a
// restart is what gets them back.
//
// chHeapAlloc is 8-byte aligned, so the uint32 chunk fields stay word-aligned.
uint8_t* const raw = static_cast<uint8_t*>(chHeapAlloc(0x0, static_cast<size_t>(fsize)));
if (!raw) return 0;
const auto rd = f->read(raw, fsize);
if (!rd || rd.value() != fsize) {
chHeapFree(raw);
return 0;
}
req->raw = raw;
req->size = static_cast<size_t>(fsize);
return 1;
Comment on lines +136 to +144
const auto* chunk = reinterpret_cast<const spi_flash::chunk_t*>(req.raw);
if (!(chunk->tag == image_tag)) {
chHeapFree(req.raw);
return false;
}
m4_start_from_chunk(chunk, to, full_reset);
// The M4 is running from its own RAM now; the compressed copy has done its job.
chHeapFree(req.raw);
return true;
Comment on lines +27 to +31
// LoRa CSS transmitter.
// Full encode chain: whiten -> Hamming(CR) -> diagonal interleave -> Gray encode -> CSS chirp.
// Frame: preamble (8 up) + sync word + SFD (2 down) + header (8 syms, CR4/8) + payload symbols.
// Meshtastic sync word = 0x12: upchirps at chips (0x1 << (SF-4)) and (0x2 << (SF-4)).

@verhogladov

verhogladov commented Aug 30, 2026

Copy link
Copy Markdown
Author

All six review findings are fixed, and I checked each one against the code before believing it. All six were real.

Three of them could take the device down rather than step aside, and all three are in paths that run for people who never open this app. BMPFileCache::ensure_slots() and the File the SD loader opens both went through operator new, which panics here instead of returning null; they now take their memory with chHeapAlloc and give up quietly. The tile cache had a guard on chCoreStatus(), which was worse than none: it reports unallocated core rather than what the heap can hand out in one piece, so it reads healthy while a contiguous request still fails. The third is the chunk header you spotted: only the tag was checked, while length and compressed_data_size steer unlz4_len, so a bad sector was enough to walk the decompressor off the end of the buffer.

Two of your suggested changes were better than what I had written, and I took them. The header size is derived with sizeof rather than written as 12, and the File is held by a unique_ptr with a deleter instead of being freed by hand at each of seven exits. Thank you for both. I kept the stricter length check: length has to match the file exactly rather than merely fit inside it, because a file cut short while being copied keeps a header whose fields still agree with each other.

One suggestion I did not take, and it is worth saying why. The sync word comment fix changes 0x12 to 0x2B but keeps the chips at nibble << (SF-4). That shift is the bug this branch fixes: it lands on 16 and 88 only at SF7, which is why transmit worked there and was rejected by a hardware SX126x at every other preset. The comment now describes what the code does, which is a fixed shift of 3, with the reason beside it.

Checked on hardware: the map opens and closes four times with the free core and the process stack high-water unmoved, so the placement new and the explicit destructor round-trip cleanly; the QR draws inside its frame; Looking Glass still loads its baseband off the card; and Weather, whose image is larger than the memory left by the time you have navigated to it, reports NoImg and leaves the device running. That run was on the build before the last two changes above, which alter how the memory is handed back rather than what the loader does; I will say so here if a repeat finds anything different.

…routing

A Meshtastic app for the PortaPack: chat, encrypted channels, a node list,
telemetry, positions, a map, and mesh routing.

The physical layer is written from scratch. A HackRF has nothing in it that
understands LoRa: it hands over a stream of samples and takes one back. So
preamble detection, timing and frequency synchronisation, dechirping, the Fourier
transform, Gray coding, interleaving, Hamming and whitening are all here, in both
directions, on the M4 core, inside 54 KB of buffers and a 1.024 ms deadline per
buffer.

Seven of the nine modem presets are confirmed in both directions against a stock
Meshtastic node used as an outside source of truth. Also confirmed on the air:
encrypted custom channels both ways, telemetry and positions received, the clock
taken from a neighbour, settings surviving a crash, traceroute answered and
asked, and the requests a phone makes answered.

SF12 receive is not offered. The algorithm is proven off the device against a
real recording; on the device it does not fit the time budget, and the limit is
written down in the code with the arithmetic that produces it.

The app does not fit in the SPI flash beside everything else, so eleven baseband
images move to the SD card and are loaded from there. The build and the release
package put them on the card; what the arrangement costs the apps that own them is
set out in the pull request, with the measurements behind it.
Tests that run on an ordinary computer against the firmware's own sources, and
scripts that drive a PortaPack and a Meshtastic node over USB to test on the air.

The end-to-end tests carry one frame recorded off the air from a stock node. A
round trip only proves the two ends agree with each other: a wrong coding rate
sailed through it for weeks because both ends read the same wrong table, and only
an outside frame could disagree.
@verhogladov

Copy link
Copy Markdown
Author

Two corrections, one of them mine to own.

The package I attached earlier was incomplete: it carried FIRMWARE and APPS but not BASEBAND, so anyone who flashed it would have found eleven apps reporting NoImg with no hint of what was missing. The commit that adds those images to the package existed but had been made on the wrong branch and was lost when I regenerated it, and I did not notice for two days while the pull request description said the packaging worked. It is restored and the branch now carries it. Please ignore that first archive; a complete one is here:
https://github.com/mamapapa1/mayhem-firmware/releases/tag/mesh-test-1

The second: I have taken back the unique_ptr with a deleter for the File in the SD loader, and gone to the explicit form. Your suggestion is the better shape and I said so, but the build carrying it took the device into a hard fault when Looking Glass loaded its baseband off the card, and the build without it does not. I cannot explain the difference. The other change in that commit, the header check, can only return early, and all 48 externalised images satisfy it, so that leaves the deleter with no mechanism I can point at. An unexplained fault in the path every externalised baseband goes through did not seem like something to hand to other people, so the code says why it is written the way it is. If you can see what I am missing I would rather have the better shape than my caution.

Checked on the exact binary that is now in the release, not on something resembling it: Looking Glass loads and sweeps, the map opens and closes with the free core unmoved, and Weather, whose image is larger than the memory left by the time you reach it, reports NoImg and leaves the device running.

@zxkmm

zxkmm commented Aug 31, 2026

Copy link
Copy Markdown
Member

Thank you for the nice feature!
Just some heads up regarding your PR, so we can sync each other's view:

  • the current change is too chaotic, mixed with documents / MacOS dedicated thing / DSP hack... etc in codebase. if it ever possible to got merged, it would need to be less chaotic.
  • we tested your change, it is so cool, but we already saw several issues in it.
  • do you mind join the discord server here: https://discord.gg/rWZTHkYq3 so we can talk together to make it better, and if possible, got merged in the end. We need long real-time talk so it would be easier on discord instead of GitHub's thread+maillist-ish chat.

Thank you again! And if possible, please join discord, and pin several of the developers so we can know your discord handle and start the discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants