Implement ATAK Plugin V2 and drop unishox2 compression support (#10105)

* Implement ATAK Plugin V2 and drop unishox2 compression support

* Fix course calculation and improve error handling in allocAtakPli

* Update src/modules/AtakPluginModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix buffer overflow in allocAtakPli by ensuring null termination for callsigns

* Add missing include for concurrency::OSThread in AtakPluginModule.h

* Update src/modules/AtakPluginModule.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor allocAtakPli to use configurable team and role; improve position source mapping

* Update src/modules/PositionModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove OSThread inheritance from AtakPluginModule - pure passthrough needs no periodic scheduling

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/bdc82eb6-77c4-4711-839c-04bcbb1aa9cd

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Apply clang-format (trunk fmt)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Meadors
2026-05-26 15:16:47 -05:00
committed by GitHub
co-authored by Copilot thebentern copilot-swe-agent[bot]
parent 32dcd90abf
commit a076d97eb6
5 changed files with 83 additions and 2021 deletions
+5 -201
View File
@@ -1,207 +1,11 @@
#include "AtakPluginModule.h"
#include "Default.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerFSM.h"
#include "configuration.h"
#include "main.h"
#include "mesh/compression/unishox2.h"
#include "meshUtils.h"
#include "meshtastic/atak.pb.h"
AtakPluginModule *atakPluginModule;
AtakPluginModule::AtakPluginModule()
: ProtobufModule("atak", meshtastic_PortNum_ATAK_PLUGIN, &meshtastic_TAKPacket_msg), concurrency::OSThread("AtakPlugin")
AtakPluginModule::AtakPluginModule() : SinglePortModule("atak", meshtastic_PortNum_ATAK_PLUGIN_V2) {}
ProcessMessage AtakPluginModule::handleReceived(const meshtastic_MeshPacket &mp)
{
ourPortNum = meshtastic_PortNum_ATAK_PLUGIN;
(void)mp; // Passthrough — no processing needed, apps handle compression/decompression
return ProcessMessage::CONTINUE;
}
/*
Encompasses the full construction and sending packet to mesh
Will be used for broadcast.
*/
int32_t AtakPluginModule::runOnce()
{
return default_broadcast_interval_secs;
}
bool AtakPluginModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_TAKPacket *r)
{
return false;
}
meshtastic_TAKPacket AtakPluginModule::cloneTAKPacketData(meshtastic_TAKPacket *t)
{
meshtastic_TAKPacket clone = meshtastic_TAKPacket_init_zero;
if (t->has_group) {
clone.has_group = true;
clone.group = t->group;
}
if (t->has_status) {
clone.has_status = true;
clone.status = t->status;
}
if (t->has_contact) {
clone.has_contact = true;
clone.contact = {0};
}
if (t->which_payload_variant == meshtastic_TAKPacket_pli_tag) {
clone.which_payload_variant = meshtastic_TAKPacket_pli_tag;
clone.payload_variant.pli = t->payload_variant.pli;
} else if (t->which_payload_variant == meshtastic_TAKPacket_chat_tag) {
clone.which_payload_variant = meshtastic_TAKPacket_chat_tag;
clone.payload_variant.chat = {0};
} else if (t->which_payload_variant == meshtastic_TAKPacket_detail_tag) {
clone.which_payload_variant = meshtastic_TAKPacket_detail_tag;
clone.payload_variant.detail.size = t->payload_variant.detail.size;
memcpy(clone.payload_variant.detail.bytes, t->payload_variant.detail.bytes, t->payload_variant.detail.size);
}
return clone;
}
void AtakPluginModule::alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_TAKPacket *t)
{
// From Phone (EUD)
if (mp.from == 0) {
LOG_DEBUG("Received uncompressed TAK payload from phone: %d bytes", mp.decoded.payload.size);
// Compress for LoRA transport
auto compressed = cloneTAKPacketData(t);
compressed.is_compressed = true;
if (t->has_contact) {
auto length = unishox2_compress_lines(
t->contact.callsign, pb_string_length(t->contact.callsign, sizeof(t->contact.callsign)),
compressed.contact.callsign, sizeof(compressed.contact.callsign) - 1, USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Compress overflow contact.callsign. Revert to uncompressed packet");
return;
}
LOG_DEBUG("Compressed callsign: %d bytes", length);
length = unishox2_compress_lines(
t->contact.device_callsign, pb_string_length(t->contact.device_callsign, sizeof(t->contact.device_callsign)),
compressed.contact.device_callsign, sizeof(compressed.contact.device_callsign) - 1, USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Compress overflow contact.device_callsign. Revert to uncompressed packet");
return;
}
LOG_DEBUG("Compressed device_callsign: %d bytes", length);
}
if (t->which_payload_variant == meshtastic_TAKPacket_chat_tag) {
auto length = unishox2_compress_lines(
t->payload_variant.chat.message,
pb_string_length(t->payload_variant.chat.message, sizeof(t->payload_variant.chat.message)),
compressed.payload_variant.chat.message, sizeof(compressed.payload_variant.chat.message) - 1, USX_PSET_DFLT,
NULL);
if (length < 0) {
LOG_WARN("Compress overflow chat.message. Revert to uncompressed packet");
return;
}
LOG_DEBUG("Compressed chat message: %d bytes", length);
if (t->payload_variant.chat.has_to) {
compressed.payload_variant.chat.has_to = true;
length = unishox2_compress_lines(
t->payload_variant.chat.to, pb_string_length(t->payload_variant.chat.to, sizeof(t->payload_variant.chat.to)),
compressed.payload_variant.chat.to, sizeof(compressed.payload_variant.chat.to) - 1, USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Compress overflow chat.to. Revert to uncompressed packet");
return;
}
LOG_DEBUG("Compressed chat to: %d bytes", length);
}
if (t->payload_variant.chat.has_to_callsign) {
compressed.payload_variant.chat.has_to_callsign = true;
length = unishox2_compress_lines(
t->payload_variant.chat.to_callsign,
pb_string_length(t->payload_variant.chat.to_callsign, sizeof(t->payload_variant.chat.to_callsign)),
compressed.payload_variant.chat.to_callsign, sizeof(compressed.payload_variant.chat.to_callsign) - 1,
USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Compress overflow chat.to_callsign. Revert to uncompressed packet");
return;
}
LOG_DEBUG("Compressed chat to_callsign: %d bytes", length);
}
}
mp.decoded.payload.size = pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes),
meshtastic_TAKPacket_fields, &compressed);
LOG_DEBUG("Final payload: %d bytes", mp.decoded.payload.size);
} else {
if (!t->is_compressed) {
// Not compressed. Something is wrong
LOG_WARN("Received uncompressed TAKPacket over radio! Skip");
return;
}
// Decompress for Phone (EUD)
auto uncompressed = cloneTAKPacketData(t);
uncompressed.is_compressed = false;
if (t->has_contact) {
auto length = unishox2_decompress_lines(
t->contact.callsign, pb_string_length(t->contact.callsign, sizeof(t->contact.callsign)),
uncompressed.contact.callsign, sizeof(uncompressed.contact.callsign) - 1, USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Decompress overflow contact.callsign. Bailing out");
return;
}
LOG_DEBUG("Decompressed callsign: %d bytes", length);
length = unishox2_decompress_lines(
t->contact.device_callsign, pb_string_length(t->contact.device_callsign, sizeof(t->contact.device_callsign)),
uncompressed.contact.device_callsign, sizeof(uncompressed.contact.device_callsign) - 1, USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Decompress overflow contact.device_callsign. Bailing out");
return;
}
LOG_DEBUG("Decompressed device_callsign: %d bytes", length);
}
if (uncompressed.which_payload_variant == meshtastic_TAKPacket_chat_tag) {
auto length = unishox2_decompress_lines(
t->payload_variant.chat.message,
pb_string_length(t->payload_variant.chat.message, sizeof(t->payload_variant.chat.message)),
uncompressed.payload_variant.chat.message, sizeof(uncompressed.payload_variant.chat.message) - 1, USX_PSET_DFLT,
NULL);
if (length < 0) {
LOG_WARN("Decompress overflow chat.message. Bailing out");
return;
}
LOG_DEBUG("Decompressed chat message: %d bytes", length);
if (t->payload_variant.chat.has_to) {
uncompressed.payload_variant.chat.has_to = true;
length = unishox2_decompress_lines(
t->payload_variant.chat.to, pb_string_length(t->payload_variant.chat.to, sizeof(t->payload_variant.chat.to)),
uncompressed.payload_variant.chat.to, sizeof(uncompressed.payload_variant.chat.to) - 1, USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Decompress overflow chat.to. Bailing out");
return;
}
LOG_DEBUG("Decompressed chat to: %d bytes", length);
}
if (t->payload_variant.chat.has_to_callsign) {
uncompressed.payload_variant.chat.has_to_callsign = true;
length = unishox2_decompress_lines(
t->payload_variant.chat.to_callsign,
pb_string_length(t->payload_variant.chat.to_callsign, sizeof(t->payload_variant.chat.to_callsign)),
uncompressed.payload_variant.chat.to_callsign, sizeof(uncompressed.payload_variant.chat.to_callsign) - 1,
USX_PSET_DFLT, NULL);
if (length < 0) {
LOG_WARN("Decompress overflow chat.to_callsign. Bailing out");
return;
}
LOG_DEBUG("Decompressed chat to_callsign: %d bytes", length);
}
}
auto decompressedCopy = packetPool.allocCopy(mp);
decompressedCopy->decoded.payload.size =
pb_encode_to_bytes(decompressedCopy->decoded.payload.bytes, sizeof(decompressedCopy->decoded.payload),
meshtastic_TAKPacket_fields, &uncompressed);
service->sendToPhone(decompressedCopy);
}
return;
}
+10 -12
View File
@@ -1,11 +1,15 @@
#pragma once
#include "ProtobufModule.h"
#include "meshtastic/atak.pb.h"
#include "SinglePortModule.h"
/**
* Waypoint message handling for meshtastic
* ATAK Plugin V2 module - passthrough for ATAK_PLUGIN_V2 payloads.
* The wire format includes a leading flags byte followed by opaque payload bytes.
* Depending on the flags, the payload may be zstd dictionary-compressed or raw/uncompressed protobuf.
* Compression/decompression and payload interpretation are handled by the apps
* (Android, iOS, ATAK plugin); firmware forwards the bytes unchanged on the
* ATAK_PLUGIN_V2 port.
*/
class AtakPluginModule : public ProtobufModule<meshtastic_TAKPacket>, private concurrency::OSThread
class AtakPluginModule : public SinglePortModule
{
public:
/** Constructor
@@ -14,13 +18,7 @@ class AtakPluginModule : public ProtobufModule<meshtastic_TAKPacket>, private co
AtakPluginModule();
protected:
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_TAKPacket *t) override;
virtual void alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_TAKPacket *t) override;
/* Does our periodic broadcast */
int32_t runOnce() override;
private:
meshtastic_TAKPacket cloneTAKPacketData(meshtastic_TAKPacket *t);
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
};
extern AtakPluginModule *atakPluginModule;
extern AtakPluginModule *atakPluginModule;
+68 -29
View File
@@ -13,7 +13,6 @@
#include "configuration.h"
#include "gps/GeoCoord.h"
#include "main.h"
#include "mesh/compression/unishox2.h"
#include "meshUtils.h"
#include "meshtastic/atak.pb.h"
#include "sleep.h"
@@ -291,37 +290,77 @@ meshtastic_MeshPacket *PositionModule::allocReply()
meshtastic_MeshPacket *PositionModule::allocAtakPli()
{
LOG_INFO("Send TAK PLI packet");
LOG_INFO("Send TAK V2 PLI packet");
meshtastic_MeshPacket *mp = allocDataPacket();
mp->decoded.portnum = meshtastic_PortNum_ATAK_PLUGIN;
mp->decoded.portnum = meshtastic_PortNum_ATAK_PLUGIN_V2;
meshtastic_TAKPacket takPacket = {.is_compressed = true,
.has_contact = true,
.contact = meshtastic_Contact_init_default,
.has_group = true,
.group = {meshtastic_MemberRole_TeamMember, meshtastic_Team_Cyan},
.has_status = true,
.status =
{
.battery = powerStatus->getBatteryChargePercent(),
},
.which_payload_variant = meshtastic_TAKPacket_pli_tag,
.payload_variant = {.pli = {
.latitude_i = localPosition.latitude_i,
.longitude_i = localPosition.longitude_i,
.altitude = localPosition.altitude_hae,
.speed = localPosition.ground_speed,
.course = static_cast<uint16_t>(localPosition.ground_track),
}}};
meshtastic_TAKPacketV2 takPacket = meshtastic_TAKPacketV2_init_zero;
takPacket.cot_type_id = meshtastic_CotType_CotType_a_f_G_U_C;
auto length = unishox2_compress_lines(owner.long_name, strlen(owner.long_name), takPacket.contact.device_callsign,
sizeof(takPacket.contact.device_callsign) - 1, USX_PSET_DFLT, NULL);
LOG_DEBUG("Uncompressed device_callsign '%s' - %d bytes", owner.long_name, strlen(owner.long_name));
LOG_DEBUG("Compressed device_callsign '%s' - %d bytes", takPacket.contact.device_callsign, length);
length = unishox2_compress_lines(owner.long_name, strlen(owner.long_name), takPacket.contact.callsign,
sizeof(takPacket.contact.callsign) - 1, USX_PSET_DFLT, NULL);
mp->decoded.payload.size =
pb_encode_to_bytes(mp->decoded.payload.bytes, sizeof(mp->decoded.payload.bytes), &meshtastic_TAKPacket_msg, &takPacket);
// Use TAK config for team/role if configured, otherwise use defaults (Cyan/TeamMember)
if (moduleConfig.has_tak && moduleConfig.tak.team != meshtastic_Team_Unspecifed_Color) {
takPacket.team = moduleConfig.tak.team;
} else {
takPacket.team = meshtastic_Team_Cyan;
}
if (moduleConfig.has_tak && moduleConfig.tak.role != meshtastic_MemberRole_Unspecifed) {
takPacket.role = moduleConfig.tak.role;
} else {
takPacket.role = meshtastic_MemberRole_TeamMember;
}
takPacket.latitude_i = localPosition.latitude_i;
takPacket.longitude_i = localPosition.longitude_i;
takPacket.altitude = localPosition.altitude_hae;
takPacket.speed = localPosition.ground_speed;
// ground_track is stored as degrees * 1e5, course field expects degrees * 100
int32_t course = localPosition.ground_track / 1000;
if (course < 0)
course = 0;
else if (course > 36000)
course = 36000;
takPacket.course = static_cast<uint16_t>(course);
takPacket.battery = powerStatus->getBatteryChargePercent();
// Map position source to CoT how/geo_src/alt_src
if (config.position.fixed_position || localPosition.location_source == meshtastic_Position_LocSource_LOC_MANUAL) {
takPacket.how = meshtastic_CotHow_CotHow_h_e;
takPacket.geo_src = meshtastic_GeoPointSource_GeoPointSource_USER;
takPacket.alt_src = meshtastic_GeoPointSource_GeoPointSource_USER;
} else {
takPacket.how = meshtastic_CotHow_CotHow_m_g;
takPacket.geo_src = meshtastic_GeoPointSource_GeoPointSource_GPS;
takPacket.alt_src = meshtastic_GeoPointSource_GeoPointSource_GPS;
}
takPacket.which_payload_variant = meshtastic_TAKPacketV2_pli_tag;
takPacket.payload_variant.pli = true;
// Callsign - stored as plain string (no compression, apps handle that)
strncpy(takPacket.callsign, owner.long_name, sizeof(takPacket.callsign) - 1);
takPacket.callsign[sizeof(takPacket.callsign) - 1] = '\0';
strncpy(takPacket.device_callsign, owner.long_name, sizeof(takPacket.device_callsign) - 1);
takPacket.device_callsign[sizeof(takPacket.device_callsign) - 1] = '\0';
// Encode TAKPacketV2 protobuf, leaving room for flags byte prefix
uint8_t protobuf_bytes[sizeof(mp->decoded.payload.bytes) - 1];
size_t proto_size = pb_encode_to_bytes(protobuf_bytes, sizeof(protobuf_bytes), &meshtastic_TAKPacketV2_msg, &takPacket);
if (proto_size == 0) {
LOG_ERROR("Failed to encode TAK V2 PLI packet");
packetPool.release(mp);
return nullptr;
}
// Wire format: [flags byte][protobuf bytes]
// Flags byte 0xFF is a reserved sentinel meaning the remainder is an
// uncompressed raw protobuf payload (that is, no zstd dictionary ID is
// encoded in the flags byte). Decoders must check for this sentinel before
// applying any dictionary-ID masking/interpretation.
mp->decoded.payload.bytes[0] = 0xFF;
memcpy(mp->decoded.payload.bytes + 1, protobuf_bytes, proto_size);
mp->decoded.payload.size = proto_size + 1;
LOG_DEBUG("TAK V2 PLI payload: %zu bytes (1 flags + %zu protobuf)", mp->decoded.payload.size, proto_size);
return mp;
}