feat(companion): add device-originated message frames

Adds RESP_CODE_CONTACT_MSG_SENT_V3 (0x1E) and
RESP_CODE_CHANNEL_MSG_SENT_V3 (0x1F) so firmware that sends messages on
its own (bot auto-replies, on-device keyboard input) can mirror them into
the companion app's history, which today only ever sees inbound traffic
and messages the app itself sent. See #3225.

Both ride the existing offline-queue / MSG_WAITING / SYNC_NEXT_MESSAGE
pipe and reuse the RECV_V3 byte layout, with the pubkey field holding the
recipient instead of the sender and the SNR byte reserved. Requires
app_target_ver >= 3; there is no pre-V3 shape, so older apps are skipped.

Mainline never calls these -- nothing in companion_radio self-originates
messages -- so this is API surface for custom builds, and the linker
drops both functions when unused (identical flash usage before/after).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-19 01:18:43 +02:00
co-authored by Claude Opus 5
parent 166f75c1b6
commit 919bf106b7
3 changed files with 112 additions and 1 deletions
+42
View File
@@ -428,6 +428,7 @@ Byte 0: 0x0A
- `PACKET_CHANNEL_MSG_RECV` (0x08) or `PACKET_CHANNEL_MSG_RECV_V3` (0x11) for channel messages
- `PACKET_CONTACT_MSG_RECV` (0x07) or `PACKET_CONTACT_MSG_RECV_V3` (0x10) for contact messages
- `PACKET_CHANNEL_DATA_RECV` (0x1B) for channel data datagrams
- `PACKET_CONTACT_MSG_SENT_V3` (0x1E) or `PACKET_CHANNEL_MSG_SENT_V3` (0x1F) for messages the device itself sent
- `PACKET_NO_MORE_MSGS` (0x0A) if no messages available
**Note**: Poll this command periodically to retrieve queued messages. The device may also send `PACKET_MESSAGES_WAITING` (0x83) as a notification when messages are available.
@@ -619,6 +620,45 @@ def parse_channel_message(data):
}
```
### Device-Sent Messages
A message the device *sent* on its own (bot auto-reply, on-device keyboard
input, etc.) rather than via a host-issued `SEND_TXT_MESSAGE` /
`SEND_CHANNEL_MESSAGE`. Mainline firmware never emits these — they're for a
custom firmware that self-originates messages, delivered through the same
offline-queue / `PACKET_MESSAGES_WAITING` / `SYNC_NEXT_MESSAGE` pipe as any
other message.
Same byte layout as the RECV_V3 counterpart, except the pubkey field is the
*recipient* (not sender), and the RECV_V3 SNR byte is reserved (no SNR for a
local send). Requires `app_target_ver >= 3` — no pre-V3 shape exists.
Text Type is limited to `TXT_TYPE_PLAIN` (0) or `TXT_TYPE_CLI_DATA` (1).
`TXT_TYPE_SIGNED_PLAIN` is not emitted: it would need the 4-byte sender prefix
that RECV_V3 carries, which is meaningless when the device is the sender.
**Contact Message Sent** (`PACKET_CONTACT_MSG_SENT_V3`, 0x1E):
```
Byte 0: 0x1E (packet type)
Bytes 1-3: Reserved
Bytes 4-9: Recipient Public Key Prefix (6 bytes, hex)
Byte 10: Path Length (always 0xFF — meaningless for a local send)
Byte 11: Text Type
Bytes 12-15: Timestamp (32-bit little-endian)
Bytes 16+: Message Text (UTF-8)
```
**Channel Message Sent** (`PACKET_CHANNEL_MSG_SENT_V3`, 0x1F):
```
Byte 0: 0x1F (packet type)
Bytes 1-3: Reserved
Byte 4: Channel Index (0-7)
Byte 5: Path Length (always 0xFF — meaningless for a local send)
Byte 6: Text Type (always 0 / TXT_TYPE_PLAIN — a channel carries no CLI-data/signed-plain concept)
Bytes 7-10: Timestamp (32-bit little-endian)
Bytes 11+: Message Text (UTF-8)
```
### Sending Messages
Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)).
@@ -662,6 +702,8 @@ Byte values are authoritative; names are aliases. When reading firmware source,
| 0x11 | PACKET_CHANNEL_MSG_RECV_V3 | Channel message (V3 with SNR) |
| 0x12 | PACKET_CHANNEL_INFO | Channel information |
| 0x1B | PACKET_CHANNEL_DATA_RECV | Channel data datagram |
| 0x1E | PACKET_CONTACT_MSG_SENT_V3 | Contact message the device itself sent (see [Device-Sent Messages](#device-sent-messages)) |
| 0x1F | PACKET_CHANNEL_MSG_SENT_V3 | Channel message the device itself sent (see [Device-Sent Messages](#device-sent-messages)) |
| 0x80 | PACKET_ADVERTISEMENT | Advertisement packet |
| 0x82 | PACKET_ACK | Acknowledgment |
| 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification |
+65 -1
View File
@@ -102,6 +102,8 @@
#define RESP_CODE_CHANNEL_DATA_RECV 27
#define RESP_CODE_DEFAULT_FLOOD_SCOPE 28
#define RESP_CODE_CLI_REPLY 29 // v14+, a reply to CMD_RUN_CLI_COMMAND
#define RESP_CODE_CONTACT_MSG_SENT_V3 30 // a reply to CMD_SYNC_NEXT_MESSAGE -- device-originated msg, see queueSentMessage()
#define RESP_CODE_CHANNEL_MSG_SENT_V3 31 // a reply to CMD_SYNC_NEXT_MESSAGE -- device-originated msg, see queueSentChannelMessage()
#define MAX_CHANNEL_DATA_LENGTH (MAX_FRAME_SIZE - 9)
@@ -222,7 +224,7 @@ void MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, co
bool MyMesh::Frame::isChannelMsg() const {
return buf[0] == RESP_CODE_CHANNEL_MSG_RECV || buf[0] == RESP_CODE_CHANNEL_MSG_RECV_V3 ||
buf[0] == RESP_CODE_CHANNEL_DATA_RECV;
buf[0] == RESP_CODE_CHANNEL_DATA_RECV || buf[0] == RESP_CODE_CHANNEL_MSG_SENT_V3;
}
void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
@@ -532,6 +534,68 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
#endif
}
// Tells the connected app this device sent `text` to `to` on its own (bot
// reply, on-device keyboard, etc.) -- mainline never calls this. No pre-V3
// fallback, so a non-V3 app just doesn't get it.
void MyMesh::queueSentMessage(const ContactInfo &to, uint8_t txt_type, uint32_t timestamp, const char *text) {
if (app_target_ver < 3) return;
if (txt_type == TXT_TYPE_SIGNED_PLAIN) return; // has no sender prefix field, and we ARE the sender
int i = 0;
out_frame[i++] = RESP_CODE_CONTACT_MSG_SENT_V3;
out_frame[i++] = 0; // reserved1 (RECV_V3's SNR byte)
out_frame[i++] = 0; // reserved2
out_frame[i++] = 0; // reserved3
memcpy(&out_frame[i], to.id.pub_key, 6); // recipient, not sender
i += 6;
out_frame[i++] = 0xFF; // path_len n/a
out_frame[i++] = txt_type;
memcpy(&out_frame[i], &timestamp, 4);
i += 4;
int tlen = strlen(text); // TODO: UTF-8 ??
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
addToOfflineQueue(out_frame, i);
if (_serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
_serial->writeFrame(frame, 1);
}
}
// Channel counterpart to queueSentMessage().
void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char *text) {
if (app_target_ver < 3) return;
int i = 0;
out_frame[i++] = RESP_CODE_CHANNEL_MSG_SENT_V3;
out_frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2
out_frame[i++] = 0; // reserved3
out_frame[i++] = channel_idx;
out_frame[i++] = 0xFF; // path_len n/a
out_frame[i++] = TXT_TYPE_PLAIN;
memcpy(&out_frame[i], &timestamp, 4);
i += 4;
int tlen = strlen(text); // TODO: UTF-8 ??
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
addToOfflineQueue(out_frame, i);
if (_serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
_serial->writeFrame(frame, 1);
}
}
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
+5
View File
@@ -192,6 +192,11 @@ public:
_prefs.clearDirty();
}
// For a custom firmware's own self-originated sends (bot replies, on-device
// keyboard, etc.) -- mainline doesn't call these. See MyMesh.cpp.
void queueSentMessage(const ContactInfo &to, uint8_t txt_type, uint32_t timestamp, const char *text);
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char *text);
#if ENV_INCLUDE_GPS == 1
void applyGpsPrefs() {
sensors.setSettingValue("gps", _prefs.gps_enabled ? "1" : "0");