Merge remote-tracking branch 'origin/keymindCascade' into keymindCascade
This commit is contained in:
commit
55373f692e
11 files changed
+692
-62
No files matched your search
@@ -955,6 +955,22 @@ ACKs and addressed responses.
|
||||
See [Companion routing details and examples](radio_profiles.md#companion-tx-routing-by-contact-or-channel)
|
||||
for temporary profiles, persistence, and the traffic covered by each setting.
|
||||
|
||||
## Companion Meshtext rebroadcasting
|
||||
|
||||
```text
|
||||
get mt.rebroadcast
|
||||
set mt.rebroadcast on|off
|
||||
```
|
||||
|
||||
Choose `on` or `off` after `set`. This saved option defaults to `off` and is
|
||||
also shown in `get mt.status`. Only broadcast text successfully decoded with
|
||||
a configured channel key can be relayed; unknown-key, non-text and direct
|
||||
messages are excluded. Relays wait five additional actual packet airtimes
|
||||
plus flood jitter, on top of existing radio waits. Hearing another forwarded
|
||||
copy cancels the pending relay. `mt.maybe_rebroadcast` is an equivalent alias.
|
||||
See [optional decoded-text rebroadcasting](radio_profiles.md#optional-decoded-text-rebroadcasting)
|
||||
for hop limits, cancellation and queue behavior.
|
||||
|
||||
## Set Companion media and SSH application TX profiles
|
||||
|
||||
```text
|
||||
|
||||
+58
-15
@@ -486,6 +486,7 @@ get mt.region
|
||||
get mt.slot
|
||||
get mt.hops
|
||||
get mt.retry
|
||||
get mt.rebroadcast
|
||||
get mt.node
|
||||
get mt.shortname
|
||||
set mt.node !12345678
|
||||
@@ -494,6 +495,7 @@ set mt.region auto
|
||||
set mt.region us
|
||||
set mt.slot 0
|
||||
set mt.hops 7
|
||||
set mt.rebroadcast off
|
||||
set mt.shortname HALO
|
||||
set mt.shortname default
|
||||
send mt.nodeinfo 0
|
||||
@@ -512,8 +514,8 @@ does not mean the MT backend is active.
|
||||
Outgoing MT messages default to a hop limit of **7**. `set mt.hops <0..7>`
|
||||
changes this budget, and `get mt.hops` or `get mt.status` shows it. `0` sends
|
||||
only the initial over-air hop with no forwarding budget; larger values permit
|
||||
protocol-compatible peers to forward within that budget. The Companion itself still
|
||||
does not relay MT packets.
|
||||
protocol-compatible peers to forward within that budget. The Companion itself
|
||||
does not relay MT packets unless explicitly enabled as described below.
|
||||
|
||||
`get mt.node` shows the effective Meshtext sender node ID. By default it is
|
||||
SHA-derived from the MeshCore public identity and remains stable for that
|
||||
@@ -556,8 +558,9 @@ successful chat announces the updated name without sending from the rename.
|
||||
the Companion channel index, not `mt.slot` (the frequency slot). The packet
|
||||
contains the actual sender ID, the global MeshCore node name as its long name
|
||||
(UTF-8-safe truncated to 24 bytes), the effective short name, `CLIENT_MUTE`
|
||||
(no relaying), and the no-DM flag. Both automatic and explicit NodeInfo omit
|
||||
GPS/coordinates, MAC, public keys and unsupported metadata. The usual radio2
|
||||
by default (`CLIENT` when `mt.rebroadcast` is on), and the no-DM flag.
|
||||
Both automatic and explicit NodeInfo omit GPS/coordinates, MAC, public keys
|
||||
and unsupported metadata. The usual radio2
|
||||
TX-mode, pending-TX, airtime and profile guards apply. The reply says
|
||||
**queued**, not delivered or acknowledged.
|
||||
|
||||
@@ -569,13 +572,14 @@ Outgoing Meshtext chat and automatic or explicitly requested NodeInfo set
|
||||
This does not start an MQTT client, bridge networks, request a reply, or mark
|
||||
the packet as having arrived through MQTT. The 228-byte text limit is unchanged.
|
||||
|
||||
Region, frequency slot, hop limit, shortname and node-ID override are saved
|
||||
atomically together; changing one preserves the others. Current 40-byte `MT03`
|
||||
settings include both overrides. New firmware accepts valid legacy
|
||||
`MT01`/`MT02` settings and defaults the shortname to the sender-ID suffix and
|
||||
Region, frequency slot, hop limit, shortname, node-ID override and rebroadcast
|
||||
choice are saved atomically together; changing one preserves the others.
|
||||
Current 40-byte `MT04` settings include all of them. New firmware accepts valid
|
||||
legacy `MT01`/`MT02` settings and defaults the shortname to the sender-ID suffix and
|
||||
the node ID to the MeshCore identity; `MT01` also defaults the hop limit to 7.
|
||||
Older MT firmware cannot read `MT03`, so downgrading does not preserve access
|
||||
to these settings. Invalid or unsupported stored settings block MT operation
|
||||
Valid `MT03` settings preserve both overrides. All legacy versions default
|
||||
rebroadcasting to off. Older MT firmware cannot read `MT04`, so downgrading
|
||||
does not preserve access to these settings. Invalid or unsupported stored settings block MT operation
|
||||
rather than silently selecting another network.
|
||||
|
||||
Private channels can be provisioned directly through the Companion CLI:
|
||||
@@ -667,8 +671,8 @@ future NodeInfo, not chat or the packet's sender ID;
|
||||
`set mt.node` changes the actual Meshtext sender ID. Neither setting change
|
||||
emits advertisements. A successful
|
||||
send response means **queued**, not acknowledged by a remote node. This backend
|
||||
does not relay MT packets or provide ACKs, administrative commands, direct
|
||||
messages, periodic node-info adverts or a node database. MeshCore emergency forwarding
|
||||
does not relay MT packets by default. It does not provide ACKs, administrative
|
||||
commands, direct messages, periodic node-info adverts or a node database. MeshCore emergency forwarding
|
||||
does not apply to MT traffic. Interoperability is with stock peers' PSK channel
|
||||
text using AES-CTR, not AES-CCM. AES-CTR channel traffic is not authenticated;
|
||||
successful decoding is not proof of sender identity.
|
||||
@@ -721,9 +725,48 @@ one minute to the airtime-based delay, matching the conservative self-advert
|
||||
policy. Turning retry count off cancels retained retries. Channel, identity,
|
||||
settings or radio-profile changes invalidate retained work rather than sending
|
||||
it on a different network. The bounded pending-frame storage may reject a new
|
||||
send when full. Other people's MT packets are never forwarded by this backend;
|
||||
MeshCore repeater prefix/bridge IDs and flood-filter rules are not MT routing
|
||||
rules.
|
||||
send when full. Forwarding other people's decoded channel text is a separate,
|
||||
default-off option below. MeshCore repeater prefix/bridge IDs and flood-filter
|
||||
rules are not MT routing rules.
|
||||
|
||||
#### Optional decoded-text rebroadcasting
|
||||
|
||||
```text
|
||||
get mt.rebroadcast
|
||||
set mt.rebroadcast on
|
||||
set mt.rebroadcast off
|
||||
```
|
||||
|
||||
This saved option defaults to **off**. `mt.maybe_rebroadcast` is an alias for
|
||||
`mt.rebroadcast`; `get mt.status` also shows the current choice.
|
||||
|
||||
When enabled, only broadcast **text successfully decrypted and parsed with a
|
||||
configured channel's key**, and admitted to the local receive queue, is eligible.
|
||||
Unknown channels, wrong keys, malformed packets, DMs, NodeInfo, GPS and other
|
||||
non-text traffic are not relayed. A packet must have a valid remaining hop
|
||||
budget. AES-CTR is unauthenticated: decoding does not authenticate its sender.
|
||||
|
||||
Before queuing a relay, the Companion waits **five additional packet airtimes**
|
||||
calculated from that packet's actual length and the current radio2 settings,
|
||||
plus the normal flood jitter. Existing CAD, receive-busy and airtime-budget
|
||||
waits still apply; this does not replace or shorten them. Hearing another
|
||||
forwarded copy while waiting cancels the redundant relay. An unchanged-hop
|
||||
retry from the original sender does not cancel it.
|
||||
|
||||
The forwarded frame retains the original sender, packet ID, channel and
|
||||
ciphertext; only remaining hops and the relay-node byte change. It is sent
|
||||
at most once, without originated-message retries or an automatic local NodeInfo
|
||||
announcement. At most one relay occupies the existing bounded chat delivery
|
||||
slots, with no additional frame-sized buffer. A new local chat can displace
|
||||
an unsent relay. Full queues skip the relay, and work delayed more than a
|
||||
minute past its due time expires. Changes to the channel/key, identity or
|
||||
radio profile cancel stale work instead of forwarding it on another network.
|
||||
Turning the option off cancels pending relay work; settings edits are refused
|
||||
while a radio transmission is already queued or in progress.
|
||||
|
||||
Relays stay on Meshtext radio2. This is not a MeshCore bridge, and ordinary
|
||||
MeshCore forwarding rules remain unchanged. Enabling it does not add periodic
|
||||
traffic or cause NodeInfo to be sent just because something was received.
|
||||
|
||||
## Choose whether traffic crosses between profiles
|
||||
|
||||
|
||||
@@ -50,11 +50,13 @@ bool validNodeOverride(uint32_t node) {
|
||||
return node == 0 || (node >= 4 && node != UINT32_MAX);
|
||||
}
|
||||
void makeSettings(uint8_t image[SettingsSize], mesh::mt::Region region, uint32_t slot,
|
||||
uint8_t hops, const char* short_name, uint32_t node_override) {
|
||||
uint8_t hops, const char* short_name, uint32_t node_override,
|
||||
bool rebroadcast = false) {
|
||||
memset(image, 0, SettingsSize);
|
||||
memcpy(image, "MT03", 4);
|
||||
memcpy(image, "MT04", 4);
|
||||
image[4] = static_cast<uint8_t>(region);
|
||||
image[5] = hops;
|
||||
image[6] = rebroadcast ? 1 : 0;
|
||||
for (unsigned i = 0; i < 4; ++i) image[8 + i] = slot >> (8 * i);
|
||||
memcpy(image + 12, short_name, strlen(short_name));
|
||||
for (unsigned i = 0; i < 4; ++i) image[32 + i] = node_override >> (8 * i);
|
||||
@@ -145,6 +147,8 @@ void MyMesh::beginMeshtext() {
|
||||
const auto region = static_cast<mesh::mt::Region>(image[4]);
|
||||
const bool legacy = size == LegacySettingsSize;
|
||||
const bool legacy_v1 = legacy && memcmp(image, "MT01", 4) == 0;
|
||||
const bool legacy_v3 = !legacy && memcmp(image, "MT03", 4) == 0;
|
||||
const uint8_t rebroadcast = legacy || legacy_v3 ? 0 : image[6];
|
||||
const uint8_t hops = legacy_v1 ? 7 : image[5];
|
||||
const uint32_t slot = uint32_t(image[8]) | (uint32_t(image[9]) << 8)
|
||||
| (uint32_t(image[10]) << 16) | (uint32_t(image[11]) << 24);
|
||||
@@ -156,13 +160,20 @@ void MyMesh::beginMeshtext() {
|
||||
} else if (validShortName(reinterpret_cast<const char*>(image + 12))) {
|
||||
const uint32_t node_override = uint32_t(image[32]) | (uint32_t(image[33]) << 8)
|
||||
| (uint32_t(image[34]) << 16) | (uint32_t(image[35]) << 24);
|
||||
if (validNodeOverride(node_override))
|
||||
makeSettings(expected, region, slot, hops, reinterpret_cast<const char*>(image + 12), node_override);
|
||||
if (validNodeOverride(node_override) && rebroadcast <= 1) {
|
||||
makeSettings(expected, region, slot, hops, reinterpret_cast<const char*>(image + 12),
|
||||
node_override, rebroadcast);
|
||||
if (legacy_v3) {
|
||||
memcpy(expected, "MT03", 4);
|
||||
mesh::Utils::sha256(expected + 36, 4, expected, 36);
|
||||
}
|
||||
}
|
||||
}
|
||||
_mt_settings_valid = region <= mesh::mt::Region::ANZ && slot <= 1024 && hops <= 7
|
||||
&& memcmp(image, expected, size) == 0;
|
||||
if (_mt_settings_valid) {
|
||||
_mt_region = region; _mt_slot = slot; _mt_hop_limit = hops;
|
||||
_mt_rebroadcast = rebroadcast != 0;
|
||||
if (!legacy) {
|
||||
memcpy(_mt_short_name, image + 12, sizeof(_mt_short_name));
|
||||
_mt_node_override = uint32_t(image[32]) | (uint32_t(image[33]) << 8)
|
||||
@@ -179,14 +190,15 @@ void MyMesh::beginMeshtext() {
|
||||
}
|
||||
|
||||
bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_t hops,
|
||||
const char* short_name, uint32_t node_override) {
|
||||
const char* short_name, uint32_t node_override, uint8_t rebroadcast) {
|
||||
if (hops == UINT8_MAX) hops = _mt_hop_limit;
|
||||
if (!short_name) short_name = _mt_short_name;
|
||||
if (node_override == UINT32_MAX) node_override = _mt_node_override;
|
||||
if (rebroadcast == UINT8_MAX) rebroadcast = _mt_rebroadcast ? 1 : 0;
|
||||
if (region > mesh::mt::Region::ANZ || slot > 1024 || hops > 7 || !validShortName(short_name)
|
||||
|| !validNodeOverride(node_override) || hasForeignRadioFrame()) return false;
|
||||
|| !validNodeOverride(node_override) || rebroadcast > 1 || hasForeignRadioFrame()) return false;
|
||||
uint8_t image[SettingsSize];
|
||||
makeSettings(image, region, slot, hops, short_name, node_override);
|
||||
makeSettings(image, region, slot, hops, short_name, node_override, rebroadcast);
|
||||
#if defined(NRF52_PLATFORM)
|
||||
const bool ok = _store->saveMeshtextSettings(image);
|
||||
#else
|
||||
@@ -209,8 +221,10 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_
|
||||
return false;
|
||||
}
|
||||
const bool announcement_changed = region != _mt_region || slot != _mt_slot
|
||||
|| node_override != _mt_node_override || strcmp(short_name, _mt_short_name) != 0;
|
||||
|| node_override != _mt_node_override || strcmp(short_name, _mt_short_name) != 0
|
||||
|| (rebroadcast != 0) != _mt_rebroadcast;
|
||||
_mt_region = region; _mt_slot = slot; _mt_hop_limit = hops; _mt_settings_valid = true;
|
||||
_mt_rebroadcast = rebroadcast != 0;
|
||||
for (auto& frame : _mt_delivery) frame.cancelled = true;
|
||||
if (announcement_changed)
|
||||
memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced));
|
||||
@@ -227,6 +241,7 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_
|
||||
}
|
||||
|
||||
bool MyMesh::eraseMeshtextSettings() {
|
||||
_mt_rebroadcast = false;
|
||||
for (auto& frame : _mt_delivery) frame.cancelled = true;
|
||||
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
|
||||
// The filesystem erase also removes channels/identity on the next boot.
|
||||
@@ -439,6 +454,7 @@ bool MyMesh::meshtextDeliveryCurrent(const mesh::mt::PendingFrame& frame) {
|
||||
}
|
||||
|
||||
uint8_t MyMesh::meshtextRetryLimit(const mesh::mt::PendingFrame& frame) const {
|
||||
if (frame.relayed) return 0; // One opportunistic relay, never origin flood retries.
|
||||
// These are local, zero-hop origins: any general path gate admits them.
|
||||
// GRP_DATA-only gates do not apply to channel text. No forwarding echo can
|
||||
// exist when mt.hops is zero, so don't force all configured duplicates.
|
||||
@@ -489,6 +505,54 @@ bool MyMesh::queueMeshtextDelivery(const ChannelDetails& channel, const uint8_t*
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MyMesh::preemptMeshtextRelay() {
|
||||
// Explicit user chat has priority over an optional, not-yet-sent relay.
|
||||
for (unsigned i = 0; i < mesh::mt::ChatDeliverySlots; ++i) {
|
||||
auto& frame = _mt_delivery[i];
|
||||
if (!frame.length || !frame.relayed) continue;
|
||||
if (_mt_tx_slot == static_cast<int>(i)) {
|
||||
frame.cancelled = true;
|
||||
if (!discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext)) return false;
|
||||
} else frame.clear();
|
||||
}
|
||||
return !hasForeignRadioFrame();
|
||||
}
|
||||
|
||||
void MyMesh::maybeRebroadcastMeshtext(const ChannelDetails& channel, const uint8_t* bytes,
|
||||
size_t length) {
|
||||
// Called only after successful text decryption/parsing and local admission.
|
||||
// Never forward unknown channels, opaque frames, DMs, NodeInfo or GPS.
|
||||
if (!_mt_rebroadcast || !_mt_active || !bytes || length <= mesh::mt::HeaderSize
|
||||
|| length > mesh::mt::MaxFrameSize || !(bytes[12] & 7)
|
||||
|| (bytes[12] >> 5) < (bytes[12] & 7) || bytes[14] != 0) return;
|
||||
const auto* profiles = _radio->profiles();
|
||||
if (!profiles || !profiles->canTransmit(1)
|
||||
|| profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return;
|
||||
for (const auto& pending : _mt_delivery) if (pending.length && pending.relayed) return;
|
||||
uint8_t fingerprint[8];
|
||||
const int index = meshtextChannelIndex(channel, fingerprint);
|
||||
if (index < 0) return;
|
||||
const uint64_t airtime = _radio->getProfileAirtime(1, length);
|
||||
if (!airtime) return;
|
||||
// Five ADDITIONAL exact packet airtimes, plus flood jitter. This does not
|
||||
// replace the Dispatcher's existing CAD, RX-busy or airtime-budget waits.
|
||||
uint64_t delay = 5 * airtime + airtime * getRNG()->nextInt(0, 201) / 100;
|
||||
if (delay > 0x7fffffffULL) delay = 0x7fffffffULL;
|
||||
for (unsigned i = 0; i < mesh::mt::ChatDeliverySlots; ++i) {
|
||||
auto& frame = _mt_delivery[i];
|
||||
if (frame.length) continue;
|
||||
frame = {};
|
||||
memcpy(frame.bytes, bytes, length);
|
||||
frame.bytes[12] = (bytes[12] & 0xf8) | ((bytes[12] & 7) - 1);
|
||||
frame.bytes[15] = static_cast<uint8_t>(_mt_node);
|
||||
memcpy(frame.fingerprint, fingerprint, sizeof(fingerprint));
|
||||
frame.length = length; frame.channel = index; frame.relayed = true;
|
||||
frame.generation = profiles->generation[1];
|
||||
frame.due = millis() + static_cast<uint32_t>(delay);
|
||||
return; // Reuse one chat slot; no additional frame-sized RAM allocation.
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::loopMeshtextDelivery() {
|
||||
const auto* profiles = _radio->profiles();
|
||||
const uint32_t now = millis();
|
||||
@@ -502,6 +566,8 @@ void MyMesh::loopMeshtextDelivery() {
|
||||
if (!frame.length) continue;
|
||||
if (!usable || profiles->generation[1] != frame.generation
|
||||
|| (_mt_delivery_config_dirty && !meshtextDeliveryCurrent(frame))) frame.cancelled = true;
|
||||
if (frame.relayed && (!_mt_rebroadcast || static_cast<int32_t>(now - frame.due) > 60000))
|
||||
frame.cancelled = true; // Do not emit an opportunistic relay minutes later.
|
||||
if (frame.cancelled || (frame.sent && !meshtextRetryLimit(frame))) {
|
||||
if (_mt_tx_slot == static_cast<int>(i)) {
|
||||
frame.cancelled = true;
|
||||
@@ -536,8 +602,13 @@ void MyMesh::loopMeshtextDelivery() {
|
||||
}
|
||||
for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) {
|
||||
auto& frame = _mt_delivery[i];
|
||||
if (!frame.length || !frame.sent || static_cast<int32_t>(now - frame.due) < 0) continue;
|
||||
if (!frame.length || static_cast<int32_t>(now - frame.due) < 0) continue;
|
||||
if (!meshtextDeliveryCurrent(frame)) { frame.clear(); continue; }
|
||||
if (frame.relayed) {
|
||||
if (queueForeignRadioFrame(frame.bytes, frame.length, 1, frame.generation)) _mt_tx_slot = i;
|
||||
return;
|
||||
}
|
||||
if (!frame.sent) continue;
|
||||
if (frame.retries >= meshtextRetryLimit(frame)) {
|
||||
++_mt_exhausted; frame.clear(); continue;
|
||||
}
|
||||
@@ -550,7 +621,7 @@ void MyMesh::loopMeshtextDelivery() {
|
||||
|
||||
bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length) {
|
||||
refreshMeshtextProfile();
|
||||
if (!_mt_active || !text || !length || hasForeignRadioFrame()) return false;
|
||||
if (!_mt_active || !text || !length) return false;
|
||||
const auto* profiles = _radio->profiles();
|
||||
if (!profiles || !profiles->canTransmit(1)
|
||||
|| profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return false;
|
||||
@@ -563,10 +634,12 @@ bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, s
|
||||
const uint32_t id = _mt_packet_id++;
|
||||
uint8_t raw[mesh::mt::MaxFrameSize];
|
||||
// Outbound Meshtext channel chat defaults to seven hops; the CLI can reduce it.
|
||||
// This does not enable forwarding or ACK responses in this Companion.
|
||||
// Forwarding remains a separate, default-off setting; no ACK responses.
|
||||
const size_t size = mesh::mt::encodeText(_mt_node, id, _mt_hop_limit, wire.name, wire.key,
|
||||
wire.key_length, text, length, raw);
|
||||
return size && queueMeshtextDelivery(channel, raw, size, false);
|
||||
// A rejected local message must not evict a valid pending relay. Validate
|
||||
// and encode first, then give an admitted user chat priority over it.
|
||||
return size && preemptMeshtextRelay() && queueMeshtextDelivery(channel, raw, size, false);
|
||||
}
|
||||
|
||||
bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) {
|
||||
@@ -588,7 +661,8 @@ bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) {
|
||||
const uint32_t id = _mt_packet_id++;
|
||||
uint8_t raw[mesh::mt::MaxFrameSize];
|
||||
const size_t size = mesh::mt::encodeNodeInfo(_mt_node, id, _mt_hop_limit, wire.name, wire.key,
|
||||
wire.key_length, _prefs.node_name, long_length, short_name, strlen(short_name), raw);
|
||||
wire.key_length, _prefs.node_name, long_length, short_name, strlen(short_name), raw,
|
||||
_mt_rebroadcast);
|
||||
// Explicit CLI or first successfully transmitted chat, never a GPS packet.
|
||||
return size && queueMeshtextDelivery(channel, raw, size, true);
|
||||
}
|
||||
@@ -606,6 +680,14 @@ bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t pr
|
||||
if (!_mt_active || !profiles || profile != 1 || generation != profiles->generation[1]) return false;
|
||||
for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) {
|
||||
auto& frame = _mt_delivery[i];
|
||||
if (frame.generation == generation && frame.matchesRelayCopy(bytes, length)
|
||||
&& meshtextDeliveryCurrent(frame)) {
|
||||
frame.heard = frame.cancelled = true;
|
||||
if (_mt_tx_slot == static_cast<int>(i))
|
||||
discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext);
|
||||
else frame.clear();
|
||||
return true; // Cancel before the received-packet dedup early return.
|
||||
}
|
||||
if (frame.generation != generation || !frame.matchesEcho(bytes, length)
|
||||
|| !meshtextDeliveryCurrent(frame)) continue;
|
||||
if (!frame.heard) ++_mt_heard;
|
||||
@@ -635,6 +717,7 @@ bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t pr
|
||||
if (queueChannelText(i, policy_channel.channel.secret, snr, decoded.hops, timestamp, text, false)) {
|
||||
_mt_seen.remember(decoded.from, decoded.id);
|
||||
++_mt_received;
|
||||
maybeRebroadcastMeshtext(channel, bytes, length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -657,6 +740,12 @@ void MyMesh::onForeignRadioSendComplete(bool success) {
|
||||
return; // e.g. an explicitly configured text beacon, not delivery work.
|
||||
}
|
||||
auto& frame = _mt_delivery[slot];
|
||||
if (frame.relayed) {
|
||||
if (success) ++_mt_sent;
|
||||
else if (!frame.heard && !frame.cancelled) ++_mt_failed;
|
||||
frame.clear();
|
||||
return; // No local packet ID, NodeInfo, origin retry or announcement state.
|
||||
}
|
||||
if (frame.nodeinfo && !success) _mt_nodeinfo_pending[frame.channel] = false;
|
||||
if (success) {
|
||||
++_mt_sent;
|
||||
@@ -782,22 +871,32 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa
|
||||
const bool get_region = !strcmp(command, "get mt.region");
|
||||
const bool get_slot = !strcmp(command, "get mt.slot");
|
||||
const bool get_hops = !strcmp(command, "get mt.hops");
|
||||
const bool get_rebroadcast = !strcmp(command, "get mt.rebroadcast")
|
||||
|| !strcmp(command, "get mt.maybe_rebroadcast");
|
||||
const bool get_node = !strcmp(command, "get mt.node");
|
||||
const bool get_short_name = !strcmp(command, "get mt.shortname");
|
||||
const char* region_value = !strncmp(command, "set mt.region ", 14) ? command + 14 : nullptr;
|
||||
const char* slot_value = !strncmp(command, "set mt.slot ", 12) ? command + 12 : nullptr;
|
||||
const char* hops_value = !strncmp(command, "set mt.hops ", 12) ? command + 12 : nullptr;
|
||||
const char* rebroadcast_value = !strncmp(command, "set mt.rebroadcast ", 19) ? command + 19
|
||||
: !strncmp(command, "set mt.maybe_rebroadcast ", 25) ? command + 25 : nullptr;
|
||||
const char* node_value = !strncmp(command, "set mt.node ", 12) ? command + 12 : nullptr;
|
||||
const char* short_name_value = !strncmp(command, "set mt.shortname ", 17) ? command + 17 : nullptr;
|
||||
if (!strcmp(command, "set mt.node")) node_value = "";
|
||||
if (!strcmp(command, "set mt.shortname")) short_name_value = "";
|
||||
if (!status && !get_region && !get_slot && !get_hops && !get_node && !get_short_name
|
||||
&& !region_value && !slot_value && !hops_value && !node_value && !short_name_value) return false;
|
||||
if (region_value || slot_value || hops_value || node_value || short_name_value) {
|
||||
if (!status && !get_region && !get_slot && !get_hops && !get_node && !get_short_name && !get_rebroadcast
|
||||
&& !region_value && !slot_value && !hops_value && !node_value && !short_name_value && !rebroadcast_value) return false;
|
||||
if (region_value || slot_value || hops_value || node_value || short_name_value || rebroadcast_value) {
|
||||
auto region = _mt_region; uint32_t slot = _mt_slot;
|
||||
uint32_t hops = _mt_hop_limit;
|
||||
uint32_t node_override = _mt_node_override;
|
||||
const char* short_name = _mt_short_name;
|
||||
uint8_t rebroadcast = _mt_rebroadcast ? 1 : 0;
|
||||
if (rebroadcast_value) {
|
||||
if (!strcmp(rebroadcast_value, "on")) rebroadcast = 1;
|
||||
else if (!strcmp(rebroadcast_value, "off")) rebroadcast = 0;
|
||||
else { snprintf(reply, capacity, "Error: use set mt.rebroadcast on|off"); return true; }
|
||||
}
|
||||
if (region_value && !mesh::mt::parseRegion(region_value, region)) {
|
||||
snprintf(reply, capacity, "Error: use set mt.region auto|us|eu868|eu433|anz"); return true;
|
||||
}
|
||||
@@ -828,8 +927,10 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa
|
||||
snprintf(reply, capacity, "Error: mt.shortname must be 1..4 UTF-8 characters (no spaces/control/colon), or default"); return true;
|
||||
}
|
||||
}
|
||||
if (!saveMeshtextSettings(region, slot, hops, short_name, node_override))
|
||||
if (!saveMeshtextSettings(region, slot, hops, short_name, node_override, rebroadcast))
|
||||
snprintf(reply, capacity, "Error: Meshtext settings not saved (pending TX or storage error)");
|
||||
else if (rebroadcast_value)
|
||||
snprintf(reply, capacity, "OK - mt.rebroadcast=%s; extra wait=5 packet airtimes", rebroadcast ? "on" : "off");
|
||||
else if (node_value)
|
||||
snprintf(reply, capacity, "OK - mt.node=!%08lx (%s); choose a unique ID", (unsigned long)_mt_node,
|
||||
_mt_node_override ? "saved override" : "identity default");
|
||||
@@ -843,6 +944,7 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa
|
||||
if (get_region) snprintf(reply, capacity, "> %s; effective=%s", mesh::mt::regionName(_mt_region), mesh::mt::regionName(meshtextRegion()));
|
||||
else if (get_slot) snprintf(reply, capacity, "> %lu%s", (unsigned long)_mt_slot, _mt_slot ? "" : " (preset default)");
|
||||
else if (get_hops) snprintf(reply, capacity, "> %u", _mt_hop_limit);
|
||||
else if (get_rebroadcast) snprintf(reply, capacity, "> %s; extra wait=5 packet airtimes", _mt_rebroadcast ? "on" : "off");
|
||||
else if (get_node) snprintf(reply, capacity, "> !%08lx (%s)", (unsigned long)_mt_node,
|
||||
_mt_node_override ? "saved override" : "identity default");
|
||||
else if (get_short_name) {
|
||||
@@ -853,8 +955,8 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa
|
||||
else {
|
||||
const auto* profiles = _radio->profiles();
|
||||
const auto p = profiles ? profiles->secondary.params : mesh::RadioProfileParams{};
|
||||
snprintf(reply, capacity, "> Meshtext !%08lx; %s; %s; %.3f/%g/SF%u/CR%u; hops=%u; RX=%lu TX=%lu fail=%lu; RX time-shared",
|
||||
(unsigned long)_mt_node, _mt_status, _radio_profiles.autoSecondaryActive() ? "auto radio2" : "manual radio2",
|
||||
snprintf(reply, capacity, "> Meshtext !%08lx; rebroadcast=%s; %s; %s; %.3f/%g/SF%u/CR%u; hops=%u; RX=%lu TX=%lu fail=%lu; RX time-shared",
|
||||
(unsigned long)_mt_node, _mt_rebroadcast ? "on" : "off", _mt_status, _radio_profiles.autoSecondaryActive() ? "auto radio2" : "manual radio2",
|
||||
p.freq, p.bw, p.sf, p.cr, _mt_hop_limit, (unsigned long)_mt_received, (unsigned long)_mt_sent, (unsigned long)_mt_failed);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -10209,6 +10209,8 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt,
|
||||
#if MESH_ENABLE_MESHTEXT_CHAT
|
||||
terminalOutput().print(" get mt.status / get mt.region / get mt.slot / get mt.hops\r\n"
|
||||
" get mt.node / get mt.shortname\r\n"
|
||||
" get mt.rebroadcast / set mt.rebroadcast <on|off> (saved; default off)\r\n"
|
||||
" Relay decoded channel text only; adds 5 packet airtimes before TX\r\n"
|
||||
" set mt.region <auto|us|eu868|eu433|anz>\r\n"
|
||||
" set mt.slot <0|1-based frequency slot>\r\n"
|
||||
" set mt.hops <0..7> (saved; default 7)\r\n"
|
||||
|
||||
@@ -427,12 +427,15 @@ protected:
|
||||
bool validateMeshtextChannelChange(int index, const ChannelDetails& channel);
|
||||
bool handleMeshtextCommand(const char* command, char* reply, size_t capacity);
|
||||
bool saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_t hops = UINT8_MAX,
|
||||
const char* short_name = nullptr, uint32_t node_override = UINT32_MAX);
|
||||
const char* short_name = nullptr, uint32_t node_override = UINT32_MAX,
|
||||
uint8_t rebroadcast = UINT8_MAX);
|
||||
bool eraseMeshtextSettings();
|
||||
bool sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length);
|
||||
bool sendMeshtextNodeInfo(const ChannelDetails& channel);
|
||||
bool queueMeshtextDelivery(const ChannelDetails& channel, const uint8_t* bytes,
|
||||
size_t length, bool nodeinfo);
|
||||
void maybeRebroadcastMeshtext(const ChannelDetails& channel, const uint8_t* bytes, size_t length);
|
||||
bool preemptMeshtextRelay();
|
||||
int meshtextChannelIndex(const ChannelDetails& channel, uint8_t fingerprint[8]);
|
||||
bool meshtextDeliveryCurrent(const mesh::mt::PendingFrame& pending);
|
||||
uint8_t meshtextRetryLimit(const mesh::mt::PendingFrame& pending) const;
|
||||
@@ -762,6 +765,7 @@ private:
|
||||
uint32_t _mt_default_node = 0, _mt_node_override = 0;
|
||||
char _mt_short_name[17] = {};
|
||||
uint8_t _mt_hop_limit = 7;
|
||||
bool _mt_rebroadcast = false;
|
||||
uint32_t _mt_sent = 0, _mt_failed = 0, _mt_received = 0;
|
||||
mesh::mt::SeenPackets _mt_seen;
|
||||
mesh::mt::PendingFrame _mt_delivery[mesh::mt::DeliverySlots];
|
||||
|
||||
@@ -291,7 +291,8 @@ size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* cha
|
||||
|
||||
size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char* channel_name,
|
||||
const uint8_t* key, size_t key_len, const char* long_name, size_t long_name_len,
|
||||
const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize]) {
|
||||
const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize],
|
||||
bool rebroadcast) {
|
||||
size_t short_characters = 0;
|
||||
if (out == nullptr || key == nullptr || (key_len != 16 && key_len != 32)
|
||||
|| !validName(channel_name) || from <= 3 || from == Broadcast || id == 0 || hop_limit > 7
|
||||
@@ -319,7 +320,7 @@ size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char*
|
||||
}
|
||||
data[count++] = 0x1a; data[count++] = static_cast<uint8_t>(short_name_len);
|
||||
memcpy(data + count, short_name, short_name_len); count += short_name_len;
|
||||
data[count++] = 0x38; data[count++] = 1; // Config.DeviceConfig.Role.CLIENT_MUTE.
|
||||
data[count++] = 0x38; data[count++] = rebroadcast ? 0 : 1; // CLIENT / CLIENT_MUTE.
|
||||
data[count++] = 0x48; data[count++] = 1; // User.is_unmessagable explicitly true.
|
||||
data[3] = static_cast<uint8_t>(count - 4);
|
||||
data[count++] = 0x48; data[count++] = 1; // Data.bitfield: MQTT upload consent.
|
||||
|
||||
@@ -54,7 +54,9 @@ size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* cha
|
||||
const uint8_t* key, size_t key_len, const char* text, size_t text_len,
|
||||
uint8_t out[MaxFrameSize]);
|
||||
// Explicit broadcast NODEINFO_APP containing only User id, long/short names,
|
||||
// CLIENT_MUTE (no forwarding), and is_unmessagable=true (no direct messages).
|
||||
// CLIENT_MUTE by default (no forwarding), and is_unmessagable=true (no DMs).
|
||||
// Explicitly enabled rebroadcast advertises CLIENT instead, without adding
|
||||
// any fields or changing encryption, names, hop policy or MQTT consent.
|
||||
// Names use explicit UTF-8 byte lengths, without their terminating NUL. Long
|
||||
// names may be empty; short names require 1..4 UTF-8 bytes (stock wire bound).
|
||||
// This can hold four ASCII characters but fewer multi-byte characters. Longer
|
||||
@@ -63,9 +65,11 @@ size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* cha
|
||||
// explicit and first-successful-chat announcements and bounded retries.
|
||||
size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char* channel_name,
|
||||
const uint8_t* key, size_t key_len, const char* long_name, size_t long_name_len,
|
||||
const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize]);
|
||||
const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize],
|
||||
bool rebroadcast = false);
|
||||
// Only broadcast TEXT_MESSAGE_APP is delivered. No DMs, ACKs, administrative
|
||||
// handling, relaying or automatic transmissions. AES-CTR is not authenticated;
|
||||
// handling or automatic transmissions occur in this decoder; a caller can
|
||||
// separately opt into bounded relay handling. AES-CTR is not authenticated;
|
||||
// a successful parse is not a proof of sender identity or PSK authenticity.
|
||||
// On failure, decoded is left unchanged. Caller records dedup only on success.
|
||||
bool decodeText(const uint8_t* raw, size_t length, const char* channel_name,
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
|
||||
namespace mesh { namespace mt {
|
||||
|
||||
// Three retained chats and one metadata slot. No heap, packet-pool entries,
|
||||
// keys, positions or forwarding state are allocated by the delivery policy.
|
||||
// Three retained chats and one metadata slot. Optional relays reuse a retained
|
||||
// frame; no heap, packet-pool entry, key or additional payload buffer is needed.
|
||||
struct PendingFrame {
|
||||
uint8_t bytes[MaxFrameSize] = {};
|
||||
uint8_t fingerprint[8] = {};
|
||||
bool relayed = false; // Occupies the existing padding before generation.
|
||||
uint32_t generation = 0, due = 0;
|
||||
uint16_t length = 0;
|
||||
uint8_t channel = 0, retries = 0;
|
||||
@@ -20,12 +21,23 @@ struct PendingFrame {
|
||||
// The sender/id/channel/ciphertext are immutable. Only remaining hops
|
||||
// and next-hop/relay bytes may change. A copy at the original hop limit
|
||||
// is not evidence that another node forwarded the transmission.
|
||||
return length && sent && raw && size == length && size > HeaderSize
|
||||
return !relayed && length && sent && raw && size == length && size > HeaderSize
|
||||
&& memcmp(raw, bytes, 12) == 0 && raw[13] == bytes[13]
|
||||
&& (raw[12] & 0xf8) == (bytes[12] & 0xf8)
|
||||
&& (raw[12] & 7) < (bytes[12] & 7)
|
||||
&& memcmp(raw + HeaderSize, bytes + HeaderSize, size - HeaderSize) == 0;
|
||||
}
|
||||
bool matchesRelayCopy(const uint8_t* raw, size_t size) const {
|
||||
// A planned relay already decremented remaining hops. Another relay at
|
||||
// that same or a lower remaining hop count makes ours redundant, even
|
||||
// before we transmit. Original-sender retries retain more hops and must
|
||||
// not cancel it. Preserve/check all immutable flags and ciphertext.
|
||||
return relayed && raw && length > HeaderSize && length <= sizeof(bytes)
|
||||
&& size == length && memcmp(raw, bytes, 12) == 0 && raw[13] == bytes[13]
|
||||
&& (raw[12] & 0xf8) == (bytes[12] & 0xf8)
|
||||
&& (raw[12] & 7) <= (bytes[12] & 7)
|
||||
&& memcmp(raw + HeaderSize, bytes + HeaderSize, size - HeaderSize) == 0;
|
||||
}
|
||||
};
|
||||
|
||||
static constexpr unsigned DeliverySlots = 4;
|
||||
|
||||
Vendored
+303
-7
@@ -60,8 +60,16 @@ struct BaseChatMesh {
|
||||
};
|
||||
struct MtRadio : Radio {
|
||||
bool supported=true;
|
||||
bool physical_airtime=false;
|
||||
uint32_t fixed_airtime=10;
|
||||
std::vector<std::pair<uint8_t,int>> airtime_calls;
|
||||
mesh::RadioProfiles& _profiles=p;
|
||||
bool supportsMeshtext() const override { return supported; }
|
||||
uint32_t getProfileAirtime(uint8_t,int,uint8_t=0) override { return 10; }
|
||||
@PROFILE_AIRTIME@
|
||||
uint32_t getProfileAirtime(uint8_t profile,int length,uint8_t cr=0) override {
|
||||
airtime_calls.emplace_back(profile,length);
|
||||
return physical_airtime ? actualProfileAirtime(profile,length,cr):fixed_airtime;
|
||||
}
|
||||
};
|
||||
struct Serial {
|
||||
bool connected=false;
|
||||
@@ -99,6 +107,7 @@ public:
|
||||
uint32_t _mt_slot=0,_mt_node=0,_mt_packet_id=0,_mt_sent=0,_mt_failed=0,_mt_received=0;
|
||||
uint32_t _mt_default_node=0,_mt_node_override=0;
|
||||
uint8_t _mt_hop_limit=7;
|
||||
bool _mt_rebroadcast=false;
|
||||
char _mt_short_name[17]={};
|
||||
mesh::mt::SeenPackets _mt_seen;
|
||||
mesh::mt::PendingFrame _mt_delivery[mesh::mt::DeliverySlots];
|
||||
@@ -138,7 +147,8 @@ public:
|
||||
void refreshMeshtextIdentity();
|
||||
void beginMeshtext();
|
||||
bool saveMeshtextSettings(mesh::mt::Region,uint32_t,uint8_t hops=UINT8_MAX,
|
||||
const char* short_name=nullptr,uint32_t node_override=UINT32_MAX);
|
||||
const char* short_name=nullptr,uint32_t node_override=UINT32_MAX,
|
||||
uint8_t rebroadcast=UINT8_MAX);
|
||||
bool eraseMeshtextSettings();
|
||||
bool isMeshtextChannel(const ChannelDetails&) const;
|
||||
bool validateMeshtextChannelChange(int,const ChannelDetails&);
|
||||
@@ -146,6 +156,8 @@ public:
|
||||
bool sendMeshtextText(const ChannelDetails&,const char*,size_t);
|
||||
bool sendMeshtextNodeInfo(const ChannelDetails&);
|
||||
bool queueMeshtextDelivery(const ChannelDetails&,const uint8_t*,size_t,bool);
|
||||
void maybeRebroadcastMeshtext(const ChannelDetails&,const uint8_t*,size_t);
|
||||
bool preemptMeshtextRelay();
|
||||
int meshtextChannelIndex(const ChannelDetails&,uint8_t[8]);
|
||||
bool meshtextDeliveryCurrent(const mesh::mt::PendingFrame&);
|
||||
uint8_t meshtextRetryLimit(const mesh::mt::PendingFrame&) const;
|
||||
@@ -544,7 +556,7 @@ static void hops() {
|
||||
char command[32]; snprintf(command,sizeof(command),"set mt.hops %u",limit);
|
||||
assert(node.handleMeshtextCommand(command,reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(node._mt_hop_limit==limit && node._mt_region==mesh::mt::Region::Unset && node._mt_slot==0);
|
||||
assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT03",4));
|
||||
assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4));
|
||||
assert(Preferences::blob[5]==limit && Preferences::blob[6]==0 && Preferences::blob[7]==0);
|
||||
MyMesh reboot; reboot.stored[0]=node.stored[0]; reboot.beginMeshtext();
|
||||
assert(reboot._mt_hop_limit==limit && reboot._mt_active && reboot._mt_region==node._mt_region && reboot._mt_slot==node._mt_slot);
|
||||
@@ -592,7 +604,7 @@ static void hops() {
|
||||
assert(migrated._mt_region==mesh::mt::Region::US && migrated._mt_slot==2);
|
||||
assert(Preferences::writes==0 && Preferences::blob==legacy_blob);
|
||||
assert(migrated.handleMeshtextCommand("set mt.hops 3",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(Preferences::writes==1 && !memcmp(Preferences::blob.data(),"MT03",4) && Preferences::blob[5]==3);
|
||||
assert(Preferences::writes==1 && !memcmp(Preferences::blob.data(),"MT04",4) && Preferences::blob[5]==3);
|
||||
assert(migrated._mt_region==mesh::mt::Region::US && migrated._mt_slot==2);
|
||||
MyMesh migrated_reboot; migrated_reboot.stored[0]=migrated.stored[0]; migrated_reboot.beginMeshtext();
|
||||
assert(migrated_reboot._mt_active && migrated_reboot._mt_hop_limit==3);
|
||||
@@ -627,7 +639,7 @@ static void shortname() {
|
||||
assert(node.handleMeshtextCommand("set mt.shortname MIKE",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(!strcmp(node._mt_short_name,"MIKE") && node._mt_node==default_node && node._mt_packet_id==original_packet);
|
||||
assert(!memcmp(meshcore_identity,node.self_id.pub_key,32));
|
||||
assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT03",4));
|
||||
assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4));
|
||||
assert(!memcmp(Preferences::blob.data()+12,"MIKE\0",5));
|
||||
for(unsigned i=17;i<36;++i) assert(Preferences::blob[i]==0);
|
||||
assert(node.handleMeshtextCommand("get mt.shortname",reply,sizeof(reply)) && strstr(reply,"MIKE"));
|
||||
@@ -694,7 +706,7 @@ static void shortname() {
|
||||
assert(legacy._mt_node==default_node && legacy._mt_hop_limit==(version?3:7));
|
||||
assert(Preferences::writes==0 && Preferences::blob==before);
|
||||
assert(legacy.handleMeshtextCommand("set mt.shortname TEST",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(Preferences::writes==1 && Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT03",4));
|
||||
assert(Preferences::writes==1 && Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4));
|
||||
assert(legacy._mt_region==mesh::mt::Region::US && legacy._mt_slot==2 && legacy._mt_hop_limit==(version?3:7));
|
||||
assertUntouched(legacy);
|
||||
}
|
||||
@@ -826,6 +838,9 @@ static void nodeinfo() {
|
||||
assert(node.handleMeshtextCommand("send mt.nodeinfo 0",reply,sizeof(reply)) && !strncmp(reply,"Error",5));
|
||||
assert(node._mt_packet_id==pending_id && node.foreign.size()==pending_count);
|
||||
assert(!node.sendMeshtextText(node.stored[0],"busy",4));
|
||||
// A fully encoded local attempt can lose TX admission, but its CTR nonce
|
||||
// must remain consumed rather than being reused by the following metadata.
|
||||
assert(node._mt_packet_id==pending_id+1 && node.foreign.size()==pending_count);
|
||||
assert(!node.saveMeshtextSettings(mesh::mt::Region::US,0));
|
||||
node.releaseForeign();
|
||||
assert(node.handleMeshtextCommand("set mt.node !12345678",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
@@ -835,7 +850,7 @@ static void nodeinfo() {
|
||||
assert(node.foreign.back()==std::vector<uint8_t>(expected_named,expected_named+sizeof(expected_named)));
|
||||
node.releaseForeign();
|
||||
auto text=decodeOwnText(node,"hello",5);
|
||||
assert(text.id==0x1234567a && !strcmp(text.text,"hello"));
|
||||
assert(text.id==0x1234567b && !strcmp(text.text,"hello"));
|
||||
const char* emojis="\xf0\x9f\x98\x80\xf0\x9f\x98\x80\xf0\x9f\x98\x80\xf0\x9f\x98\x80";
|
||||
assert(node.saveMeshtextSettings(node._mt_region,node._mt_slot,UINT8_MAX,emojis));
|
||||
memset(node._prefs.node_name,'A',22); strcpy(node._prefs.node_name+22,"\xe2\x82\xac" "rest");
|
||||
@@ -1311,6 +1326,282 @@ static void readinessCache() {
|
||||
}
|
||||
puts("PASS readiness-cache");
|
||||
}
|
||||
static const uint8_t relay_short[]=@RELAY_SHORT@,relay_long[]=@RELAY_LONG@,
|
||||
relay_nodeinfo[]=@RELAY_NODEINFO@,relay_private128[]=@RELAY_PRIVATE128@,
|
||||
relay_private256[]=@RELAY_PRIVATE256@;
|
||||
static mesh::mt::PendingFrame* pendingRelay(MyMesh& node) {
|
||||
mesh::mt::PendingFrame* found=nullptr;
|
||||
for(auto& frame:node._mt_delivery) if(frame.length && frame.relayed) {
|
||||
assert(!found);found=&frame; // Retained forwarding is bounded to ONE copy.
|
||||
}
|
||||
return found;
|
||||
}
|
||||
static void enableRelay(MyMesh& node) {
|
||||
char reply[256];assert(node.handleMeshtextCommand("set mt.rebroadcast on",reply,sizeof(reply)));
|
||||
assert(!strncmp(reply,"OK",2) && node._mt_rebroadcast);node.advance(0);
|
||||
}
|
||||
static std::vector<uint8_t> plannedRelay(MyMesh& node,const uint8_t* raw,size_t length) {
|
||||
std::vector<uint8_t> expected(raw,raw+length);
|
||||
expected[12]=(raw[12]&0xf8)|((raw[12]&7)-1);expected[15]=uint8_t(node._mt_node);
|
||||
return expected;
|
||||
}
|
||||
static void assertRelayBytes(MyMesh& node,const uint8_t* raw,size_t length) {
|
||||
const auto* frame=pendingRelay(node);assert(frame && frame->length==length);
|
||||
const auto expected=plannedRelay(node,raw,length);
|
||||
assert(!memcmp(frame->bytes,expected.data(),length));
|
||||
assert(!frame->nodeinfo && !frame->sent && !frame->retries && !frame->cancelled);
|
||||
assert(frame->generation==node.radio.p.generation[1]);
|
||||
}
|
||||
static void rebroadcastSettings() {
|
||||
Preferences::reset();MyMesh node;active(node);char reply[256];
|
||||
assert(!node._mt_rebroadcast);
|
||||
for(const char* get:{"get mt.rebroadcast","get mt.maybe_rebroadcast"}) {
|
||||
assert(node.handleMeshtextCommand(get,reply,sizeof(reply)) && strstr(reply,"> off;"));
|
||||
}
|
||||
assert(node.handleMeshtextCommand("get mt.status",reply,sizeof(reply)) && strstr(reply,"rebroadcast=off"));
|
||||
const auto original=Preferences::blob;const auto packet=node._mt_packet_id;
|
||||
for(const char* invalid:{"set mt.rebroadcast ","set mt.rebroadcast yes","set mt.rebroadcast true",
|
||||
"set mt.rebroadcast 1","set mt.rebroadcast ON","set mt.rebroadcast on junk",
|
||||
"set mt.maybe_rebroadcast -1"}) {
|
||||
assert(node.handleMeshtextCommand(invalid,reply,sizeof(reply)) && !strncmp(reply,"Error",5));
|
||||
assert(!node._mt_rebroadcast && Preferences::blob==original);
|
||||
}
|
||||
assert(!node.saveMeshtextSettings(node._mt_region,node._mt_slot,UINT8_MAX,nullptr,UINT32_MAX,2));
|
||||
assert(node.handleMeshtextCommand("set mt.maybe_rebroadcast on",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(node._mt_rebroadcast && Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4));
|
||||
assert(Preferences::blob[6]==1 && Preferences::blob[7]==0 && node._mt_packet_id==packet);
|
||||
uint8_t digest[4];mesh::Utils::sha256(digest,4,Preferences::blob.data(),36);
|
||||
assert(!memcmp(digest,Preferences::blob.data()+36,4));
|
||||
assert(node.handleMeshtextCommand("set mt.hops 3",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(node._mt_rebroadcast && node._mt_hop_limit==3 && Preferences::blob[6]==1);
|
||||
MyMesh reboot;reboot.stored[0]=node.stored[0];reboot.beginMeshtext();
|
||||
assert(reboot._mt_active && reboot._mt_rebroadcast && reboot._mt_hop_limit==3);assertUntouched(reboot);
|
||||
const auto valid=Preferences::blob;
|
||||
for(unsigned fault=0;fault<3;++fault) {
|
||||
Preferences::blob=valid;
|
||||
if(fault==0) Preferences::blob[6]=2;
|
||||
if(fault==1) Preferences::blob[7]=1;
|
||||
if(fault==2) {memcpy(Preferences::blob.data(),"MT03",4);Preferences::blob[6]=1;}
|
||||
mesh::Utils::sha256(Preferences::blob.data()+36,4,Preferences::blob.data(),36);
|
||||
MyMesh invalid;invalid.stored[0]=node.stored[0];invalid.beginMeshtext();
|
||||
assert(!invalid._mt_settings_valid && !invalid._mt_active && !invalid._mt_rebroadcast);assertUntouched(invalid);
|
||||
}
|
||||
Preferences::blob=valid;Preferences::fail_write=true;
|
||||
assert(node.handleMeshtextCommand("set mt.rebroadcast off",reply,sizeof(reply)) && !strncmp(reply,"Error",5));
|
||||
assert(node._mt_rebroadcast && Preferences::blob==valid && !node._mt_active && !node._mt_settings_valid);
|
||||
Preferences::fail_write=false;Preferences::fail_read=true;
|
||||
assert(!node.saveMeshtextSettings(node._mt_region,node._mt_slot,UINT8_MAX,nullptr,UINT32_MAX,0));
|
||||
assert(node._mt_rebroadcast && !node._mt_active && !node._mt_settings_valid && Preferences::blob[6]==0);
|
||||
Preferences::fail_read=false;MyMesh recovered;recovered.stored[0]=node.stored[0];recovered.beginMeshtext();
|
||||
assert(recovered._mt_active && recovered._mt_settings_valid && !recovered._mt_rebroadcast);
|
||||
assert(recovered.handleMeshtextCommand("set mt.rebroadcast on",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(recovered.eraseMeshtextSettings() && !recovered._mt_rebroadcast && Preferences::blob.empty());
|
||||
const uint8_t mt01[]=@LEGACY_SETTINGS@,mt02[]=@LEGACY_HOPS_SETTINGS@,mt03[]=@LEGACY_MT03_SETTINGS@,
|
||||
mt03_named[]=@LEGACY_MT03_NAMED@;
|
||||
const std::vector<std::vector<uint8_t>> legacy={
|
||||
{mt01,mt01+sizeof(mt01)},{mt02,mt02+sizeof(mt02)},{mt03,mt03+sizeof(mt03)},
|
||||
{mt03_named,mt03_named+sizeof(mt03_named)}};
|
||||
for(const auto& image:legacy) {
|
||||
Preferences::reset();Preferences::blob=image;MyMesh old;old.stored[0]=channel("#mt-longfast");old.beginMeshtext();
|
||||
assert(old._mt_active && old._mt_settings_valid && !old._mt_rebroadcast && Preferences::writes==0);
|
||||
assert(Preferences::blob==image && receiveFrame(old,relay_short,sizeof(relay_short)));
|
||||
old.advance(100000);assertUntouched(old);assert(!pendingRelay(old));
|
||||
if(image.size()==40 && image[12]) {
|
||||
assert(!strcmp(old._mt_short_name,"OLD") && old._mt_node_override==0x12345678 && old._mt_node==0x12345678);
|
||||
}
|
||||
const uint32_t old_node=old._mt_node,old_override=old._mt_node_override,old_slot=old._mt_slot;
|
||||
const uint8_t old_hops=old._mt_hop_limit;const std::string old_name=old._mt_short_name;
|
||||
assert(old.handleMeshtextCommand("set mt.rebroadcast on",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
|
||||
assert(old._mt_rebroadcast && !memcmp(Preferences::blob.data(),"MT04",4) && Preferences::writes==1);
|
||||
assert(old._mt_node==old_node && old._mt_node_override==old_override && old._mt_slot==old_slot
|
||||
&& old._mt_hop_limit==old_hops && old_name==old._mt_short_name);assertUntouched(old);
|
||||
}
|
||||
puts("PASS rebroadcast-settings");
|
||||
}
|
||||
static void rebroadcastTiming() {
|
||||
Preferences::reset();MyMesh off;active(off);
|
||||
assert(receiveFrame(off,relay_short,sizeof(relay_short)));off.advance(100000);
|
||||
assert(!pendingRelay(off) && off.offline.size()==1);assertUntouched(off);
|
||||
for(uint32_t jitter:{0U,200U}) {
|
||||
Preferences::reset();MyMesh node;active(node);enableRelay(node);node.random.jitter=jitter;
|
||||
const auto packet=node._mt_packet_id;
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short)));assertRelayBytes(node,relay_short,sizeof(relay_short));
|
||||
const auto due=pendingRelay(node)->due;assert(uint32_t(due-millis())==50+jitter/10);
|
||||
assert(node.radio.airtime_calls.back()==std::make_pair(uint8_t(1),int(sizeof(relay_short))));
|
||||
node.advance(uint32_t(due-millis())-1);assertUntouched(node);
|
||||
node.advance(1);assert(node.foreign_pending && node.foreign.size()==1);
|
||||
assert(node.foreign.back()==plannedRelay(node,relay_short,sizeof(relay_short)));
|
||||
assert(node.foreign_profile==1 && node.foreign_generation==node.radio.p.generation[1]);
|
||||
assert(node._mt_packet_id==packet && node._mt_received==1);
|
||||
node.completeForeign();assert(!pendingRelay(node) && node._mt_sent==1 && !node._mt_retried && !node._mt_exhausted);
|
||||
for(bool pending:node._mt_nodeinfo_pending) assert(!pending);
|
||||
for(bool announced:node._mt_nodeinfo_announced) assert(!announced);
|
||||
node.advance(3600000);assert(node.foreign.size()==1 && node._mt_packet_id==packet && !node.meshcore_sends);
|
||||
}
|
||||
Preferences::reset();MyMesh wrap;active(wrap);enableRelay(wrap);g_mock_millis=UINT32_MAX-20;
|
||||
assert(receiveFrame(wrap,relay_short,sizeof(relay_short)));assert(uint32_t(pendingRelay(wrap)->due-millis())==50);
|
||||
wrap.advance(49);assertUntouched(wrap);wrap.advance(1);assert(wrap.foreign_pending);wrap.completeForeign();
|
||||
Preferences::reset();MyMesh zero;active(zero);enableRelay(zero);zero.radio.fixed_airtime=0;
|
||||
assert(receiveFrame(zero,relay_short,sizeof(relay_short)));zero.advance(100000);assert(!pendingRelay(zero));assertUntouched(zero);
|
||||
// A later explicit chat, rather than relaying by itself, announces the
|
||||
// standard CLIENT role with no position / direct-message capability.
|
||||
const uint8_t expected_info[]=@NODEINFO_REBROADCAST@;
|
||||
Preferences::reset();MyMesh named;active(named);enableRelay(named);named._prefs.flood_retry_attempts=0;
|
||||
assert(receiveFrame(named,relay_short,sizeof(relay_short)));advanceTo(named,pendingRelay(named)->due);named.completeForeign();
|
||||
named.advance(1000);assert(named.foreign.size()==1);
|
||||
assert(named.sendMeshtextText(named.stored[0],"human",5));named.completeForeign();named.advance(250);
|
||||
assert(named.foreign.size()==3 && named.foreign.back()==std::vector<uint8_t>(expected_info,expected_info+sizeof(expected_info)));
|
||||
puts("PASS rebroadcast-timing");
|
||||
}
|
||||
static void rebroadcastDuplicates() {
|
||||
for(uint8_t remaining:{uint8_t(2),uint8_t(0)}) {
|
||||
Preferences::reset();MyMesh node;active(node);enableRelay(node);
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short)));const auto due=pendingRelay(node)->due;
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short))); // Original sender's retry is not a relay.
|
||||
assert(pendingRelay(node) && pendingRelay(node)->due==due && node.offline.size()==1);
|
||||
auto peer=plannedRelay(node,relay_short,sizeof(relay_short));peer[12]=(peer[12]&0xf8)|remaining;
|
||||
peer[14]=0x42;peer[15]=0x43;assert(receiveFrame(node,peer.data(),peer.size()));
|
||||
assert(!pendingRelay(node) && node.offline.size()==1 && !node._mt_heard);
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short)));node.advance(100000);
|
||||
assert(!pendingRelay(node));assertUntouched(node);
|
||||
}
|
||||
Preferences::reset();MyMesh exact;active(exact);enableRelay(exact);
|
||||
assert(receiveFrame(exact,relay_short,sizeof(relay_short)));
|
||||
const auto expected=plannedRelay(exact,relay_short,sizeof(relay_short));
|
||||
for(unsigned changed:{0U,4U,8U,12U,13U,16U}) {
|
||||
auto wrong=expected;wrong[changed]^=changed==12 ? 0x10:1;
|
||||
receiveFrame(exact,wrong.data(),wrong.size());assert(pendingRelay(exact));
|
||||
}
|
||||
auto shorter=expected;shorter.pop_back();receiveFrame(exact,shorter.data(),shorter.size());assert(pendingRelay(exact));
|
||||
assert(!exact.onForeignRadioFrame(expected.data(),expected.size(),0,exact.radio.p.generation[1],3,-90));
|
||||
assert(!exact.onForeignRadioFrame(expected.data(),expected.size(),1,exact.radio.p.generation[1]-1,3,-90));
|
||||
assert(pendingRelay(exact));
|
||||
for(bool inflight:{false,true}) {
|
||||
Preferences::reset();MyMesh node;active(node);enableRelay(node);
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short)));advanceTo(node,pendingRelay(node)->due);
|
||||
assert(node.foreign_pending);node.foreign_sending=inflight;
|
||||
auto peer=plannedRelay(node,relay_short,sizeof(relay_short));const auto failed=node._mt_failed;
|
||||
assert(receiveFrame(node,peer.data(),peer.size()));
|
||||
assert(node.foreign_pending==inflight);
|
||||
if(inflight) {assert(pendingRelay(node)->cancelled);node.completeForeign();}
|
||||
assert(!pendingRelay(node) && node._mt_failed==failed && !node._mt_retried);
|
||||
node.advance(100000);assert(node.foreign.size()==1);
|
||||
for(bool pending:node._mt_nodeinfo_pending) assert(!pending);
|
||||
}
|
||||
puts("PASS rebroadcast-duplicates");
|
||||
}
|
||||
static void rebroadcastLifecycle() {
|
||||
for(unsigned change=0;change<7;++change) {
|
||||
Preferences::reset();MyMesh node;active(node);enableRelay(node);
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short)));assert(pendingRelay(node));
|
||||
char reply[256];
|
||||
switch(change) {
|
||||
case 0:assert(node.handleMeshtextCommand("set mt.rebroadcast off",reply,sizeof(reply)) && !strncmp(reply,"OK",2));break;
|
||||
case 1:node.commit(0,{});break;
|
||||
case 2:node.commit(0,channel("#mt-longfast",32));break;
|
||||
case 3:node.self_id.pub_key[0]=43;node.refreshMeshtextIdentity();break;
|
||||
case 4:node._radio_available=false;break;
|
||||
case 5:node.cli("set radio2 off");break;
|
||||
case 6:node.cli("set tempradio2 909.5,500,5,5,rx,1,32");break;
|
||||
}
|
||||
node.advance(2000);node.advance(100000);
|
||||
assert(!pendingRelay(node) && !node.foreign_pending && !node._mt_retried && !node._mt_exhausted);assertUntouched(node);
|
||||
}
|
||||
Preferences::reset();MyMesh expired;active(expired);enableRelay(expired);
|
||||
assert(receiveFrame(expired,relay_short,sizeof(relay_short)));expired.admit_foreign=false;
|
||||
advanceTo(expired,pendingRelay(expired)->due);assert(pendingRelay(expired));
|
||||
expired.advance(59999);assert(pendingRelay(expired));expired.advance(2);assert(!pendingRelay(expired));
|
||||
expired.admit_foreign=true;expired.advance(100000);assertUntouched(expired);
|
||||
Preferences::reset();MyMesh priority;active(priority);enableRelay(priority);
|
||||
assert(receiveFrame(priority,relay_short,sizeof(relay_short)));const auto packet=priority._mt_packet_id;
|
||||
assert(priority.sendMeshtextText(priority.stored[0],"human",5));
|
||||
assert(!pendingRelay(priority) && priority.foreign_pending && priority.foreign.size()==1 && priority._mt_packet_id==packet+1);
|
||||
mesh::mt::DecodedText decoded;const auto& raw=priority.foreign.back();
|
||||
assert(mesh::mt::decodeText(raw.data(),raw.size(),"LongFast",mesh::mt::DefaultPsk,16,1,decoded));
|
||||
assert(!strcmp(decoded.text,"human") && decoded.from==priority._mt_node);
|
||||
for(unsigned invalid=0;invalid<3;++invalid) {
|
||||
Preferences::reset();MyMesh node;active(node);enableRelay(node);
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short)));
|
||||
const auto retained=*pendingRelay(node);std::string oversized(229,'x');
|
||||
const char invalid_utf8[]={char(0xc0),char(0xaf)};const char embedded_nul[]={'x',0,'y'};
|
||||
const char* text=invalid==0 ? oversized.data():invalid==1 ? invalid_utf8:embedded_nul;
|
||||
const size_t length=invalid==0 ? oversized.size():invalid==1 ? sizeof(invalid_utf8):sizeof(embedded_nul);
|
||||
assert(!node.sendMeshtextText(node.stored[0],text,length));
|
||||
const auto* current=pendingRelay(node);
|
||||
assert(current && current->length==retained.length && current->due==retained.due && !current->cancelled);
|
||||
assert(!memcmp(current->bytes,retained.bytes,retained.length));assertUntouched(node);
|
||||
}
|
||||
for(bool inflight:{false,true}) {
|
||||
Preferences::reset();MyMesh node;active(node);enableRelay(node);
|
||||
assert(receiveFrame(node,relay_short,sizeof(relay_short)));advanceTo(node,pendingRelay(node)->due);
|
||||
node.foreign_sending=inflight;const auto packet=node._mt_packet_id,failed=node._mt_failed;
|
||||
const bool accepted=node.sendMeshtextText(node.stored[0],"priority",8);
|
||||
const uint32_t after_attempt=node._mt_packet_id;
|
||||
assert(after_attempt>=packet && after_attempt<=packet+1);
|
||||
assert(accepted==!inflight);
|
||||
if(inflight) {
|
||||
assert(node.foreign_pending && pendingRelay(node)->cancelled);
|
||||
node.completeForeign();assert(!pendingRelay(node));
|
||||
assert(node.sendMeshtextText(node.stored[0],"priority",8));
|
||||
}
|
||||
assert(node.foreign_pending && !pendingRelay(node) && node._mt_packet_id==after_attempt+unsigned(inflight) && node._mt_failed==failed);
|
||||
mesh::mt::DecodedText sent;const auto& bytes=node.foreign.back();
|
||||
assert(mesh::mt::decodeText(bytes.data(),bytes.size(),"LongFast",mesh::mt::DefaultPsk,16,1,sent));
|
||||
assert(!strcmp(sent.text,"priority") && sent.from==node._mt_node && sent.id==node._mt_packet_id-1);
|
||||
}
|
||||
Preferences::reset();MyMesh bounded;active(bounded);enableRelay(bounded);
|
||||
assert(receiveFrame(bounded,relay_short,sizeof(relay_short)));const auto retained=pendingRelay(bounded)->length;
|
||||
assert(receiveFrame(bounded,relay_long,sizeof(relay_long)));
|
||||
assert(pendingRelay(bounded)->length==retained && bounded.offline.size()==2);
|
||||
Preferences::reset();MyMesh full;active(full);enableRelay(full);
|
||||
for(unsigned i=0;i<mesh::mt::ChatDeliverySlots;++i) {
|
||||
assert(full.sendMeshtextText(full.stored[0],"local",5));full.completeForeign();
|
||||
}
|
||||
const auto count=full.foreign.size();assert(receiveFrame(full,relay_short,sizeof(relay_short)));
|
||||
assert(!pendingRelay(full) && full.foreign.size()==count && full.offline.size()==1);
|
||||
puts("PASS rebroadcast-lifecycle");
|
||||
}
|
||||
static void rebroadcastGuardsAndPhy() {
|
||||
for(unsigned invalid=0;invalid<7;++invalid) {
|
||||
Preferences::reset();MyMesh node;active(node);enableRelay(node);
|
||||
std::vector<uint8_t> raw(relay_short,relay_short+sizeof(relay_short));
|
||||
if(invalid==0) raw[12]&=0xf8; // No hops left.
|
||||
if(invalid==1) raw[12]=(1U<<5)|3; // Malformed hop_start < hop_limit.
|
||||
if(invalid==2) raw[14]=1; // Explicit next-hop routing is not a broadcast relay.
|
||||
if(invalid==3) raw[0]=1; // A DM / non-broadcast destination.
|
||||
if(invalid==4) raw.assign(relay_nodeinfo,relay_nodeinfo+sizeof(relay_nodeinfo));
|
||||
if(invalid==5) raw.assign(rx_other,rx_other+sizeof(rx_other));
|
||||
if(invalid==6) raw.resize(mesh::mt::HeaderSize); // Opaque/truncated ciphertext.
|
||||
receiveFrame(node,raw.data(),raw.size());node.advance(100000);
|
||||
assert(!pendingRelay(node));assertUntouched(node);
|
||||
}
|
||||
Preferences::reset();MyMesh capacity;active(capacity);enableRelay(capacity);capacity.offline_capacity=0;
|
||||
assert(receiveFrame(capacity,relay_short,sizeof(relay_short)));
|
||||
assert(!pendingRelay(capacity) && !capacity._mt_seen.seen(0x11223344,201));
|
||||
capacity.offline_capacity=4;assert(receiveFrame(capacity,relay_short,sizeof(relay_short)));assert(pendingRelay(capacity));
|
||||
for(unsigned key_bytes:{16U,32U}) {
|
||||
Preferences::reset();MyMesh node;node.beginMeshtext();
|
||||
node.cli(key_bytes==16 ? "set radio2 909.5,500,7,5,rxtx,32":"set radio2 909.5,125,9,5,rxtx,32");
|
||||
node.advance(2000);node.commit(0,channel("mt-Team",key_bytes));assert(node._mt_active);enableRelay(node);
|
||||
node.radio.physical_airtime=true;node.radio.airtime_calls.clear();node.random.jitter=key_bytes==16 ? 0:200;
|
||||
const auto* raw=key_bytes==16 ? relay_private128:relay_private256;
|
||||
const size_t length=key_bytes==16 ? sizeof(relay_private128):sizeof(relay_private256);
|
||||
const uint32_t airtime=node.radio.actualProfileAirtime(1,length);
|
||||
const auto packet=node._mt_packet_id;
|
||||
assert(receiveFrame(node,raw,length));assertRelayBytes(node,raw,length);
|
||||
assert((node.radio.airtime_calls==std::vector<std::pair<uint8_t,int>>({{1,int(length)}})));
|
||||
const uint32_t delay=airtime*(key_bytes==16 ? 5:7);
|
||||
assert(uint32_t(pendingRelay(node)->due-millis())==delay && delay>0);
|
||||
node.advance(delay-1);assertUntouched(node);node.advance(1);
|
||||
assert(node.foreign_pending && node.foreign.back()==plannedRelay(node,raw,length));
|
||||
node.completeForeign();node.advance(100000);
|
||||
assert(node.foreign.size()==1 && node._mt_packet_id==packet && !node._mt_retried && !node._mt_exhausted);
|
||||
}
|
||||
Preferences::reset();MyMesh wrong_key;active(wrong_key);enableRelay(wrong_key);
|
||||
assert(!receiveFrame(wrong_key,relay_private256,sizeof(relay_private256)));
|
||||
wrong_key.advance(100000);assert(!pendingRelay(wrong_key));assertUntouched(wrong_key);
|
||||
puts("PASS rebroadcast-guards-phy");
|
||||
}
|
||||
int main(int argc,char** argv) {
|
||||
assert(argc==2);
|
||||
if(!strcmp(argv[1],"keys"))keys();
|
||||
@@ -1336,5 +1627,10 @@ int main(int argc,char** argv) {
|
||||
else if(!strcmp(argv[1],"announcement-lifecycle"))announcementLifecycle();
|
||||
else if(!strcmp(argv[1],"advert-name-delivery"))advertNameDelivery();
|
||||
else if(!strcmp(argv[1],"readiness-cache"))readinessCache();
|
||||
else if(!strcmp(argv[1],"rebroadcast-settings"))rebroadcastSettings();
|
||||
else if(!strcmp(argv[1],"rebroadcast-timing"))rebroadcastTiming();
|
||||
else if(!strcmp(argv[1],"rebroadcast-duplicates"))rebroadcastDuplicates();
|
||||
else if(!strcmp(argv[1],"rebroadcast-lifecycle"))rebroadcastLifecycle();
|
||||
else if(!strcmp(argv[1],"rebroadcast-guards-phy"))rebroadcastGuardsAndPhy();
|
||||
else assert(!"unknown test case");
|
||||
}
|
||||
@@ -82,6 +82,10 @@ class CompanionMeshtextTest(unittest.TestCase):
|
||||
fixture = (ROOT / "test/fixtures/radio_profiles/cli_test.cpp").read_text(encoding="utf-8")
|
||||
fixture = fixture[:fixture.index("static void permanentScheduleOrder()")]
|
||||
text = (ROOT / "test/fixtures/companion_meshtext.cpp").read_text(encoding="utf-8")
|
||||
radio_source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text(encoding="utf-8")
|
||||
airtime = body(radio_source, "uint32_t RadioLibWrapper::getProfileAirtime(")
|
||||
airtime = "uint32_t actualProfileAirtime(uint8_t profile, int len, uint8_t cr=0) " + airtime[airtime.index("{"):]
|
||||
text = text.replace("@PROFILE_AIRTIME@", airtime)
|
||||
payload = ("A" * 153 + "\u20ac" + "B" * 69).encode() # 225 bytes, truncation splits a scalar
|
||||
vectors = {
|
||||
"@RX_FRAME@": reference_frame(data_text(payload), packet_id=101),
|
||||
@@ -89,30 +93,40 @@ class CompanionMeshtextTest(unittest.TestCase):
|
||||
"@RX_DM@": reference_frame(data_text(b"dm"), to=0x99887766, packet_id=103),
|
||||
"@RX_ACK@": reference_frame(data_text(b"want ack") + b"\x18\x01", flags=0x6B, packet_id=104),
|
||||
"@RX_OTHER@": reference_frame(b"\x08\x02\x12\x01x", packet_id=105),
|
||||
"@RELAY_SHORT@": reference_frame(data_text(b"relay short"), packet_id=201),
|
||||
"@RELAY_LONG@": reference_frame(data_text(b"L" * 180), packet_id=202),
|
||||
"@RELAY_NODEINFO@": reference_frame(b"\x08\x04\x12\x00", packet_id=203),
|
||||
"@RELAY_PRIVATE128@": reference_frame(data_text(b"private relay"), "Team", bytes(range(1, 17)), packet_id=204),
|
||||
"@RELAY_PRIVATE256@": reference_frame(data_text(b"L" * 180), "Team", bytes(range(1, 33)), packet_id=205),
|
||||
}
|
||||
# Independent standard User/NodeInfo wire expectations; never encode
|
||||
# an expected NodeInfo through the production codec under test.
|
||||
def nodeinfo_frame(sender, packet_id, long_name, short_name, name="LongFast", key=None):
|
||||
def nodeinfo_frame(sender, packet_id, long_name, short_name, name="LongFast", key=None,
|
||||
rebroadcast=False):
|
||||
def field(number, value):
|
||||
return varint((number << 3) | 2) + varint(len(value)) + value
|
||||
user = field(1, ("!%08x" % sender).encode())
|
||||
if long_name:
|
||||
user += field(2, long_name)
|
||||
user += field(3, short_name) + b"\x38\x01\x48\x01" # CLIENT_MUTE; no DMs
|
||||
user += field(3, short_name) + b"\x38" + varint(0 if rebroadcast else 1) + b"\x48\x01"
|
||||
data = b"\x08\x04" + field(2, user) + b"\x48\x01" # NODEINFO_APP; MQTT consent only
|
||||
args = {} if key is None else {"key": key}
|
||||
return reference_frame(data, name, sender=sender, packet_id=packet_id, flags=0xE7, **args)
|
||||
default_node = int.from_bytes(hashlib.sha256(bytes([42]) + bytes(31)).digest()[:4], "little")
|
||||
vectors.update({
|
||||
"@RELAY_NODEINFO@": nodeinfo_frame(0x11223344, 203, b"Peer", b"PEER"),
|
||||
"@NODEINFO_DEFAULT@": nodeinfo_frame(default_node, 0x12345678, b"Test",
|
||||
("%04x" % (default_node & 0xFFFF)).encode()),
|
||||
"@NODEINFO_AFTER_CHAT@": nodeinfo_frame(default_node, 0x12345679, b"Test",
|
||||
("%04x" % (default_node & 0xFFFF)).encode()),
|
||||
"@NODEINFO_REBROADCAST@": nodeinfo_frame(default_node, 0x12345679, b"Test",
|
||||
("%04x" % (default_node & 0xFFFF)).encode(),
|
||||
rebroadcast=True),
|
||||
"@NODEINFO_RENAMED@": nodeinfo_frame(default_node, 0x1234567A, b"Renamed",
|
||||
("%04x" % (default_node & 0xFFFF)).encode()),
|
||||
"@NODEINFO_NAMED@": nodeinfo_frame(0x12345678, 0x12345679, b"Mike Carper", b"MIKE"),
|
||||
"@NODEINFO_UTF8@": nodeinfo_frame(0x12345678, 0x1234567B, b"A" * 22, "\U0001f600".encode()),
|
||||
"@NODEINFO_PRIVATE@": nodeinfo_frame(0x12345678, 0x1234567C, b"A" * 22,
|
||||
"@NODEINFO_NAMED@": nodeinfo_frame(0x12345678, 0x1234567A, b"Mike Carper", b"MIKE"),
|
||||
"@NODEINFO_UTF8@": nodeinfo_frame(0x12345678, 0x1234567C, b"A" * 22, "\U0001f600".encode()),
|
||||
"@NODEINFO_PRIVATE@": nodeinfo_frame(0x12345678, 0x1234567D, b"A" * 22,
|
||||
"\U0001f600".encode(), "Team", bytes(range(1, 33))),
|
||||
})
|
||||
text = text.replace("@FIXTURE@", fixture).replace("@CONSTANTS@", "\n".join(constants))
|
||||
@@ -130,6 +144,17 @@ class CompanionMeshtextTest(unittest.TestCase):
|
||||
legacy_hops[5] = 3
|
||||
legacy_hops[12:] = hashlib.sha256(legacy_hops[:12]).digest()[:4]
|
||||
text = text.replace("@LEGACY_HOPS_SETTINGS@", array(legacy_hops))
|
||||
mt03 = bytearray(40)
|
||||
mt03[:4] = b"MT03"
|
||||
mt03[4:6] = bytes((1, 3))
|
||||
mt03[8:12] = (2).to_bytes(4, "little")
|
||||
mt03[36:] = hashlib.sha256(mt03[:36]).digest()[:4]
|
||||
text = text.replace("@LEGACY_MT03_SETTINGS@", array(mt03))
|
||||
mt03_named = bytearray(mt03)
|
||||
mt03_named[12:15] = b"OLD"
|
||||
mt03_named[32:36] = (0x12345678).to_bytes(4, "little")
|
||||
mt03_named[36:] = hashlib.sha256(mt03_named[:36]).digest()[:4]
|
||||
text = text.replace("@LEGACY_MT03_NAMED@", array(mt03_named))
|
||||
public_key = bytes([42]) + bytes(31)
|
||||
text = text.replace("@LOCAL_NODE@", str(int.from_bytes(hashlib.sha256(public_key).digest()[:4], "little")))
|
||||
for first in (43, 44):
|
||||
@@ -246,6 +271,21 @@ class CompanionMeshtextTest(unittest.TestCase):
|
||||
def test_same_tuple_publish_busy_release_refreshes_without_stealing_external_owner(self):
|
||||
self.run_case("readiness-cache")
|
||||
|
||||
def test_rebroadcast_is_opt_in_persisted_atomically_and_legacy_settings_default_off(self):
|
||||
self.run_case("rebroadcast-settings")
|
||||
|
||||
def test_rebroadcast_waits_five_exact_airtimes_plus_jitter_and_never_arms_local_metadata(self):
|
||||
self.run_case("rebroadcast-timing")
|
||||
|
||||
def test_rebroadcast_duplicates_cancel_before_seen_shortcut_and_inflight_is_not_unsent(self):
|
||||
self.run_case("rebroadcast-duplicates")
|
||||
|
||||
def test_rebroadcast_stale_work_expires_or_cancels_and_explicit_chat_has_priority(self):
|
||||
self.run_case("rebroadcast-lifecycle")
|
||||
|
||||
def test_rebroadcast_requires_decoded_broadcast_text_capacity_and_uses_secondary_phy(self):
|
||||
self.run_case("rebroadcast-guards-phy")
|
||||
|
||||
def test_delivery_service_is_wired_before_radio_processing_and_paused_for_morse(self):
|
||||
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8")
|
||||
loop = body(source, "void MyMesh::loop()")
|
||||
|
||||
+117
-7
@@ -32,14 +32,14 @@ def data_text(text):
|
||||
return b"\x08\x01\x12" + varint(len(text)) + text
|
||||
|
||||
|
||||
def data_nodeinfo(long_name, short_name, sender):
|
||||
def data_nodeinfo(long_name, short_name, sender, rebroadcast=False):
|
||||
# Independent handwritten schema oracle: User fields 1/2/3/7/9 only.
|
||||
user_id = ("!%08x" % sender).encode("ascii")
|
||||
user = b"\x0a" + varint(len(user_id)) + user_id
|
||||
if long_name:
|
||||
user += b"\x12" + varint(len(long_name)) + long_name
|
||||
user += b"\x1a" + varint(len(short_name)) + short_name
|
||||
user += b"\x38\x01\x48\x01" # CLIENT_MUTE=1, is_unmessagable=true.
|
||||
user += b"\x38" + bytes([0 if rebroadcast else 1]) + b"\x48\x01"
|
||||
return b"\x08\x04\x12" + varint(len(user)) + user + b"\x48\x01"
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ def literal(value):
|
||||
|
||||
HARNESS = r'''
|
||||
#include <helpers/MeshtextChat.h>
|
||||
#include <helpers/MeshtextDelivery.h>
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -84,6 +86,7 @@ struct NodeInfoGolden {
|
||||
uint32_t from, id; uint8_t hops; uint8_t frame[255]; size_t frame_len;
|
||||
};
|
||||
static const NodeInfoGolden node_info_golden[] = { @NODEINFO_GOLDEN@ };
|
||||
static const NodeInfoGolden relay_node_info_golden[] = { @RELAY_NODEINFO_GOLDEN@ };
|
||||
struct Malformed { const char* label; uint8_t frame[255]; size_t length; };
|
||||
static const Malformed malformed[] = { @MALFORMED@ };
|
||||
struct Modem {
|
||||
@@ -260,6 +263,100 @@ static void node_info_bounds() {
|
||||
assert(encodeNodeInfo(4, 1, 3, "LongFast", DefaultPsk, 16, "\xc3\xa9", 2, "\xf0\x9f\x9a\x80", 4, frame));
|
||||
puts("PASS nodeinfo_bounds");
|
||||
}
|
||||
static void node_info_roles() {
|
||||
for (size_t i = 0; i < sizeof(node_info_golden) / sizeof(node_info_golden[0]); ++i) {
|
||||
const auto& original = node_info_golden[i];
|
||||
const auto& expected = relay_node_info_golden[i];
|
||||
uint8_t passive[MaxFrameSize], explicit_false[MaxFrameSize], forwarding[MaxFrameSize];
|
||||
const size_t count = encodeNodeInfo(original.from, original.id, original.hops,
|
||||
original.name, original.key, original.key_len, original.long_name,
|
||||
original.long_name_len, original.short_name, original.short_name_len, passive);
|
||||
assert(count == original.frame_len && !memcmp(passive, original.frame, count));
|
||||
assert(encodeNodeInfo(original.from, original.id, original.hops, original.name,
|
||||
original.key, original.key_len, original.long_name, original.long_name_len,
|
||||
original.short_name, original.short_name_len, explicit_false, false) == count);
|
||||
assert(!memcmp(passive, explicit_false, count));
|
||||
assert(encodeNodeInfo(expected.from, expected.id, expected.hops, expected.name,
|
||||
expected.key, expected.key_len, expected.long_name, expected.long_name_len,
|
||||
expected.short_name, expected.short_name_len, forwarding, true) == count);
|
||||
assert(count == expected.frame_len && !memcmp(forwarding, expected.frame, count));
|
||||
assert(!memcmp(passive, forwarding, HeaderSize));
|
||||
size_t changes = 0;
|
||||
for (size_t offset = HeaderSize; offset < count; ++offset) {
|
||||
if (passive[offset] != forwarding[offset]) {
|
||||
++changes;
|
||||
assert((passive[offset] ^ forwarding[offset]) == 1);
|
||||
}
|
||||
}
|
||||
assert(changes == 1); // Only User.role changes; CTR leaves all other bytes identical.
|
||||
DecodedText decoded;
|
||||
assert(!decodeText(forwarding, count, expected.name, expected.key,
|
||||
expected.key_len, 0x99887766, decoded)); // Still not text/GPS/DM.
|
||||
}
|
||||
puts("PASS nodeinfo_roles");
|
||||
}
|
||||
static void relay_matching() {
|
||||
struct PreviousPendingFrame {
|
||||
uint8_t bytes[MaxFrameSize] = {}, fingerprint[8] = {};
|
||||
uint32_t generation = 0, due = 0;
|
||||
uint16_t length = 0;
|
||||
uint8_t channel = 0, retries = 0;
|
||||
bool nodeinfo = false, sent = false, heard = false, cancelled = false;
|
||||
};
|
||||
static_assert(sizeof(PendingFrame) == sizeof(PreviousPendingFrame), "relay flag must reuse padding");
|
||||
static_assert(offsetof(PendingFrame, generation) == offsetof(PreviousPendingFrame, generation),
|
||||
"relay flag must not move existing aligned state");
|
||||
static_assert(offsetof(PendingFrame, relayed) == offsetof(PendingFrame, fingerprint) + 8,
|
||||
"relay flag belongs immediately after fingerprint");
|
||||
const auto& golden_frame = golden[0];
|
||||
PendingFrame pending;
|
||||
pending.length = golden_frame.frame_len;
|
||||
memcpy(pending.bytes, golden_frame.frame, pending.length);
|
||||
pending.sent = true;
|
||||
uint8_t copy[MaxFrameSize]; memcpy(copy, pending.bytes, pending.length);
|
||||
assert(!pending.relayed && !pending.matchesRelayCopy(copy, pending.length));
|
||||
assert(!pending.matchesEcho(copy, pending.length)); // Original copy has not been relayed.
|
||||
copy[12] = (copy[12] & 0xf8) | 2;
|
||||
assert(pending.matchesEcho(copy, pending.length));
|
||||
pending.relayed = true;
|
||||
assert(!pending.matchesEcho(copy, pending.length)); // Relays are not originated delivery ACKs.
|
||||
pending.bytes[12] = (pending.bytes[12] & 0xf8) | 2; // Planned relay remaining hops.
|
||||
pending.sent = false; // Cancellation is allowed before the first relay TX.
|
||||
assert(pending.matchesRelayCopy(copy, pending.length));
|
||||
for (uint8_t remaining : {0, 1, 2}) {
|
||||
copy[12] = (pending.bytes[12] & 0xf8) | remaining;
|
||||
copy[14] = 0x12; copy[15] = 0x34; // Mutable routing bytes do not defeat cancellation.
|
||||
assert(pending.matchesRelayCopy(copy, pending.length));
|
||||
}
|
||||
for (uint8_t remaining : {3, 4, 5, 6, 7}) {
|
||||
copy[12] = (pending.bytes[12] & 0xf8) | remaining;
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length));
|
||||
}
|
||||
for (size_t offset = 0; offset < pending.length; ++offset) {
|
||||
memcpy(copy, pending.bytes, pending.length);
|
||||
if (offset == 12 || offset == 14 || offset == 15) continue;
|
||||
copy[offset] ^= 1;
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length));
|
||||
}
|
||||
for (uint8_t flag : {0x08, 0x10, 0x20, 0x40, 0x80}) {
|
||||
memcpy(copy, pending.bytes, pending.length); copy[12] ^= flag;
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length));
|
||||
}
|
||||
memcpy(copy, pending.bytes, pending.length);
|
||||
assert(!pending.matchesRelayCopy(nullptr, pending.length));
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length - 1));
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length + 1));
|
||||
pending.relayed = false;
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length));
|
||||
pending.relayed = true;
|
||||
pending.length = HeaderSize;
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length));
|
||||
pending.length = MaxFrameSize + 1;
|
||||
assert(!pending.matchesRelayCopy(copy, pending.length));
|
||||
pending.clear();
|
||||
assert(!pending.matchesRelayCopy(copy, 0));
|
||||
puts("PASS relay_matching");
|
||||
}
|
||||
static void argument_bounds() {
|
||||
uint8_t frame[MaxFrameSize] = {};
|
||||
char text[MaxTextSize + 2]; memset(text, 'x', sizeof(text));
|
||||
@@ -347,6 +444,8 @@ int main(int argc, char** argv) {
|
||||
else if (!strcmp(argv[1], "malformed")) malformed_frames();
|
||||
else if (!strcmp(argv[1], "nodeinfo")) node_info_frames();
|
||||
else if (!strcmp(argv[1], "nodeinfo_bounds")) node_info_bounds();
|
||||
else if (!strcmp(argv[1], "nodeinfo_roles")) node_info_roles();
|
||||
else if (!strcmp(argv[1], "relay_matching")) relay_matching();
|
||||
else if (!strcmp(argv[1], "bounds")) argument_bounds();
|
||||
else if (!strcmp(argv[1], "extensions")) extensions();
|
||||
else if (!strcmp(argv[1], "stock_zero_bitfield")) stock_zero_bitfield_frames();
|
||||
@@ -386,15 +485,19 @@ def vectors():
|
||||
node_inputs += [(DEFAULT_KEY, "LongFast", "\u6d4b\u8bd5".encode(), "\u6d4bA".encode(), FROM, hops)
|
||||
for hops in (2, 4, 5, 6)]
|
||||
node_info = []
|
||||
relay_node_info = []
|
||||
for offset, (key, name, long_name, short_name, sender, hops) in enumerate(node_inputs):
|
||||
packet_id = PACKET_ID + offset
|
||||
frame = reference_frame(data_nodeinfo(long_name, short_name, sender), name, key,
|
||||
sender=sender, packet_id=packet_id, flags=hops | (hops << 5))
|
||||
node_info.append("{" + array(key) + "," + str(len(key)) + ',"' + name + '",'
|
||||
+ literal(long_name) + "," + str(len(long_name)) + ","
|
||||
+ literal(short_name) + "," + str(len(short_name)) + ","
|
||||
+ str(sender) + "U," + str(packet_id) + "U," + str(hops) + ","
|
||||
+ array(frame) + "," + str(len(frame)) + "}")
|
||||
prefix = ("{" + array(key) + "," + str(len(key)) + ',"' + name + '",'
|
||||
+ literal(long_name) + "," + str(len(long_name)) + ","
|
||||
+ literal(short_name) + "," + str(len(short_name)) + ","
|
||||
+ str(sender) + "U," + str(packet_id) + "U," + str(hops) + ",")
|
||||
node_info.append(prefix + array(frame) + "," + str(len(frame)) + "}")
|
||||
relay = reference_frame(data_nodeinfo(long_name, short_name, sender, rebroadcast=True),
|
||||
name, key, sender=sender, packet_id=packet_id, flags=hops | (hops << 5))
|
||||
relay_node_info.append(prefix + array(relay) + "," + str(len(relay)) + "}")
|
||||
bad_data = {
|
||||
"empty Data": b"", "missing port": b"\x12\x01x", "missing text": b"\x08\x01",
|
||||
"empty text": b"\x08\x01\x12\x00", "other app": b"\x08\x02\x12\x01x",
|
||||
@@ -441,6 +544,7 @@ def vectors():
|
||||
return (HARNESS.replace("@GOLDEN@", ",\n".join(golden)).replace("@MALFORMED@", ",\n".join(malformed))
|
||||
.replace("@STOCK_ZERO_BITFIELD@", ",\n".join(stock_zero_bitfield))
|
||||
.replace("@NODEINFO_GOLDEN@", ",\n".join(node_info))
|
||||
.replace("@RELAY_NODEINFO_GOLDEN@", ",\n".join(relay_node_info))
|
||||
.replace("@MODEMS@", ",\n".join(modems)).replace("@EXTENSION_FRAME@", array(extension))
|
||||
.replace("@REQUEST_FRAME@", array(request)))
|
||||
|
||||
@@ -536,6 +640,12 @@ class MeshtextChatTest(unittest.TestCase):
|
||||
def test_nodeinfo_stock_name_utf8_and_argument_bounds(self):
|
||||
self.run_case("nodeinfo_bounds")
|
||||
|
||||
def test_default_nodeinfo_unchanged_and_opt_in_changes_only_client_role(self):
|
||||
self.run_case("nodeinfo_roles")
|
||||
|
||||
def test_bounded_relay_copy_match_and_origin_echo_are_disjoint_without_ram_growth(self):
|
||||
self.run_case("relay_matching")
|
||||
|
||||
def test_argument_and_text_bounds(self):
|
||||
self.run_case("bounds")
|
||||
|
||||
|
||||
Reference in new issue
Block a user