Add MediaMesh radio2 presets and automatic media routing

This commit is contained in:
mikecarper committed 2026-10-21 10:35:14 -07:00
1 parent 38f68596f7
commit 53c7bb141a
14 files changed
+358 -19

No files matched your search

+9
View File
@@ -7,6 +7,12 @@
See [two LoRa profiles](radio_profiles.md) for `radio2`, `tempradio2`, scheduling,
RX-only mode, cross-transmission policy, and optional radio preambles.
For a saved MediaMesh second profile, use `set radio2 mediamesh`
(911.150 MHz, 250 kHz, SF7, CR 4/5, `rxtx`, automatic preamble), or
`set radio2 mediamesh-range` for 125 kHz. These leave the primary profile
and crossing policy unchanged. Both radio2 and `radio2.cross` default to `off`.
See [MediaMesh presets](radio_profiles.md#mediamesh-presets).
For supported Full Companions, see [Meshtext channel chat](radio_profiles.md#meshtext-channel-chat)
for `mt-` channels and the explicit `send mt.nodeinfo <channel slot>` command.
Chat remains plain text. A separate GPS-free NodeInfo is sent after the first
@@ -995,6 +1001,9 @@ set tx.ssh auto|radio|radio2|both|off
Choose one value after `set`. Both default to `auto` and are saved across
reboot. Explicit choices override a channel route for the matching application
data only; they do not change ordinary text, adverts or contact ACKs.
`tx.media auto` selects only the active `rxtx` MeshCore second profile,
including `tempradio2`, when available. Otherwise, it uses the normal
channel/global route. `tx.ssh auto` keeps that normal route.
`radio2` requires an active `rxtx` MeshCore profile and never falls back to
the primary. `both` uses the available profiles; `off` blocks sending only.
Queries include the effective `active=` selection and warn if radio2 is
+7 -1
View File
@@ -92,6 +92,7 @@ Examples use private channel index `3`:
```text
fleet send 3 get radio2
fleet send 3 set radio2 mediamesh
fleet send 3 set tempradio2 910.5,500,8,5,rxtx,120
fleet send 3 set radio2.cross auto
fleet send 3 set flood.rule.4 type=grp_txt hops=6+ drop
@@ -295,7 +296,7 @@ Reboot or changing enrollment discards the partial transfers.
| Control | Fleet commands |
| --- | --- |
| Saved second profile | `get radio2`, `set radio2 <tuple|off>` |
| Saved second profile | `get radio2`, `set radio2 <tuple|mediamesh|mediamesh-range|off>` |
| Temporary second profile | `get tempradio2`, `set tempradio2 <tuple|off>` |
| Scheduled primary profile | `get/set/del radioat`, `get/set/del tempradioat` |
| Scheduled second profile | `get/set/del radioat2`, `get/set/del tempradioat2` |
@@ -313,6 +314,11 @@ Each role still checks whether it supports the requested setting. Immediate
primary radio changes, identity export, passwords, ACL edits, filesystem operations, firmware
installation, reboot, and unrestricted CLI are outside fleet capability.
`set radio2 mediamesh` saves 911.150 MHz, 250 kHz, SF7, CR 4/5, `rxtx`,
with automatic scan-aware preamble. `mediamesh-range` uses 125 kHz instead.
These presets preserve the primary profile and crossing policy; new
configurations default to radio2 off and `radio2.cross off`.
## Schedule radio changes and set clocks
Use the normal schedule syntax, with UTC epoch seconds or `+N` whole minutes.
+57 -6
View File
@@ -8,6 +8,9 @@
settings. It is useful for listening to two networks, or keeping your usual
channel available during a LoRa firmware update.
The second profile and `radio2.cross` both default to `off`. Saved settings
survive firmware updates; selecting a profile does not change the crossing policy.
There are **at most two active profiles**. `tempradio` temporarily replaces
`radio`; `tempradio2` temporarily replaces `radio2`. These are time-shared receive
windows, not simultaneous receivers. Receiving or transmitting a packet on one
@@ -33,6 +36,40 @@ get radio2.status
The tuple is **frequency in MHz, bandwidth in kHz, spreading factor, coding-rate
denominator, mode, optional preamble in symbols**. `5` means coding rate 4/5.
### MediaMesh presets
Enable the saved MediaMesh profile with one command:
```text
set radio2 mediamesh
```
This sets **911.150 MHz, 250 kHz, SF7, CR 4/5, `rxtx`, automatic preamble**.
For the same profile at **125 kHz**, use:
```text
set radio2 mediamesh-range
```
The RF settings match the presets documented by
[MeshCore Open TAK](https://github.com/jade-on-mesh/meshcore-open-tak/blob/main/documentation/media-mesh.md)
and [Wadamesh TAK](https://github.com/jade-on-mesh/wadamesh-tak/blob/main/MEDIAMESH.md).
Nodes exchanging media need matching radio settings.
Both leave the primary profile and `radio2.cross` unchanged. The center is
625 kHz above a 910.525 MHz primary. Automatic preamble remains scan-aware;
neither preset fixes it at 32 symbols. See [preamble](#preamble) for the
switching measurement and recommendations.
On Companions, the default `tx.media auto` sends recognized binary photo/voice
traffic only on an active `rxtx` MeshCore second profile, including `tempradio2`.
Apps do not need to set `tx.media`. Ordinary traffic keeps its existing route.
If the second profile cannot transmit MeshCore, media uses the normal
channel/global route; `set tx.media radio2` instead requires radio2 with no
primary fallback. See [application TX routing](#companion-media-and-ssh-tx-routing).
### Channel spacing
When setting a saved, temporary or scheduled second profile, the response warns
if its occupied bandwidth overlaps the primary channel or comes within **6 kHz
of either edge**. This check uses both channel bandwidths, regardless of SF or
@@ -67,6 +104,7 @@ For transmission on both permanent profiles:
```text
set radio2 910.5,500,8,5,rxtx
set radio2.cross on
```
The same encoded message is queued separately for each allowed transmit profile.
@@ -283,17 +321,27 @@ set tx.ssh radio
Both accept `auto|radio|radio2|both|off` and default to `auto`. An explicit
application route overrides the channel's TX choice for that datagram only;
`auto` preserves the channel/global behavior. `radio2` never falls back to
`radio`, and must be an enabled `rxtx` **MeshCore** profile. `both` uses the
`tx.media auto` chooses only radio2 whenever its active profile permits
`rxtx` **MeshCore** transmission, including an active `tempradio2`. This
overrides the channel/global route for recognized media even when an app
has never configured `tx.media`. Otherwise, media uses the normal channel/global
route. `tx.ssh auto` always preserves that normal route.
The explicit `radio2` choice never falls back to `radio`, and requires an enabled
`rxtx` **MeshCore** profile. `both` uses the
profiles available for MeshCore transmission. `off` blocks transmission, not
reception. Getters show the saved choice and current `active=` mask; `auto`
shows `active=channel/global`, because the effective route depends on the
shows `active=radio2` while media automatically selects radio2, and
`active=channel/global` otherwise, because the effective route then depends on the
destination channel (or global rules for raw data). Settings
survive reboot; a failed save returns an error and keeps the previous choice.
Already queued packets retain their original route.
`tx.media` recognizes Open TAK photo/voice channel data type `0x5643`, raw
clip packets `0x56`/`0x57`, and well-formed binary fetch requests `0x72`.
This covers `CMD_SEND_CHANNEL_DATA` and `CMD_SEND_RAW_DATA`, plus visible
raw media in `CMD_SEND_RAW_PACKET`. Fully encoded encrypted group packets
submitted through `CMD_SEND_RAW_PACKET` retain their existing route.
Text announcements (`VE3:`, `VM1:`, `VI1:`), text fetch requests (`VR3:`),
ordinary chat, contact ACKs, adverts, OTA, and unrelated raw data keep their
normal routing. The format is defined in
@@ -313,9 +361,11 @@ adopt the contract. Other channel data types do not acquire an SSH route
merely because their contents start with `SSH`. Raw packets still require
a direct path, including zero-hop; this does not enable raw flooding.
For a media-only side channel, configure `radio2`/`tempradio2` in `rxtx`,
set `radio2.cross off`, then set `tx.media radio2`. Ordinary text can stay on
the primary profile without routing every raw application onto radio2.
For a media-only side channel, use `set radio2 mediamesh` with the default
`radio2.cross off` and `tx.media auto`. Ordinary text can stay on the primary
profile without routing every raw application onto radio2. Choose
`set tx.media radio2` if media must fail rather than use the primary when
radio2 is off, RX-only, or owned by another protocol.
## ESP32 Companion Ethernet over LoRa (KISS)
@@ -821,6 +871,7 @@ setting is preserved across firmware updates and reboots.
These are the global rules used by Companion targets set to `auto`; an explicit
contact/channel choice above overrides crossing for its own outbound packets.
Recognized media uses the separate `tx.media auto` selection described above.
Ordinary crossing respects `rx`. The infrastructure-only `tx.reply ... force`
override above permits locally generated replies on that profile.
With crossing allowed, a packet received there may still be forwarded on the
+19 -4
View File
@@ -3690,9 +3690,11 @@ bool MyMesh::handleTrafficRoutingCommand(const char* command, char* reply, size_
return true;
}
}
const uint8_t mask = mesh::companion::trafficTransmitMask(policy, _radio->profiles());
const uint8_t effective = mesh::companion::trafficPolicy(parsed.kind,
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
const uint8_t mask = mesh::companion::trafficTransmitMask(effective, _radio->profiles());
snprintf(reply, reply_size, "%s tx.%s %s; active=%s%s", parsed.set ? "OK -" : ">", kind,
mesh::radioTxPolicyName(policy), policy == mesh::RADIO_TX_AUTO ? "channel/global"
mesh::radioTxPolicyName(policy), effective == mesh::RADIO_TX_AUTO ? "channel/global"
: mask ? mesh::radioTxPolicyName(mask) : "off",
(policy == mesh::RADIO_TX_SECONDARY || policy == mesh::RADIO_TX_BOTH) && !(mask & 2)
? " (radio2 unavailable for TX)" : "");
@@ -3703,7 +3705,7 @@ bool MyMesh::sendClientGroupData(mesh::GroupChannel& channel, uint8_t* path, uin
uint16_t data_type, const uint8_t* data, int data_len) {
if (data_len < 0 || data_len > MAX_GROUP_DATA_LENGTH || (data_len && !data)) return false;
const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::groupTrafficKind(data_type),
_prefs.tx_media, _prefs.tx_ssh);
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
if (policy == mesh::RADIO_TX_AUTO) return sendGroupData(channel, path, path_len, data_type, data, data_len);
// Override only this datagram, not the saved channel, PSK, alias or text route.
mesh::GroupChannel routed = channel;
@@ -3716,7 +3718,7 @@ bool MyMesh::sendClientRawData(const uint8_t* data, size_t data_len, uint8_t* pa
auto pkt = createRawData(data, data_len);
if (!pkt) return false;
const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::rawTrafficKind(data, data_len),
_prefs.tx_media, _prefs.tx_ssh);
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
if (policy != mesh::RADIO_TX_AUTO) pkt->tx_radio = policy;
// sendDirect owns/releases the packet on either success or failure.
return sendDirect(pkt, path, path_len);
@@ -7601,6 +7603,19 @@ void MyMesh::handleCmdFrame(size_t len) {
if (pkt) {
uint8_t priority = cmd_frame[1];
if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) {
if (pkt->getPayloadType() == PAYLOAD_TYPE_RAW_CUSTOM) {
const uint8_t policy = mesh::companion::trafficPolicy(
mesh::companion::rawTrafficKind(pkt->payload, pkt->payload_len),
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
if (policy != mesh::RADIO_TX_AUTO) {
if (!mesh::companion::trafficTransmitMask(policy, _radio->profiles())) {
releasePacket(pkt);
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
pkt->tx_radio = policy;
}
}
sendPacket(pkt, priority, 0);
writeOKFrame();
} else {
+10 -2
View File
@@ -42,9 +42,17 @@ inline TrafficKind rawTrafficKind(const uint8_t* data, size_t length) {
return TrafficKind::Other;
}
inline uint8_t trafficPolicy(TrafficKind kind, uint8_t media, uint8_t ssh) {
return kind == TrafficKind::Media ? media
inline uint8_t trafficPolicy(TrafficKind kind, uint8_t media, uint8_t ssh,
const RadioProfiles* profiles = nullptr) {
const uint8_t policy = kind == TrafficKind::Media ? media
: kind == TrafficKind::Ssh ? ssh : static_cast<uint8_t>(RADIO_TX_AUTO);
// Keep media off the main mesh whenever the second MeshCore profile can TX.
// An explicit radio2 choice also pins queued packets if radio2 is disabled
// before they reach Dispatcher, rather than falling back onto the main mesh.
return kind == TrafficKind::Media && policy == RADIO_TX_AUTO && profiles
&& profiles->canTransmit(1)
&& profiles->params(1).protocol == RadioProfileProtocol::MeshCore
? static_cast<uint8_t>(RADIO_TX_SECONDARY) : policy;
}
inline uint8_t trafficTransmitMask(uint8_t policy, const RadioProfiles* profiles) {
+3 -1
View File
@@ -1,4 +1,5 @@
#include "FleetCommand.h"
#include "RadioProfilePresets.h"
#include <SHA256.h>
#include <math.h>
#include <string.h>
@@ -598,7 +599,8 @@ bool FleetCommand::commandAllowed(const char* command) {
: (get || del) && scheduleSelector(arguments, scheduled_secondary);
}
if (kind == CommandKind::Radio) {
return get ? !*arguments : set && radioTuple(arguments, key_length == 10);
return get ? !*arguments : set && ((key_length == 6
&& cli::radio2PresetTuple(arguments)) || radioTuple(arguments, key_length == 10));
}
if (kind == CommandKind::RadioStatus) return get && !*arguments;
if (kind == CommandKind::Cross) {
+5
View File
@@ -4,6 +4,7 @@
#include "CarrierWaveCLI.h"
#endif
#include "RadioProfileCommandUtils.h"
#include "RadioProfilePresets.h"
#include "FilePresence.h"
#include "PersistentStoreFormat.h"
#include "radiolib/RXPowerSaving.h"
@@ -1005,6 +1006,10 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity,
return true;
}
if (verb != Set) { cli::formatError(reply, capacity, "invalid radio2 command"); return true; }
if (base) {
const char* preset = cli::radio2PresetTuple(args);
if (preset) args = preset;
}
char local[140];
if (strlen(args) >= sizeof(local)) { cli::formatError(reply, capacity, "params too long"); return true; }
strcpy(local, args);
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <string.h>
namespace mesh { namespace cli {
// Use the ordinary tuple path for validation, persistence and remote replies.
inline const char* radio2PresetTuple(const char* name) {
if (!strcmp(name, "mediamesh")) return "911.150,250,7,5,rxtx,auto";
if (!strcmp(name, "mediamesh-range")) return "911.150,125,7,5,rxtx,auto";
return nullptr;
}
} }
+47
View File
@@ -502,6 +502,52 @@ static void spectrumOverlap() {
assert(roundtrips == 1000);
}
static void mediaMeshPresets() {
for (bool infrastructure : {false, true}) {
for (bool range : {false, true}) {
Fixture f(infrastructure);
const auto primary = f.radio.p.primary;
assert(!f.radio.p.enabled() && f.radio.p.cross == mesh::RadioCrossMode::Off);
const char* command = range ? "set radio2 mediamesh-range" : "set radio2 mediamesh";
f.cmd(command);
assert(!f.radio.p.enabled()); // Same delayed apply as a saved tuple.
f.advance(2000);
const auto config = f.radio.p.secondary;
assert(config.mode == mesh::RadioProfileMode::RxTx);
assert(fabsf(config.params.freq - 911.150f) < 0.0001f
&& config.params.bw == (range ? 125 : 250));
assert(config.params.sf == 7 && config.params.cr == 5 && !config.params.preamble);
assert(config.params.protocol == mesh::RadioProfileProtocol::MeshCore);
assert(f.radio.p.primary == primary && f.radio.p.cross == mesh::RadioCrossMode::Off);
assert(!f.fs.files["/radio_profiles"][14] && !f.fs.files["/radio_profiles"][15]);
Radio reboot; mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &reboot, &f.clock, infrastructure);
assert(reboot.p.secondary.params == config.params);
assert(reboot.p.secondary.mode == mesh::RadioProfileMode::RxTx);
assert(reboot.p.cross == mesh::RadioCrossMode::Off);
// A preset replaces an old preamble override but retains operator crossover.
f.cmd("set radio2.cross on");
f.cmd("set radio2 912.5,500,8,5,rxtx,80"); f.advance(2000);
f.cmd(command); f.advance(2000);
assert(!f.radio.p.secondary.params.preamble && f.radio.p.cross == mesh::RadioCrossMode::On);
const auto committed = f.fs.files["/radio_profiles"];
f.fs.fail_write = true;
f.cmd(range ? "set radio2 mediamesh" : "set radio2 mediamesh-range", false);
assert(f.radio.p.secondary.params == config.params
&& f.fs.files["/radio_profiles"] == committed);
}
}
Fixture invalid;
for (const char* command : {"set radio2 mediamesh-extra", "set radio2 mediamesh,32",
"set tempradio2 mediamesh", "set radioat2 mediamesh"}) {
invalid.cmd(command, false);
assert(!invalid.radio.p.enabled() && invalid.fs.files.empty());
}
invalid.radio.minimum_frequency = 912;
invalid.cmd("set radio2 mediamesh", false); // The hardware validator still applies.
assert(!invalid.radio.p.enabled() && invalid.fs.files.empty());
}
int main(int argc, char** argv) {
static_assert(uint8_t(mesh::RadioCrossMode::Auto)==0 && uint8_t(mesh::RadioCrossMode::On)==1
&& uint8_t(mesh::RadioCrossMode::Off)==2, "saved crossover encoding must remain compatible");
@@ -547,6 +593,7 @@ int main(int argc, char** argv) {
else if (!strcmp(argv[1], "storage_isolation")) storageBackoffDoesNotDelayTemporaryWindows();
else if (!strcmp(argv[1], "relative_schedule")) relativeSchedules();
else if (!strcmp(argv[1], "spectrum_overlap")) spectrumOverlap();
else if (!strcmp(argv[1], "mediamesh")) mediaMeshPresets();
else return 2;
return 0;
}
+3 -1
View File
@@ -22,6 +22,7 @@ class Inspector : public mesh::Dispatcher {
static void secondaryReplies() {
unsigned checks = 0;
for (const char* command : {"set tempradio2 911.5,500,8,5,rxtx,1",
"set radio2 mediamesh", "set radio2 mediamesh-range",
"set radio2 912.5,500,8,5,rxtx", "set radio2 off",
"set tempradio2 off", "del tempradioat2 all"}) {
for (bool success : {false, true}) {
@@ -57,7 +58,8 @@ static void secondaryReplies() {
if (!success) {
assert(before == f.radio.p.generation[1]);
assert(saved == f.fs.files["/radio_profiles"]);
} else if (strstr(command, "911.5") || strstr(command, "912.5") || !strcmp(command,"set radio2 off")) {
} else if (strstr(command, "911.5") || strstr(command, "912.5")
|| strstr(command, "mediamesh") || !strcmp(command,"set radio2 off")) {
assert(before != f.radio.p.generation[1]);
assert(!inspector.isPacketRadioCurrent(&reply));
}
+170 -3
View File
@@ -116,6 +116,27 @@ int main() {
assert(trafficTransmitMask(RADIO_TX_AUTO,&profiles)==0);
for (uint8_t policy=0;policy<=4;++policy)
assert(trafficTransmitMask(policy,nullptr)==((policy==RADIO_TX_AUTO || policy==RADIO_TX_PRIMARY || policy==RADIO_TX_BOTH) ? 1 : 0));
// AUTO media selects only radio2 independently of crossover, temporary
// isolation, and the channel route. Other traffic retains normal AUTO.
for (auto cross : {RadioCrossMode::Off,RadioCrossMode::Auto,RadioCrossMode::On})
for (bool primary_temporary : {false,true}) for (bool secondary_temporary : {false,true})
for (auto mode : {RadioProfileMode::Off,RadioProfileMode::Rx,RadioProfileMode::RxTx})
for (auto protocol : {RadioProfileProtocol::MeshCore,RadioProfileProtocol::Meshtext,RadioProfileProtocol::Ethernet}) {
profiles=RadioProfiles();
profiles.cross=cross; profiles.primary_temporary=primary_temporary;
profiles.secondary_temporary=secondary_temporary;
profiles.secondary.mode=mode; profiles.secondary.params.protocol=protocol;
const bool media_secondary=mode==RadioProfileMode::RxTx && protocol==RadioProfileProtocol::MeshCore;
for (uint8_t media=0;media<=4;++media) for (uint8_t ssh_policy=0;ssh_policy<=4;++ssh_policy) {
const auto effective=trafficPolicy(TrafficKind::Media,media,ssh_policy,&profiles);
assert(effective==(media==RADIO_TX_AUTO && media_secondary ? uint8_t(RADIO_TX_SECONDARY) : media));
if (media==RADIO_TX_AUTO && media_secondary) assert(trafficTransmitMask(effective,&profiles)==2);
assert(trafficPolicy(TrafficKind::Ssh,media,ssh_policy,&profiles)==ssh_policy);
assert(trafficPolicy(TrafficKind::Other,media,ssh_policy,&profiles)==RADIO_TX_AUTO);
assert(trafficPolicy(TrafficKind::Media,media,ssh_policy,nullptr)==media);
}
}
}
'''
@@ -185,7 +206,7 @@ int main() {
for (const char* name : {"media","ssh"}) {
char text[64];
snprintf(text,sizeof(text),"get tx.%s",name); cmd(text);
assert(strstr(reply,"auto") && strstr(reply,"active=channel/global"));
assert(strstr(reply,"auto") && strstr(reply,!strcmp(name,"media") ? "active=radio2" : "active=channel/global"));
for (uint8_t policy=0;policy<=4;++policy) {
snprintf(text,sizeof(text),"set tx.%s %s",name,radioTxPolicyName(policy)); cmd(text);
assert(!strncmp(reply,"OK",2));
@@ -228,13 +249,13 @@ int main() {
for (uint8_t policy=0;policy<=4;++policy) {
node._prefs.tx_media=policy; node._prefs.tx_ssh=RADIO_TX_BOTH;
bool result=node.sendClientGroupData(channel,path,2,0x5643,media,sizeof(media));
const uint8_t expected=policy==RADIO_TX_AUTO ? original.tx_radio : policy;
const uint8_t expected=policy==RADIO_TX_AUTO ? uint8_t(RADIO_TX_SECONDARY) : policy;
assert(result==(expected!=RADIO_TX_OFF));
if (result) { assert(node.last_policy==expected && node.last_type==0x5643 && node.last_path_len==2); }
assert(!memcmp(&channel,&original,sizeof(channel)));
result=node.sendClientRawData(media,sizeof(media),path,2);
assert(result==(policy!=RADIO_TX_OFF));
if (result) assert(node.last_policy==policy && node.last_data==std::vector<uint8_t>(media,media+sizeof(media)));
if (result) assert(node.last_policy==expected && node.last_data==std::vector<uint8_t>(media,media+sizeof(media)));
}
node._prefs.tx_media=RADIO_TX_OFF;
for (uint8_t policy=0;policy<=4;++policy) {
@@ -253,6 +274,40 @@ int main() {
assert(node.last_policy==original.tx_radio && node.last_type==0x5654);
assert(node.sendClientRawData(unknown,sizeof(unknown),path,2));
assert(node.last_policy==RADIO_TX_AUTO);
// Existing apps need no new command or saved preference: AUTO media wins
// over channel/global routing while radio2 supports MeshCore transmission.
node._prefs.tx_media=RADIO_TX_AUTO;
for (auto cross : {RadioCrossMode::Off,RadioCrossMode::Auto,RadioCrossMode::On})
for (uint8_t channel_policy=0;channel_policy<=4;++channel_policy) {
node.radio.config.cross=cross;
channel.tx_radio=channel_policy;
const auto before=channel;
assert(node.sendClientGroupData(channel,path,2,0x5643,media,sizeof(media)));
assert(node.last_policy==RADIO_TX_SECONDARY);
assert(!memcmp(&channel,&before,sizeof(channel)));
assert(node.sendClientRawData(media,sizeof(media),path,2));
assert(node.last_policy==RADIO_TX_SECONDARY);
}
assert(node._prefs.tx_media==RADIO_TX_AUTO);
channel=original;
// Unavailable/receive-only/foreign profiles preserve the existing AUTO
// fallback. Group media follows its channel; raw media follows global AUTO.
for (auto mode : {RadioProfileMode::Off,RadioProfileMode::Rx,RadioProfileMode::RxTx})
for (auto protocol : {RadioProfileProtocol::MeshCore,RadioProfileProtocol::Meshtext,RadioProfileProtocol::Ethernet}) {
if (mode==RadioProfileMode::RxTx && protocol==RadioProfileProtocol::MeshCore) continue;
node.radio.config.secondary.mode=mode;
node.radio.config.secondary.params.protocol=protocol;
channel.tx_radio=RADIO_TX_PRIMARY;
cmd("get tx.media"); assert(strstr(reply,"auto") && strstr(reply,"active=channel/global"));
assert(node.sendClientGroupData(channel,path,2,0x5643,media,sizeof(media)));
assert(node.last_policy==RADIO_TX_PRIMARY);
assert(node.sendClientRawData(media,sizeof(media),path,2));
assert(node.last_policy==RADIO_TX_AUTO);
}
node.radio.config.secondary.mode=RadioProfileMode::RxTx;
node.radio.config.secondary.params.protocol=RadioProfileProtocol::MeshCore;
channel=original;
node._prefs.tx_media=RADIO_TX_SECONDARY;
node.radio.config.secondary.mode=RadioProfileMode::Off;
assert(!node.sendClientRawData(media,sizeof(media),path,2));
@@ -355,6 +410,100 @@ int main() {
'''
RAW_PACKET_HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <vector>
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-parameter"
#include <Packet.h>
#pragma GCC diagnostic pop
#include <helpers/CompanionTrafficRouting.h>
@CONSTANTS@
namespace mesh {
@PACKET_CONSTRUCTOR@
}
struct TestRadio {
mesh::RadioProfiles config;
mesh::RadioProfiles* profiles() { return &config; }
uint8_t receiveProfile() const { return 0; }
uint32_t receiveProfileGeneration() const { return 1; }
};
class MyMesh {
public:
TestRadio radio;
TestRadio* _radio=&radio;
struct { uint8_t tx_media=mesh::RADIO_TX_AUTO,tx_ssh=mesh::RADIO_TX_AUTO; } _prefs;
mesh::Packet packet;
unsigned ok=0,errors=0,sends=0,releases=0;
uint8_t error=0,last_policy=255;
bool allocate=true;
mesh::Packet* obtainNewPacket() { return allocate ? &packet : nullptr; }
void releasePacket(mesh::Packet* value) { assert(value==&packet); ++releases; }
void writeOKFrame() { ++ok; }
void writeErrFrame(uint8_t value) { ++errors; error=value; }
bool tryParsePacket(mesh::Packet*,const uint8_t*,int);
bool sendPacket(mesh::Packet* value,uint8_t priority,uint32_t delay) {
assert(value==&packet && priority==17 && !delay);
++sends; last_policy=value->tx_radio; return true;
}
void onFrame(uint8_t* cmd_frame,size_t len) {
@RAW_CLAUSE@
else writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
}
};
using namespace mesh;
@PARSE_PACKET@
int main() {
using namespace mesh;
using namespace mesh::companion;
const std::vector<uint8_t> payloads[] = {
{0x56,0,1,2,3,0,99}, {0x57,0,1,2,3,0,99,42},
{0x72,0,0,0,0,0,0,0,0,0,0,0,0}, {'S','S','H',1,42},
{0x56}, {0x12,0x34,0x56}, {}
};
for (uint8_t type : {PAYLOAD_TYPE_RAW_CUSTOM,PAYLOAD_TYPE_GRP_DATA,PAYLOAD_TYPE_ACK})
for (auto mode : {RadioProfileMode::Off,RadioProfileMode::Rx,RadioProfileMode::RxTx})
for (auto protocol : {RadioProfileProtocol::MeshCore,RadioProfileProtocol::Meshtext,RadioProfileProtocol::Ethernet})
for (auto cross : {RadioCrossMode::Off,RadioCrossMode::Auto,RadioCrossMode::On})
for (uint8_t policy=RADIO_TX_AUTO;policy<=RADIO_TX_OFF;++policy)
for (const auto& data : payloads) {
MyMesh node;
node.radio.config.secondary.mode=mode;
node.radio.config.secondary.params.protocol=protocol;
node.radio.config.cross=cross;
node._prefs.tx_media=node._prefs.tx_ssh=policy;
std::vector<uint8_t> frame={CMD_SEND_RAW_PACKET,17,
static_cast<uint8_t>((type << PH_TYPE_SHIFT)|ROUTE_TYPE_DIRECT),0};
frame.insert(frame.end(),data.begin(),data.end());
const auto original=frame;
node.onFrame(frame.data(),frame.size());
const auto kind=type==PAYLOAD_TYPE_RAW_CUSTOM ? rawTrafficKind(data.data(),data.size()) : TrafficKind::Other;
const auto effective=trafficPolicy(kind,policy,policy,&node.radio.config);
const bool blocked=effective!=RADIO_TX_AUTO && !trafficTransmitMask(effective,&node.radio.config);
assert(frame==original);
if (blocked) {
assert(node.errors==1 && node.error==ERR_CODE_BAD_STATE && !node.ok && !node.sends && node.releases==1);
} else {
assert(node.ok==1 && !node.errors && node.sends==1 && !node.releases);
assert(node.last_policy==effective);
assert(node.packet.payload_len==data.size());
if (!data.empty()) assert(!memcmp(node.packet.payload,data.data(),data.size()));
}
}
// Parsing and allocation errors still release exactly once or not at all.
uint8_t invalid[]={CMD_SEND_RAW_PACKET,17,0xff,0};
MyMesh bad; bad.onFrame(invalid,sizeof(invalid));
assert(bad.error==ERR_CODE_ILLEGAL_ARG && bad.releases==1 && !bad.sends && !bad.ok);
MyMesh full; full.allocate=false; full.onFrame(invalid,sizeof(invalid));
assert(full.error==ERR_CODE_TABLE_FULL && !full.releases && !full.sends && !full.ok);
}
'''
class CompanionTrafficRoutingTest(unittest.TestCase):
def compile_and_run(self, source):
with tempfile.TemporaryDirectory() as directory:
@@ -414,6 +563,24 @@ class CompanionTrafficRoutingTest(unittest.TestCase):
.replace('@PACKET_METHODS@', packet_methods)
.replace('@RAW_CLAUSE@', clause))
def test_fully_encoded_raw_media_packets_use_routing(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
packet = (ROOT / 'src/Packet.cpp').read_text()
dispatcher = (ROOT / 'src/Dispatcher.cpp').read_text()
clause = method(source, '} else if (cmd_frame[0] == CMD_SEND_RAW_PACKET')
clause = clause.removeprefix('} else ')
names = ('CMD_SEND_RAW_PACKET', 'ERR_CODE_UNSUPPORTED_CMD',
'ERR_CODE_ILLEGAL_ARG', 'ERR_CODE_BAD_STATE', 'ERR_CODE_TABLE_FULL')
constants = '\n'.join(re.search(
r'^#define\s+' + name + r'\s+\d+.*$', source, re.MULTILINE).group(0)
for name in names)
parser = method(dispatcher, 'bool Dispatcher::tryParsePacket(')
parser = parser.replace('Dispatcher::tryParsePacket(', 'MyMesh::tryParsePacket(')
self.compile_and_run(RAW_PACKET_HARNESS.replace('@CONSTANTS@', constants)
.replace('@PACKET_CONSTRUCTOR@', method(packet, 'Packet::Packet('))
.replace('@PARSE_PACKET@', parser)
.replace('@RAW_CLAUSE@', clause))
if __name__ == '__main__':
unittest.main()
@@ -109,6 +109,7 @@ TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) {
"get radio2", "get tempradio2", "get radio2.status", "get radio2.timing",
"get radio2.cross", "set radio2.cross auto", "set radio2.cross on",
"set radio2.cross off", "set radio2 off", "set tempradio2 off",
"set radio2 mediamesh", "set radio2 mediamesh-range",
"set radio2 910.5,500,5,5,rxtx",
"set tempradio2 910.5,500,5,5,rx,10,auto",
"set radio2 910.5,500,5,5,rx,32",
@@ -256,6 +257,8 @@ TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) {
"get flood.rule.01", "get flood.rule.256", "get flood.rule.9999999999",
"xy|set radio2 off", "GET radio2", "get radio2", " get radio2",
"get radio2 ", "get radio2 off", "del radio2", "set radio2",
"set radio2 mediamesh-extra", "set radio2 mediamesh,32",
"set tempradio2 mediamesh", "get radio2 mediamesh",
"set radio2.cross true", "del flood.channel.data", "set flood.filter.2",
"del flood.rule.2 extra", "get flood.rule.1 all", "set flood.max",
"set radio2 910.5,500,5,5,rxtx,auto,99",
+11
View File
@@ -448,6 +448,17 @@ class FleetCommandCryptoTests(unittest.TestCase):
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_mediamesh_presets_have_cross_language_real_signatures(self):
for preset in ["mediamesh", "mediamesh-range"]:
command = "set radio2 " + preset
expected = envelope(command)
self.assertEqual(self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command),
expected.hex())
self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\n{command}")
for command in ["set radio2 mediamesh-extra", "set radio2 mediamesh,32",
"set tempradio2 mediamesh", "get radio2 mediamesh"]:
self.assertEqual(self.decode(envelope(command)), "reject")
def test_scheduled_radio_commands_have_cross_language_real_signatures(self):
for command in [
"set radioat 910.5,500,5,5,+1,auto",
+1 -1
View File
@@ -27,7 +27,7 @@ class RadioProfileCLITest(unittest.TestCase):
'-o', str(exe)], check=True, capture_output=True, text=True)
for scenario in (None, 'permanent_order', 'temporary_boundary', 'lease_creation',
'save_failure', 'schedule_permutations', 'storage_isolation',
'relative_schedule', 'spectrum_overlap'):
'relative_schedule', 'spectrum_overlap', 'mediamesh'):
with self.subTest(fleet=fleet_enabled, carrier=carrier_enabled,
scenario=scenario or 'commands_persistence_and_timers'):
subprocess.run([str(exe)] + ([scenario] if scenario else []), check=True)