Reorg for mesh control-based version

This commit is contained in:
Alec K2XAP 2026-05-07 20:38:43 -04:00
parent bf3ddfb440
commit 7424416b58
7 changed files with 21437 additions and 35 deletions

View file

@ -1,17 +0,0 @@
# LoRa Modulation Jam
Programmatically send packets between two stations across a set of LoRa radio configurations.
Given nodes at two different locations with computers attached via USB, run one of these commands within a few minutes of each other on each computer:
`python modjam.py run --this-station=A` and `python modjam.py run --this-station=B`
Each station will iterate through the full permutation of LoRa radio settings.
Use the parameters to change the set of settings used:
This will run for all BWs, but only test SF 7 and 11, coding rate 5, and power level 1.
```
python3 ./modjam.py run --this-station=A --spread-factor=7 --spread-factor=11 --coding-rate=5 --power=1
```

10630
reference/A.tsv Normal file

File diff suppressed because it is too large Load diff

10606
reference/B.tsv Normal file

File diff suppressed because it is too large Load diff

139
reference/concept.md Normal file
View file

@ -0,0 +1,139 @@
# LoRa Modulation Jam
This is a project to measure packet delivery rates across different permutations of LoRa modulation settings. Two or more nodes operate as test stations are set up to send and receive packets at the same LoRa settings. Another node operates as the control station and sends commands to initiate the tests.
This test framework uses MeshCore since it creates realistic packet sizes, has a useful python interface, and does not require restarting the node to change radio settings.
## Test Stations
Python service running on a Raspberry Pi and interfacing with a MeshCore node using the `meshcore` python library over USB Serial.
The service runs on Pi boot and connects to the USB-attached node. It reads the config from `~/modjam-config.json` that looks something like this:
```json
{
"this_station_name": "A",
"channel_name": "modjam",
"channel_psk": "1234abcdefg==",
"idle_heartbeat_min": 15,
"control_radio": {
"frequency_mhz": 915.125,
"bandwidth_khz": 500,
"spread_factor": 12,
"coding_rate": 8,
"tx_power_dbm": 22,
},
"data_radio": {
"frequency_mhz": 915.125,
"bandwidth_khz": 500,
"spread_factor": 7,
"coding_rate": 5,
"tx_power_dbm": 1,
}
}
```
### Commands
The Test Stations listen for commands from the Control Station that come on the configured channel (channel_name, channel_psk, control_radio).
Commands follow this format: `<cmd>[:<station>]|<attr1:val1,val2,…>|<attr2:val1,val2,…>…`. They may be unicast addressed to a specific station or multicast to all stations. The commands take named arguments that are pipe-separated. Each consists of a name followed by a colon followed by a comma-separated list of values (there may be no comma if there is only one value). The arguments are not ordered.
#### `START`
The `START` command is not directed at a specific Test Station, it is multicast to all stations. It provides a test name, set of frequencies, set of bandwidths, set of spread factors, set of coding rates, and set of transmit powers that together provide all the permutations to be tested. The named test will run for all combinations of these radio parameters. The command also lists all the stations that should participate in that test.
`START|name:<testname>|f:<freq1>,<freq2>,…|bw:<62.5|125|250|500>,…|sf:<6,7,8,9,10,11,12>,…|cr:<4,5,6,7,8>,…|pow:<pow1>,<pow2>|stations:<a>,<b>,…|duration:<s>|padding:<s>|spacing:<s>|size:<bytes1>,<bytes2>,…|at:<n>`
- name (required): the string name of the test run, used to name the test file and reference the results later
- f: the float frequencies to test in MHz (default: 916.1)
- bw: a choice of bandwidths to test from the possible options 62.5, 125, 250, 500 (default: all of these)
- sf: a choice of spread factors to test from the options 7,8,9,10,11,12 (default: all of these)
- cr: a choice of coding rates to test from the options of 5,6,7,8 (default: all of these)
- pow: the int tx powers to test (default: 22,)
- stations: the string names of stations to participate (default: 'A','B')
- duration: the int seconds of each test case (default: 300)
- padding: the int seconds between each test case (default: 60)
- spacing: the int seconds between each test transmission (default: 2)
- size: the list of int payload sizes to test in bytes (default: 40,)
- at: the int %nth minute of the hour to begin the test (default: 5)
These arguments have defaults so not all need to be specified for the `START` command to be valid. Only the `name` is required.
A complete example command:
`START|name:ab123|f:915.1|bw:62.5,125,250,500|sf:6,7,8,9,10,11,12|cr:4,5,6,7,8|pow:10,22|stations:A,B,C|padding:60|duration:600|spacing:2|at:5|size:40`
#### `STOP`
The `STOP` command is a multicast command to all Test Stations. When they receive this, if they are `RUNNING`, they switch to an `IDLE` state and stop their test protocol wherever they were in it.
### States
The Test Station has two states: `IDLE`, `RUNNING`:
#### IDLE
After reading the config, the process configures the attached node with a channel named the `encryption_name` and using the `encryption_psk` (if such a channel is not already configured), and sets the radio to the `control_radio` settings, then enters the `IDLE` state.
While in the `IDLE` state the node sends heartbeat messages every `idle_heartbeat_min` on the configured channel: `<now>|<this_station_name>|<state>|<noise_floor>` where `now` is the unix timestamp, `this_station_name` is from the config file, `state` is the current process state, and `noise_floor` is the noise floor as measured by the attached node.
An example heartbeat message: `1778184205|B|IDLE|-102`.
The service stays in this `IDLE` state, sending regular heartbeats, until it receives a `START` command.
#### RUNNING
The control node sends a `START` command on the `control_radio` settings. This is received by all Test Stations in `IDLE` state. They build a cuesheet based on the specified test parameters. Then they enter a `RUNNING` state, wait until the time specified by the `START` command, then proceed to iterate through the cuesheet-defined permutations and taking turns transmitting or listening.
While `RUNNING`, the process iterates through the cuesheet based on the defined timing. If the specified `sender` matches its own station name, it executes transmissions. Otherwise, it waits. Every packet it sends or receives gets logged to a tab-separated `<testname>.tsv` file.
In between each test case, during the `padding` interval specified by the start command, the node switches back to the `control_radio` setting so it can emit status information and listen for other commands. `10` seconds _before_ the start time of the test case it sends a "next" message: `<now_ts>|<this_station_name>|next:<f>/<bw>/<sf>/<cr>/<pow>/<size>`. `10` seconds _after_ the end of the test case it sends a "done" message: `<now_ts>|<this_station_name>|done:<f>/<bw>/<sf>/<cr>/<pow>/<size> <n_sent>/<n_rcvd>`.
## Example
Two Test Stations start up, with nearly identical configurations but one is named `'A'` and one is named `'B'`. They tune to the `control_radio` settings and enter `IDLE` state.
The Control Station sends a `START|test1|f:910.2|bw:62.5,500|sf:8,9,10|cr:5` command at clock time 10:07:23. Test Stations A and B hear this enter `RUNNING` states. At time 10:10:10, stations A and B send "next" messages:
- `1778148610|A|next:910.2/62.5/8/5/22/40`
- `1778148610|B|next:910.2/62.5/8/5/22/40`
At 10:10:20 stations A and B set their radios to frequency 910.2 MHz, bandwidth 62.5 kHz, spread factor 8, coding rate 5, tx power 22.
At 10:10:30 station A begins a series of transmissions spaced 2 seconds apart:
- 1778148610.2168489,30.12679696083069,1|551097625881289327832
- 1778148612.738883,32.648837089538574,2|738453771722320023996
- 1778148615.468757,35.378714084625244,3|7910624607885536300710
- …
Station A logs every message it queues for the radio and every message the radio reports it transmitted. Station B logs every message received.
At 10:14:30 station A stops its test transmissions.
At 10:15:30 station B starts a similar series of transmissions. It logs every message it queues and transmits. Station A logs every message it receives.
At 10:19:30 station B stops its test transmissions.
At 10:19:40 stations A and B tune to the control_radio settings
At 10:19:50 stations A and B send "done" messages:
- 1778149190|A|done:910.2/62.5/8/5/22/40 120/110
- 1778149190|B|done:910.2/62.5/8/5/22/40 120/114
At 10:20:10 stations A and B send "next" messages, at 10:20:20 tune to the next test radio settings (910.2 MHz, 62.5 BW, 9 SF, 5 CR, 22 power), and repeat the process of sending and listening.
They continue doing this until they have performed tests in each direction for all permutations, at 11:10:00, retune to the control radio settings and enter IDLE state.

View file

@ -13,7 +13,7 @@ import sys
logfile = None logfile = None
interface: SerialInterface | None = None interface: SerialInterface | TCPInterface | None = None
type Bandwidth = Literal[62,125,250,500] type Bandwidth = Literal[62,125,250,500]
@ -186,10 +186,12 @@ def log (**parts):
logfile.write(json.dumps({**parts, 'ts': ts}) + '\n') logfile.write(json.dumps({**parts, 'ts': ts}) + '\n')
def configureRadio (conf: RadioConfig): async def configureRadio (conf: RadioConfig):
if not interface: if not interface:
raise Exception('No interface connected') raise Exception('No interface connected')
await reconnectRadio()
node = interface.getNode('^all') node = interface.getNode('^all')
print(node)
changed = False changed = False
config_ok_to_mqtt = False config_ok_to_mqtt = False
@ -245,25 +247,34 @@ def configureRadio (conf: RadioConfig):
node.writeConfig('lora') node.writeConfig('lora')
node.commitSettingsTransaction() node.commitSettingsTransaction()
print('changes applied, waiting for reboot') print('changes applied, waiting for reboot')
await asyncio.sleep(10)
return changed return changed
# raise MeshInterface.MeshInterfaceError(
def reconnectRadio (): # "Timed out waiting for connection completion"
# )
# WARNING file:stream_interface.py __reader line:216 Meshtastic serial port disconnected, disconnecting... device reports readiness to read but returned no data (device disconnected or multiple access on port?)
async def reconnectRadio ():
global interface global interface
if not interface: if not interface:
raise Exception('No interface connected') raise Exception('No interface connected')
reconnected = None print('reconnectRadio disconnect', interface)
if interface.isConnected:
interface.close()
await asyncio.sleep(1)
print('reconnectRadio connect', interface)
if isinstance(interface, TCPInterface):
interface = TCPInterface(interface.hostname, noNodes=True)
elif isinstance(interface, SerialInterface):
interface = SerialInterface(interface.devPath, noNodes=True)
await asyncio.sleep(1)
reconnected_node = None reconnected_node = None
while not reconnected and not reconnected_node: while not reconnected_node:
try: print('reconnectRadio getNode', interface.isConnected)
reconnected = SerialInterface(interface.devPath, noNodes=True) reconnected_node = interface.getNode('^all')
except: print(reconnected_node)
pass await asyncio.sleep(2)
sleep(1) # interface = reconnected
if reconnected:
reconnected_node = interface.getNode('^all')
sleep(2)
interface = reconnected
def onReceiveText (packet, interface): def onReceiveText (packet, interface):
@ -280,6 +291,7 @@ txed = dict()
active_tx_id = None active_tx_id = None
active_tx_ms = None active_tx_ms = None
def onStatus (line: str): def onStatus (line: str):
# print(line)
global active_tx_id global active_tx_id
global active_tx_ms global active_tx_ms
if 'Started Tx' in line: if 'Started Tx' in line:
@ -305,7 +317,7 @@ async def waitForTx (packet_id: int):
timed_out = False timed_out = False
while not packet_id in txed and not timed_out: while not packet_id in txed and not timed_out:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if time() - s > 45: if time() - s > 30:
timed_out = True timed_out = True
ms = txed.pop(packet_id, None) # pop to avoid a memory leak ms = txed.pop(packet_id, None) # pop to avoid a memory leak
if timed_out: if timed_out:
@ -339,9 +351,9 @@ async def runCues (cuesheet: Cuesheet, run_config: RunConfig):
print(scenario) print(scenario)
print('configure radio') print('configure radio')
scenario_prefix = f'{scenario['freq']},{scenario['bw']},{scenario['sf']},{scenario['cr']},{scenario['pow']}' scenario_prefix = f'{scenario['freq']},{scenario['bw']},{scenario['sf']},{scenario['cr']},{scenario['pow']}'
did_change = configureRadio(scenario) did_change = await configureRadio(scenario)
if did_change: if did_change:
reconnectRadio() await reconnectRadio()
if scenario['sender'] == run_config['this_station']: if scenario['sender'] == run_config['this_station']:
print('waiting for start') print('waiting for start')
while t < scenario['start']: # Give other stations time to get ready while t < scenario['start']: # Give other stations time to get ready

View file

@ -0,0 +1,31 @@
import asyncio
from meshcore import MeshCore, EventType
PRESETS = [
(250,11,5), # LongFast
(250,10,5), # MediumSlow
(250,9,5), # MediumFast
(250,8,5), # ShortSlow
(250,7,5), # ShortFast
(125,8,5), # MeshOregon
(62,7,5), # MC Narrow
(500,11,8), # LongTurbo
(500,7,5), # ShortTurbo
]
async def main():
meshcore = await MeshCore.create_serial("/dev/tty.usbmodem1301")
print(meshcore)
print(await meshcore.commands.set_tx_power(1))
await asyncio.sleep(1)
for (bw,sf,cr) in PRESETS:
await meshcore.commands.set_radio(912.4, bw, sf, cr)
result = await meshcore.commands.send_chan_msg(0, "The quick brown fox jumped over the lazy dog")
print(result)
await asyncio.sleep(1)
await meshcore.disconnect()
asyncio.run(main())

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
meshcore==2.3.7