TAKPacket-SDK
0.7.0indexedConverts Cursor-on-Target XML into TAKPacketV2 protobufs, compresses with zstd using dual dictionaries for LoRa, sanitizes mesh XML, and encodes compact structured payloads with delta geometry.
Converts Cursor-on-Target XML into TAKPacketV2 protobufs, compresses with zstd using dual dictionaries for LoRa, sanitizes mesh XML, and encodes compact structured payloads with delta geometry.
Shared libraries for converting ATAK Cursor-on-Target (CoT) XML to Meshtastic's TAKPacketV2 protobuf format and compressing it for LoRa transport using zstd dictionary compression.
This SDK is the single source of truth for CoT conversion and compression across all Meshtastic client platforms. Each language implementation produces interoperable compressed payloads, validated by 47 shared test fixtures and 1,000+ cross-platform tests.
📚 Browse the full API reference at meshtastic.github.io/TAKPacket-SDK — generated per language from the in-source doc comments (Dokka, DocC, TypeDoc, pdoc, DocFX).
For the wire contract see WIRE_FORMAT.md; to build, test, and release see CONTRIBUTING.md.
flowchart TD
subgraph send ["Sending App"]
A["CoT XML<br/>(~400-2300 B)"]
B["TAKPacketV2<br/>Protobuf"]
C["Wire Payload<br/>(median 87B,<br/>max 184B)"]
A -->|"CotMeshSanitizer<br/>→ CotXmlParser"| B
B -->|TakCompressor| C
end
C -->|"LoRa / Meshtastic<br/>(≤237 B MTU)"| D
subgraph recv ["Receiving App"]
D["Wire Payload"]
E["TAKPacketV2<br/>Protobuf"]
F["CoT XML"]
D -->|TakCompressor| E
E -->|CotXmlBuilder| F
end
CotXmlParser extracts structured fields from a CoT XML event and maps them into a TAKPacketV2 protobuf message. The parser recognizes all major CoT categories and decomposes each into a strongly-typed payload_variant case:
flowchart TD
A[TAKPacketV2 protobuf bytes] --> B{Classify CoT type}
B -->|"Air domain\n(3rd atom = 'A')"| C["Aircraft dictionary\n(4KB) — dict ID 0x01"]
B -->|"Ground / chat / shapes /\nmarkers / routes / alerts"| D["Non-aircraft dictionary\n(512KB) — dict ID 0x00"]
C --> E["zstd compress with dictionary\n(dictID / contentSize / checksum off)"]
D --> E
E --> S["Strip 4-byte zstd magic\n(re-prepended on decode)"]
S --> H{"Raw proto ≤\ncompressed body?"}
H -->|"yes (tiny packet)"| I["Emit 0xFF flags\n+ raw protobuf"]
H -->|no| F["Prepend 1-byte flags\n(dict ID in bits 0-5)"]
I --> G["Wire payload"]
F --> G
Two pre-trained zstd dictionaries are used because aircraft and non-aircraft CoT messages have fundamentally different structural patterns. Using the wrong dictionary degrades compression past the LoRa MTU on the worst-case fixtures. The classification result is encoded into the flags byte on the wire so the receiver knows which dictionary to use for decompression — see Wire Format below. On the bundled test set the pipeline achieves:
See testdata/compression-report.md for the per-fixture breakdown, regenerated on every Kotlin test run.
The sender's dictionary selection (step 2 above) is encoded into the flags byte so the receiver can pick the matching dictionary for decompression without re-parsing the CoT type:
The compressed body carries no zstd magic number: the encoder compresses each
frame with dictID / contentSize / checksum disabled and then strips the 4-byte
zstd magic (28 B5 2F FD), re-prepending it on decode. This is done uniformly in all
five language bindings and saves ~8 bytes per packet. See WIRE_FORMAT.md
for the full specification including error handling requirements and annotated examples.
CotXmlBuilder reconstructs a standards-compliant CoT XML event from a TAKPacketV2 protobuf, preserving every structured field extracted during parsing — including geometry vertices, stroke/fill colors, marker iconsets, and route waypoints.
CotMeshSanitizer)ATAK and its plugins emit CoT XML that carries display-only <detail> children (map-rendering hints, geofences, archive flags, creator metadata, stroke styling, …) and is often pretty-printed with an <?xml?> prologue. None of that is needed by a receiver, and every byte competes for the 237-byte LoRa MTU. CotMeshSanitizer is the SDK's shared, golden-tested pre-processing pass that consumers run on raw CoT XML before handing it to CotXmlParser:
Both are pure string transforms with no protobuf, compression, or platform dependencies. In Kotlin the object lives in commonMain, so KMP consumers (e.g. Meshtastic-Android's core:takserver) can call it from their own commonMain and reuse it on iOS. These rules previously lived as ad-hoc regex lists duplicated inside Meshtastic-Android and Meshtastic-Apple — they drifted and silently broke features (most recently TAK-Talk voice/marti stopped surfacing end-to-end). Centralizing them here, with byte-for-byte cross-binding fixtures under testdata/sanitizer/, keeps every consumer in lockstep. Regexes use (not the DOTALL flag) for identical behavior across all five bindings.
TAKPacketV2.payload_variant is a proto oneof with twelve strongly-typed cases (tags 31–42) rather than a single opaque bytes field — plus an implicit thirteenth: a packet with no payload_variant set is a PLI position report (the old bool pli arm at tag 30 was removed because the boolean was pure overhead on the highest-frequency packet; tag 30 is now reserved). Decomposing the <detail> element into structured messages gives three concrete benefits:
DrawnShape doesn't break older receivers, they just see an unknown varint and skip it.Every geometry variant delta-encodes vertices as sint32 offsets from the event anchor so a 32-vertex telestration clustered inside 100m encodes in ~60 bytes of vertex data instead of ~320 bytes with absolute coordinates. stores its vertices as two packed columns ( / ); single-endpoint geometry (, ) uses a delta sub-message. Color fields use a two-field encoding: a palette enum for the 14 ATAK-standard colors (2 bytes on the wire) plus a fallback for custom user-picked colors (5 bytes). Round-trip is byte-exact — custom colors are never quantized to the nearest palette entry.
Covers ten tactical graphic kinds. The shape's anchor point lives on TAKPacketV2.latitude_i/longitude_i; geometry vertices are stored as two packed repeated sint32 columns (vertex_lat_deltas / vertex_lon_deltas), zigzag deltas from that anchor.
StyleMode discriminates StrokeOnly vs FillOnly vs StrokeAndFill, preserving the distinction between "this polyline has no fill" and "this shape has a transparent black fill".
KML Style Links: Circle and ellipse shapes include a <link type="b-x-KmlStyle"> element inside <shape> for iTAK compatibility. ATAK encodes colors in KML's ABGR hex format (not ARGB). The builder converts stroke_argb/fill_argb proto fields (ARGB int32) to ABGR hex strings automatically:
Kotlin — parse a drawing_circle and read its structured fields:
val parser = CotXmlParser()
val packet = parser.parse(drawingCircleXml)
val shape = packet.payload as TakPacketV2Data.Payload.DrawnShape
println("Kind: ${shape.kind}") // 1 = Circle
println("Radius: ${shape.majorCm / 100.0} meters")
println(.format(shape.strokeArgb))
println()
println()
Swift — extract a polygon's vertices:
Fixed markers with a Kind enum covering the common ATAK categories plus the mission-point set that ATAK CIV added in 4.x (GoTo / Initial Point / Contact Point / Observation Post) and standalone image markers. iconset holds the full iconset path verbatim (no prefix stripping) — round-trip works for COT_MAPPING_SPOTMAP/…, COT_MAPPING_2525B/…, and custom UUID/group/icon.png paths.
Python — classify a marker by kind:
packet = parser.parse(marker_2525_xml)
if packet.HasField("marker"):
marker = packet.marker
print(f"Kind: {marker.kind}") # 5 = Symbol2525
print(f"Iconset: {marker.iconset}") # "COT_MAPPING_2525B/a-u/a-u-G"
print(f"Parent UID: {marker.parent_uid}")
print(f"Readiness: ")
Single-leg range-and-bearing line. The anchor endpoint is a CotGeoPoint delta-encoded from the event point, so an anchor identical to the event (common for self-anchored RAB) encodes in zero bytes.
TypeScript — extract range, bearing, and reconstruct the anchor:
Ordered waypoint sequence with travel method and direction. Link count caps at 16 — longer routes are truncated and the truncated flag is set.
C# — iterate a route's waypoints:
var packet = parser.Parse(route3wpXml);
if (packet.PayloadVariantCase == TAKPacketV2.PayloadVariantOneofCase.Route)
{
var route = packet.Route;
Console.WriteLine();
( link route.Links)
{
lat = (packet.LatitudeI + link.Point.LatDeltaI) / ;
lon = (packet.LongitudeI + link.Point.LonDeltaI) / ;
kind = link.LinkType == ? : ;
Console.WriteLine();
}
}
9-line MEDEVAC request for CoT type b-r-f-h-c. Mirrors ATAK's MedLine <_medevac_> detail element: precedence, equipment flags bitfield, patient counts, HLZ marking method, zone marker, security at PZ, nationality counts, terrain obstacles bitfield, and comms frequency. Every field is optional so senders omit lines they don't have. The envelope carries Line 1 (location) and Line 2 (callsign).
| Bitfield | Bit layout |
|---|---|
equipment_flags | 0=none, 1=hoist, 2=extraction, 3=ventilator, 4=blood |
terrain_flags | 0=slope, 1=rough, 2=loose, 3=trees, 4=wires, 5=other |
A bare CASEVAC compresses to ~134B and a full 9-line MEDEVAC to ~160B — well under the 237B LoRa MTU even with all 9 lines populated.
Kotlin — build a CASEVAC request:
val packet = TakPacketV2Data(
cotTypeId = CotTypeMapper.typeToEnum("b-r-f-h-c"),
how = CotTypeMapper.howToEnum("h-g-i-g-o"),
callsign = "MEDEVAC-1",
uid = "medevac-01",
latitudeI = ( * ).roundToInt(),
longitudeI = ( * ).roundToInt(),
payload = TakPacketV2Data.Payload.CasevacReport(
precedence = CotXmlParser.PRECEDENCE_URGENT,
litterPatients = ,
ambulatoryPatients = ,
equipmentFlags = or ,
security = CotXmlParser.SECURITY_POSSIBLE_ENEMY,
hlzMarking = CotXmlParser.HLZ_MARKING_SMOKE,
zoneMarker = ,
frequency = ,
),
)
wire = TakCompressor().compress(packet)
Small, high-priority structured record for emergency CoT types (b-a-o-*, b-a-g). The CoT type string is still set on cot_type_id so receivers that don't handle payload_variant can still display the alert; the typed fields let modern receivers show the authoring unit and handle cancel referencing without XML parsing.
Typical self-authored alert compresses to ~72B on the wire (911) / ~79B (cancel). Cancel events reference the original alert UID via cancel_reference_uid.
Swift — read an emergency alert:
let packet = parser.parse(emergency911Xml)
if case .emergency( emergency) packet.payloadVariant {
emergency.type {
.alert911:
()
.inContact:
()
.cancel:
()
:
()
}
}
Structured tasking record for CoT type t-s. Captures the target UID, assignee, priority, status, and a short note — everything the raw-detail fallback loses when flattening a task into remarks text. The envelope carries the requester UID (implicit) and creation time.
The task_type field is free-text (capped at 12 chars) to avoid proto churn when ATAK adds new task categories — common values are "engage", "observe", "recon", "rescue".
Python — inspect a task request:
Delivered (b-t-f-d) and read (b-t-f-r) chat receipts ride on the same chat = 31 slot as regular chat messages. The CoT type string on cot_type_id distinguishes delivered vs read at the envelope level; two new fields on GeoChat carry the referenced message UID so receivers can match the receipt back to the outbound message without XML parsing.
| Field | Values |
|---|---|
TypeScript — handle an incoming chat or receipt:
Every color field in DrawnShape, Marker, and RangeAndBearing uses two parallel fields:
Team enum for the 14 ATAK palette colors (White, Yellow, Orange, Magenta, Red, Maroon, Purple, Dark Blue, Blue, Cyan, Teal, Green, Dark Green, Brown) — encodes in 2 bytes on the wirefixed32 _argb fallback for custom user-picked colors — encodes in 5 bytesThe AtakPalette helper (shipped in every SDK) does the bidirectional lookup: argbToTeam(0xFFFFFFFF) returns Team.White, teamToArgb(Team.Red) returns 0xFFFF0000. The parser sets both fields so receivers can pick whichever one suits them; the builder uses the palette's canonical ARGB when the team enum is set, otherwise the raw fallback bits so custom colors round-trip byte-for-byte.
compressWithRemarksFallback() preserves user-authored <remarks> text (shape descriptions, route notes, etc.) when the compressed packet fits under the LoRa MTU. If it doesn't fit, remarks are stripped and the packet is re-compressed. If it still doesn't fit, the method returns null and the caller drops the packet. This is the recommended entry point for mesh transmission — it subsumes the size guard that callers would otherwise need:
val wire = compressor.compressWithRemarksFallback(packet, maxWireBytes = 225)
?: return // packet too large even without remarks
End-to-end walkthroughs showing actual CoT XML from ATAK/iTAK being compressed for LoRa mesh transmission. Each example shows the raw XML, what gets stripped before compression, the resulting proto structure, and the final compressed wire payload. The LoRa MTU is 237 bytes.
Note: The per-example compressed byte sizes in this section are illustrative — they predate the v0.4.0 wire changes (512KB proto-trained dictionary, magic-number strip, skip-compress) and are kept here only to show the shape of the reduction. For exact, regenerated per-fixture sizes see
testdata/compression-report.md.
a-f-G-U-C)Raw CoT XML from TAK client (754 bytes)
After stripping (~400 bytes) — <takv>, <precisionlocation>, <_flow-tags_> removed
TAKPacketV2 proto fields
cot_type_id: (a-f-G-U-C) how: (h-e)
device_callsign:
longitude_i:
- speed: (cm/s) course: (deg×)
team: (Cyan) role: (TeamMember)
(GPS) alt_src: (GPS)
(normalized) phone:
(no payload_variant → implicit PLI)
| Stage | Size | Reduction |
|---|---|---|
| Raw XML |
b-t-f)Raw CoT XML from iTAK (1031 bytes)
TAKPacketV2 proto fields — chat.to omitted for broadcast (saves 16 bytes)
cot_type_id: 25 (b-t-f) how: 3 (h-g-i-g-o)
callsign: "iPad" uid: "GeoChat.23131970-...All Chat Rooms.08C6FA28"
chat {
message: "Test"
to_callsign: "iPad" # to: null (broadcast = 0 bytes)
}
| Stage | Size | Reduction |
|---|---|---|
| Raw XML |
u-d-r)Raw CoT XML from ATAK (945 bytes)
After stripping (~500 bytes) — <__shapeExtras>, <creator>, <tog>, <archive>, <remarks/>, <strokeStyle>, <precisionlocation> removed
TAKPacketV2 proto fields — vertices delta-encoded from anchor point
| Stage | Size | Reduction |
|---|---|---|
| Raw XML |
u-d-c-c)Raw CoT XML from ATAK (851 bytes)
TAKPacketV2 proto fields — circle stored as major/minor radii in centimeters
cot_type_id: 42 (u-d-c-c) how: 1 (h-e)
callsign: "Shape 324"
latitude_i: 347720486 longitude_i: -924584657
shape {
kind: 1 (Circle) style: 1 (StrokeOnly)
major_cm: 39314 minor_cm: 39314 angle_deg: 360
stroke_argb: 0xFFFF4245 fill_argb:
}
| Stage | Size | Reduction |
|---|---|---|
| Raw XML |
b-m-r)Raw CoT XML from iTAK (890 bytes)
After stripping (~380 bytes) — <precisionLocation>, <marti/>, ??? attrs, routetype, order, color, empty callsign, and waypoint attrs removed
TAKPacketV2 proto fields — waypoints delta-encoded, UIDs omitted (receiver derives)
| Stage | Size | Reduction |
|---|---|---|
| Raw XML |
Note: Routes are the tightest fit under the 237B LoRa MTU. The stripper removes waypoint
uidattributes (~40 bytes each in proto wire format),routetype,order,color, and emptycallsignattributes. Without UID stripping, a 5-waypoint route compresses to ~271 bytes (over the 237B limit); with it, even routes with 5-6 waypoints fit comfortably. The builder generates deterministic UIDs () on reconstruction so ATAK can create internal waypoint markers.
Route reconstruction details: The CotXmlBuilder emits route elements in the order ATAK expects:
The stale time for routes (and all static CoT types) is extended to a minimum of 15 minutes before mesh transmission to survive multi-hop LoRa delivery. iTAK uses a 2-minute stale for routes which expires before mesh delivery completes.
b-m-p-s-m)Raw CoT XML from ATAK (721 bytes)
After stripping (~400 bytes) — <archive>, <remarks/>, <precisionlocation>, ??? removed
TAKPacketV2 proto fields — kind derived from CoT type, color as palette enum
cot_type_id: 8 (b-m-p-s-m) how: 3 (h-g-i-g-o)
callsign: "R 1"
latitude_i: 100060600 longitude_i: 950036200
marker {
kind: 1 (Spot) readiness: true
color: 4 (Red) : xFFFF0000
:
:
:
:
}
| Stage | Size | Reduction |
|---|---|---|
| Raw XML |
Median compression ratio across all 47 fixture types: 7.2× (400-2300 bytes XML → 42-184 bytes wire).
The SDK applies several optimizations to minimize wire payload size:
Stripped elements and attributes are not needed for rendering — the SDK extracts all structurally meaningful data (coordinates, waypoints, colors, stroke weight, method, direction, prefix) into typed proto fields. The stripped metadata is display-only, UI-state, or redundant with proto fields.
ATAK silently ignores b-m-r route CoT events received over TCP streaming connections. Routes are only accepted from KML/GPX file import, TAK Server mission sync, or data packages auto-imported from /sdcard/atak/tools/datapackage/. iTAK does not have this limitation.
The Meshtastic Android app works around this by converting mesh-received routes into KML data packages:
RouteDataPackageGenerator extracts waypoints and generates a KML <LineString>manifest.xml/sdcard/atak/tools/datapackage/{routeUid}.zipRoute over LoRa mesh (~122 bytes)
↓ TakCompressor.decompress()
Route CoT XML (waypoints, method, direction)
↓ RouteDataPackageGenerator.generateDataPackage()
{routeUid}.zip
├── {routeUid}.kml ← KML LineString with lon,lat,hae coordinates
└── manifest.xml ← MissionPackageManifest v2
↓ AtakFileWriter.writeToImportDir()
/sdcard/atak/tools/datapackage/{routeUid}.zip
↓ ATAK auto-import
Route rendered in ATAK Route Manager
Counts are source-declared test methods (current as of v0.5.1, including the new CotMeshSanitizer suites). Parametrized methods expand to more individual cases at run time — e.g. the Kotlin JVM suite executes 315 tests and Python's pytest 229.
Every platform is byte-interoperable: .pb and .bin golden files written by Kotlin's CompressionTest.generate compression report are consumed by the other four platforms for exact-match validation (within protobuf field-order tolerance) and full round-trip equivalence.
val parser = CotXmlParser()
val compressor = TakCompressor()
// Compress a CoT message for LoRa
// Pre-process raw ATAK CoT XML before parsing (see "Mesh Hygiene" above —
// the same helper exists in all five bindings)
var clean = CotMeshSanitizer.normalizeCotXml(cotXmlString)
clean = CotMeshSanitizer.stripNonEssentialForMesh(clean)
val packet = parser.parse(clean)
val wirePayload = compressor.compress(packet)
// Decompress a received payload
val received = compressor.decompress(wirePayload)
val cotXml = CotXmlBuilder().build(received)
let parser = CotXmlParser()
let compressor = TakCompressor()
// Compress
let packet = parser.parse(cotXmlString)
let wirePayload = try compressor.compress(packet)
// Decompress
received compressor.decompress(wirePayload)
cotXml ().build(received)
from meshtastic_tak import CotXmlParser, CotXmlBuilder, TakCompressor
parser = CotXmlParser()
compressor = TakCompressor()
# Compress
packet = parser.parse(cot_xml_string)
wire_payload = compressor.compress(packet)
# Decompress
received = compressor.decompress(wire_payload)
cot_xml = CotXmlBuilder().build(received)
import { parseCotXml, buildCotXml, TakCompressor } from "@meshtastic/takpacket-sdk";
const compressor = new TakCompressor();
// Compress
const packet = parseCotXml(cotXmlString);
wirePayload = compressor.(packet);
received = compressor.(wirePayload);
cotXml = (received);
using Meshtastic.TAK;
var parser = new CotXmlParser();
var compressor = new TakCompressor();
var builder = new CotXmlBuilder();
// Compress
var packet = parser.Parse(cotXmlString);
var wirePayload = compressor.Compress(packet);
// Decompress
var received = compressor.Decompress(wirePayload);
var cotXml = builder.Build(received);
📚 Browse the full generated API reference for each language at meshtastic.github.io/TAKPacket-SDK.
Each platform implements the same components with identical behavior:
All five language implementations share the same test vectors in testdata/:
Run tests for each platform:
cd kotlin && ./gradlew test
cd swift && swift test
cd csharp && dotnet test
cd typescript && npm test
cd python && pytest
Or run all five at once:
./build.sh test
The Kotlin CompressionTest.generate compression report test regenerates testdata/compression-report.md, testdata/protobuf/*.pb, and testdata/golden/*.bin — it's the canonical fixture generator for the entire SDK.
GPL-3.0 — see LICENSE for details.
| Getting started | API reference |
|---|
| Kotlin / KMP | kotlin/README.md | Dokka |
| Swift | swift/README.md | DocC |
| Python | python/README.md | pdoc |
| TypeScript | typescript/README.md | TypeDoc |
| C# / .NET | csharp/README.md | DocFX |
t-x-d-d CoT type string| Metric | Value |
|---|
| Total test messages | 47 |
| 100% under 237B LoRa MTU | ✅ YES |
| Median compressed size | 87B |
| Median compression ratio | 7.2× |
| Worst case | 184B (77% of LoRa MTU — route_itak_3wp) |
+----------+---------------------------------------------+
| Flags | zstd-compressed TAKPacketV2 protobuf body |
| (1 byte) | (N bytes, zstd magic number stripped) |
+----------+---------------------------------------------+
Flags byte:
bits 0-5: Dictionary ID — written by the sender after classification,
read by the receiver to select the matching dictionary
0x00 = Non-aircraft (PLI, chat, ground, shapes, markers,
routes, ranging, alerts)
0x01 = Aircraft (3rd atom = 'A' — ADS-B, military air tracks, helicopters)
0x02-0x3E = Reserved for future dictionaries
bits 6-7: Reserved / version
Special value:
0xFF = Uncompressed raw protobuf (sent by TAK_TRACKER firmware, and
emitted by the encoder's skip-compress path when the raw protobuf
is no larger than the compressed body — so tiny packets never expand)
| Method | What it does |
|---|
stripNonEssentialForMesh(xml) | Removes display-only <detail> content (e.g. takv, __geofence, archive, creator, stroke/precision-location styling, route-link UIDs, unknown ="???" attributes) while preserving everything the receiver needs to render or route — including TAK-Talk <voice> and <marti> directed routing. Safe on any CoT XML; a no-op when there's nothing to strip. |
normalizeCotXml(xml) | Drops the <?xml …?> declaration and collapses inter-tag whitespace (> < → ><) so the event matches the single-line stream TAK TCP clients expect. Whitespace inside text nodes is left intact. |
[\s\S]| Binding | Entry point |
|---|
| Kotlin | CotMeshSanitizer.stripNonEssentialForMesh(xml) / .normalizeCotXml(xml) — object, commonMain |
| Swift | CotMeshSanitizer.stripNonEssentialForMesh(_:) / .normalizeCotXml(_:) — enum |
| Python | strip_non_essential_for_mesh(xml) / normalize_cot_xml(xml) — module fns (also a CotMeshSanitizer static facade) |
| TypeScript | stripNonEssentialForMesh(xml) / normalizeCotXml(xml) — exported fns |
| C# | CotMeshSanitizer.StripNonEssentialForMesh(xml) / .NormalizeCotXml(xml) — static class |
| Tag | Variant | Proto message | CoT type atoms | Contents |
|---|
| (none) | (implicit PLI) | — | a-f-G-U-C, a-f-G-U-C-I, … | Default ground-unit position — no payload_variant set |
| 31 | chat | GeoChat | b-t-f, b-t-f-d, b-t-f-r | Team chat message (plus delivered/read receipts via new fields) |
| 32 | aircraft | AircraftTrack | a-n-A-C-F, a-h-A-M-F-F, … | ADS-B / military air track |
| 33 | raw_detail | bytes | any | Raw <detail> fallback for callers that build a packet directly (no public compressor path) |
| 34 | shape | DrawnShape | u-d-c-c, u-d-r, u-d-f, u-d-f-m, u-d-p, u-r-b-c-c, u-r-b-bullseye, u-d-c-e, u-d-v, u-d-v-m | Tactical graphics |
| 35 | marker | Marker | b-m-p-s-m, b-m-p-w, b-m-p-c, a-u-G, b-m-p-w-GOTO, b-m-p-c-ip, b-m-p-c-cp, b-m-p-s-p-op, b-i-x-i, … | Fixed markers, 2525 symbols, icons, mission points |
| 36 | rab | RangeAndBearing | u-rb-a | Range-and-bearing measurement |
| 37 | route | Route | b-m-r | Waypoint + control point sequence |
| 38 | casevac | CasevacReport | b-r-f-h-c | 9-line MEDEVAC request |
| 39 | emergency | EmergencyAlert | b-a-o-tbl, b-a-o-pan, b-a-o-opn, b-a-g, b-a-o-c, b-a-o-can | Emergency / 911 alert |
| 40 | task | TaskRequest | t-s | Tasking / engagement request |
| 41 | taktalk | TakTalkMessage | m-t-t | TAKTALK chat message (text envelope; voice rides UDP/RTP off-mesh) |
| 42 | taktalk_room | TakTalkRoomData | y- | TAKTALK room/membership broadcast (resolves room UUIDs to name + roster) |
DrawnShaperepeated sint32vertex_lat_deltasvertex_lon_deltasRangeAndBearing.anchorRoute.Link.pointCotGeoPointTeamfixed32 _argb| Kind value | Name | Wire fields used |
|---|
| 1 | Circle | major_cm, minor_cm, angle_deg |
| 2 | Rectangle | 4 vertices (corner points) |
| 3 | Freeform | N vertices (polyline) |
| 4 | Telestration | N vertices (may truncate at 32, sets truncated=true) |
| 5 | Polygon | N vertices (implicitly closed) |
| 6 | RangingCircle | major_cm, minor_cm, angle_deg, stroke only |
| 7 | Bullseye | Ellipse + bullseye_distance_dm, bullseye_bearing_ref, bullseye_flags, bullseye_uid_ref |
| 8 | Ellipse | major_cm, minor_cm, angle_deg (distinct major/minor, not a circle) |
| 9 | Vehicle2D | N vertices (footprint polygon) |
| 10 | Vehicle3D | N vertices (footprint polygon; receiver extrudes) |
| Proto field (ARGB) | KML hex (ABGR) | Example |
|---|
0xFFFF0000 (opaque red) | ff0000ff | <color>ff0000ff</color> |
0x9F00FF00 (semi-transparent green) | 9f00ff00 | <color>9f00ff00</color> |
0x00000000 (transparent) | not emitted | PolyStyle omitted |
let packet = parser.parse(drawingPolygonXml)
if case .shape(let shape) = packet.payloadVariant,
shape.kind == .polygon {
// Vertices are two packed columns of zigzag deltas from the event anchor.
for (latDelta, lonDelta) in zip(shape.vertexLatDeltas, shape.vertexLonDeltas) {
let lat = Double(packet.latitudeI + latDelta) / 1e7
let lon = Double(packet.longitudeI + lonDelta) / 1e7
print(" (\(lat), \(lon))")
}
}
| Kind value | Name | Typical CoT type |
|---|
| 1 | Spot | b-m-p-s-m |
| 2 | Waypoint | b-m-p-w |
| 3 | Checkpoint | b-m-p-c |
| 4 | SelfPosition | b-m-p-s-p-i, b-m-p-s-p-loc |
| 5 | Symbol2525 | a-*-G with iconsetpath="COT_MAPPING_2525B/…" |
| 6 | SpotMap | iconsetpath="COT_MAPPING_SPOTMAP/…" |
| 7 | CustomIcon | Any type with a UUID/group/icon.png iconset |
| 8 | GoToPoint | b-m-p-w-GOTO |
| 9 | InitialPoint | b-m-p-c-ip |
| 10 | ContactPoint | b-m-p-c-cp |
| 11 | ObservationPost | b-m-p-s-p-op |
| 12 | ImageMarker | b-i-x-i |
import { parseCotXml } from "@meshtastic/takpacket-sdk";
const packet = parseCotXml(rangingLineXml);
if (packet.rab) {
const rab = packet.rab as any;
const rangeM = rab.rangeCm / 100;
const bearingDeg = rab.bearingCdeg / 100;
const anchorLat = (packet.latitudeI + (rab.anchor?.latDeltaI ?? 0)) / 1e7;
const anchorLon = (packet.longitudeI + (rab.anchor?.lonDeltaI ?? 0)) / 1e7;
console.log(`Range: ${rangeM} m @ ${bearingDeg}°`);
console.log(`Anchor: ${anchorLat.toFixed(6)}, ${anchorLon.toFixed(6)}`);
}
| Field | Values |
|---|
method | Driving, Walking, Flying, Swimming, Watercraft |
direction | Infil, Exfil |
prefix | Short waypoint prefix (e.g. "CP", "RP") |
links[] | Up to 16 waypoint/checkpoint entries, each with delta-encoded point, uid, callsign, and link_type (0=waypoint, 1=checkpoint) |
| Enum | Values |
|---|
Precedence | Urgent (A), UrgentSurgical (B), Priority (C), Routine (D), Convenience (E) |
HlzMarking | Panels, PyroSignal, Smoke, None, Other |
Security | NoEnemy (N), PossibleEnemy (P), EnemyInArea (E), EnemyInArmedContact (X) |
| Enum | Value | CoT type |
|---|
Type_Alert911 | 1 | b-a-o-tbl |
Type_RingTheBell | 2 | b-a-o-pan |
Type_InContact | 3 | b-a-o-opn |
Type_GeoFenceBreached | 4 | b-a-g |
Type_Custom | 5 | b-a-o-c |
Type_Cancel | 6 | b-a-o-can |
| Enum | Values |
|---|
Priority | Low, Normal, High, Critical |
Status | Pending, Acknowledged, InProgress, Completed, Cancelled |
packet = parser.parse(task_engage_xml)
if packet.HasField("task"):
task = packet.task
print(f"Task type: {task.task_type}") # "engage"
print(f"Target: {task.target_uid}") # "target-01"
print(f"Assigned to: {task.assignee_uid}") # "ANDROID-..."
print(f"Priority: {task.priority}") # 3 = High
print(f"Status: {task.status}") # 1 = Pending
print(f"Note: {task.note}") # "cover by fire"
receipt_for_uid| UID of the chat message being acknowledged |
receipt_type | None (normal chat), Delivered (b-t-f-d), Read (b-t-f-r) |
import { parseCotXml } from "@meshtastic/takpacket-sdk";
const packet = parseCotXml(incomingXml);
if (packet.chat) {
const chat = packet.chat as any;
if (chat.receiptType === 1) {
console.log(`Delivered receipt for message ${chat.receiptForUid}`);
} else if (chat.receiptType === 2) {
console.log(`Read receipt for message ${chat.receiptForUid}`);
} else {
console.log(`Message from ${chat.toCallsign}: ${chat.message}`);
}
}
Two-phase optimization: The examples below show two reduction stages. First, the app strips non-essential XML elements (<takv>, <voice>, <precisionLocation>, etc.) before the SDK sees the XML. Second, the SDK parser eliminates further redundancy at parse time: version="2.0" is not stored (hardcoded on rebuild), time and start are discarded (rebuilt from receiver clock), stale becomes a compact staleSeconds varint (delta from time), and ce/le sentinels are dropped (hardcoded 9999999 on rebuild). The "After stripping" blocks below show the XML after phase 1 only — the event-envelope attributes visible there are NOT on the wire.
<event version="2.0" uid="ANDROID-0000000000000002" type="a-f-G-U-C" how="h-e"
time="2026-03-15T15:30:00Z" start="2026-03-15T15:30:00Z" stale="2026-03-15T15:30:45Z">
<point lat="12.00000" lon="91.00000" hae="-29.667" ce="32.2" le="9999999"/>
<detail>
<takv os="34" version="4.12.0.1 (00000000)[playstore].0000000000-CIV"
device="Simulator" platform="ATAK-CIV"/>
<contact endpoint="*:-1:stcp" phone="+15550000001" callsign="TESTNODE-01"/>
<uid Droid="TESTNODE-01"/>
<precisionlocation altsrc="GPS" geopointsrc="GPS"/>
<__group role="Team Member" name="Cyan"/>
<status battery="88"/>
<track course="142.75" speed="1.2"/>
<_flow-tags_ TAK-Server-00000000="2026-03-15T15:30:00Z"/>
</detail>
</event>
<event version="2.0" uid="ANDROID-0000000000000002" type="a-f-G-U-C" how="h-e"
time="2026-03-15T15:30:00Z" start="2026-03-15T15:30:00Z" stale="2026-03-15T15:30:45Z">
<point lat="12.00000" lon="91.00000" hae="-29.667" ce="32.2" le="9999999"/>
<detail>
<contact endpoint="*:-1:stcp" phone="+15550000001" callsign="TESTNODE-01"/>
<uid Droid="TESTNODE-01"/>
<__group role="Team Member" name="Cyan"/>
<status battery="88"/>
<track course="142.75" speed="1.2"/>
</detail>
</event>
| 754 B |
| — |
| Stripped | ~400 B | -47% |
| Compressed | 98 B | 87% total |
<event version="2.0" uid="GeoChat.23131970-4D02-4092-A30A-8A49EBD04AA0.All Chat Rooms.08C6FA28"
type="b-t-f" how="h-g-i-g-o" time="2026-04-10T13:41:23Z" ...>
<point lat="38.8895" lon="-77.0353" hae="9999999.0" .../>
<detail>
<__chat parent="RootContactGroup" groupOwner="false" messageId="08C6FA28"
chatroom="All Chat Rooms" id="All Chat Rooms" senderCallsign="iPad">
<chatgrp uid0="23131970-4D02-4092-A30A-8A49EBD04AA0" uid1="All Chat Rooms"/>
</__chat>
<link uid="23131970-4D02-4092-A30A-8A49EBD04AA0" type="a-f-G-E-V-C" relation="p-p"/>
<remarks source="BAO.F.ATAK.23131970-..." to="All Chat Rooms" time="...">Test</remarks>
<__serverdestination destinations="*:4242:tcp:23131970-..."/>
<_flow-tags_ TAK-Server-dd4055d1="2026-04-10T13:41:23Z"/>
</detail>
</event>
| 1,031 B |
| — |
| Stripped | ~700 B | -32% |
| Compressed | 80 B | 92% total |
<event version="2.0" uid="ace0fc3f-9587-406c-be66-a52f02cdbedf" type="u-d-r"
time="2026-04-11T01:09:56.557Z" stale="2026-04-12T01:09:56.557Z" how="h-e">
<point lat="38.8895" lon="-77.0353" hae="67.004" .../>
<detail>
<link point="38.88951,-77.03531"/>
<link point="38.88952,-77.03532"/>
<link point="38.88953,-77.03533"/>
<link point="38.88954,-77.03534"/>
<__shapeExtras cpvis="false" editable="true"/>
<remarks/>
<creator uid="ANDROID-0000000000000011" callsign="ETHEL" .../>
<strokeColor value="-16777089"/>
<strokeWeight value="3.0"/>
<strokeStyle value="solid"/>
<fillColor value="-1778384769"/>
<contact callsign="Rectangle 2"/>
<tog enabled="0"/>
<precisionlocation altsrc="SRTM1" geopointsrc="USER"/>
<labels_on value="false"/>
<archive/>
</detail>
</event>
<event version="2.0" uid="ace0fc3f-..." type="u-d-r" ...>
<point lat="38.8895" lon="-77.0353" hae="67.004" .../>
<detail>
<link point="38.88951,-77.03531"/>
<link point="38.88952,-77.03532"/>
<link point="38.88953,-77.03533"/>
<link point="38.88954,-77.03534"/>
<strokeColor value="-16777089"/>
<strokeWeight value="3.0"/>
<fillColor value="-1778384769"/>
<contact callsign="Rectangle 2"/>
<labels_on value="false"/>
</detail>
</event>
cot_type_id: 41 (u-d-r) how: 1 (h-e)
callsign: "Rectangle 2"
latitude_i: 348044064 longitude_i: -924361140
shape {
kind: 2 (Rectangle) style: 3 (StrokeAndFill)
stroke_argb: 0xFF0000FF fill_argb: 0x960000FF
stroke_weight_x10: 30 labels_on: false
# two packed columns of zigzag deltas from the anchor (vertex N =
# vertex_lat_deltas[N], vertex_lon_deltas[N])
vertex_lat_deltas: [ +1245, -1835, -2737, -1527 ]
vertex_lon_deltas: [ -7219, +1655, +719, -1559 ]
}
| 945 B |
| — |
| Stripped | ~500 B | -47% |
| Compressed | 101 B | 87% total |
<event version="2.0" uid="67EBAF59-A216-4B0C-BD24-9AE5EE4D65E6" type="u-d-c-c" ...>
<point lat="38.8814" lon="-77.0502" hae="9999999.0" .../>
<detail>
<shape>
<ellipse major="393.14" minor="393.14" angle="360"/>
<link uid="67EBAF59-...Style" type="b-x-KmlStyle" relation="p-c">
<Style><LineStyle><color>ffff4245</color><width>3.0</width></LineStyle>
<PolyStyle><color>00000000</color></PolyStyle></Style>
</link>
</shape>
<__shapeExtras cpvis="true" editable="true"/>
<strokeColor value="-48571"/>
<strokeWeight value="3.0"/>
<fillColor value="0"/>
<contact callsign="Shape 324"/>
<labels_on value="false"/>
<archive/>
<uid Droid="Shape 324"/>
</detail>
</event>
| 851 B |
| — |
| Stripped | ~450 B | -47% |
| Compressed | 90 B | 90% total |
<event version="2.0" uid="139A3009-681E-4B1A-8F23-DBB49A2C338D" type="b-m-r" ...>
<point lat="38.87481" lon="-77.03521" hae="0.0" .../>
<detail>
<contact callsign="Route - 04/11 06:48:00"/>
<precisionLocation geopointsrc="???" altsrc="???"/>
<link uid="D71306C3-..." callsign="SP" type="b-m-p-w"
point="38.87481,-77.03521"/>
<link uid="06BDF9C8-..." callsign="" type="b-m-p-c"
point="38.87482,-77.03522"/>
<link uid="A5449578-..." callsign="VDO" type="b-m-p-w"
point="38.87483,-77.03523"/>
<link_attr color="-65281" method="Walking" prefix="CP" direction="Infil"
routetype="Primary" order="Ascending Check Points"/>
<marti/>
</detail>
</event>
uid<event version="2.0" uid="139A3009-..." type="b-m-r" ...>
<point lat="38.87481" lon="-77.03521" hae="0.0" .../>
<detail>
<contact callsign="Route - 04/11 06:48:00"/>
<link callsign="SP" type="b-m-p-w"
point="38.87481,-77.03521"/>
<link type="b-m-p-c"
point="38.87482,-77.03522"/>
<link callsign="VDO" type="b-m-p-w"
point="38.87483,-77.03523"/>
<link_attr method="Walking" prefix="CP" direction="Infil"/>
</detail>
</event>
cot_type_id: 10 (b-m-r) how: 1 (h-e)
callsign: "Route - 04/11 06:48:00"
latitude_i: 347482943 longitude_i: -924352021
route {
method: 1 (Walking) direction: 1 (Infil) prefix: "CP"
stroke_weight_x10: 30
links: [
{ lat_i: 347482943, lon_i: -924352021, callsign: "SP", link_type: 0 }
{ lat_i: 347465055, lon_i: -924319555, link_type: 1 }
{ lat_i: 347485785, lon_i: -924354345, callsign: "VDO", link_type: 0 }
]
}
| 890 B |
| — |
| Stripped | ~380 B | -57% |
| Compressed | ~80 B | 91% total |
{eventUid}-{index}<link>uidtypecallsignpoint="lat,lon,hae"relation="c"<link_attr> with method, direction, prefix, stroke<__routeinfo><__navcues/></__routeinfo> (non-self-closing, with navcues child)<event version="2.0" uid="9405e320-9356-41c4-8449-f46990aa17f8" type="b-m-p-s-m"
time="2026-03-15T14:22:10Z" stale="2026-03-16T14:22:10Z" how="h-g-i-g-o">
<point lat="10.00606" lon="95.00362" hae="9999999.0" .../>
<detail>
<status readiness="true"/>
<archive/>
<link uid="ANDROID-0000000000000001" type="a-f-G-U-C"
parent_callsign="SIM-01" relation="p-p"/>
<contact callsign="R 1"/>
<remarks/>
<color argb="-65536"/>
<precisionlocation altsrc="???"/>
<usericon iconsetpath="COT_MAPPING_SPOTMAP/b-m-p-s-m/-65536"/>
</detail>
</event>
<event version="2.0" uid="9405e320-..." type="b-m-p-s-m" ...>
<point lat="10.00606" lon="95.00362" hae="9999999.0" .../>
<detail>
<status readiness="true"/>
<link uid="ANDROID-0000000000000001" type="a-f-G-U-C"
parent_callsign="SIM-01" relation="p-p"/>
<contact callsign="R 1"/>
<color argb="-65536"/>
<usericon iconsetpath="COT_MAPPING_SPOTMAP/b-m-p-s-m/-65536"/>
</detail>
</event>
| 721 B |
| — |
| Stripped | ~400 B | -44% |
| Compressed | 81 B | 89% total |
| Payload Type | Raw XML | Compressed | Ratio | Fits LoRa? |
|---|
| PLI (position) | 754 B | 98 B | 7.7x | ✅ |
| GeoChat (text) | 1,031 B | 80 B | 12.9x | ✅ |
| Rectangle (4 vertices) | 945 B | 101 B | 9.4x | ✅ |
| Circle (ellipse) | 851 B | 90 B | 9.5x | ✅ |
| Route (3 waypoints) | 890 B | ~80 B | 11.1x | ✅ |
| Marker (spot) | 721 B | 81 B | 8.9x | ✅ |
| Optimization | Savings | Description |
|---|
| Endpoint normalization | ~20 B/msg | Default endpoints (0.0.0.0:4242:tcp, *:-1:stcp) normalized to empty; builder restores the default on reconstruction |
| Broadcast sentinel | ~16 B/chat | chat.to = "All Chat Rooms" normalized to null (proto field omitted) |
| Element stripping | ~100-200 B/msg | Non-essential XML elements (<takv>, <voice>, <precisionLocation>, <__geofence>, <marti>, <__shapeExtras>, <creator>, <tog>, <archive>, <strokeStyle>, empty <remarks>) stripped before SDK parsing |
| Attribute stripping | ~30-80 B/msg | Display-only attributes stripped: routetype, order, color (from <link_attr>), access, empty callsign/phone, and all "???" placeholder values |
| Route waypoint UID stripping | ~40 B/waypoint | UUID uid attributes stripped from route <link> elements before compression. Builder generates deterministic UIDs ({eventUid}-{index}) on reconstruction. Saves ~120 bytes proto for a 3-waypoint route |
| Stale time extension | 0 B (metadata) | Static CoT types (routes b-m-r, shapes u-d-*, markers b-m-p-*) get a minimum 15-minute stale TTL before mesh transmission. Prevents iTAK's 2-min stale from expiring during multi-hop LoRa delivery |
| Delta vertex encoding | ~50% vs abs | Shape/route vertices stored as deltas from the event anchor point. DrawnShape packs them into two repeated sint32 columns (vertex_lat_deltas / vertex_lon_deltas) so field framing is paid once per column, not once per vertex |
| Remarks preservation | variable | Non-empty <remarks> text preserved for shapes, markers, routes, casevac, emergency, and task types via top-level string remarks = 24 proto field. compressWithRemarksFallback() strips remarks automatically if the compressed packet exceeds the LoRa MTU, so annotations survive when there's room |
| zstd dictionaries (proto-trained) | ~5-14x | Two pre-trained dictionaries — non-aircraft 512 KB + aircraft 4 KB — trained on serialized TAKPacketV2 protobuf bytes (not raw CoT XML), at zstd level 19 |
| zstd magic-number strip | ~8 B/msg | The 4-byte zstd frame magic (28 B5 2F FD) is stripped on encode and re-prepended on decode; frames are compressed with dictID / contentSize / checksum disabled. Done identically in all five language bindings |
| Skip-compress for tiny packets | prevents expansion | When the raw protobuf is no larger than the compressed body, the encoder emits the 0xFF uncompressed sentinel + raw protobuf instead, so small packets never expand on the wire |
| Platform | Language | Directory | Tests |
|---|
| Android / ATAK Plugin | Kotlin | kotlin/ | ✅ 80 |
| iOS / macOS | Swift | swift/ | ✅ 43 |
| Windows / .NET | C# | csharp/ | ✅ 52 |
| Web / Node.js | TypeScript | typescript/ | ✅ 57 |
| CLI / Scripting | Python | python/ | ✅ 45 |
| Class | Purpose |
|---|
| CotXmlParser | Parses a CoT XML event string into a TAKPacketV2 protobuf with the appropriate typed payload variant |
| CotXmlBuilder | Builds a CoT XML event string from a TAKPacketV2 protobuf (handles every typed variant including raw_detail) |
| TakCompressor | Compresses/decompresses TAKPacketV2 using zstd dictionaries, with compress(), compressWithRemarksFallback(), and compressWithStats() entry points |
| CotTypeMapper | Maps CoT type strings to/from CotType enum values; classifies aircraft types for dictionary selection |
| AtakPalette | Bidirectional lookup between ATAK's 14-color palette and Team enum values, for color round-trip preservation |
| CotMeshSanitizer | CoT-XML mesh hygiene applied before parsing: stripNonEssentialForMesh (drop display-only <detail>, preserving TAK-Talk <voice>/<marti>) + normalizeCotXml (strip <?xml?>, collapse inter-tag whitespace). Pure regex, no protobuf/compression deps — see Mesh Hygiene |
TAKPacketV2 protobuf bytes (not raw CoT XML), derived from real CoT corpora from TAK Server databases and augmented with synthetic shape/marker/route/casevac/emergency/task samples for the typed-payload extensions. Training the dictionary on the exact bytes that go over the wire — the post-parse protobuf, not the source XML — is what lets a 512 KB dictionary pay off.TAKPacket-ZTSD/deploy.sh and ship with SDK releases; old dictionary IDs remain valid on the wire.cot_xml/ — 47 input CoT XML fixtures captured from real ATAK-CIV, iTAK, and WebTAK clients (coordinates scrubbed to synthetic test ranges so no real user locations leak), covering 7 PLI variants (incl. iTAK, WebTAK, TAK Aware, stationary, sensor), 3 GeoChat bodies + delivered/read receipts + 2 TAKTALK-flavored chats, 2 aircraft tracks, CASEVAC (bare + full 9-line), delete events, 8 drawings (circle, circle large, ellipse, freeform, polygon, rectangle, rectangle iTAK, telestration), 6 markers (2525, GoTo, GoTo iTAK, icon set, spot, tank), 3 ranging (bullseye, circle, line), 2 routes (3-waypoint canonical + iTAK variant), emergency alerts (911 + cancel), tasking, 4 TAKTALK payloads (room data, text, voice, voice+marti), and the alert_tic / waypoint envelopes.protobuf/ — Expected TAKPacketV2 protobuf bytes (pre-compression), written by Kotlin's CompressionTest, consumed by every other platform for exact-match validation.golden/ — Expected compressed wire payloads, byte-for-byte identical across platforms.sanitizer/ — CotMeshSanitizer input/expected XML pairs (strip.{in,out}.xml, normalize.{in,out}.xml) that lock the mesh-hygiene transforms to byte-for-byte parity across all five bindings.Surfaced from shared tags and platforms — no rankings paid for.