diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 59d00855c..9ebbf7987 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -588,6 +588,7 @@ jobs: python3 -B test/test_companion_prefs_transactions.py -v python3 -B test/test_companion_primary_radio_persistence.py -v python3 -B test/test_companion_response_bounds.py -v + python3 -B test/test_companion_rx_via.py -v python3 -B test/test_companion_ack_delivery.py -v python3 -B test/test_companion_lost_reply.py -v python3 -B test/test_companion_delayed_reply_delivery.py -v diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 658226a61..bdd8104e7 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -715,7 +715,8 @@ Inbound group datagrams (radio-level `PAYLOAD_TYPE_GRP_DATA`, 0x06) are forwarde ``` Byte 0: 0x1B (packet type) Byte 1: SNR (signed int8, scaled x4 - divide by 4.0 to recover dB) -Bytes 2-3: Reserved (clients MUST ignore) +Byte 2: Receive profile while radio2 is on: 0xA0 primary, 0xA1 radio2; otherwise 0 (reserved) +Byte 3: Reserved (clients MUST ignore) Byte 4: Channel index (0 through max_channels - 1) Byte 5: Path Length (actual path length when flooded, otherwise 0xFF for direct) Bytes 6-7: Data Type (uint16 little-endian) @@ -1036,7 +1037,7 @@ Byte values are authoritative; names are aliases. When reading firmware source, | `0x81` | `PUSH_CODE_PATH_UPDATED` | A contact path changed. | | `0x82` | `PUSH_CODE_SEND_CONFIRMED` | A sent message was acknowledged. | | `0x83` | `PUSH_CODE_MSG_WAITING` | One or more offline frames are waiting. | -| `0x84` | `PUSH_CODE_RAW_DATA` | Raw application data received. | +| `0x84` | `PUSH_CODE_RAW_DATA` | Raw application data received: `[0x84][SNR x4][RSSI][profile][payload]`. `profile` is 0xA0 (primary) or 0xA1 (radio2) while radio2 is on, otherwise 0xFF (reserved). | | `0x85` / `0x86` | `PUSH_CODE_LOGIN_SUCCESS` / `PUSH_CODE_LOGIN_FAIL` | Server login result. | | `0x87` | `PUSH_CODE_STATUS_RESPONSE` | Server status response. | | `0x88` | `PUSH_CODE_LOG_RX_DATA` | Radio receive log data. | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e3fd93c8d..dde5c33f1 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2358,7 +2358,7 @@ void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet * int i = 0; out_frame[i++] = RESP_CODE_CHANNEL_DATA_RECV; out_frame[i++] = (int8_t)(pkt->getSNR() * 4); - out_frame[i++] = 0; // reserved1 + out_frame[i++] = rxViaTag(pkt, 0); // reserved1: the profile it arrived on, while radio2 is on out_frame[i++] = 0; // reserved2 const int selected_channel = findChannelIdxForPacket(channel, pkt); @@ -2707,6 +2707,21 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { } } +// Which profile a received packet arrived on, for the app, in a frame byte that +// stock firmware leaves reserved: 0xA0 the primary, 0xA1 radio2. MeshCore Open +// TAK shows it under a MediaMesh photo or voice clip ("Received via MediaMesh", +// "via main mesh", or both); Wadamesh standalones send the same tags. While +// radio2 is off everything is the primary, so the stock value (`none`) stays, +// as it does for a packet that came in over the UDP bridge. +uint8_t MyMesh::rxViaTag(const mesh::Packet *pkt, uint8_t none) const { + const mesh::RadioProfiles *profiles = _radio ? _radio->profiles() : nullptr; + if (!pkt || !profiles || !profiles->enabled()) return none; +#if defined(WITH_UDP_BRIDGE) + if (pkt->udp_bridge_ingress) return none; +#endif + return pkt->radio_origin == 1 ? 0xA1 : 0xA0; +} + void MyMesh::onRawDataRecv(mesh::Packet *packet) { if (packet->payload_len + 4 > MAX_FRAME_SIZE) { MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len); @@ -2716,7 +2731,7 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) { out_frame[i++] = PUSH_CODE_RAW_DATA; out_frame[i++] = (int8_t)(packet->getSNR() * 4); out_frame[i++] = (int8_t)packet->getRSSI(); - out_frame[i++] = 0xFF; // reserved (possibly path_len in future) + out_frame[i++] = rxViaTag(packet, 0xFF); // reserved: the profile it arrived on, while radio2 is on memcpy(&out_frame[i], packet->payload, packet->payload_len); i += packet->payload_len; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index d5b3f5463..4a4859ada 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -555,6 +555,9 @@ protected: void onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) override; void onControlDataRecv(mesh::Packet *packet) override; void onRawDataRecv(mesh::Packet *packet) override; + // The profile a received packet arrived on, for a frame's reserved byte + // (see rxViaTag in MyMesh.cpp). + uint8_t rxViaTag(const mesh::Packet *pkt, uint8_t none) const; void onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override; diff --git a/test/test_companion_response_bounds.py b/test/test_companion_response_bounds.py index a03b12b37..0ae63fb7e 100644 --- a/test/test_companion_response_bounds.py +++ b/test/test_companion_response_bounds.py @@ -80,6 +80,7 @@ struct MyMesh { void onContactResponse(const ContactInfo&, const uint8_t*, uint8_t); void onControlDataRecv(mesh::Packet*); void onRawDataRecv(mesh::Packet*); + uint8_t rxViaTag(const mesh::Packet*, uint8_t none) const { return none; } void onTraceRecv(mesh::Packet*,uint32_t,uint32_t,uint8_t,const uint8_t*,const uint8_t*,uint8_t); void clearBinaryTraceReply(); void checkGuards() const { diff --git a/test/test_companion_rx_via.py b/test/test_companion_rx_via.py new file mode 100644 index 000000000..2e5343423 --- /dev/null +++ b/test/test_companion_rx_via.py @@ -0,0 +1,82 @@ +"""Companion receive-profile byte: raw and channel data frames say which +profile a packet arrived on (0xA0 primary, 0xA1 radio2) while radio2 is on, +and keep the stock reserved value otherwise or for UDP-bridge ingress.""" +from pathlib import Path +import os +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +namespace mesh { +struct Packet { uint8_t radio_origin = 0; bool udp_bridge_ingress = false; }; +struct Radio { + RadioProfiles* p = nullptr; + const RadioProfiles* profiles() const { return p; } +}; +} +struct MyMesh { + mesh::Radio* _radio = nullptr; + uint8_t rxViaTag(const mesh::Packet* pkt, uint8_t none) const; +}; +@METHOD@ +int main() { + mesh::RadioProfiles profiles; + mesh::Radio radio; + MyMesh mesh_; + mesh::Packet primary, second, bridged; + second.radio_origin = 1; + bridged.radio_origin = 1; bridged.udp_bridge_ingress = true; + + // No radio, no profiles, radio2 off: the stock reserved values. + assert(mesh_.rxViaTag(&primary, 0xFF) == 0xFF); + mesh_._radio = &radio; + assert(mesh_.rxViaTag(&second, 0) == 0); + radio.p = &profiles; + profiles.secondary.mode = mesh::RadioProfileMode::Off; + assert(mesh_.rxViaTag(&second, 0xFF) == 0xFF); + assert(mesh_.rxViaTag(nullptr, 0) == 0); + + // radio2 listening or sending: tagged by the profile it arrived on. + for (auto mode : {mesh::RadioProfileMode::Rx, mesh::RadioProfileMode::RxTx}) { + profiles.secondary.mode = mode; + assert(mesh_.rxViaTag(&primary, 0xFF) == 0xA0); + assert(mesh_.rxViaTag(&second, 0) == 0xA1); + assert(mesh_.rxViaTag(&bridged, 0) == 0); // came over the UDP bridge, not a radio + } + return 0; +} +''' + + +class CompanionRxViaTest(unittest.TestCase): + def test_rx_via_tag(self): + source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text(encoding='utf-8') + method = extract_braced(source, 'uint8_t MyMesh::rxViaTag(') + self.assertIn('rxViaTag(pkt, 0)', source) # channel data, reserved1 + self.assertIn('rxViaTag(packet, 0xFF)', source) # raw data, reserved + with tempfile.TemporaryDirectory(prefix='companion-rx-via-') as directory: + work = Path(directory) + cpp = work / 'test.cpp' + cpp.write_text(HARNESS.replace('@METHOD@', method), encoding='utf-8') + flags = ['-fsanitize=address,undefined', '-fno-sanitize-recover=all'] if sys.platform.startswith('linux') else [] + binary = work / 'test.exe' + compiled = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-DWITH_UDP_BRIDGE', *flags, f'-I{ROOT / "src"}', str(cpp), '-o', str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_esp32_uart_quiet_session.py b/test/test_esp32_uart_quiet_session.py index 28898f161..cd269559e 100644 --- a/test/test_esp32_uart_quiet_session.py +++ b/test/test_esp32_uart_quiet_session.py @@ -92,6 +92,7 @@ struct MyMesh { if(pending_route==route)pending_route=nullptr; } int findChannelIdxForPacket(const mesh::GroupChannel&,mesh::Packet*) const { return 0; } + uint8_t rxViaTag(const mesh::Packet*,uint8_t none) const { return none; } int getOfflineQueueCapacity() const; Frame& offlineQueueFrameAt(int); bool addToOfflineQueue(const uint8_t*,int);