From 122986fdff38af189a2d8698c94b2809e37e2e55 Mon Sep 17 00:00:00 2001 From: KMX415 Date: Sun, 3 May 2026 09:43:41 -0400 Subject: [PATCH] Fix coding rate on Short and Medium presets, add LongTurbo The preset table in src/radio/presets.py shipped with coding_rate=4/8 on ShortFast, ShortSlow, MediumFast, and MediumSlow. Per the upstream Meshtastic spec (RadioInterface.cpp), only the Long Range presets and the deprecated VeryLongSlow use 4/8. The other six all use 4/5. The wrong value cascaded through the dashboard preset dropdown into local.yaml on every Meshpoint that picked one of the affected presets, and from there into every TX (NodeInfo broadcasts, DM ACKs, relay). 4/8 is roughly 60% more airtime per byte than 4/5, so fleet members on those presets were paying a notable airtime tax for no benefit. Changes: - src/radio/presets.py: corrected CR to 4/5 on the four Short and Medium presets. Added LongTurbo (SF11 / BW500 / CR 4/8) which was missing from the table entirely. - src/transmit/tx_service.py: added (11, 500) -> "LongTurbo" to PRESET_DISPLAY_NAMES so MQTT topics and the channel-name resolver label LongTurbo consistently instead of falling through to "Custom". - src/config.py: cosmetic fix to the RadioConfig dataclass default (4/8 -> 4/5). default.yaml is already correct so this only affects code-readers and tests that instantiate RadioConfig() directly. - tests/test_radio_presets_canonical.py: new file locking every preset's (sf, bw, cr) tuple to the upstream spec so this never regresses silently. - tests/test_tx_service.py: added test_longturbo, expanded the test_all_presets_present expected set. - docs/COMMON-ERRORS.md: entry under Concentrator and radio documenting the symptom, the airtime impact, and the dashboard re-pick or yaml edit fix path for existing installs. No version bump. Existing installs keep their cached CR until the user re-picks the preset on the dashboard or edits local.yaml. RX is unaffected since the SX1302 reads CR from the LoRa header. Reported by Chiumanfu on Discord while setting up a Vancouver Meshpoint on MediumSlow. --- docs/COMMON-ERRORS.md | 36 ++++++++++++ src/config.py | 2 +- src/radio/presets.py | 15 +++-- src/transmit/tx_service.py | 1 + tests/test_radio_presets_canonical.py | 85 +++++++++++++++++++++++++++ tests/test_tx_service.py | 5 +- 6 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 tests/test_radio_presets_canonical.py diff --git a/docs/COMMON-ERRORS.md b/docs/COMMON-ERRORS.md index 6580972..005b7f5 100644 --- a/docs/COMMON-ERRORS.md +++ b/docs/COMMON-ERRORS.md @@ -360,6 +360,42 @@ something on air, could not classify it, moving on." **Fix:** None required. Safe to ignore. +### My Short or Medium preset shows the wrong coding rate + +**Cause:** Meshpoints running v0.7.1 or earlier shipped the Short and +Medium presets with `coding_rate: "4/8"` in the preset table. The +correct value per the [Meshtastic spec](https://meshtastic.org/docs/overview/radio-settings/) +is `4/5` for `ShortFast`, `ShortSlow`, `ShortTurbo`, `MediumFast`, +`MediumSlow`, and `LongFast`. Only `LongModerate`, `LongSlow`, +`LongTurbo`, and the deprecated `VeryLongSlow` use `4/8`. + +If you picked one of the affected presets in the dashboard Radio tab +on a pre-fix install, your `config/local.yaml` has the wrong CR +cached, even after `git pull`. + +**Impact:** RX is unaffected (the SX1302 decodes any CR automatically +because the LoRa header carries it). TX takes roughly 60% more +airtime than a stock Meshtastic node sending the "same" preset: +4/8 has 2x overhead per data byte vs 4/5's 1.25x. NodeInfo broadcasts, +DM ACKs, and relay (when enabled) all pay this airtime tax. + +**Fix:** After `sudo git pull origin main && sudo systemctl restart +meshpoint`, do one of the following: + +- **Dashboard re-pick** (recommended): open the dashboard, go to the + Radio tab, re-select your preset from the Radio Configuration + dropdown, click Save Radio, and apply the restart prompt. This + rewrites `local.yaml` with the corrected CR. +- **Manual edit**: edit `config/local.yaml` and set + `radio.coding_rate` to the correct value for your preset (`"4/5"` + for the six presets listed above, `"4/8"` for the four Long + presets). Then `sudo systemctl restart meshpoint`. + +To confirm the fix took effect, check the startup banner or +`meshpoint status` for the active radio config, and watch the next +NodeInfo broadcast log line: airtime should drop noticeably (e.g., +~723ms to ~456ms for a NodeInfo on MediumSlow). + --- ## API and dashboard diff --git a/src/config.py b/src/config.py index f9318f7..045ecc4 100644 --- a/src/config.py +++ b/src/config.py @@ -43,7 +43,7 @@ class RadioConfig: slot: Optional[int] = None # Meshtastic 1-indexed slot; used when frequency_mhz absent spreading_factor: int = 11 bandwidth_khz: float = 250.0 - coding_rate: str = "4/8" + coding_rate: str = "4/5" sync_word: int = 0x2B preamble_length: int = 16 tx_power_dbm: int = 22 diff --git a/src/radio/presets.py b/src/radio/presets.py index c812966..371c21d 100644 --- a/src/radio/presets.py +++ b/src/radio/presets.py @@ -30,6 +30,13 @@ class ModemPreset: bandwidth_khz=250, coding_rate="4/5", ), + "LONG_TURBO": ModemPreset( + name="LONG_TURBO", + display_name="LongTurbo", + spreading_factor=11, + bandwidth_khz=500, + coding_rate="4/8", + ), "LONG_MODERATE": ModemPreset( name="LONG_MODERATE", display_name="LongModerate", @@ -57,28 +64,28 @@ class ModemPreset: display_name="MediumFast", spreading_factor=9, bandwidth_khz=250, - coding_rate="4/8", + coding_rate="4/5", ), "MEDIUM_SLOW": ModemPreset( name="MEDIUM_SLOW", display_name="MediumSlow", spreading_factor=10, bandwidth_khz=250, - coding_rate="4/8", + coding_rate="4/5", ), "SHORT_FAST": ModemPreset( name="SHORT_FAST", display_name="ShortFast", spreading_factor=7, bandwidth_khz=250, - coding_rate="4/8", + coding_rate="4/5", ), "SHORT_SLOW": ModemPreset( name="SHORT_SLOW", display_name="ShortSlow", spreading_factor=8, bandwidth_khz=250, - coding_rate="4/8", + coding_rate="4/5", ), "SHORT_TURBO": ModemPreset( name="SHORT_TURBO", diff --git a/src/transmit/tx_service.py b/src/transmit/tx_service.py index 9a5e6c4..7b4a7dd 100644 --- a/src/transmit/tx_service.py +++ b/src/transmit/tx_service.py @@ -37,6 +37,7 @@ (9, 250): "MediumFast", (10, 250): "MediumSlow", (11, 250): "LongFast", + (11, 500): "LongTurbo", (11, 125): "LongMod", (12, 125): "LongSlow", (12, 62): "VLongSlow", diff --git a/tests/test_radio_presets_canonical.py b/tests/test_radio_presets_canonical.py new file mode 100644 index 0000000..d69e783 --- /dev/null +++ b/tests/test_radio_presets_canonical.py @@ -0,0 +1,85 @@ +"""Lock MODEM_PRESETS to the upstream Meshtastic spec. + +Each preset's (spreading_factor, bandwidth_khz, coding_rate) tuple +must match the canonical Meshtastic firmware values to avoid silent +drift like the v0.7.1-and-earlier bug where Short and Medium presets +shipped with CR 4/8 instead of 4/5, costing ~60% extra airtime per TX. + +Reference: https://meshtastic.org/docs/overview/radio-settings/ +""" + +from __future__ import annotations + +import unittest + +from src.radio.presets import ( + MODEM_PRESETS, + all_presets_list, + get_preset, + preset_from_params, +) + +# Canonical (sf, bw_khz, cr) per upstream Meshtastic firmware +# RadioInterface.cpp. Keys must match MODEM_PRESETS keys exactly. +CANONICAL: dict[str, tuple[int, float, str]] = { + "SHORT_TURBO": (7, 500, "4/5"), + "SHORT_FAST": (7, 250, "4/5"), + "SHORT_SLOW": (8, 250, "4/5"), + "MEDIUM_FAST": (9, 250, "4/5"), + "MEDIUM_SLOW": (10, 250, "4/5"), + "LONG_FAST": (11, 250, "4/5"), + "LONG_TURBO": (11, 500, "4/8"), + "LONG_MODERATE": (11, 125, "4/8"), + "LONG_SLOW": (12, 125, "4/8"), + "VERY_LONG_SLOW": (12, 62.5, "4/8"), +} + + +class TestPresetCanonicalValues(unittest.TestCase): + + def test_full_preset_coverage(self): + """MODEM_PRESETS keys exactly match the canonical preset set.""" + self.assertEqual(set(MODEM_PRESETS.keys()), set(CANONICAL.keys())) + + def test_each_preset_matches_meshtastic_spec(self): + """Every preset's (sf, bw, cr) tuple matches upstream firmware.""" + for name, expected in CANONICAL.items(): + with self.subTest(preset=name): + p = MODEM_PRESETS[name] + actual = (p.spreading_factor, p.bandwidth_khz, p.coding_rate) + self.assertEqual( + actual, + expected, + f"{name} drifted from upstream spec", + ) + + def test_get_preset_round_trip(self): + """get_preset() returns the same instance MODEM_PRESETS holds.""" + for name in CANONICAL: + with self.subTest(preset=name): + self.assertIs(get_preset(name), MODEM_PRESETS[name]) + self.assertIs(get_preset(name.lower()), MODEM_PRESETS[name]) + + def test_reverse_lookup_round_trips(self): + """preset_from_params(sf, bw, cr) returns the original preset name.""" + for name, (sf, bw, cr) in CANONICAL.items(): + with self.subTest(preset=name): + self.assertEqual(preset_from_params(sf, bw, cr), name) + + def test_all_presets_list_includes_every_canonical_entry(self): + """API-facing all_presets_list() exposes every canonical preset.""" + api_names = {entry["name"] for entry in all_presets_list()} + self.assertEqual(api_names, set(CANONICAL.keys())) + + def test_long_turbo_is_tx_capable(self): + """LongTurbo mirrors ShortTurbo's tx_capable=True (BW500 region-aware).""" + self.assertTrue(MODEM_PRESETS["LONG_TURBO"].tx_capable) + self.assertTrue(MODEM_PRESETS["SHORT_TURBO"].tx_capable) + + def test_very_long_slow_is_not_tx_capable(self): + """Deprecated VeryLongSlow remains RX-only per existing constraint.""" + self.assertFalse(MODEM_PRESETS["VERY_LONG_SLOW"].tx_capable) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tx_service.py b/tests/test_tx_service.py index 585321d..ac3706a 100644 --- a/tests/test_tx_service.py +++ b/tests/test_tx_service.py @@ -30,11 +30,14 @@ def test_shortturbo(self): def test_mediumfast(self): self.assertEqual(PRESET_DISPLAY_NAMES[(9, 250)], "MediumFast") + def test_longturbo(self): + self.assertEqual(PRESET_DISPLAY_NAMES[(11, 500)], "LongTurbo") + def test_all_presets_present(self): expected = { "ShortFast", "ShortTurbo", "ShortSlow", "MediumFast", "MediumSlow", - "LongFast", "LongMod", "LongSlow", "VLongSlow", + "LongFast", "LongTurbo", "LongMod", "LongSlow", "VLongSlow", } self.assertEqual(set(PRESET_DISPLAY_NAMES.values()), expected)