diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 310eb776c..978d74a48 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 ` 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 diff --git a/docs/fleet_management.md b/docs/fleet_management.md index 0436b5e74..9eb889887 100644 --- a/docs/fleet_management.md +++ b/docs/fleet_management.md @@ -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 ` | +| Saved second profile | `get radio2`, `set radio2 ` | | Temporary second profile | `get tempradio2`, `set tempradio2 ` | | 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. diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 4bc272c93..dc4cf990d 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8bd6311f6..803cf8197 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 { diff --git a/src/helpers/CompanionTrafficRouting.h b/src/helpers/CompanionTrafficRouting.h index 88a05d671..2dc0700af 100644 --- a/src/helpers/CompanionTrafficRouting.h +++ b/src/helpers/CompanionTrafficRouting.h @@ -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(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(RADIO_TX_SECONDARY) : policy; } inline uint8_t trafficTransmitMask(uint8_t policy, const RadioProfiles* profiles) { diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index 7f909f2ed..665ca8cf8 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -1,4 +1,5 @@ #include "FleetCommand.h" +#include "RadioProfilePresets.h" #include #include #include @@ -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) { diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index e63a191ac..5fad95b8a 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -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); diff --git a/src/helpers/RadioProfilePresets.h b/src/helpers/RadioProfilePresets.h new file mode 100644 index 000000000..e4f9155fb --- /dev/null +++ b/src/helpers/RadioProfilePresets.h @@ -0,0 +1,13 @@ +#pragma once +#include + +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; +} + +} } diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 7fc4a1c6a..deccdfd75 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -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; } diff --git a/test/fixtures/radio_profiles/reply_mutation_test.cpp b/test/fixtures/radio_profiles/reply_mutation_test.cpp index 83277de65..a518721b1 100644 --- a/test/fixtures/radio_profiles/reply_mutation_test.cpp +++ b/test/fixtures/radio_profiles/reply_mutation_test.cpp @@ -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)); } diff --git a/test/test_companion_traffic_routing.py b/test/test_companion_traffic_routing.py index 75183c6eb..6a38d46cf 100644 --- a/test/test_companion_traffic_routing.py +++ b/test/test_companion_traffic_routing.py @@ -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(media,media+sizeof(media))); + if (result) assert(node.last_policy==expected && node.last_data==std::vector(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 +#include +#include +#include +#include +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#include +#pragma GCC diagnostic pop +#include +@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 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 frame={CMD_SEND_RAW_PACKET,17, + static_cast((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() diff --git a/test/test_fleet_command/test_fleet_command.cpp b/test/test_fleet_command/test_fleet_command.cpp index a0ee2aa0b..d375c1cd2 100644 --- a/test/test_fleet_command/test_fleet_command.cpp +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -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", diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index 32110e69f..e60a36d77 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -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", diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py index 94a73846e..4d3768cbb 100644 --- a/test/test_radio_profile_cli.py +++ b/test/test_radio_profile_cli.py @@ -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)