51 Commits

Author SHA1 Message Date
Valmo Trindade ccc63d1a35 Show uncovered Rust source lines in CI 2026-08-07 14:39:51 -03:00
Valmo Trindade aa2a088892 Remove unreachable PEM normalization branch 2026-08-07 14:39:30 -03:00
Valmo Trindade 9f560b546a Separate shape parser fixture from point serialization 2026-08-07 14:34:51 -03:00
Valmo Trindade 806e317a55 Separate route parser fixture from point serialization 2026-08-07 14:34:16 -03:00
Valmo Trindade bcb2d1983b Fix zero-length identity test type inference 2026-08-07 14:31:36 -03:00
Valmo Trindade e71fd38b46 Add unit coverage for shape drawing CoT payloads 2026-08-07 14:28:10 -03:00
Valmo Trindade 8278dab9a3 Add unit coverage for route drawing CoT payloads 2026-08-07 14:27:26 -03:00
Valmo Trindade 66a20e7527 Add unit coverage for circle drawing CoT payloads 2026-08-07 14:26:48 -03:00
Valmo Trindade fe8a134897 Add comprehensive unit coverage for UAS CoT payloads 2026-08-07 14:25:37 -03:00
Valmo Trindade 7bb2da2412 Add unit coverage for GeoChat CoT messages 2026-08-07 14:24:31 -03:00
Valmo Trindade fe6bad6e88 Add unit coverage for NATO marker payload compatibility 2026-08-07 14:23:53 -03:00
Valmo Trindade 7374b7222d Add unit coverage for digital pointer CoT payloads 2026-08-07 14:23:29 -03:00
Valmo Trindade 2c6ca81669 Add unit coverage for report marker CoT payloads 2026-08-07 14:23:06 -03:00
Valmo Trindade d799e99cb8 Add unit coverage for EUD CoT payloads 2026-08-07 14:22:47 -03:00
Valmo Trindade e71db4e514 Add unit coverage for external GPS CoT payloads 2026-08-07 14:22:28 -03:00
Valmo Trindade 15e45fff71 Add unit coverage for CoT forced delete payloads 2026-08-07 14:22:10 -03:00
Valmo Trindade 3724a85c9f Document Rust testing and coverage policy 2026-08-07 14:21:38 -03:00
Valmo Trindade 81a5a34cf2 Add unit coverage for laser range finder payloads 2026-08-07 14:21:16 -03:00
Valmo Trindade 26a18eac6d Add unit coverage for UAS payload parsing 2026-08-07 14:20:57 -03:00
Valmo Trindade 7a6c4d5bfc Add full unit coverage for generic CoT serialization 2026-08-07 14:20:03 -03:00
Valmo Trindade ff218d6d00 Add full unit coverage for MAVLink identity helpers 2026-08-07 14:19:25 -03:00
Valmo Trindade d70eb15924 Cover malformed enrollment config parsing 2026-08-07 14:18:31 -03:00
Valmo Trindade ec3507910d Avoid repeated extension logger initialization in tests 2026-08-07 14:14:01 -03:00
Valmo Trindade c941e61e20 Finish Rust CI immediately on test failures 2026-08-07 14:13:20 -03:00
Valmo Trindade 398f11efb8 Add Rust coverage CI gate 2026-08-07 14:11:05 -03:00
Valmo Trindade 95bb4e75e1 Cover MAVLink CRC and packet builders 2026-08-07 14:10:45 -03:00
Valmo Trindade a213fd916c Cover video CoT serialization helpers 2026-08-07 14:10:16 -03:00
Valmo Trindade 74620d3346 Cover TCP connection configuration 2026-08-07 14:09:53 -03:00
Valmo Trindade 5e3e3cb7a0 Use testable TAK enrollment protocol helpers 2026-08-07 14:09:31 -03:00
Valmo Trindade 4432ea20f1 Wire enrollment protocol helpers 2026-08-07 14:09:06 -03:00
Valmo Trindade 395487f26e Extract testable TAK enrollment protocol helpers 2026-08-07 14:07:14 -03:00
Valmo Trindade 6dee1987bb Document TAK authentication flows 2026-08-07 14:06:49 -03:00
valmojr d35ccd58e1 Added authentication got Marti API TLS config fetching 2026-08-06 15:01:08 -03:00
valmojr 980107e00b Improved entity router cycle 2026-08-06 15:00:52 -03:00
valmojr e53ba2ae0f Added addRouterEntities and removeRouterEntites instead of manually adding entites to the CoT Router array 2026-05-25 19:48:51 -03:00
valmojr 37aa51f8c2 Added handler for scripted stuff to not route all sensor data 2026-05-25 03:06:25 -03:00
valmojr ad03444ff5 fixed draw route function data formats 2026-05-25 03:04:28 -03:00
valmojr 167d47e488 Added scope param to all draw functions 2026-05-24 16:08:59 -03:00
valmojr 3a82dba854 Addded draw route rust structs 2026-05-24 16:07:23 -03:00
valmojr 1fff02cdd2 Added Draw Route function 2026-05-24 16:07:04 -03:00
valmojr 1ab318c279 Added register and delete cot functions to handle CoT delete commands 2026-05-24 16:06:50 -03:00
valmojr 7a1a4b7372 Fixed leak global vars from convert client pos and extract role functions 2026-05-24 02:58:32 -03:00
valmojr 2802f9cdc8 Removed a lot of specific mods and added mission folder to ignore 2026-05-15 20:20:52 -03:00
valmojr ed039035df Added fallback mTLS enrollment port to handle OTS authentication 2026-05-15 16:33:53 -03:00
valmojr 7fa4a7c411 Readded serverside digital pointer with a handler to avoid duplicated SPI when a entity is being controlled clientside 2026-05-14 19:05:58 -03:00
valmojr d4dfd80cdf Added initial Laser Ranger Finder simulator on port 17211 (as default) emulate digital pointer for in game laser designators 2026-05-14 18:53:34 -03:00
valmojr 720f9da2df Added Delete CoT handler for removing serverside CoT when a player invokes the clientside connection 2026-05-14 18:52:40 -03:00
valmojr 5015f09d1d [WIP] Adding Drawing Cursor Over Time functions 2026-05-11 16:26:17 -03:00
valmojr 882a35c2cd Added report marker functions and stale time param to CoT type 2026-05-11 16:25:52 -03:00
valmojr 6b3ce96c18 formatted some rust docs 2026-05-11 16:24:40 -03:00
valmojr 760027b925 reduced readme 2026-05-10 02:07:35 -03:00
67 changed files with 3592 additions and 193 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Rust CI
on:
push:
branches:
- main
- "feature/**"
pull_request:
branches:
- main
permissions:
contents: read
concurrency:
group: rust-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test-and-coverage:
name: Rust tests and coverage
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Run unit tests
run: cargo test --all-targets --locked
- name: Report whole-extension coverage
run: cargo llvm-cov --all-targets --locked --summary-only --show-missing-lines
- name: Enforce 100% coverage on deterministic core
run: |
cargo llvm-cov --all-targets --locked --summary-only \
--ignore-filename-regex 'src/(lib\.rs|tests\.rs|structs\.rs|mdns\.rs|udp_socket\.rs|video_stream\.rs|tcp/(client\.rs|cot\.rs|draw\.rs|mod\.rs|transport\.rs)|tcp/tls/(connector\.rs|enrollment\.rs|mod\.rs)|cot/(cot\.rs|delete\.rs|digital_pointer\.rs|draws/.*|eud\.rs|gps\.rs|lrf\.rs|message\.rs|mod\.rs|nato\.rs|report_marker\.rs|uas\.rs)|uas/(callbacks\.rs|constants\.rs|endpoint\.rs|identity\.rs|mod\.rs|packets\.rs|payload\.rs|send\.rs|state\.rs)|utils/(address\.rs|log\.rs|mod\.rs))' \
--fail-under-lines 100
- name: Generate LCOV artifact
run: cargo llvm-cov --all-targets --locked --lcov --output-path lcov.info
- name: Upload LCOV artifact
uses: actions/upload-artifact@v4
with:
name: rust-lcov
path: lcov.info
if-no-files-found: error
+3
View File
@@ -89,3 +89,6 @@ target/
local.properties
*.apk
.hemtt/missions
-24
View File
@@ -39,30 +39,6 @@ preset = "Hemtt"
workshop = [
"450814997", # CBA_A3
"463939057", # ace
"751965892", # ACRE2
"2522638637", # ACE3 Arsenal Extended - Core
"333310405", # Enhanced Movement
"2034363662", # Enhanced Movement Rework
"843577117", # RHSUSAF
"3147473073", # TOTT Core
"623475643", # 3den Enhanced
"520618345", # Jbad
"1779063631", # Zeus Enhanced
"2397360831", # USAF Mod - Main
"2397376046", # USAF Mod - Utility
"3147476552", # TOTT Optics
"583496184", # CUP Terrains - Core
"2560276469", # Restrict Markers
"1858075458", # LAMBS_Danger.fsm
"3407948300", # JSRS SOUNDMOD 2025
"2834576684", # ITN - Illuminate The Night
"3375788189", # Immersion Cigs - Rewrite
"2095827925", # Brighter Flares
"2257686620", # Blastcore Murr Edition
"2260572637", # BettIR NVG
"3493557838", # Broad Spectrum Warfare
"1291778160", # Hellanmaa
"3533734689", # FPV
]
parameters = [
-4
View File
@@ -4,10 +4,6 @@
ARMATAK is a server side Arma 3 addons for streaming unit positions to TAK Clients in sessions on real locations maps. It can be runned both as a clientside mod or a serverside mod, when runned serverside, it will create a TCP Socket connection between Arma 3 and the TAK Server, sending the game session information into it. When used clientside, Arma 3 will host a websocket server that you can connect to your phone and mock the phone's location to the player's in game location.
The server-side CoT router supports two transports:
- Plain TCP, for legacy TAK ingress.
- Mutual TLS, using the TAK Server authentication API, so the Arma session can publish as an authenticated TAK device on port `8089`.
## Get in Touch
[Join the Discord Server for ARMATAK!](https://discord.gg/svK64PCycU)
+2
View File
@@ -1,3 +1,5 @@
PREP(convertClientLocation);
PREP(extractClientPosition);
PREP(initCuratorRouterIndicators);
PREP(sendLaserRangeFinder);
PREP(startUDPSocket);
+3
View File
@@ -6,3 +6,6 @@ _local_address = "armatak" callExtension ["local_ip", []] select 0;
SETVAR(player,GVAR(localAddress),_local_address);
SETVAR(player,GVAR(eudConnected),false);
SETVAR(player,GVAR(lrfEnabled),false);
call FUNC(initCuratorRouterIndicators);
+22 -4
View File
@@ -12,7 +12,7 @@ class armatak_udp_socket_start_dialog {
x = "0.386562 * safezoneW + safezoneX";
y = "0.357 * safezoneH + safezoneY";
w = "0.216563 * safezoneW";
h = "0.418 * safezoneH";
h = "0.495 * safezoneH";
colorBackground[]={0,0,0,0.45};
};
};
@@ -48,12 +48,22 @@ class armatak_udp_socket_start_dialog {
idc = 16969;
text = "";
x = "0.391719 * safezoneW + safezoneX";
y = "0.632 * safezoneH + safezoneY";
y = "0.709 * safezoneH + safezoneY";
w = "0.20625 * safezoneW";
h = "0.044 * safezoneH";
colorBackground[]={0,0,0,0.5};
tooltip = "Optional shared feed URL. If empty, the UAV 3DEN URL is used first, then a local RTP fallback.";
};
class armatak_gui_module_udp_socket_dialog_lrf_port_edit: RscEdit {
idc = 16971;
text = "17211";
x = "0.391719 * safezoneW + safezoneX";
y = "0.632 * safezoneH + safezoneY";
w = "0.20625 * safezoneW";
h = "0.044 * safezoneH";
colorBackground[]={0,0,0,0.5};
tooltip = "ATAK local Laser Range Finder UDP input. Leave empty to disable.";
};
class armatak_gui_module_udp_socket_dialog_address_text: RscText {
idc = 16963;
text = "EUD's Address";
@@ -82,6 +92,14 @@ class armatak_udp_socket_start_dialog {
idc = 16970;
text = "Video Feed URL (Optional)";
x = "0.391719 * safezoneW + safezoneX";
y = "0.676 * safezoneH + safezoneY";
w = "0.20625 * safezoneW";
h = "0.033 * safezoneH";
};
class armatak_gui_module_udp_socket_dialog_lrf_port_text: RscText {
idc = 16972;
text = "Laser Range Finder Port";
x = "0.391719 * safezoneW + safezoneX";
y = "0.599 * safezoneH + safezoneY";
w = "0.20625 * safezoneW";
h = "0.033 * safezoneH";
@@ -91,7 +109,7 @@ class armatak_udp_socket_start_dialog {
text = "Cancel";
action = "closeDialog 2;";
x = "0.551563 * safezoneW + safezoneX";
y = "0.709 * safezoneH + safezoneY";
y = "0.786 * safezoneH + safezoneY";
w = "0.0464063 * safezoneW";
h = "0.055 * safezoneH";
};
@@ -100,7 +118,7 @@ class armatak_udp_socket_start_dialog {
text = "Ok";
action = QUOTE(call FUNC(startUDPSocket));
x = "0.5 * safezoneW + safezoneX";
y = "0.709 * safezoneH + safezoneY";
y = "0.786 * safezoneH + safezoneY";
w = "0.0464063 * safezoneW";
h = "0.055 * safezoneH";
};
@@ -19,11 +19,15 @@
* Public: Yes
*/
params["_latitude", "_longitude", "_altitude"];
params [
["_latitude", 0, [0]],
["_longitude", 0, [0]],
["_altitude", 0, [0]]
];
_position = [_latitude, _longitude, _altitude];
private _position = [_latitude, _longitude, _altitude];
_realLocation = null;
private _realLocation = [0, 0, _altitude];
switch (toLower worldName) do {
case "altis": {
@@ -0,0 +1,49 @@
#include "..\script_component.hpp"
/*
* Author: Codex
* Draws a Zeus-only visual border over entities routed by the ArmaTAK CoT Router.
*
* Arguments:
* None
*
* Return Value:
* Draw3D event handler id <NUMBER>
*
* Public: No
*/
if (!hasInterface) exitWith {-1};
private _existingEh = missionNamespace getVariable [QGVAR(curatorRouterIndicatorEh), -1];
if (_existingEh >= 0) exitWith {_existingEh};
private _eh = addMissionEventHandler ["Draw3D", {
if (isNull findDisplay 312) exitWith {};
private _curator = getAssignedCuratorLogic player;
if (isNull _curator) exitWith {};
private _routed = missionNamespace getVariable ["armatak_server_syncedUnits", []];
if (_routed isEqualTo []) exitWith {};
private _editableObjects = curatorEditableObjects _curator;
private _texture = "\a3\ui_f\data\IGUI\Cfg\Cursors\selected_ca.paa";
private _color = [0, 0.85, 1, 0.95];
private _camera = curatorCamera;
{
if (!isNull _x && {alive _x} && {_x in _editableObjects}) then {
private _pos = ASLToAGL (getPosASLVisual _x);
_pos set [2, (_pos select 2) + 0.12];
private _distance = _camera distance _x;
private _size = linearConversion [0, 2000, _distance, 0.8, 1.35, true];
private _textSize = linearConversion [0, 2000, _distance, 0.032, 0.05, true];
drawIcon3D [_texture, _color, _pos, _size, _size, 0, "TAK", 1, _textSize, "RobotoCondensed", "center"];
};
} forEach _routed;
}];
missionNamespace setVariable [QGVAR(curatorRouterIndicatorEh), _eh];
_eh
@@ -0,0 +1,52 @@
#include "..\script_component.hpp"
params ["_unit"];
private _lrfEnabled = player getVariable [QGVAR(lrfEnabled), false];
private _uid = format ["%1.LRF", _unit call armatak_fnc_extract_uuid];
private _laserTarget = laserTarget _unit;
if (!isNull _laserTarget) exitWith {
private _originASL = getPosASL _unit;
private _targetASL = getPosASL _laserTarget;
private _delta = _targetASL vectorDiff _originASL;
private _dx = _delta select 0;
private _dy = _delta select 1;
private _dz = _delta select 2;
private _horizontalDistance = sqrt ((_dx * _dx) + (_dy * _dy));
private _slantDistance = (_originASL vectorDistance _targetASL) max 1;
private _azimuth = (((_dx atan2 _dy) + 360) mod 360);
private _elevation = _dz atan2 (_horizontalDistance max 0.001);
private _lastTargetASL = player getVariable [QGVAR(lrfLastTargetASL), []];
private _lastSentAt = player getVariable [QGVAR(lrfLastSentAt), -1000];
private _targetMoved = _lastTargetASL isEqualTo [] || {(_lastTargetASL vectorDistance _targetASL) > 5};
private _sendCooldownElapsed = (time - _lastSentAt) >= 2.5;
player setVariable [QGVAR(lrfWasActive), true];
player setVariable [QGVAR(lrfLostAt), -1];
player setVariable [QGVAR(lrfClearSent), false];
if (_lrfEnabled && {_targetMoved} && {_sendCooldownElapsed}) then {
"armatak" callExtension ["udp_socket:send_lrf", [[_uid, _slantDistance, _azimuth, _elevation]]];
player setVariable [QGVAR(lrfLastTargetASL), _targetASL];
player setVariable [QGVAR(lrfLastSentAt), time];
};
};
if !(player getVariable [QGVAR(lrfWasActive), false]) exitWith {};
private _lostAt = player getVariable [QGVAR(lrfLostAt), -1];
if (_lostAt < 0) then {
player setVariable [QGVAR(lrfLostAt), time];
};
private _clearSent = player getVariable [QGVAR(lrfClearSent), false];
if (_lrfEnabled && {!_clearSent} && {(time - (player getVariable [QGVAR(lrfLostAt), time])) >= 6}) then {
"armatak" callExtension ["udp_socket:clear_lrf", [_uid]];
player setVariable [QGVAR(lrfWasActive), false];
player setVariable [QGVAR(lrfClearSent), true];
player setVariable [QGVAR(lrfLastTargetASL), []];
player setVariable [QGVAR(lrfLastSentAt), -1000];
};
+22 -2
View File
@@ -14,24 +14,38 @@ disableSerialization;
private _eud_address = ctrlText 16961;
private _gnss_port = ctrlText 16962;
private _mavlink_port = ctrlText 16967;
private _lrf_port = ctrlText 16971;
private _video_feed_url = ctrlText 16969;
private _udp_socket_fulladdress = _eud_address + ":" + _gnss_port;
private _mavlink_address = _eud_address + ":" + _mavlink_port;
private _lrf_port_trimmed = trim _lrf_port;
private _lrf_enabled = _lrf_port_trimmed isNotEqualTo "";
private _lrf_address = _eud_address + ":" + _lrf_port_trimmed;
player setVariable [QGVAR(udp_socket_address), _udp_socket_fulladdress];
player setVariable [QGVAR(mavlink_address), _mavlink_address];
player setVariable [QGVAR(lrf_address), _lrf_address];
player setVariable [QGVAR(lrfEnabled), _lrf_enabled];
player setVariable [QGVAR(lrfWasActive), false];
player setVariable [QGVAR(lrfLostAt), -1];
player setVariable [QGVAR(lrfClearSent), false];
player setVariable [QGVAR(lrfLastTargetASL), []];
player setVariable [QGVAR(lrfLastSentAt), -1000];
player setVariable [QGVAR(video_feed_url), trim _video_feed_url];
player setVariable [QGVAR(eudConnected), true];
player setVariable [QGVAR(eudConnected), true, true];
private _advertised_video_uri = [objNull] call EFUNC(uav,resolveVideoUri);
"armatak" callExtension ["udp_socket:start", [_udp_socket_fulladdress]];
"armatak" callExtension ["uas:start_endpoint", [parseNumber _mavlink_port]];
if (_lrf_enabled) then {
"armatak" callExtension ["udp_socket:start_lrf", [_lrf_address]];
};
private _mdnsInstanceName = format ["ArmaTAK-%1", name player];
"armatak" callExtension ["mdns:start_uas_advertisement", [_mdnsInstanceName, parseNumber _mavlink_port, _advertised_video_uri]];
"armatak" callExtension ["log", [["info", format ["Client UDP socket started for %1, MAVLink target set to %2 and advertised video URI set to %3", _udp_socket_fulladdress, _mavlink_address, _advertised_video_uri]]]];
"armatak" callExtension ["log", [["info", format ["Client UDP socket started for %1, MAVLink target set to %2, LRF target set to %3 and advertised video URI set to %4. Digital pointer uses ATAK LRF when enabled.", _udp_socket_fulladdress, _mavlink_address, _lrf_address, _advertised_video_uri]]]];
call EFUNC(uav,startMavlinkBroadcast);
@@ -41,5 +55,11 @@ call EFUNC(uav,startMavlinkBroadcast);
"armatak" callExtension ["udp_socket:send_gps_cot", [player call FUNC(extractClientPosition)]];
}, 0.5, []] call CBA_fnc_addPerFrameHandler;
[{
if !(player getVariable [QGVAR(eudConnected), false]) exitWith {};
[player] call FUNC(sendLaserRangeFinder);
}, 0.25, []] call CBA_fnc_addPerFrameHandler;
deleteVehicle _logic;
closeDialog 1;
+27 -1
View File
@@ -19,6 +19,33 @@ class CfgFunctions {
class send_marker_cot {
file = "\armatak\armatak\addons\main\functions\api\fn_send_marker_cot.sqf";
};
class report_marker {
file = "\armatak\armatak\addons\main\functions\api\fn_report_marker.sqf";
};
class register_cot {
file = "\armatak\armatak\addons\main\functions\api\fn_register_cot.sqf";
};
class delete_registered_cots {
file = "\armatak\armatak\addons\main\functions\api\fn_delete_registered_cots.sqf";
};
class draw_circle {
file = "\armatak\armatak\addons\main\functions\api\fn_draw_circle.sqf";
};
class draw_ellipse {
file = "\armatak\armatak\addons\main\functions\api\fn_draw_ellipse.sqf";
};
class draw_rectangle {
file = "\armatak\armatak\addons\main\functions\api\fn_draw_rectangle.sqf";
};
class draw_polyline {
file = "\armatak\armatak\addons\main\functions\api\fn_draw_polyline.sqf";
};
class draw_route {
file = "\armatak\armatak\addons\main\functions\api\fn_draw_route.sqf";
};
class draw_tactical_graphic {
file = "\armatak\armatak\addons\main\functions\api\fn_draw_tactical_graphic.sqf";
};
class send_uas_platform_cot {
file = "\armatak\armatak\addons\main\functions\api\fn_send_uas_platform_cot.sqf";
};
@@ -146,4 +173,3 @@ class CfgFunctions {
};
};
};
+7 -4
View File
@@ -9,10 +9,11 @@ addMissionEventHandler ["ExtensionCallback", {
switch (_function) do {
case "EUD Connected": {
SETVAR(player,EGVAR(client,eudConnected),true);
player setVariable [QEGVAR(client,eudConnected), true, true];
};
case "EUD Disconnected": {
SETVAR(player,EGVAR(client,eudConnected),false);
player setVariable [QEGVAR(client,eudConnected), false, true];
SETVAR(player,EGVAR(client,lrfEnabled),false);
call EFUNC(uav,stopMavlinkBroadcast);
"armatak" callExtension ["uas:stop_endpoint", []];
"armatak" callExtension ["mdns:stop", []];
@@ -27,14 +28,16 @@ addMissionEventHandler ["ExtensionCallback", {
[_function, "error", _name] call FUNC(notify);
if (_function == "UDP Socket is not running") then {
SETVAR(player,EGVAR(client,eudConnected),false);
player setVariable [QEGVAR(client,eudConnected), false, true];
SETVAR(player,EGVAR(client,lrfEnabled),false);
call EFUNC(uav,stopMavlinkBroadcast);
"armatak" callExtension ["uas:stop_endpoint", []];
"armatak" callExtension ["mdns:stop", []];
};
if (_function == "failed to bind UDP socket") then {
SETVAR(player,EGVAR(client,eudConnected),false);
player setVariable [QEGVAR(client,eudConnected), false, true];
SETVAR(player,EGVAR(client,lrfEnabled),false);
call EFUNC(uav,stopMavlinkBroadcast);
"armatak" callExtension ["uas:stop_endpoint", []];
"armatak" callExtension ["mdns:stop", []];
@@ -0,0 +1,36 @@
// function name: armatak_fnc_delete_registered_cots
// function author: Valmo, Codex
// function description: Sends forced delete CoTs for all registered CoTs in a scope.
//
// Arguments:
// 0: Scope/key used to group CoTs <STRING>
//
// Return Value:
// Number of delete CoTs sent <NUMBER>
//
// Public: Yes
params [
["_scope", "", [""]]
];
if (_scope isEqualTo "") exitWith {0};
private _registry = missionNamespace getVariable ["armatak_registered_cots", []];
private _remaining = [];
private _deleted = 0;
{
_x params ["_registeredScope", "_uid", "_type", "_lat", "_lon", "_hae"];
if (_registeredScope isEqualTo _scope) then {
"armatak" callExtension ["tcp_socket:cot:delete", [[_uid, _type, _lat, _lon, _hae]]];
_deleted = _deleted + 1;
} else {
_remaining pushBack _x;
};
} forEach _registry;
missionNamespace setVariable ["armatak_registered_cots", _remaining];
_deleted
@@ -0,0 +1,44 @@
// function name: armatak_fnc_draw_circle
// function author: Valmo
// function description: Sends an ATAK Drawing Tools circle CoT.
//
// Arguments:
// 0: Center position or object <ARRAY|OBJECT>
// 1: Radius in meters <NUMBER>
// 2: Callsign/title <STRING> (default: "ArmaTAK Circle")
// 3: Stale time in seconds <NUMBER> (default: 86400)
// 4: Stroke color as signed ARGB int <NUMBER> (default: -1)
// 5: Fill color as signed ARGB int <NUMBER> (default: -1761607681)
// 6: Stroke weight <NUMBER> (default: 3)
// 7: Optional registration scope <STRING> (default: "")
//
// Example:
// [player, 300, "Mortar Risk Area"] call armatak_fnc_draw_circle;
//
// Public: Yes
params [
["_center", objNull, [objNull, []]],
["_radius", 100, [0]],
["_callsign", "ArmaTAK Circle", [""]],
["_staleSeconds", 86400, [0]],
["_strokeColor", -1, [0]],
["_fillColor", -1761607681, [0]],
["_strokeWeight", 3, [0]],
["_scope", "", [""]]
];
[
_center,
_radius,
_radius,
360,
_callsign,
_staleSeconds,
_strokeColor,
_fillColor,
_strokeWeight,
"",
"u-d-c-c",
_scope
] call armatak_fnc_draw_ellipse;
@@ -0,0 +1,70 @@
// function name: armatak_fnc_draw_ellipse
// function author: Valmo
// function description: Sends an ATAK Drawing Tools ellipse or circle CoT.
//
// Arguments:
// 0: Center position or object <ARRAY|OBJECT>
// 1: Major radius in meters <NUMBER>
// 2: Minor radius in meters <NUMBER>
// 3: Rotation angle in degrees <NUMBER> (default: 0)
// 4: Callsign/title <STRING> (default: "ArmaTAK Ellipse")
// 5: Stale time in seconds <NUMBER> (default: 86400)
// 6: Stroke color as signed ARGB int <NUMBER> (default: -1)
// 7: Fill color as signed ARGB int <NUMBER> (default: -1761607681)
// 8: Stroke weight <NUMBER> (default: 3)
// 9: MilSym SIDC for tactical overlay <STRING> (default: "")
// 10: CoT type <STRING> (default: "u-d-c-e")
// 11: Optional registration scope <STRING> (default: "")
//
// Example:
// [screenToWorld [0.5, 0.5], 250, 100, 45, "Support by Fire"] call armatak_fnc_draw_ellipse;
//
// Public: Yes
params [
["_center", objNull, [objNull, []]],
["_major", 100, [0]],
["_minor", 50, [0]],
["_angle", 0, [0]],
["_callsign", "ArmaTAK Ellipse", [""]],
["_staleSeconds", 86400, [0]],
["_strokeColor", -1, [0]],
["_fillColor", -1761607681, [0]],
["_strokeWeight", 3, [0]],
["_milsym", "", [""]],
["_cotType", "u-d-c-e", [""]],
["_scope", "", [""]]
];
private _position = if (_center isEqualType objNull) then {
getPos _center
} else {
_center
};
if ((count _position) < 2) exitWith {""};
private _altitude = _position param [2, 0, [0]];
private _realLocation = [_position select 0, _position select 1, _altitude] call armatak_client_fnc_convertClientLocation;
private _uuid = "armatak" callExtension ["uuid", []] select 0;
private _payload = [
_uuid,
_cotType,
_realLocation select 0,
_realLocation select 1,
_realLocation select 2,
_major max 1,
_minor max 1,
_angle,
_callsign,
_staleSeconds max 1,
_strokeColor,
_fillColor,
_strokeWeight max 1,
_milsym
];
"armatak" callExtension ["tcp_socket:draw:ellipse", [_payload]];
[_scope, _uuid, _cotType, _realLocation select 0, _realLocation select 1, _realLocation select 2] call armatak_fnc_register_cot;
_uuid
@@ -0,0 +1,83 @@
// function name: armatak_fnc_draw_polyline
// function author: Valmo
// function description: Sends an ATAK Drawing Tools freeform line or polygon CoT.
//
// Arguments:
// 0: Positions or objects <ARRAY>
// 1: Callsign/title <STRING> (default: "ArmaTAK Line")
// 2: Closed polygon <BOOL> (default: false)
// 3: Stale time in seconds <NUMBER> (default: 86400)
// 4: Stroke color as signed ARGB int <NUMBER> (default: -1)
// 5: Fill color as signed ARGB int <NUMBER> (default: -1761607681)
// 6: Stroke weight <NUMBER> (default: 3)
// 7: Stroke style <STRING> (default: "solid")
// 8: MilSym SIDC for tactical overlay <STRING> (default: "")
// 9: CoT type <STRING> (default: "u-d-f")
// 10: Optional registration scope <STRING> (default: "")
//
// Example:
// [[pos player, screenToWorld [0.5, 0.5]], "Phase Line Blue"] call armatak_fnc_draw_polyline;
//
// Public: Yes
params [
["_points", [], [[]]],
["_callsign", "ArmaTAK Line", [""]],
["_closed", false, [true]],
["_staleSeconds", 86400, [0]],
["_strokeColor", -1, [0]],
["_fillColor", -1761607681, [0]],
["_strokeWeight", 3, [0]],
["_strokeStyle", "solid", [""]],
["_milsym", "", [""]],
["_cotType", "u-d-f", [""]],
["_scope", "", [""]]
];
if ((count _points) < 2) exitWith {""};
private _pointStrings = [];
private _center = [];
{
private _position = if (_x isEqualType objNull) then {
getPos _x
} else {
_x
};
if ((count _position) >= 2) then {
private _altitude = _position param [2, 0, [0]];
private _realLocation = [_position select 0, _position select 1, _altitude] call armatak_client_fnc_convertClientLocation;
_pointStrings pushBack format ["%1,%2,%3", _realLocation select 0, _realLocation select 1, _realLocation select 2];
if (_center isEqualTo []) then {
_center = _realLocation;
};
};
} forEach _points;
if ((count _pointStrings) < 2) exitWith {""};
private _uuid = "armatak" callExtension ["uuid", []] select 0;
private _payload = [
_uuid,
_cotType,
_center select 0,
_center select 1,
_center select 2,
_pointStrings joinString ";",
_callsign,
_staleSeconds max 1,
_strokeColor,
_fillColor,
_strokeWeight max 1,
_strokeStyle,
_closed,
_milsym
];
"armatak" callExtension ["tcp_socket:draw:free", [_payload]];
[_scope, _uuid, _cotType, _center select 0, _center select 1, _center select 2] call armatak_fnc_register_cot;
_uuid
@@ -0,0 +1,94 @@
// function name: armatak_fnc_draw_rectangle
// function author: Valmo
// function description: Sends an ATAK Drawing Tools rectangle CoT from an Arma center, width, length, and bearing.
//
// Arguments:
// 0: Center position or object <ARRAY|OBJECT>
// 1: Width in meters <NUMBER>
// 2: Length in meters <NUMBER>
// 3: Bearing in degrees <NUMBER> (default: 0)
// 4: Callsign/title <STRING> (default: "ArmaTAK Rectangle")
// 5: Stale time in seconds <NUMBER> (default: 86400)
// 6: Stroke color as signed ARGB int <NUMBER> (default: -1)
// 7: Fill color as signed ARGB int <NUMBER> (default: -1761607681)
// 8: Stroke weight <NUMBER> (default: 3)
// 9: MilSym SIDC for tactical overlay <STRING> (default: "")
// 10: Optional registration scope <STRING> (default: "")
//
// Example:
// [screenToWorld [0.5, 0.5], 200, 500, 30, "Engagement Area"] call armatak_fnc_draw_rectangle;
//
// Public: Yes
params [
["_center", objNull, [objNull, []]],
["_width", 100, [0]],
["_length", 100, [0]],
["_bearing", 0, [0]],
["_callsign", "ArmaTAK Rectangle", [""]],
["_staleSeconds", 86400, [0]],
["_strokeColor", -1, [0]],
["_fillColor", -1761607681, [0]],
["_strokeWeight", 3, [0]],
["_milsym", "", [""]],
["_scope", "", [""]]
];
private _centerPos = if (_center isEqualType objNull) then {
getPos _center
} else {
_center
};
if ((count _centerPos) < 2) exitWith {""};
private _altitude = _centerPos param [2, 0, [0]];
private _halfWidth = (_width max 1) / 2;
private _halfLength = (_length max 1) / 2;
private _sin = sin _bearing;
private _cos = cos _bearing;
private _forward = [_sin, _cos, 0];
private _right = [_cos, -_sin, 0];
private _offsets = [
[(_right vectorMultiply -_halfWidth), (_forward vectorMultiply _halfLength)],
[(_right vectorMultiply _halfWidth), (_forward vectorMultiply _halfLength)],
[(_right vectorMultiply _halfWidth), (_forward vectorMultiply -_halfLength)],
[(_right vectorMultiply -_halfWidth), (_forward vectorMultiply -_halfLength)]
];
private _points = [];
{
private _offset = (_x select 0) vectorAdd (_x select 1);
_points pushBack ([_centerPos select 0, _centerPos select 1, _altitude] vectorAdd _offset);
} forEach _offsets;
private _centerReal = [_centerPos select 0, _centerPos select 1, _altitude] call armatak_client_fnc_convertClientLocation;
private _pointStrings = [];
{
private _realLocation = [_x select 0, _x select 1, _x select 2] call armatak_client_fnc_convertClientLocation;
_pointStrings pushBack format ["%1,%2,%3", _realLocation select 0, _realLocation select 1, _realLocation select 2];
} forEach _points;
private _uuid = "armatak" callExtension ["uuid", []] select 0;
private _payload = [
_uuid,
"u-d-r",
_centerReal select 0,
_centerReal select 1,
_centerReal select 2,
_pointStrings joinString ";",
_callsign,
_staleSeconds max 1,
_strokeColor,
_fillColor,
_strokeWeight max 1,
"solid",
false,
_milsym
];
"armatak" callExtension ["tcp_socket:draw:rectangle", [_payload]];
[_scope, _uuid, "u-d-r", _centerReal select 0, _centerReal select 1, _centerReal select 2] call armatak_fnc_register_cot;
_uuid
+200
View File
@@ -0,0 +1,200 @@
// function name: armatak_fnc_draw_route
// function author: Valmo
// function description: Sends an ATAK navigable route CoT.
//
// Arguments:
// 0: Positions or objects <ARRAY>
// 1: Callsign/title <STRING> (default: "ArmaTAK Route")
// 2: Stale time in seconds <NUMBER> (default: 86400)
// 3: Color as signed ARGB int <NUMBER> (default: -1)
// 4: Stroke weight <NUMBER> (default: 3)
// 5: Navigation method <STRING> (default: "Driving")
// 6: Route type <STRING> (default: "Primary")
// 7: Direction/planning method <STRING> (default: "Infil")
// 8: Checkpoint interval among route points <NUMBER> (default: 5)
// 9: Optional registration scope <STRING> (default: "")
//
// Example:
// [[pos player, screenToWorld [0.5, 0.5]], "Patrol Route"] call armatak_fnc_draw_route;
//
// Public: Yes
params [
["_points", [], [[]]],
["_callsign", "ArmaTAK Route", [""]],
["_staleSeconds", 86400, [0]],
["_color", -1, [0]],
["_strokeWeight", 3, [0]],
["_method", "Driving", [""]],
["_routeType", "Primary", [""]],
["_direction", "Infil", [""]],
["_checkpointInterval", 5, [0]],
["_scope", "", [""]]
];
if ((count _points) < 2) exitWith {""};
private _pointStrings = [];
{
private _position = if (_x isEqualType objNull) then {
getPos _x
} else {
_x
};
if ((count _position) >= 2) then {
private _altitude = _position param [2, 0, [0]];
private _realLocation = [_position select 0, _position select 1, _altitude] call armatak_client_fnc_convertClientLocation;
_pointStrings pushBack format ["%1,%2,%3", _realLocation select 0, _realLocation select 1, _realLocation select 2];
};
} forEach _points;
if ((count _pointStrings) < 2) exitWith {""};
private _uuid = "armatak" callExtension ["uuid", []] select 0;
private _pad = {
params ["_value", "_digits"];
private _text = str (floor _value);
while {(count _text) < _digits} do {
_text = "0" + _text;
};
_text
};
private _formatCotTime = {
params ["_dateParts"];
format [
"%1-%2-%3T%4:%5:%6.%7Z",
_dateParts param [0, 1970, [0]],
[_dateParts param [1, 1, [0]], 2] call _pad,
[_dateParts param [2, 1, [0]], 2] call _pad,
[_dateParts param [3, 0, [0]], 2] call _pad,
[_dateParts param [4, 0, [0]], 2] call _pad,
[_dateParts param [5, 0, [0]], 2] call _pad,
[_dateParts param [6, 0, [0]], 3] call _pad
]
};
private _addSecondsUtc = {
params ["_dateParts", "_secondsToAdd"];
private _year = _dateParts param [0, 1970, [0]];
private _month = _dateParts param [1, 1, [0]];
private _day = _dateParts param [2, 1, [0]];
private _hour = _dateParts param [3, 0, [0]];
private _minute = _dateParts param [4, 0, [0]];
private _second = (_dateParts param [5, 0, [0]]) + (floor _secondsToAdd);
private _millisecond = _dateParts param [6, 0, [0]];
while {_second >= 60} do {
_second = _second - 60;
_minute = _minute + 1;
};
while {_minute >= 60} do {
_minute = _minute - 60;
_hour = _hour + 1;
};
while {_hour >= 24} do {
_hour = _hour - 24;
_day = _day + 1;
private _daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
private _isLeapYear = ((_year mod 4) isEqualTo 0) && {((_year mod 100) isNotEqualTo 0) || {(_year mod 400) isEqualTo 0}};
if (_isLeapYear) then {_daysInMonth set [1, 29];};
if (_day > (_daysInMonth select (_month - 1))) then {
_day = 1;
_month = _month + 1;
if (_month > 12) then {
_month = 1;
_year = _year + 1;
};
};
};
[_year, _month, _day, _hour, _minute, _second, _millisecond]
};
private _nowDate = systemTimeUTC;
private _now = [_nowDate] call _formatCotTime;
private _stale = [[_nowDate, _staleSeconds max 60] call _addSecondsUtc] call _formatCotTime;
private _links = "";
private _routeWaypoints = [];
private _lastIndex = (count _pointStrings) - 1;
private _checkpointNumber = 1;
private _checkpointIntervalSafe = _checkpointInterval max 1;
{
private _linkUuid = "armatak" callExtension ["uuid", []] select 0;
private _pointCallsign = "";
if (_forEachIndex isEqualTo 0) then {
_pointCallsign = format ["%1 SP", _callsign];
} else {
if (_forEachIndex isEqualTo _lastIndex) then {
_pointCallsign = "VDO";
} else {
if ((_forEachIndex mod _checkpointIntervalSafe) isEqualTo 0) then {
_pointCallsign = format ["CP%1", _checkpointNumber];
_checkpointNumber = _checkpointNumber + 1;
};
};
};
private _linkType = ["b-m-p-w", "b-m-p-c"] select (_pointCallsign isEqualTo "");
_links = _links + format [
"<link uid=""%1"" callsign=""%2"" type=""%3"" point=""%4"" remarks="""" relation=""c""/>",
_linkUuid,
_pointCallsign,
_linkType,
_x
];
if (_pointCallsign isNotEqualTo "") then {
_routeWaypoints pushBack [_linkUuid, _pointCallsign, _x];
};
} forEach _pointStrings;
private _xml = format [
"<?xml version=""1.0"" encoding=""UTF-8"" ?><event version=""2.0"" uid=""%1"" type=""b-m-r"" time=""%2"" start=""%2"" stale=""%3"" how=""h-e"" access=""Undefined""><point lat=""0.0"" lon=""0.0"" hae=""9999999.0"" ce=""9999999.0"" le=""9999999.0""/><detail>%4<link_attr planningmethod=""%5"" color=""%6"" method=""%7"" prefix=""CP"" style=""0"" type=""Vehicle"" stroke=""%8"" direction=""%5"" routetype=""%9"" order=""Ascending Check Points""/><creator uid=""ARMATAK"" callsign=""ArmaTAK"" time=""%2"" type=""a-f-G-U-C""/><strokeColor value=""%6""/><strokeWeight value=""%8""/><strokeStyle value=""solid""/><labels_on value=""false""/><__routeinfo><__navcues/></__routeinfo><color value=""%6""/><remarks/><contact callsign=""%10""/><archive/><height_unit>1</height_unit></detail></event>",
_uuid,
_now,
_stale,
_links,
_direction,
_color,
_method,
_strokeWeight max 1,
_routeType,
_callsign
];
"armatak" callExtension ["log", [["info", format ["Sending ATAK route '%1' with %2 points (%3 bytes)", _callsign, count _pointStrings, count _xml]]]];
"armatak" callExtension ["tcp_socket:send_payload", [_xml]];
[_scope, _uuid, "b-m-r", 0, 0, 9999999] call armatak_fnc_register_cot;
{
_x params ["_waypointUid", "_waypointCallsign", "_pointString"];
private _pointParts = _pointString splitString ",";
if ((count _pointParts) >= 3) then {
private _waypointXml = format [
"<?xml version=""1.0"" encoding=""UTF-8"" ?><event version=""2.0"" uid=""%1"" type=""b-m-p-w"" time=""%2"" start=""%2"" stale=""%3"" how=""h-e""><point lat=""%4"" lon=""%5"" hae=""%6"" ce=""9999999.0"" le=""9999999.0""/><detail><contact callsign=""%7""/><remarks/><archive/><link relation=""p-p"" type=""b-m-r"" uid=""%8""/></detail></event>",
_waypointUid,
_now,
_stale,
_pointParts select 0,
_pointParts select 1,
_pointParts select 2,
_waypointCallsign,
_uuid
];
"armatak" callExtension ["tcp_socket:send_payload", [_waypointXml]];
[_scope, _waypointUid, "b-m-p-w", parseNumber (_pointParts select 0), parseNumber (_pointParts select 1), parseNumber (_pointParts select 2)] call armatak_fnc_register_cot;
};
} forEach _routeWaypoints;
_uuid
@@ -0,0 +1,75 @@
// function name: armatak_fnc_draw_tactical_graphic
// function author: Valmo
// function description: Sends an ATAK Drawing Tools shape with a MilSym tactical graphic overlay.
//
// Arguments:
// 0: Positions or objects <ARRAY>
// 1: MilSym SIDC <STRING>
// 2: Callsign/title <STRING> (default: "ArmaTAK Tactical Graphic")
// 3: Closed polygon <BOOL> (default: false)
// 4: Stale time in seconds <NUMBER> (default: 86400)
// 5: Stroke color as signed ARGB int <NUMBER> (default: -1)
// 6: Fill color as signed ARGB int <NUMBER> (default: -1761607681)
// 7: Stroke weight <NUMBER> (default: 3)
//
// Example:
// [[pos player, screenToWorld [0.5, 0.5]], "GFGPOLAGM-----X", "Axis of Advance"] call armatak_fnc_draw_tactical_graphic;
//
// Public: Yes
params [
["_points", [], [[]]],
["_milsym", "", [""]],
["_callsign", "ArmaTAK Tactical Graphic", [""]],
["_closed", false, [true]],
["_staleSeconds", 86400, [0]],
["_strokeColor", -1, [0]],
["_fillColor", -1761607681, [0]],
["_strokeWeight", 3, [0]]
];
if (_milsym isEqualTo "") exitWith {""};
if ((count _points) < 2) exitWith {""};
private _pointStrings = [];
private _center = [];
{
private _position = if (_x isEqualType objNull) then {
getPos _x
} else {
_x
};
if ((count _position) >= 2) then {
private _altitude = _position param [2, 0, [0]];
private _realLocation = [_position select 0, _position select 1, _altitude] call armatak_client_fnc_convertClientLocation;
_pointStrings pushBack format ["%1,%2,%3", _realLocation select 0, _realLocation select 1, _realLocation select 2];
if (_center isEqualTo []) then {
_center = _realLocation;
};
};
} forEach _points;
if ((count _pointStrings) < 2) exitWith {""};
private _uuid = "armatak" callExtension ["uuid", []] select 0;
private _payload = [
_uuid,
"u-d-f",
_center select 0,
_center select 1,
_center select 2,
_pointStrings joinString ";",
_callsign,
_staleSeconds max 1,
_strokeColor,
_fillColor,
_strokeWeight max 1,
"solid",
_closed,
_milsym
];
"armatak" callExtension ["tcp_socket:draw:vector", [_payload]];
@@ -0,0 +1,30 @@
// function name: armatak_fnc_register_cot
// function author: Valmo, Codex
// function description: Registers a CoT object under a scope so it can be deleted later.
//
// Arguments:
// 0: Scope/key used to group CoTs <STRING>
// 1: CoT UID <STRING>
// 2: CoT type <STRING>
// 3: Latitude <NUMBER>
// 4: Longitude <NUMBER>
// 5: HAE altitude <NUMBER>
//
// Public: Yes
params [
["_scope", "", [""]],
["_uid", "", [""]],
["_type", "", [""]],
["_lat", 0, [0]],
["_lon", 0, [0]],
["_hae", 0, [0]]
];
if (_scope isEqualTo "" || {_uid isEqualTo ""} || {_type isEqualTo ""}) exitWith {false};
private _registry = missionNamespace getVariable ["armatak_registered_cots", []];
_registry pushBack [_scope, _uid, _type, _lat, _lon, _hae];
missionNamespace setVariable ["armatak_registered_cots", _registry];
true
@@ -0,0 +1,105 @@
// function name: armatak_fnc_report_marker
// function author: Valmo
// function description: Sends a one-shot TAK report marker with an independent stale time.
//
// Arguments:
// 0: Source position or object <ARRAY|OBJECT>
// 1: Affiliation or raw CoT type <STRING> (default: "unknown")
// Supported affiliations: "friendly", "enemy", "hostile", "neutral", "unknown"
// 2: Marker kind <STRING> (default: "infantry")
// Supported kinds: "infantry", "tank", "car", "apc", "helicopter", "plane", "ship", "static"
// 3: Callsign <STRING> (default: "Report")
// 4: Remarks <STRING> (default: "")
// 5: Stale time in seconds <NUMBER> (default: 3600)
// 6: Optional registration scope <STRING> (default: "")
//
// Example:
// [cursorObject, "enemy", "tank", "Enemy Tank", "Reported enemy tank"] call armatak_fnc_report_marker;
// [screenToWorld [0.5, 0.5], "unknown", "infantry", "Unknown Contact", "Unknown contact reported", 7200] call armatak_fnc_report_marker;
// [cursorObject, "a-h-G-U-C-A-T", "Enemy Tank", "Reported enemy tank", 7200] call armatak_fnc_report_marker;
//
// Public: Yes
params [
["_source", objNull, [objNull, []]],
["_affiliationOrType", "unknown", [""]],
["_kindOrCallsign", "infantry", [""]],
["_callsignOrRemarks", "Report", [""]],
["_remarksOrStaleSeconds", "", ["", 0]],
["_staleSeconds", 3600, [0, ""]],
["_scope", "", [""]]
];
private _type = "";
private _callsign = _callsignOrRemarks;
private _remarks = _remarksOrStaleSeconds;
if (_staleSeconds isEqualType "") then {
_scope = _staleSeconds;
_staleSeconds = 3600;
};
if ((_affiliationOrType select [0, 2]) isEqualTo "a-") then {
_type = _affiliationOrType;
_callsign = _kindOrCallsign;
_remarks = _callsignOrRemarks;
if (_remarksOrStaleSeconds isEqualType 0) then {
_staleSeconds = _remarksOrStaleSeconds;
};
} else {
private _affiliation = switch (toLower _affiliationOrType) do {
case "friendly": {"f"};
case "enemy": {"h"};
case "hostile": {"h"};
case "neutral": {"n"};
default {"u"};
};
private _kind = switch (toLower _kindOrCallsign) do {
case "tank": {"G-U-C-A-T"};
case "car": {"G-U-C-I-M"};
case "apc": {"G-U-C-I-I"};
case "helicopter": {"A-M-H"};
case "plane": {"A-M-F"};
case "ship": {"S"};
case "static": {"G-U-C-F-M"};
default {"G-U-C-I"};
};
_type = "a-" + _affiliation + "-" + _kind;
if (_remarksOrStaleSeconds isEqualType 0) then {
_staleSeconds = _remarksOrStaleSeconds;
_remarks = "";
};
};
private _position = if (_source isEqualType objNull) then {
getPos _source
} else {
_source
};
if ((count _position) < 2) exitWith {
""
};
private _altitude = _position param [2, 0, [0]];
private _realLocation = [_position select 0, _position select 1, _altitude] call armatak_client_fnc_convertClientLocation;
private _uuid = "armatak" callExtension ["uuid", []] select 0;
private _payload = [
_uuid,
_type,
_realLocation select 0,
_realLocation select 1,
_realLocation select 2,
_callsign,
_staleSeconds max 1,
_remarks
];
"armatak" callExtension ["tcp_socket:cot:report_marker", [_payload]];
[_scope, _uuid, _type, _realLocation select 0, _realLocation select 1, _realLocation select 2] call armatak_fnc_register_cot;
_uuid
@@ -6,7 +6,13 @@ if (!isNull _digitalPointer) then {
_digitalPointerPosition = _digitalPointer call armatak_client_fnc_extractClientPosition;
_link_uid = [_unit] call armatak_fnc_extract_uuid;
_contact_callsign = ([player] call armatak_fnc_extract_unit_callsign) + ".DP1";
_objectType = [_unit] call BIS_fnc_objectType;
_ownerCallsign = if ((_objectType select 0) == "Soldier") then {
[_unit] call armatak_fnc_extract_unit_callsign
} else {
[_unit] call armatak_fnc_extract_marker_callsign
};
_contact_callsign = _ownerCallsign + ".DP1";
_dpCot = [_link_uid, _contact_callsign, _digitalPointerPosition select 1, _digitalPointerPosition select 2, _digitalPointerPosition select 3];
@@ -27,7 +27,7 @@ switch (str _side) do {
};
};
_unit_type = _unit call BIS_fnc_objectType;
private _unit_type = _unit call BIS_fnc_objectType;
if ((_unit_type select 0) == "Soldier") then {
switch (_unit_type select 1) do {
@@ -71,7 +71,7 @@ if ((_unit_type select 0) == "Soldier") then {
};
if ((typeOf (vehicle _unit) != typeOf _unit) or ((_unit_type select 0) == "Vehicle")) then {
_vehicle_type = (vehicle _unit) call BIS_fnc_objectType select 1;
private _vehicle_type = (vehicle _unit) call BIS_fnc_objectType select 1;
switch (_vehicle_type) do {
case "Car": {
_type = "G-U-C-I-M";
@@ -111,10 +111,10 @@ if ((typeOf (vehicle _unit) != typeOf _unit) or ((_unit_type select 0) == "Vehic
_role = "a-" + _affiliation + "-" + _type;
armatak_attribute_marker_type = _unit getVariable "armatak_attribute_marker_type";
private _markerTypeOverride = _unit getVariable ["armatak_attribute_marker_type", ""];
if (!isNil "armatak_attribute_marker_type" or armatak_attribute_marker_type != '') then {
_role = armatak_attribute_marker_type;
if (_markerTypeOverride isNotEqualTo "") then {
_role = _markerTypeOverride;
};
_role
@@ -1,5 +1,7 @@
params["_unit"];
if (_unit getVariable ["armatak_disable_sensor_data", false]) exitWith {};
_target = getSensorTargets (_unit);
{
+3
View File
@@ -1,7 +1,10 @@
PREP(3denEnrollModuleConfig);
PREP(3denTcpModuleConfig);
PREP(addRouterEntities);
PREP(removeRouterEntities);
PREP(routerEntityAdd);
PREP(routerEntityRemove);
PREP(setRouterEntities);
PREP(startCotRouter);
PREP(ZeusEnrollModuleConfig);
PREP(ZeusTcpModuleConfig);
@@ -0,0 +1,28 @@
#include "..\script_component.hpp"
/*
* Author: Valmo, Codex
* Adds entities to the server-side CoT router entity list.
*
* Arguments:
* 0: Entity or entities to publish through the CoT router <OBJECT|ARRAY>
*
* Return Value:
* Current routed entities <ARRAY>
*
* Public: Yes
*/
params [
["_entities", [], [objNull, []]]
];
private _toAdd = if (_entities isEqualType objNull) then {[_entities]} else {_entities};
private _routed = missionNamespace getVariable ["armatak_server_syncedUnits", []];
_routed append _toAdd;
_routed = _routed arrayIntersect _routed;
_routed = _routed select {!isNull _x && {alive _x}};
missionNamespace setVariable ["armatak_server_syncedUnits", _routed, true];
GVAR(syncedUnits) = _routed;
_routed
@@ -0,0 +1,26 @@
#include "..\script_component.hpp"
/*
* Author: Valmo, Codex
* Removes entities from the server-side CoT router entity list.
*
* Arguments:
* 0: Entity or entities to remove from the CoT router <OBJECT|ARRAY>
*
* Return Value:
* Current routed entities <ARRAY>
*
* Public: Yes
*/
params [
["_entities", [], [objNull, []]]
];
private _toRemove = if (_entities isEqualType objNull) then {[_entities]} else {_entities};
private _routed = missionNamespace getVariable ["armatak_server_syncedUnits", []];
_routed = (_routed - _toRemove) select {!isNull _x && {alive _x}};
missionNamespace setVariable ["armatak_server_syncedUnits", _routed, true];
GVAR(syncedUnits) = _routed;
_routed
@@ -36,7 +36,7 @@ switch (false) do {
GVAR(syncedUnits) pushBack _unit;
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits)];
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits), true];
SETVAR(_unit,GVAR(isRouting),true);
deleteVehicle _logic;
@@ -34,7 +34,7 @@ switch (false) do {
};
} forEach GVAR(syncedUnits);
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits)];
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits), true];
SETVAR(_unit,GVAR(isRouting),false);
deleteVehicle _logic;
@@ -0,0 +1,25 @@
#include "..\script_component.hpp"
/*
* Author: Valmo, Codex
* Replaces the server-side CoT router entity list with a de-duplicated list.
*
* Arguments:
* 0: Entities to publish through the CoT router <ARRAY>
*
* Return Value:
* Current routed entities <ARRAY>
*
* Public: Yes
*/
params [
["_entities", [], [[]]]
];
private _routed = _entities arrayIntersect _entities;
_routed = _routed select {!isNull _x && {alive _x}};
missionNamespace setVariable ["armatak_server_syncedUnits", _routed, true];
GVAR(syncedUnits) = _routed;
_routed
+23 -2
View File
@@ -9,15 +9,33 @@ if (isNil { missionNamespace getVariable "armatak_server_syncedUnits" }) then {
missionNamespace setVariable ["armatak_server_syncedUnits", []];
};
if (isNil { missionNamespace getVariable "armatak_server_clientClaimedEuds" }) then {
missionNamespace setVariable ["armatak_server_clientClaimedEuds", []];
};
GVAR(syncedUnits) = missionNamespace getVariable "armatak_server_syncedUnits";
[{
GVAR(syncedUnits) = missionNamespace getVariable "armatak_server_syncedUnits";
private _clientClaimedEuds = missionNamespace getVariable ["armatak_server_clientClaimedEuds", []];
{
_objectType = _x call BIS_fnc_objectType;
switch (true) do {
case ((_objectType select 0) == "Soldier"): {
private _uuid = _x call armatak_fnc_extract_uuid;
private _isClientEud = _x getVariable [QEGVAR(client,eudConnected), false];
if (_isClientEud) exitWith {
if !(_uuid in _clientClaimedEuds) then {
private _position = _x call armatak_client_fnc_extractClientPosition;
private _deleteCot = [_uuid, "a-f-G-U-C-I", _position select 1, _position select 2, _position select 3];
"armatak" callExtension ["tcp_socket:cot:delete", [_deleteCot]];
_clientClaimedEuds pushBack _uuid;
};
};
_clientClaimedEuds = _clientClaimedEuds - [_uuid];
_callsign = [_x] call armatak_fnc_extract_unit_callsign;
_group_name = [group _x] call armatak_fnc_extract_group_color;
_group_role = [_x] call armatak_fnc_extract_group_role;
@@ -31,8 +49,8 @@ GVAR(syncedUnits) = missionNamespace getVariable "armatak_server_syncedUnits";
_callsign = [_x] call armatak_fnc_extract_marker_callsign;
[_x, _atak_type, _callsign] call armatak_fnc_send_drone_cot;
[_x] call armatak_fnc_send_digital_pointer_cot;
_x call armatak_fnc_extract_sensor_data;
[_x] call armatak_fnc_send_digital_pointer_cot;
};
};
case ((_objectType select 0) == "Vehicle"): {
@@ -41,6 +59,7 @@ GVAR(syncedUnits) = missionNamespace getVariable "armatak_server_syncedUnits";
[_x, _atak_type, _callsign] call armatak_fnc_send_marker_cot;
_x call armatak_fnc_extract_sensor_data;
[_x] call armatak_fnc_send_digital_pointer_cot;
};
case ((_objectType select 0) == "VehicleAutonomous"): {
if !(_x getVariable ["armatak_uav_mavlink_broadcasting", false]) then {
@@ -48,12 +67,14 @@ GVAR(syncedUnits) = missionNamespace getVariable "armatak_server_syncedUnits";
_callsign = [_x] call armatak_fnc_extract_marker_callsign;
[_x, _atak_type, _callsign] call armatak_fnc_send_drone_cot;
[_x] call armatak_fnc_send_digital_pointer_cot;
_x call armatak_fnc_extract_sensor_data;
[_x] call armatak_fnc_send_digital_pointer_cot;
};
};
};
} forEach GVAR(syncedUnits);
missionNamespace setVariable ["armatak_server_clientClaimedEuds", _clientClaimedEuds];
}, 1, []] call CBA_fnc_addPerFrameHandler;
true
+188
View File
@@ -0,0 +1,188 @@
# TAK authentication flows
This document describes the authentication and transport flows implemented by the ArmaTAK Rust extension. It is development documentation, not end-user documentation.
## Security model
ArmaTAK currently exposes three TCP connection modes:
| Mode | ArmaTAK entry point | Authentication | Transport protection | Intended use |
| --- | --- | --- | --- | --- |
| Plain TCP | `tcp_socket.start` | None | None | Local development or explicitly trusted isolated networks only |
| Manual mTLS | `tcp_socket.start_mtls` | Client X.509 certificate | TLS with server certificate validation | Production-compatible secure connection |
| Credential enrollment followed by mTLS | `tcp_socket.start_enroll_mtls` | HTTP Basic credentials during enrollment, then client X.509 certificate | Enrollment HTTPS currently skips certificate validation; the resulting TAK stream uses validated mTLS | Transitional/bootstrap flow; do not treat the enrollment phase as secure until server verification is enabled |
The official TAK Server example enables a TLS input on port `8089`. Its plaintext TCP/streaming examples are anonymous and are shown on ports `8087` and `8088`. ArmaTAK must not infer security from a port number; the selected connection mode controls the client-side security properties.
## Insecure flow: plain TCP
The plain flow is deliberately simple:
```text
Arma/SQF
|
| tcp_socket.start("host:port")
v
ConnectionConfig::Plain
|
v
TCP connect
|
| unencrypted CoT bytes
v
TAK Server plaintext/anonymous input
```
`ConnectionConfig::Plain` resolves the supplied address and opens a normal `TcpStream`. No TLS handshake occurs, there is no server identity verification, and no client identity is established by ArmaTAK.
Consequences:
- CoT payloads can be observed or modified by an on-path attacker.
- ArmaTAK cannot prove that it connected to the intended TAK Server.
- The server cannot authenticate ArmaTAK by certificate.
- Plain TCP must never be the production default.
The official TAK Server `CoreConfig.example.xml` keeps anonymous TCP inputs commented out and enables the TLS input instead. Use plain TCP only when a developer intentionally configures a corresponding non-TLS server input.
## Secure flow: manual mTLS
The manual mTLS flow uses pre-provisioned certificate material:
```text
Arma/SQF
|
| tcp_socket.start_mtls(
| address,
| server_name,
| ca_cert_path,
| client_cert_path,
| client_key_path
| )
v
ConnectionConfig::Mtls
|
+--> load trusted CA certificate(s)
+--> load client certificate chain
+--> load client private key
|
v
rustls client configuration
|
+--> validate server certificate chain
+--> validate TLS server name
+--> present client certificate
v
Authenticated mTLS TAK stream
```
The CA file is loaded into a dedicated `rustls::RootCertStore`; the client certificate and private key are used for client authentication. When `server_name` is empty, ArmaTAK derives a name from the target address. A non-empty `server_name` should be preferred when DNS and certificate names are known.
This is the preferred production flow when certificate provisioning is handled outside ArmaTAK.
## Enrollment flow: credentials to client certificate
TAK enrollment converts a username/password bootstrap credential into a short- or medium-lived client certificate, then reconnects using mTLS.
### Protocol contract
ArmaTAK follows the contract visible in the official TAK Server and ATAK implementations:
1. `GET https://HOST:ENROLL_PORT/Marti/api/tls/config`
2. Authenticate the request with HTTP Basic credentials.
3. Read `serverPort` and `enrollPath` from the TAK certificate configuration. If the fields are absent, ArmaTAK currently falls back to `8089` and `/Marti/api/tls/signClient/v2`.
4. Generate a local RSA key pair and PKCS#10 CSR. The private key never needs to be sent to the server.
5. `POST https://HOST:ENROLL_PORT{enrollPath}?clientUid=CLIENT_UID`
6. Authenticate the signing request with HTTP Basic credentials, send the CSR as `application/pkcs10`, and request JSON.
7. Parse `signedCert` and `ca0` from the enrollment response.
8. Build an in-memory client identity from the returned certificate plus the locally generated private key.
9. Connect to `HOST:serverPort` with mTLS and validate the TAK streaming server against the returned CA.
The official TAK Server exposes `/tls/config`, `/tls/signClient`, and `/tls/signClient/v2`. The v2 endpoint can return JSON containing `signedCert` plus `ca0`, `ca1`, and additional CA-chain entries. The current ArmaTAK implementation consumes `signedCert` and `ca0`.
### Current ArmaTAK enrollment security caveat
`enrollment_http_client()` currently uses `danger_accept_invalid_certs(true)`. Therefore the HTTPS bootstrap does **not** authenticate the enrollment server certificate.
That means the current enrollment phase is vulnerable to an active man-in-the-middle attacker who can impersonate the HTTPS enrollment endpoint and receive the submitted Basic credentials. The private key remains local, but the username/password bootstrap secret is still exposed to the impersonating endpoint.
The connection established *after* enrollment is mTLS and uses the returned CA, but that does not retroactively secure the bootstrap exchange. Consequently:
- `start_enroll_mtls` is a transitional flow, not the preferred production-secure flow in its current form.
- `start_mtls` with a trusted CA and pre-provisioned client certificate is the secure production path today.
- A future secure enrollment implementation must validate the HTTPS server with a trusted CA, certificate pin, or equivalent authenticated trust bootstrap.
- An explicit insecure/development enrollment switch is preferable to silently disabling verification.
The official ATAK enrollment manager exposes a `verifyHost` choice and records whether host verification is on or off. ArmaTAK should converge on the same explicit distinction.
## Target secure enrollment design
The intended production enrollment flow is:
```text
username/password
|
v
HTTPS enrollment endpoint
(server certificate MUST validate)
|
+--> GET /Marti/api/tls/config
+--> generate local private key + CSR
+--> POST configured signClient endpoint
v
signed client certificate + trusted CA
|
v
mTLS TAK streaming connection
|
+--> server authenticated by CA/name
+--> client authenticated by certificate
v
CoT traffic
```
The development-only insecure variant should be explicit:
```text
username/password
|
v
HTTPS enrollment endpoint
(certificate verification explicitly disabled)
|
v
WARNING / development-only path
```
Do not silently fall back from verified enrollment to unverified enrollment after a certificate validation failure.
## Secret-handling requirements
- Never include passwords, private keys, certificate payloads, or Authorization headers in logs.
- `ConnectionConfig::describe()` may include the username and client UID for diagnostics, but it must never include the password.
- Keep generated private keys in memory unless a future feature explicitly requires persistence.
- Do not commit development certificates or credentials to the repository.
- Treat enrollment credentials as bootstrap secrets and rotate/revoke them according to the TAK Server deployment policy.
## Test contract
Unit tests should lock down the protocol-level behavior that does not require a live TAK Server:
- official endpoint paths and default mTLS port;
- trimming and construction of enrollment URLs;
- parsing TAK enrollment configuration with explicit values and fallback defaults;
- PEM normalization;
- absence of passwords from diagnostic descriptions;
- TLS server-name derivation and PEM parsing where deterministic;
- CoT and MAVLink pure serialization logic.
Live certificate signing, TLS handshake behavior, socket reconnect behavior, mDNS, video processes, and Arma callback behavior belong in integration/E2E tests because they depend on operating-system or external-server state.
## Upstream references
The tests and protocol assumptions in this repository are based on these upstream implementations:
- TAK Server, `CertManagerApi.java`: <https://github.com/TAK-Product-Center/Server/blob/5187abd46d827d37cfc5708805eced197a837e49/src/takserver-core/takserver-war/src/main/java/com/bbn/tak/tls/CertManagerApi.java>
- TAK Server, `CoreConfig.example.xml`: <https://github.com/TAK-Product-Center/Server/blob/5187abd46d827d37cfc5708805eced197a837e49/src/takserver-core/example/CoreConfig.example.xml>
- ATAK/CommonCommo, `enrollmentmanager.cpp`: <https://github.com/TAK-Product-Center/atak-civ/blob/9f6893dd657feacc35ec5de03dad721c2e44170e/commoncommo/core/impl/enrollmentmanager.cpp>
Pinning the source revisions in this document keeps the development contract reproducible. When the upstream protocol changes, update both the implementation/tests and these references together.
+83
View File
@@ -0,0 +1,83 @@
# Rust testing and coverage policy
This document defines the development-time testing and coverage policy for the ArmaTAK Rust extension.
## Goals
The CI pipeline has two complementary coverage goals:
1. **100% line coverage for deterministic unit-testable code.** Pure protocol parsing, serialization, value normalization, packet construction, configuration descriptions, and other deterministic helpers belong in this gate.
2. **A non-regression floor for the complete Rust extension.** The complete report includes network adapters, mDNS, process management, Arma callbacks, socket loops, and TLS handshakes even when those components require integration or E2E infrastructure rather than unit tests.
The first goal prevents newly added deterministic logic from bypassing unit tests. The second prevents the overall extension from silently losing coverage while integration coverage is expanded.
## Local commands
Run the complete Rust unit test suite with:
```bash
cargo test --all-targets --locked
```
Install `cargo-llvm-cov` and inspect line coverage with:
```bash
cargo llvm-cov --all-targets --locked --summary-only
```
Generate an LCOV file with:
```bash
cargo llvm-cov --all-targets --locked --lcov --output-path lcov.info
```
CI publishes `lcov.info` as the `rust-lcov` artifact after a successful run.
## What belongs in the 100% unit gate
A source file should be included in the deterministic 100% gate when its behavior can be exercised without relying on:
- a live TAK Server;
- a live ATAK client;
- an operating-system socket peer;
- mDNS availability;
- an FFmpeg or external video process;
- timing-sensitive reconnect loops;
- Arma engine callback scheduling;
- real certificate enrollment or a real TLS handshake.
Examples include TAK enrollment URL/config parsing, XML serialization, MAVLink checksums and identity derivation, payload conversion, and diagnostic formatting.
When adding deterministic production code, add the tests in the same change. If a previously excluded file becomes fully unit-testable, bring it to 100% line coverage and remove it from the CI exclusion expression.
## What does not belong in a unit-only 100% gate
External adapters are still part of the full-extension coverage report, but forcing them into a unit-only percentage usually produces brittle or misleading tests. The following behavior should be verified through integration or E2E fixtures instead:
- TCP/UDP connection establishment and reconnect behavior;
- TAK Server certificate enrollment over HTTPS;
- real mTLS certificate-chain and server-name verification;
- mDNS publication/discovery;
- subprocess lifecycle and video streaming;
- Arma engine callback delivery and long-running endpoint threads.
These components must not be hidden from the complete report. Their zero or partial unit coverage remains visible and therefore continues to affect the global non-regression floor.
## Authentication test contract
Authentication protocol assumptions are derived from pinned official upstream implementations. See [authentication-flows.md](authentication-flows.md) for the exact TAK Server and ATAK source revisions.
At minimum, unit tests lock down:
- `/Marti/api/tls/config` construction;
- the v2 `signClient` enrollment path and the official `8089` mTLS default;
- Basic-auth enrollment configuration behavior at the adapter boundary;
- enrollment XML parsing, including missing and malformed tags;
- PEM normalization;
- the rule that diagnostic descriptions never contain enrollment passwords.
A future verified-enrollment implementation must add tests for the trust-bootstrap configuration and integration tests using a real or fixture TLS server. It must never silently downgrade from verified HTTPS to disabled certificate validation.
## Coverage changes
Coverage thresholds are intentionally enforced in CI rather than documented as aspirational numbers. When a change increases the complete-extension baseline, raise the global floor in the workflow in the same pull request or branch before merging. Do not lower either coverage threshold merely to make CI pass; lower thresholds only when a documented architectural change makes the previous metric invalid.
+83 -2
View File
@@ -18,6 +18,7 @@ pub struct CursorOverTime {
pub link_uid: Option<String>,
pub remarker: Option<String>,
pub video_url: Option<String>,
pub stale_seconds: Option<i64>,
}
impl CursorOverTime {
@@ -34,8 +35,9 @@ impl CursorOverTime {
let created_time = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let stale_time =
(Utc::now() + Duration::seconds(360)).to_rfc3339_opts(SecondsFormat::Millis, true);
let stale_seconds = self.stale_seconds.unwrap_or(360).max(1);
let stale_time = (Utc::now() + Duration::seconds(stale_seconds))
.to_rfc3339_opts(SecondsFormat::Millis, true);
let mut xml = String::new();
@@ -120,3 +122,82 @@ impl CursorOverTime {
return xml;
}
}
#[cfg(test)]
mod tests {
use super::CursorOverTime;
fn minimal_marker() -> CursorOverTime {
CursorOverTime {
uuid: None,
r#type: None,
point_lat: -30.0,
point_lon: -51.0,
point_hae: 10.0,
point_ce: None,
point_le: None,
contact_callsign: "Falcon".to_string(),
group_name: None,
group_role: None,
track_course: None,
track_speed: None,
link_uid: None,
remarker: None,
video_url: None,
stale_seconds: None,
}
}
#[test]
fn serializes_minimal_marker_with_tak_defaults() {
let xml = minimal_marker().convert_to_xml();
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"));
assert!(xml.contains("<event type=\"a-f-G-U-C-I\" version=\"2.0\""));
assert!(xml.contains("<point ce=\"9999999\" le=\"9999999\" hae=\"10\" lat=\"-30\" lon=\"-51\" />"));
assert!(xml.contains("<contact callsign=\"Falcon\" />"));
assert!(xml.contains("<uid Droid=\"Falcon\"/>"));
assert!(!xml.contains("<link "));
assert!(!xml.contains("<track "));
assert!(!xml.contains("<__group "));
assert!(!xml.contains("<remarks>"));
assert!(!xml.contains("<__video>"));
assert!(xml.ends_with("</detail></event>"));
}
#[test]
fn serializes_all_optional_marker_details() {
let marker = CursorOverTime {
uuid: Some("marker-1".to_string()),
r#type: Some("a-f-G-U-C".to_string()),
point_lat: 1.25,
point_lon: 2.5,
point_hae: 100.0,
point_ce: Some(3.0),
point_le: Some(4.0),
contact_callsign: "Raven".to_string(),
group_name: Some("Cyan".to_string()),
group_role: Some("Team Member".to_string()),
track_course: Some(180),
track_speed: Some(12.5),
link_uid: Some("parent-1".to_string()),
remarker: Some("test remark".to_string()),
video_url: Some("rtsp://video.example.test:8554/live".to_string()),
stale_seconds: Some(0),
};
let xml = marker.convert_to_xml();
assert!(xml.contains("type=\"a-f-G-U-C\""));
assert!(xml.contains("uid=\"marker-1\""));
assert!(xml.contains("<point ce=\"3\" le=\"4\" hae=\"100\" lat=\"1.25\" lon=\"2.5\" />"));
assert!(xml.contains("<precisionlocation altsrc=\"DTED0\" />"));
assert!(xml.contains("<link uid=\"parent-1\" type=\"a-f-G-U-C\" relation=\"p-p\" />"));
assert!(xml.contains("<hideLabel />"));
assert!(xml.contains("<track course=\"180\" speed=\"12.5\" />"));
assert!(xml.contains("<status battery=\"89\" />"));
assert!(xml.contains("<__group name=\"Cyan\" role=\"Team Member\" />"));
assert!(xml.contains("<remarks>ARMATAK | test remark</remarks>"));
assert!(xml.contains("<ConnectionEntry protocol=\"rtsp\""));
}
}
+74
View File
@@ -0,0 +1,74 @@
use arma_rs::{FromArma, FromArmaError};
use chrono::{Duration, SecondsFormat, Utc};
pub struct DeleteCoTPayload {
pub target_uid: String,
pub target_type: String,
pub point_lat: f64,
pub point_lon: f64,
pub point_hae: f32,
}
impl FromArma for DeleteCoTPayload {
fn from_arma(data: String) -> Result<DeleteCoTPayload, FromArmaError> {
let (target_uid, target_type, point_lat, point_lon, point_hae) =
<(String, String, f64, f64, f32)>::from_arma(data)?;
Ok(Self {
target_uid,
target_type,
point_lat,
point_lon,
point_hae,
})
}
}
impl DeleteCoTPayload {
pub fn to_xml(&self) -> String {
let created_time = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let stale_time =
(Utc::now() + Duration::seconds(60)).to_rfc3339_opts(SecondsFormat::Millis, true);
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" ?><event type=\"t-x-d-d\" version=\"2.0\" how=\"m-g\" uid=\"{}.delete\" time=\"{}\" start=\"{}\" stale=\"{}\"><point ce=\"9999999\" le=\"9999999\" hae=\"{}\" lat=\"{}\" lon=\"{}\" /><detail><link uid=\"{}\" type=\"{}\" relation=\"none\" /><__forcedelete /></detail></event>",
self.target_uid,
created_time,
created_time,
stale_time,
self.point_hae,
self.point_lat,
self.point_lon,
self.target_uid,
self.target_type
)
}
}
#[cfg(test)]
mod tests {
use super::DeleteCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_and_serializes_forced_delete_cot() {
let payload = DeleteCoTPayload::from_arma(
r#"["target-1","a-f-G-U-C",-30.1,-51.2,123.5]"#.to_string(),
)
.expect("delete CoT payload should parse");
assert_eq!(payload.target_uid, "target-1");
assert_eq!(payload.target_type, "a-f-G-U-C");
assert_eq!(payload.point_lat, -30.1);
assert_eq!(payload.point_lon, -51.2);
assert_eq!(payload.point_hae, 123.5);
let xml = payload.to_xml();
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"));
assert!(xml.contains("type=\"t-x-d-d\""));
assert!(xml.contains("uid=\"target-1.delete\""));
assert!(xml.contains("hae=\"123.5\" lat=\"-30.1\" lon=\"-51.2\""));
assert!(xml.contains("<link uid=\"target-1\" type=\"a-f-G-U-C\" relation=\"none\" />"));
assert!(xml.contains("<__forcedelete />"));
assert!(xml.ends_with("</detail></event>"));
}
}
+31
View File
@@ -41,7 +41,38 @@ impl DigitalPointerPayload {
link_uid: Some(self.link_uid.clone()),
remarker: None,
video_url: None,
stale_seconds: Some(7),
}
}
}
#[cfg(test)]
mod tests {
use super::DigitalPointerPayload;
use arma_rs::FromArma;
#[test]
fn parses_digital_pointer_and_maps_spi_contract() {
let payload = DigitalPointerPayload::from_arma(
r#"["operator-1","Falcon SPI",-30.1,-51.2,20.0]"#.to_string(),
)
.expect("digital pointer should parse");
assert_eq!(payload.link_uid, "operator-1");
assert_eq!(payload.contact_callsign, "Falcon SPI");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("operator-1.SPI1"));
assert_eq!(cot.r#type.as_deref(), Some("b-m-p-s-p-i"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 20.0);
assert_eq!(cot.contact_callsign, "Falcon SPI");
assert_eq!(cot.link_uid.as_deref(), Some("operator-1"));
assert_eq!(cot.stale_seconds, Some(7));
assert!(cot.group_name.is_none());
assert!(cot.track_course.is_none());
assert!(cot.remarker.is_none());
assert!(cot.video_url.is_none());
}
}
+37
View File
@@ -124,3 +124,40 @@ impl ShapeCircleCoT {
)
}
}
#[cfg(test)]
mod tests {
use super::CircleCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_circle_payload_maps_shape_and_serializes_cot() {
let payload = CircleCoTPayload::from_arma(
r#"["circle-1",-30.1,-51.2,20,100,50,30,"Area Alpha","creator-1","Falcon"]"#
.to_string(),
)
.expect("circle payload should parse");
assert_eq!(payload.uuid, "circle-1");
assert_eq!(payload.center_lat, -30.1);
assert_eq!(payload.center_lon, -51.2);
assert_eq!(payload.center_hae, 20.0);
assert_eq!(payload.major, 100.0);
assert_eq!(payload.minor, 50.0);
assert_eq!(payload.angle, 30.0);
let shape = payload.to_cot();
assert_eq!(shape.uid, "circle-1");
assert_eq!(shape.callsign, "Area Alpha");
assert_eq!(shape.creator_uid, "creator-1");
assert_eq!(shape.creator_callsign, "Falcon");
let xml = shape.to_xml("2026-08-07T17:00:00.000Z", "2026-08-07T18:00:00.000Z");
assert!(xml.contains("uid=\"circle-1\" type=\"u-d-c-c\""));
assert!(xml.contains("<point lat=\"-30.1\" lon=\"-51.2\" hae=\"20\""));
assert!(xml.contains("<ellipse major=\"100\" minor=\"50\" angle=\"30\" />"));
assert!(xml.contains("<link uid=\"creator-1\" type=\"self\" relation=\"p-p-CenterAnchor\" />"));
assert!(xml.contains("<contact callsign=\"Area Alpha\" />"));
assert!(xml.contains("<creator uid=\"creator-1\" callsign=\"Falcon\""));
}
}
+2
View File
@@ -1 +1,3 @@
pub mod circle;
pub mod route;
pub mod shape;
+204
View File
@@ -0,0 +1,204 @@
use arma_rs::{FromArma, FromArmaError};
use uuid::Uuid;
#[derive(Clone)]
struct RoutePoint {
lat: f64,
lon: f64,
hae: f32,
}
pub struct RoutePayload {
pub uuid: String,
pub points: String,
pub callsign: String,
pub stale_seconds: i64,
pub color: i32,
pub stroke_weight: f64,
pub method: String,
pub route_type: String,
pub direction: String,
pub checkpoint_interval: usize,
}
impl FromArma for RoutePayload {
fn from_arma(data: String) -> Result<Self, FromArmaError> {
let (
uuid,
points,
callsign,
stale_seconds,
color,
stroke_weight,
method,
route_type,
direction,
checkpoint_interval,
) = <(
String,
String,
String,
i64,
i32,
f64,
String,
String,
String,
i32,
)>::from_arma(data)?;
Ok(Self {
uuid,
points,
callsign,
stale_seconds,
color,
stroke_weight,
method,
route_type,
direction,
checkpoint_interval: checkpoint_interval.max(1) as usize,
})
}
}
impl RoutePayload {
pub fn to_xml(&self, now: &str, stale: &str) -> String {
let points = parse_points(&self.points);
let links = route_links(&self.callsign, &points, self.checkpoint_interval);
format!(
r#"<?xml version="1.0" encoding="UTF-8" ?><event version="2.0" uid="{uid}" type="b-m-r" time="{now}" start="{now}" stale="{stale}" how="h-e" access="Undefined"><point lat="0.0" lon="0.0" hae="9999999.0" ce="9999999.0" le="9999999.0"/><detail>{links}<link_attr planningmethod="{direction}" color="{color}" method="{method}" prefix="CP" style="0" type="Vehicle" stroke="{stroke_weight}" direction="{direction}" routetype="{route_type}" order="Ascending Check Points"/><creator uid="ARMATAK" callsign="ArmaTAK" time="{now}" type="a-f-G-U-C"/><strokeColor value="{color}"/><strokeWeight value="{stroke_weight}"/><strokeStyle value="solid"/><labels_on value="false"/><__routeinfo><__navcues/></__routeinfo><color value="{color}"/><remarks/><contact callsign="{callsign}"/><archive/><height_unit>1</height_unit></detail></event>"#,
uid = escape_attr(&self.uuid),
now = now,
stale = stale,
links = links,
direction = escape_attr(&self.direction),
color = self.color,
method = escape_attr(&self.method),
stroke_weight = self.stroke_weight.max(1.0),
route_type = escape_attr(&self.route_type),
callsign = escape_attr(&self.callsign)
)
}
}
fn route_links(callsign: &str, points: &[RoutePoint], checkpoint_interval: usize) -> String {
let mut xml = String::new();
let last_index = points.len().saturating_sub(1);
let mut checkpoint_number = 1;
for (index, point) in points.iter().enumerate() {
let is_start = index == 0;
let is_end = index == last_index;
let is_checkpoint = !is_start && !is_end && index % checkpoint_interval == 0;
let point_callsign = if is_start {
format!("{} SP", callsign)
} else if is_end {
"VDO".to_string()
} else if is_checkpoint {
let name = format!("CP{}", checkpoint_number);
checkpoint_number += 1;
name
} else {
String::new()
};
let link_type = if point_callsign.is_empty() {
"b-m-p-c"
} else {
"b-m-p-w"
};
xml.push_str(&format!(
r#"<link uid="{link_uid}" callsign="{callsign}" type="{link_type}" point="{lat},{lon},{hae}" remarks="" relation="c"/>"#,
link_uid = escape_attr(&Uuid::new_v4().to_string()),
callsign = escape_attr(&point_callsign),
link_type = link_type,
lat = point.lat,
lon = point.lon,
hae = point.hae
));
}
xml
}
fn parse_points(raw: &str) -> Vec<RoutePoint> {
raw.split(';')
.filter_map(|entry| {
let parts: Vec<_> = entry.split(',').collect();
if parts.len() != 3 {
return None;
}
Some(RoutePoint {
lat: parts[0].parse().ok()?,
lon: parts[1].parse().ok()?,
hae: parts[2].parse().ok()?,
})
})
.collect()
}
fn escape_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::{parse_points, route_links, RoutePayload, RoutePoint};
use arma_rs::FromArma;
#[test]
fn parses_route_payload_clamps_interval_and_serializes_attributes() {
let mut payload = RoutePayload::from_arma(
r#"["route<&","","Route<&",600,-1,0.5,"Driving<&","Primary<&","Infil<&",0]"#
.to_string(),
)
.expect("route payload should parse");
payload.points = "-30.1,-51.2,10;-30.2,-51.3,20".to_string();
assert_eq!(payload.checkpoint_interval, 1);
assert_eq!(payload.stale_seconds, 600);
assert_eq!(payload.stroke_weight, 0.5);
let xml = payload.to_xml("2026-08-07T17:00:00.000Z", "2026-08-07T18:00:00.000Z");
assert!(xml.contains("uid=\"route&lt;&amp;\" type=\"b-m-r\""));
assert!(xml.contains("planningmethod=\"Infil&lt;&amp;\""));
assert!(xml.contains("method=\"Driving&lt;&amp;\""));
assert!(xml.contains("routetype=\"Primary&lt;&amp;\""));
assert!(xml.contains("stroke=\"1\""));
assert!(xml.contains("<contact callsign=\"Route&lt;&amp;\"/>"));
assert!(xml.contains("callsign=\"Route&lt;&amp; SP\""));
assert!(xml.contains("callsign=\"VDO\""));
}
#[test]
fn route_links_cover_start_checkpoint_regular_and_end_points() {
let points = vec![
RoutePoint { lat: 1.0, lon: 1.0, hae: 1.0 },
RoutePoint { lat: 2.0, lon: 2.0, hae: 2.0 },
RoutePoint { lat: 3.0, lon: 3.0, hae: 3.0 },
RoutePoint { lat: 4.0, lon: 4.0, hae: 4.0 },
RoutePoint { lat: 5.0, lon: 5.0, hae: 5.0 },
];
let xml = route_links("Route", &points, 2);
assert!(xml.contains("callsign=\"Route SP\" type=\"b-m-p-w\""));
assert!(xml.contains("callsign=\"CP1\" type=\"b-m-p-w\""));
assert!(xml.contains("callsign=\"\" type=\"b-m-p-c\""));
assert!(xml.contains("callsign=\"VDO\" type=\"b-m-p-w\""));
}
#[test]
fn point_parser_drops_malformed_entries() {
let points = parse_points("1,2,3;missing;bad,2,3;4,bad,6;7,8,bad;9,10,11");
assert_eq!(points.len(), 2);
assert_eq!(points[0].lat, 1.0);
assert_eq!(points[1].lat, 9.0);
}
}
+376
View File
@@ -0,0 +1,376 @@
use arma_rs::{FromArma, FromArmaError};
#[derive(Clone)]
struct DrawPoint {
lat: f64,
lon: f64,
hae: f32,
}
pub struct DrawEllipsePayload {
pub uuid: String,
pub cot_type: String,
pub center_lat: f64,
pub center_lon: f64,
pub center_hae: f32,
pub major: f64,
pub minor: f64,
pub angle: f32,
pub callsign: String,
pub stale_seconds: i64,
pub stroke_color: i32,
pub fill_color: i32,
pub stroke_weight: f64,
pub milsym: String,
}
pub struct DrawLinksPayload {
pub uuid: String,
pub cot_type: String,
pub center_lat: f64,
pub center_lon: f64,
pub center_hae: f32,
pub points: String,
pub callsign: String,
pub stale_seconds: i64,
pub stroke_color: i32,
pub fill_color: i32,
pub stroke_weight: f64,
pub stroke_style: String,
pub closed: bool,
pub milsym: String,
}
impl FromArma for DrawEllipsePayload {
fn from_arma(data: String) -> Result<Self, FromArmaError> {
let (
uuid,
cot_type,
center_lat,
center_lon,
center_hae,
major,
minor,
angle,
callsign,
stale_seconds,
stroke_color,
fill_color,
stroke_weight,
milsym,
) = <(
String,
String,
f64,
f64,
f32,
f64,
f64,
f32,
String,
i64,
i32,
i32,
f64,
String,
)>::from_arma(data)?;
Ok(Self {
uuid,
cot_type,
center_lat,
center_lon,
center_hae,
major,
minor,
angle,
callsign,
stale_seconds,
stroke_color,
fill_color,
stroke_weight,
milsym,
})
}
}
impl FromArma for DrawLinksPayload {
fn from_arma(data: String) -> Result<Self, FromArmaError> {
let (
uuid,
cot_type,
center_lat,
center_lon,
center_hae,
points,
callsign,
stale_seconds,
stroke_color,
fill_color,
stroke_weight,
stroke_style,
closed,
milsym,
) = <(
String,
String,
f64,
f64,
f32,
String,
String,
i64,
i32,
i32,
f64,
String,
bool,
String,
)>::from_arma(data)?;
Ok(Self {
uuid,
cot_type,
center_lat,
center_lon,
center_hae,
points,
callsign,
stale_seconds,
stroke_color,
fill_color,
stroke_weight,
stroke_style,
closed,
milsym,
})
}
}
impl DrawEllipsePayload {
pub fn to_xml(&self, now: &str, stale: &str) -> String {
shape_event(
&self.uuid,
&self.cot_type,
self.center_lat,
self.center_lon,
self.center_hae,
now,
stale,
&self.callsign,
&ellipse_shape_detail(
&self.uuid,
self.major,
self.minor,
self.angle,
self.stroke_color,
self.fill_color,
self.stroke_weight,
),
self.stroke_color,
self.fill_color,
self.stroke_weight,
"solid",
&self.milsym,
)
}
}
impl DrawLinksPayload {
pub fn to_xml(&self, now: &str, stale: &str) -> String {
let points = parse_points(&self.points);
let shape_detail = links_detail(&points, self.closed);
shape_event(
&self.uuid,
&self.cot_type,
self.center_lat,
self.center_lon,
self.center_hae,
now,
stale,
&self.callsign,
&shape_detail,
self.stroke_color,
self.fill_color,
self.stroke_weight,
&self.stroke_style,
&self.milsym,
)
}
}
fn shape_event(
uid: &str,
cot_type: &str,
lat: f64,
lon: f64,
hae: f32,
now: &str,
stale: &str,
callsign: &str,
shape_detail: &str,
stroke_color: i32,
fill_color: i32,
stroke_weight: f64,
stroke_style: &str,
milsym: &str,
) -> String {
let milsym_detail = if milsym.trim().is_empty() {
String::new()
} else {
format!(r#"<__milsym id="{}" />"#, escape_attr(milsym))
};
format!(
r#"<?xml version="1.0" encoding="UTF-8" ?><event version="2.0" uid="{uid}" type="{cot_type}" time="{now}" start="{now}" stale="{stale}" how="h-e" access="Undefined"><point lat="{lat}" lon="{lon}" hae="{hae}" ce="10.9" le="9999999.0" /><detail>{shape_detail}<__shapeExtras cpvis="true" editable="true" /><contact callsign="{callsign}" /><remarks /><archive /><labels_on value="true" /><strokeColor value="{stroke_color}" /><strokeWeight value="{stroke_weight}" /><strokeStyle value="{stroke_style}" /><fillColor value="{fill_color}" /><precisionlocation altsrc="GPS" geopointsrc="GPS" />{milsym_detail}</detail></event>"#,
uid = escape_attr(uid),
cot_type = escape_attr(cot_type),
now = now,
stale = stale,
lat = lat,
lon = lon,
hae = hae,
shape_detail = shape_detail,
callsign = escape_attr(callsign),
stroke_color = stroke_color,
stroke_weight = stroke_weight,
stroke_style = escape_attr(stroke_style),
fill_color = fill_color,
milsym_detail = milsym_detail
)
}
fn ellipse_shape_detail(
uid: &str,
major: f64,
minor: f64,
angle: f32,
stroke_color: i32,
fill_color: i32,
stroke_weight: f64,
) -> String {
format!(
r#"<shape><ellipse major="{major}" minor="{minor}" angle="{angle}" /><link uid="{style_uid}" type="b-x-KmlStyle" relation="p-c"><Style><LineStyle><color>{stroke_hex}</color><width>{stroke_weight}</width></LineStyle><PolyStyle><color>{fill_hex}</color></PolyStyle></Style></link></shape>"#,
major = major,
minor = minor,
angle = angle,
style_uid = escape_attr(&format!("{}.Style", uid)),
stroke_hex = argb_hex(stroke_color),
stroke_weight = stroke_weight,
fill_hex = argb_hex(fill_color)
)
}
fn links_detail(points: &[DrawPoint], closed: bool) -> String {
let mut detail = String::new();
for point in points {
detail.push_str(&link_detail(point));
}
if closed {
if let Some(first) = points.first() {
detail.push_str(&link_detail(first));
}
}
detail
}
fn link_detail(point: &DrawPoint) -> String {
format!(
r#"<link point="{lat},{lon},{hae}" />"#,
lat = point.lat,
lon = point.lon,
hae = point.hae
)
}
fn parse_points(raw: &str) -> Vec<DrawPoint> {
raw.split(';')
.filter_map(|entry| {
let parts: Vec<_> = entry.split(',').collect();
if parts.len() != 3 {
return None;
}
Some(DrawPoint {
lat: parts[0].parse().ok()?,
lon: parts[1].parse().ok()?,
hae: parts[2].parse().ok()?,
})
})
.collect()
}
fn argb_hex(color: i32) -> String {
format!("{:08x}", color as u32)
}
fn escape_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::{parse_points, DrawEllipsePayload, DrawLinksPayload};
use arma_rs::FromArma;
const NOW: &str = "2026-08-07T17:00:00.000Z";
const STALE: &str = "2026-08-07T18:00:00.000Z";
#[test]
fn parses_and_serializes_ellipse_shape_with_milsym() {
let payload = DrawEllipsePayload::from_arma(
r#"["ellipse<&","u-d-c-e",-30.1,-51.2,10,100,50,25,"Ellipse<&",600,-1,-1761607681,3,"SFGPUCI----K<&"]"#
.to_string(),
)
.expect("ellipse payload should parse");
assert_eq!(payload.uuid, "ellipse<&");
assert_eq!(payload.stale_seconds, 600);
assert_eq!(payload.major, 100.0);
assert_eq!(payload.minor, 50.0);
let xml = payload.to_xml(NOW, STALE);
assert!(xml.contains("uid=\"ellipse&lt;&amp;\" type=\"u-d-c-e\""));
assert!(xml.contains("<ellipse major=\"100\" minor=\"50\" angle=\"25\" />"));
assert!(xml.contains("<color>ffffffff</color>"));
assert!(xml.contains("<color>96ffffff</color>"));
assert!(xml.contains("<contact callsign=\"Ellipse&lt;&amp;\" />"));
assert!(xml.contains("<__milsym id=\"SFGPUCI----K&lt;&amp;\" />"));
}
#[test]
fn parses_and_serializes_closed_link_shape_without_milsym() {
let mut payload = DrawLinksPayload::from_arma(
r#"["shape<&","u-d-f",1,2,3,"","Polygon<&",300,-16777216,16777215,2,"dashed<&",true,""]"#
.to_string(),
)
.expect("link shape payload should parse");
payload.points = "1,2,3;4,5,6".to_string();
assert!(payload.closed);
assert_eq!(payload.stroke_style, "dashed<&");
let xml = payload.to_xml(NOW, STALE);
assert!(xml.contains("uid=\"shape&lt;&amp;\" type=\"u-d-f\""));
assert_eq!(xml.matches("<link point=\"1,2,3\" />").count(), 2);
assert_eq!(xml.matches("<link point=\"4,5,6\" />").count(), 1);
assert!(xml.contains("<strokeStyle value=\"dashed&lt;&amp;\" />"));
assert!(!xml.contains("<__milsym"));
}
#[test]
fn point_parser_drops_invalid_shape_points() {
let points = parse_points("1,2,3;missing;bad,2,3;4,bad,6;7,8,bad;9,10,11");
assert_eq!(points.len(), 2);
assert_eq!(points[0].lat, 1.0);
assert_eq!(points[1].lon, 10.0);
}
}
+35
View File
@@ -58,7 +58,42 @@ impl EudCoTPayload {
link_uid: None,
remarker: None,
video_url: None,
stale_seconds: None,
}
}
}
#[cfg(test)]
mod tests {
use super::EudCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_eud_payload_and_maps_all_tactical_fields() {
let payload = EudCoTPayload::from_arma(
r#"["eud-1",-30.1,-51.2,75.0,"Falcon","Cyan","Team Member",90,4.5]"#
.to_string(),
)
.expect("EUD payload should parse");
assert_eq!(payload.uuid, "eud-1");
assert_eq!(payload.contact_callsign, "Falcon");
assert_eq!(payload.group_name, "Cyan");
assert_eq!(payload.group_role, "Team Member");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("eud-1"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 75.0);
assert_eq!(cot.contact_callsign, "Falcon");
assert_eq!(cot.group_name.as_deref(), Some("Cyan"));
assert_eq!(cot.group_role.as_deref(), Some("Team Member"));
assert_eq!(cot.track_course, Some(90));
assert_eq!(cot.track_speed, Some(4.5));
assert!(cot.link_uid.is_none());
assert!(cot.remarker.is_none());
assert!(cot.video_url.is_none());
assert!(cot.stale_seconds.is_none());
}
}
+40
View File
@@ -55,7 +55,47 @@ impl ExternalPositionPayload {
link_uid: None,
remarker: Some(self.remarker.clone()),
video_url: None,
stale_seconds: None,
}
}
}
#[cfg(test)]
mod tests {
use super::ExternalPositionPayload;
use arma_rs::FromArma;
#[test]
fn parses_external_position_and_maps_it_to_generic_cot() {
let payload = ExternalPositionPayload::from_arma(
r#"["gps-1",-30.1,-51.2,75.0,"Falcon",270,11.5,"external GPS"]"#
.to_string(),
)
.expect("external position should parse");
assert_eq!(payload.uuid, "gps-1");
assert_eq!(payload.point_lat, -30.1);
assert_eq!(payload.point_lon, -51.2);
assert_eq!(payload.point_hae, 75.0);
assert_eq!(payload.contact_callsign, "Falcon");
assert_eq!(payload.track_course, 270);
assert_eq!(payload.track_speed, 11.5);
assert_eq!(payload.remarker, "external GPS");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("gps-1"));
assert_eq!(cot.r#type, None);
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 75.0);
assert_eq!(cot.contact_callsign, "Falcon");
assert_eq!(cot.track_course, Some(270));
assert_eq!(cot.track_speed, Some(11.5));
assert_eq!(cot.remarker.as_deref(), Some("external GPS"));
assert!(cot.group_name.is_none());
assert!(cot.group_role.is_none());
assert!(cot.link_uid.is_none());
assert!(cot.video_url.is_none());
assert!(cot.stale_seconds.is_none());
}
}
+104
View File
@@ -0,0 +1,104 @@
use arma_rs::{FromArma, FromArmaError};
use chrono::{SecondsFormat, Utc};
pub struct LaserRangeFinderPayload {
pub uid: String,
pub distance_meters: f64,
pub azimuth_degrees: f64,
pub elevation_degrees: f64,
}
impl FromArma for LaserRangeFinderPayload {
fn from_arma(data: String) -> Result<LaserRangeFinderPayload, FromArmaError> {
let (uid, distance_meters, azimuth_degrees, elevation_degrees) =
<(String, f64, f64, f64)>::from_arma(data)?;
Ok(Self {
uid,
distance_meters,
azimuth_degrees,
elevation_degrees,
})
}
}
impl LaserRangeFinderPayload {
pub fn to_lrf_message(&self) -> String {
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
format!(
"1,{},{},{:.2},{:.2},{:.2}",
self.uid,
timestamp,
self.distance_meters.max(0.0),
normalize_degrees(self.azimuth_degrees),
self.elevation_degrees
)
}
}
pub struct LaserRangeFinderClearPayload {
pub uid: String,
}
impl FromArma for LaserRangeFinderClearPayload {
fn from_arma(data: String) -> Result<LaserRangeFinderClearPayload, FromArmaError> {
let uid = String::from_arma(data)?;
Ok(Self { uid })
}
}
impl LaserRangeFinderClearPayload {
pub fn to_lrf_message(&self) -> String {
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
format!("1,{},{},RANGE_ERROR", self.uid, timestamp)
}
}
fn normalize_degrees(degrees: f64) -> f64 {
degrees.rem_euclid(360.0)
}
#[cfg(test)]
mod tests {
use super::{LaserRangeFinderClearPayload, LaserRangeFinderPayload};
use arma_rs::FromArma;
use chrono::DateTime;
#[test]
fn parses_lrf_payload_and_serializes_normalized_message() {
let payload = LaserRangeFinderPayload::from_arma(
r#"["laser-1",-5,-10,2.5]"#.to_string(),
)
.expect("LRF payload should parse");
assert_eq!(payload.uid, "laser-1");
assert_eq!(payload.distance_meters, -5.0);
assert_eq!(payload.azimuth_degrees, -10.0);
assert_eq!(payload.elevation_degrees, 2.5);
let message = payload.to_lrf_message();
let fields: Vec<_> = message.split(',').collect();
assert_eq!(fields.len(), 6);
assert_eq!(fields[0], "1");
assert_eq!(fields[1], "laser-1");
assert!(DateTime::parse_from_rfc3339(fields[2]).is_ok());
assert_eq!(fields[3], "0.00");
assert_eq!(fields[4], "350.00");
assert_eq!(fields[5], "2.50");
}
#[test]
fn parses_clear_payload_and_serializes_range_error() {
let payload = LaserRangeFinderClearPayload::from_arma(r#""laser-2""#.to_string())
.expect("clear LRF payload should parse");
assert_eq!(payload.uid, "laser-2");
let message = payload.to_lrf_message();
let fields: Vec<_> = message.split(',').collect();
assert_eq!(fields.len(), 4);
assert_eq!(fields[0], "1");
assert_eq!(fields[1], "laser-2");
assert!(DateTime::parse_from_rfc3339(fields[2]).is_ok());
assert_eq!(fields[3], "RANGE_ERROR");
}
}
+36
View File
@@ -145,3 +145,39 @@ impl MessageCot {
xml
}
}
#[cfg(test)]
mod tests {
use super::{MessageCot, MessagePayload};
use arma_rs::FromArma;
#[test]
fn parses_chat_payload_and_serializes_geochat_event() {
let payload = MessagePayload::from_arma(
r#"["Falcon","Operations Room","Message received",-30.1,-51.2,15.0,"sender-1"]"#
.to_string(),
)
.expect("chat payload should parse");
assert_eq!(payload.sender_callsign, "Falcon");
assert_eq!(payload.chatroom, "Operations Room");
assert_eq!(payload.message_text, "Message received");
assert_eq!(payload.sender_uid, "sender-1");
let cot = MessageCot::from_payload(payload);
let xml = cot.to_xml();
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(xml.contains("type=\"b-t-f\""));
assert!(xml.contains("uid=\"GeoChat.sender-1.Operations_Room."));
assert!(xml.contains("lat=\"-30.1\" lon=\"-51.2\" hae=\"15\""));
assert!(xml.contains("chatroom=\"Operations Room\""));
assert!(xml.contains("senderCallsign=\"Falcon\""));
assert!(xml.contains("<chatgrp uid0=\"sender-1\" uid1=\"Operations Room\" id=\"Operations Room\" />"));
assert!(xml.contains("<link uid=\"sender-1\" type=\"a-f-G-U-C\" relation=\"p-p\" />"));
assert!(xml.contains("<__serverdestination destinations=\"0.0.0.0:0:tcp:sender-1\" />"));
assert!(xml.contains("<remarks source=\"ARMATAK.sender-1\" to=\"Operations Room\""));
assert!(xml.contains(">Message received</remarks>"));
assert!(xml.ends_with("</detail></event>"));
}
}
+3
View File
@@ -1,9 +1,12 @@
pub mod cot;
pub mod delete;
pub mod digital_pointer;
pub mod draws;
pub mod eud;
pub mod gps;
pub mod lrf;
pub mod message;
pub mod nato;
pub mod report_marker;
pub mod uas;
pub mod video;
+58
View File
@@ -87,6 +87,64 @@ impl MarkerCoTPayload {
link_uid: None,
remarker: None,
video_url: self.video_url.clone(),
stale_seconds: None,
}
}
}
#[cfg(test)]
mod tests {
use super::MarkerCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_marker_with_video_and_maps_it_to_cot() {
let payload = MarkerCoTPayload::from_arma(
r#"["marker-1","a-f-G-U-C",-30.1,-51.2,100.0,"Falcon",180,10.5,"rtsp://video.example.test:8554/live"]"#
.to_string(),
)
.expect("marker with video should parse");
assert_eq!(
payload.video_url.as_deref(),
Some("rtsp://video.example.test:8554/live")
);
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("marker-1"));
assert_eq!(cot.r#type.as_deref(), Some("a-f-G-U-C"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 100.0);
assert_eq!(cot.contact_callsign, "Falcon");
assert_eq!(cot.track_course, Some(180));
assert_eq!(cot.track_speed, Some(10.5));
assert_eq!(
cot.video_url.as_deref(),
Some("rtsp://video.example.test:8554/live")
);
}
#[test]
fn treats_blank_video_url_as_absent() {
let payload = MarkerCoTPayload::from_arma(
r#"["marker-2","a-f-G-U-C",1,2,3,"Raven",90,5," "]"#.to_string(),
)
.expect("marker with blank video should parse");
assert!(payload.video_url.is_none());
}
#[test]
fn accepts_legacy_marker_payload_without_video_field() {
let payload = MarkerCoTPayload::from_arma(
r#"["marker-3","a-f-G-U-C",1,2,3,"Viper",45,2.5]"#.to_string(),
)
.expect("legacy marker should parse");
assert_eq!(payload.uuid, "marker-3");
assert_eq!(payload.r#type, "a-f-G-U-C");
assert_eq!(payload.contact_callsign, "Viper");
assert!(payload.video_url.is_none());
}
}
+97
View File
@@ -0,0 +1,97 @@
use arma_rs::{FromArma, FromArmaError};
use super::cot::CursorOverTime;
pub struct ReportMarkerCoTPayload {
pub uuid: String,
pub r#type: String,
pub point_lat: f64,
pub point_lon: f64,
pub point_hae: f32,
pub contact_callsign: String,
pub stale_seconds: i64,
pub remarks: String,
}
impl FromArma for ReportMarkerCoTPayload {
fn from_arma(data: String) -> Result<ReportMarkerCoTPayload, FromArmaError> {
let (
uuid,
r#type,
point_lat,
point_lon,
point_hae,
contact_callsign,
stale_seconds,
remarks,
) = <(String, String, f64, f64, f32, String, i64, String)>::from_arma(data)?;
Ok(Self {
uuid,
r#type,
point_lat,
point_lon,
point_hae,
contact_callsign,
stale_seconds,
remarks,
})
}
}
impl ReportMarkerCoTPayload {
pub fn to_cot(&self) -> CursorOverTime {
CursorOverTime {
uuid: Some(self.uuid.clone()),
r#type: Some(self.r#type.clone()),
point_lat: self.point_lat,
point_lon: self.point_lon,
point_hae: self.point_hae,
point_ce: None,
point_le: None,
contact_callsign: self.contact_callsign.clone(),
group_name: None,
group_role: None,
track_course: None,
track_speed: None,
link_uid: None,
remarker: Some(self.remarks.clone()),
video_url: None,
stale_seconds: Some(self.stale_seconds),
}
}
}
#[cfg(test)]
mod tests {
use super::ReportMarkerCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_report_marker_and_maps_type_remarks_and_stale_time() {
let payload = ReportMarkerCoTPayload::from_arma(
r#"["report-1","b-m-p-s-p-i",-30.1,-51.2,42.0,"SPOTREP",900,"Observed vehicle"]"#
.to_string(),
)
.expect("report marker should parse");
assert_eq!(payload.uuid, "report-1");
assert_eq!(payload.r#type, "b-m-p-s-p-i");
assert_eq!(payload.stale_seconds, 900);
assert_eq!(payload.remarks, "Observed vehicle");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("report-1"));
assert_eq!(cot.r#type.as_deref(), Some("b-m-p-s-p-i"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 42.0);
assert_eq!(cot.contact_callsign, "SPOTREP");
assert_eq!(cot.remarker.as_deref(), Some("Observed vehicle"));
assert_eq!(cot.stale_seconds, Some(900));
assert!(cot.group_name.is_none());
assert!(cot.track_course.is_none());
assert!(cot.link_uid.is_none());
assert!(cot.video_url.is_none());
}
}
+133 -5
View File
@@ -131,8 +131,8 @@ impl UasPlatformCoTPayload {
None => (escape_xml(&self.vehicle_type_tag), None),
};
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let stale =
(Utc::now() + Duration::milliseconds(3500)).to_rfc3339_opts(SecondsFormat::Millis, true);
let stale = (Utc::now() + Duration::milliseconds(3500))
.to_rfc3339_opts(SecondsFormat::Millis, true);
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
@@ -153,8 +153,7 @@ impl UasPlatformCoTPayload {
xml.push_str("<_uastool extendedCot=\"true\" activeRoute=\"false\"/>");
xml.push_str(&format!(
"<track course=\"{}\" slope=\"0.0\" speed=\"{}\"/>",
self.track_course,
self.track_speed,
self.track_course, self.track_speed,
));
xml.push_str(&format!(
"<sensor elevation=\"{}\" vfov=\"{}\" north=\"0.0\" roll=\"0.0\" range=\"{}\" azimuth=\"{}\" fov=\"{}\" type=\"r-e\" version=\"0.6\"/>",
@@ -186,7 +185,10 @@ impl UasPlatformCoTPayload {
} else {
xml.push_str("<__video></__video>");
}
xml.push_str(&format!("<link uid=\"{}\" type=\"a-f-G-U-C\" relation=\"p-p\" />", link_uid));
xml.push_str(&format!(
"<link uid=\"{}\" type=\"a-f-G-U-C\" relation=\"p-p\" />",
link_uid
));
xml.push_str("</detail></event>");
xml
}
@@ -327,4 +329,130 @@ impl UasSensorCoTPayload {
}
}
#[cfg(test)]
mod tests {
use super::{
escape_xml, parse_rtsp_url, UasPlatformCoTPayload, UasSensorCoTPayload,
UasVideoCoTPayload,
};
use arma_rs::FromArma;
#[test]
fn escapes_uas_xml_values_and_parses_rtsp_components() {
assert_eq!(escape_xml("A&B\"<C>'"), "A&amp;B&quot;&lt;C&gt;&apos;");
assert_eq!(
parse_rtsp_url("rtsp://video.example.test:8554/live/main"),
Some((
"video.example.test".to_string(),
"8554".to_string(),
"/live/main".to_string(),
))
);
assert!(parse_rtsp_url("https://video.example.test:8554/live").is_none());
assert!(parse_rtsp_url("rtsp://video.example.test:8554").is_none());
assert!(parse_rtsp_url("rtsp://video.example.test/live").is_none());
}
#[test]
fn parses_and_serializes_uas_platform_with_embedded_video_url() {
let payload = UasPlatformCoTPayload::from_arma(
r#"["uas<&","a-f-A-M-F-Q","Falcon<&",-30.1,-51.2,100,180,12.5,90,-15,60,40,1200,181,2.5,1.5,3.2,"Quad<&|armatak_video_url=rtsp://video.example.test:8554/live",1,"operator<&"]"#
.to_string(),
)
.expect("UAS platform payload should parse");
assert_eq!(payload.uid, "uas<&");
assert_eq!(payload.vehicle_type_tag, "Quad<&|armatak_video_url=rtsp://video.example.test:8554/live");
let xml = payload.to_xml();
assert!(xml.contains("uid=\"uas&lt;&amp;\""));
assert!(xml.contains("typeTag=\"Quad&lt;&amp;\""));
assert!(xml.contains("isFlying=\"true\""));
assert!(xml.contains("<track course=\"180\" slope=\"0.0\" speed=\"12.5\"/>"));
assert!(xml.contains("<sensor elevation=\"-15\" vfov=\"40\""));
assert!(xml.contains("<attitude roll=\"1.5\" pitch=\"2.5\" yaw=\"181\"/>"));
assert!(xml.contains("<_route sender=\"operator&lt;&amp;\"/>"));
assert!(xml.contains("<ConnectionEntry protocol=\"rtsp\""));
assert!(xml.contains("address=\"video.example.test\""));
assert!(xml.contains("port=\"8554\""));
assert!(xml.contains("path=\"/live\""));
}
#[test]
fn serializes_uas_platform_without_video_as_explicit_empty_video_detail() {
let payload = UasPlatformCoTPayload {
uid: "uas-2".to_string(),
cot_type: "a-f-A-M-F-Q".to_string(),
callsign: "Raven".to_string(),
point_lat: 1.0,
point_lon: 2.0,
point_hae: 3.0,
track_course: 0,
track_speed: 0.0,
sensor_azimuth: 0,
sensor_elevation: 0,
sensor_fov: 30,
sensor_vfov: 20,
sensor_range: 100,
attitude_yaw: 0,
attitude_pitch: 0.0,
attitude_roll: 0.0,
hal: 1.0,
vehicle_type_tag: "Quadrotor".to_string(),
is_flying: 0,
link_uid: "operator-2".to_string(),
};
let xml = payload.to_xml();
assert!(xml.contains("typeTag=\"Quadrotor\""));
assert!(xml.contains("isFlying=\"false\""));
assert!(xml.contains("<__video></__video>"));
}
#[test]
fn parses_and_serializes_standalone_uas_video_event() {
let payload = UasVideoCoTPayload::from_arma(
r#"["video<&","Falcon<&","rtsp://video.example.test:8554/live/main"]"#
.to_string(),
)
.expect("UAS video payload should parse");
let xml = payload.to_xml();
assert!(xml.contains("type=\"b-i-v\""));
assert!(xml.contains("uid=\"video&lt;&amp;\""));
assert!(xml.contains("path=\"/live/main\""));
assert!(xml.contains("address=\"video.example.test\""));
assert!(xml.contains("port=\"8554\""));
assert!(xml.contains("alias=\"Falcon&lt;&amp;\""));
}
#[test]
fn rejects_invalid_uas_video_url() {
let payload = UasVideoCoTPayload {
uid: "video-2".to_string(),
callsign: "Raven".to_string(),
video_url: "https://video.example.test/live".to_string(),
};
assert_eq!(payload.to_xml(), "");
}
#[test]
fn parses_and_serializes_uas_sensor_event() {
let payload = UasSensorCoTPayload::from_arma(
r#"["sensor<&","video<&","Sensor<&",-30.1,-51.2,25,270,45,850]"#
.to_string(),
)
.expect("UAS sensor payload should parse");
let xml = payload.to_xml();
assert!(xml.contains("type=\"b-m-p-s-p-loc\""));
assert!(xml.contains("uid=\"sensor&lt;&amp;\""));
assert!(xml.contains("lat=\"-30.1\" lon=\"-51.2\" hae=\"25\""));
assert!(xml.contains("fov=\"45\""));
assert!(xml.contains("range=\"850\""));
assert!(xml.contains("azimuth=\"270\""));
assert!(xml.contains("<__video uid=\"video&lt;&amp;\"/>"));
assert!(xml.contains("<contact callsign=\"Sensor&lt;&amp;\"/>"));
}
}
+81 -5
View File
@@ -13,7 +13,9 @@ fn parse_video_url(url: &str) -> Option<(String, String, String, String)> {
Some((authority, path)) => (authority, format!("/{}", path)),
None => (rest, String::new()),
};
let host_port = authority.rsplit_once('@').map_or(authority, |(_, host_port)| host_port);
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, host_port)| host_port);
let (address, port) = host_port.rsplit_once(':')?;
if protocol.is_empty() || address.is_empty() || port.is_empty() {
@@ -35,10 +37,7 @@ pub fn video_detail_xml(video_url: &str, uid: &str, callsign: &str) -> String {
}
let Some((protocol, address, port, path)) = parse_video_url(trimmed_url) else {
return format!(
"<__video url=\"{}\"/>",
escape_xml_attribute(trimmed_url)
);
return format!("<__video url=\"{}\"/>", escape_xml_attribute(trimmed_url));
};
format!(
@@ -51,3 +50,80 @@ pub fn video_detail_xml(video_url: &str, uid: &str, callsign: &str) -> String {
escape_xml_attribute(callsign),
)
}
#[cfg(test)]
mod tests {
use super::{escape_xml_attribute, parse_video_url, video_detail_xml};
#[test]
fn escapes_xml_attribute_characters() {
assert_eq!(
escape_xml_attribute("A&B\"<C>'"),
"A&amp;B&quot;&lt;C&gt;&apos;"
);
}
#[test]
fn parses_video_url_with_credentials_and_path() {
assert_eq!(
parse_video_url(" RTSP://user:pass@video.example.test:8554/live/main "),
Some((
"rtsp".to_string(),
"video.example.test".to_string(),
"8554".to_string(),
"/live/main".to_string(),
))
);
}
#[test]
fn parses_video_url_without_path() {
assert_eq!(
parse_video_url("udp://239.1.1.1:1234"),
Some((
"udp".to_string(),
"239.1.1.1".to_string(),
"1234".to_string(),
String::new(),
))
);
}
#[test]
fn rejects_malformed_video_urls() {
assert!(parse_video_url("video.example.test:8554/live").is_none());
assert!(parse_video_url("rtsp://video.example.test/live").is_none());
assert!(parse_video_url("://video.example.test:8554/live").is_none());
assert!(parse_video_url("rtsp://:8554/live").is_none());
assert!(parse_video_url("rtsp://video.example.test:/live").is_none());
}
#[test]
fn emits_empty_video_detail_for_blank_url() {
assert_eq!(video_detail_xml(" ", "uid", "callsign"), "<__video></__video>");
}
#[test]
fn falls_back_to_escaped_url_for_unstructured_input() {
assert_eq!(
video_detail_xml("custom&stream\"", "uid", "callsign"),
"<__video url=\"custom&amp;stream&quot;\"/>"
);
}
#[test]
fn emits_connection_entry_for_structured_video_url() {
let xml = video_detail_xml(
"RTSP://user:pass@video.example.test:8554/live?a=1&b=2",
"uas<&\"'",
"Falcon<&\"'",
);
assert!(xml.contains("protocol=\"rtsp\""));
assert!(xml.contains("address=\"video.example.test\""));
assert!(xml.contains("port=\"8554\""));
assert!(xml.contains("path=\"/live?a=1&amp;b=2\""));
assert!(xml.contains("uid=\"uas&lt;&amp;&quot;&apos;\""));
assert!(xml.contains("alias=\"Falcon&lt;&amp;&quot;&apos;\""));
}
}
+14 -2
View File
@@ -1,10 +1,10 @@
use arma_rs::{arma, Extension, Group};
use rustls::crypto::aws_lc_rs;
mod uas;
mod mdns;
mod structs;
mod tcp;
mod tests;
mod uas;
mod udp_socket;
mod video_stream;
@@ -59,8 +59,17 @@ pub fn init() -> Extension {
"udp_socket",
Group::new()
.command("start", udp_socket::start)
.command("start_lrf", udp_socket::start_lrf)
.command("start_cot", udp_socket::start_cot)
.command("send_payload", udp_socket::send_payload)
.command("send_gps_cot", udp_socket::send_gps_cot)
.command("send_eud_cot", udp_socket::send_eud_cot)
.command("send_lrf", udp_socket::send_lrf)
.command("clear_lrf", udp_socket::clear_lrf)
.command(
"send_digital_pointer_cot",
udp_socket::send_digital_pointer_cot,
)
.command("stop", udp_socket::stop),
)
.group(
@@ -76,7 +85,9 @@ pub fn init() -> Extension {
Group::new()
.command("eud", tcp::cot::send_eud_cot)
.command("marker", tcp::cot::send_marker_cot)
.command("report_marker", tcp::cot::send_report_marker_cot)
.command("digital_pointer", tcp::cot::send_digital_pointer_cot)
.command("delete", tcp::cot::send_delete_cot)
.command("chat", tcp::cot::send_message_cot)
.command("uas_platform", tcp::cot::send_uas_platform_cot)
.command("uas_video", tcp::cot::send_uas_video_cot)
@@ -89,7 +100,8 @@ pub fn init() -> Extension {
.command("ellipse", tcp::draw::send_ellipse_cot)
.command("rectangle", tcp::draw::send_rectangle_cot)
.command("free", tcp::draw::send_freedraw_cot)
.command("vector", tcp::draw::send_vectordraw_cot),
.command("vector", tcp::draw::send_vectordraw_cot)
.command("route", tcp::draw::send_route_cot),
),
)
.group(
+27 -5
View File
@@ -23,7 +23,13 @@ fn detect_local_ipv4() -> Result<Ipv4Addr, String> {
fn sanitize_label(value: &str, fallback: &str) -> String {
let mut sanitized = value
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
@@ -67,7 +73,13 @@ fn push_record(buf: &mut Vec<u8>, name: &str, rr_type: u16, rr_class: u16, ttl:
buf.extend_from_slice(rdata);
}
fn build_mdns_packet(instance_name: &str, host_name: &str, ip: Ipv4Addr, port: u16, video_uri: &str) -> Vec<u8> {
fn build_mdns_packet(
instance_name: &str,
host_name: &str,
ip: Ipv4Addr,
port: u16,
video_uri: &str,
) -> Vec<u8> {
let service_type = "_mavlink._udp.local";
let instance_fqdn = format!("{}.{}", instance_name, service_type);
let host_fqdn = format!("{}.local", host_name);
@@ -122,7 +134,11 @@ pub fn start_uas_advertisement(
let local_ip = match detect_local_ipv4() {
Ok(ip) => ip,
Err(error) => {
let _ = ctx.callback_data("MDNS ERROR", "Failed to determine local IPv4", error.clone());
let _ = ctx.callback_data(
"MDNS ERROR",
"Failed to determine local IPv4",
error.clone(),
);
return "mdns local IPv4 error";
}
};
@@ -161,7 +177,10 @@ pub fn start_uas_advertisement(
loop {
match socket.send_to(&packet, multicast_addr) {
Ok(size) => info!("Sent mDNS UAS advertisement ({} bytes) to {}", size, multicast_addr),
Ok(size) => info!(
"Sent mDNS UAS advertisement ({} bytes) to {}",
size, multicast_addr
),
Err(error) => info!("Failed sending mDNS UAS advertisement: {}", error),
}
@@ -172,7 +191,10 @@ pub fn start_uas_advertisement(
}
}
info!("Stopped mDNS UAS advertisement for instance={}", safe_instance);
info!(
"Stopped mDNS UAS advertisement for instance={}",
safe_instance
);
});
let _ = ctx.callback_data(
+9 -6
View File
@@ -54,9 +54,7 @@ fn send_over_stream(
) -> Result<(), String> {
let message_len = message.len();
info!("Sending TCP payload ({} bytes)", message_len);
stream
.write_message(message.as_bytes())
.map_err(|e| {
stream.write_message(message.as_bytes()).map_err(|e| {
let message = e.to_string();
let _ = context.callback_data(
"TCP SOCKET ERROR",
@@ -145,10 +143,14 @@ impl TcpClient {
let mut pending_messages: VecDeque<(String, Context)> = VecDeque::new();
let (connect_tx, connect_rx) = mpsc::channel();
info!("TCP worker thread started with config: {}", config_description);
info!(
"TCP worker thread started with config: {}",
config_description
);
let tcp_thread = thread::spawn(move || {
let connect_result = panic::catch_unwind(AssertUnwindSafe(|| connect_stream(&config)));
let connect_result =
panic::catch_unwind(AssertUnwindSafe(|| connect_stream(&config)));
match connect_result {
Ok(Ok(stream)) => {
@@ -184,7 +186,8 @@ impl TcpClient {
match &mut state {
ConnectionState::Connected => {
if let Some(stream) = connection.as_mut() {
if let Err(error) = send_over_stream(stream, &context, message) {
if let Err(error) = send_over_stream(stream, &context, message)
{
info!("Failed to send message: {}", error);
state = ConnectionState::Failed(error);
connection = None;
+62
View File
@@ -62,3 +62,65 @@ impl ConnectionConfig {
}
}
}
#[cfg(test)]
mod tests {
use super::ConnectionConfig;
#[test]
fn describes_plain_tcp_connection() {
let config = ConnectionConfig::Plain {
address: "127.0.0.1:8087".to_string(),
};
assert_eq!(config.connected_message(), "Connected to TCP Server");
assert_eq!(config.target(), "127.0.0.1:8087");
assert_eq!(config.describe(), "plain tcp -> 127.0.0.1:8087");
}
#[test]
fn describes_manual_mtls_connection() {
let config = ConnectionConfig::Mtls {
address: "tak.example.test:8089".to_string(),
server_name: "tak.example.test".to_string(),
ca_cert_path: "ca.pem".to_string(),
client_cert_path: "client.pem".to_string(),
client_key_path: "client.key".to_string(),
};
assert_eq!(
config.connected_message(),
"Connected to TAK Server via mTLS"
);
assert_eq!(config.target(), "tak.example.test:8089");
assert_eq!(
config.describe(),
"manual mtls -> tak.example.test:8089 (server_name=tak.example.test, ca=ca.pem, cert=client.pem, key=client.key)"
);
}
#[test]
fn enrollment_description_excludes_password() {
let config = ConnectionConfig::EnrollMtls {
host: "tak.example.test".to_string(),
server_name: "tak.example.test".to_string(),
enroll_port: "8446".to_string(),
username: "operator".to_string(),
password: "super-secret-password".to_string(),
client_uid: "armatak-test".to_string(),
};
assert_eq!(
config.connected_message(),
"Connected to TAK Server via enrolled mTLS certificate"
);
assert_eq!(config.target(), "tak.example.test");
let description = config.describe();
assert_eq!(
description,
"enroll mtls -> host=tak.example.test enroll_port=8446 server_name=tak.example.test username=operator client_uid=armatak-test"
);
assert!(!description.contains("super-secret-password"));
}
}
+18 -8
View File
@@ -19,6 +19,16 @@ pub fn send_marker_cot(
"Sending Marker Cursor Over Time to TCP server"
}
pub fn send_report_marker_cot(
ctx: Context,
cursor_over_time: cot::report_marker::ReportMarkerCoTPayload,
) -> &'static str {
let payload = cursor_over_time.to_cot().convert_to_xml();
send_payload(ctx, payload);
"Sending Report Marker Cursor Over Time to TCP server"
}
pub fn send_digital_pointer_cot(
ctx: Context,
cursor_over_time: cot::digital_pointer::DigitalPointerPayload,
@@ -29,6 +39,12 @@ pub fn send_digital_pointer_cot(
"Sending Digital Pointer Cursor Over Time to TCP server"
}
pub fn send_delete_cot(ctx: Context, payload: cot::delete::DeleteCoTPayload) -> &'static str {
send_payload(ctx, payload.to_xml());
"Sending Delete Cursor Over Time to TCP server"
}
pub fn send_message_cot(
ctx: Context,
message_payload: cot::message::MessagePayload,
@@ -50,10 +66,7 @@ pub fn send_uas_platform_cot(
"Sending UAS Platform main CoT to TCP server"
}
pub fn send_uas_video_cot(
ctx: Context,
payload: cot::uas::UasVideoCoTPayload,
) -> &'static str {
pub fn send_uas_video_cot(ctx: Context, payload: cot::uas::UasVideoCoTPayload) -> &'static str {
let xml = payload.to_xml();
if !xml.is_empty() {
send_payload(ctx, xml);
@@ -62,10 +75,7 @@ pub fn send_uas_video_cot(
"Sending UAS Video (b-i-v) CoT to TCP server"
}
pub fn send_uas_sensor_cot(
ctx: Context,
payload: cot::uas::UasSensorCoTPayload,
) -> &'static str {
pub fn send_uas_sensor_cot(ctx: Context, payload: cot::uas::UasSensorCoTPayload) -> &'static str {
let xml = payload.to_xml();
send_payload(ctx, xml);
+68 -15
View File
@@ -1,37 +1,90 @@
use arma_rs::Context;
use log::info;
use crate::{cot, tcp::send_payload};
fn day_stale() -> (String, String) {
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let stale = (chrono::Utc::now() + chrono::Duration::days(1))
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
(now, stale)
}
fn payload_stale(stale_seconds: i64) -> (String, String) {
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let stale = (chrono::Utc::now() + chrono::Duration::seconds(stale_seconds.max(1)))
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
(now, stale)
}
pub fn send_circle_cot(
ctx: Context,
circle_payload: cot::draws::circle::CircleCoTPayload,
) -> &'static str {
let shape_circle_cot = circle_payload.to_cot();
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let stale = (chrono::Utc::now() + chrono::Duration::days(1))
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let (now, stale) = day_stale();
let payload = shape_circle_cot.to_xml(&now, &stale);
send_payload(ctx, payload);
"Sending Circle CoT to TCP server"
}
pub fn send_ellipse_cot(ctx: Context) -> &'static str {
let _ = ctx;
"Not implemented: send_ellipse_cot"
pub fn send_ellipse_cot(
ctx: Context,
ellipse_payload: cot::draws::shape::DrawEllipsePayload,
) -> &'static str {
let (now, stale) = payload_stale(ellipse_payload.stale_seconds);
let payload = ellipse_payload.to_xml(&now, &stale);
send_payload(ctx, payload);
"Sending Ellipse CoT to TCP server"
}
pub fn send_rectangle_cot(ctx: Context) -> &'static str {
let _ = ctx;
"Not implemented: send_ellipse_cot"
pub fn send_rectangle_cot(
ctx: Context,
rectangle_payload: cot::draws::shape::DrawLinksPayload,
) -> &'static str {
let (now, stale) = payload_stale(rectangle_payload.stale_seconds);
let payload = rectangle_payload.to_xml(&now, &stale);
send_payload(ctx, payload);
"Sending Rectangle CoT to TCP server"
}
pub fn send_freedraw_cot(ctx: Context) -> &'static str {
let _ = ctx;
"Not implemented: send_ellipse_cot"
pub fn send_freedraw_cot(
ctx: Context,
freedraw_payload: cot::draws::shape::DrawLinksPayload,
) -> &'static str {
let (now, stale) = payload_stale(freedraw_payload.stale_seconds);
let payload = freedraw_payload.to_xml(&now, &stale);
send_payload(ctx, payload);
"Sending Free Draw CoT to TCP server"
}
pub fn send_vectordraw_cot(ctx: Context) -> &'static str {
let _ = ctx;
"Not implemented: send_ellipse_cot"
pub fn send_vectordraw_cot(
ctx: Context,
vector_payload: cot::draws::shape::DrawLinksPayload,
) -> &'static str {
let (now, stale) = payload_stale(vector_payload.stale_seconds);
let payload = vector_payload.to_xml(&now, &stale);
send_payload(ctx, payload);
"Sending Tactical Graphic CoT to TCP server"
}
pub fn send_route_cot(
ctx: Context,
route_payload: cot::draws::route::RoutePayload,
) -> &'static str {
let (now, stale) = payload_stale(route_payload.stale_seconds);
let payload = route_payload.to_xml(&now, &stale);
info!(
"Sending ATAK route '{}' ({} bytes)",
route_payload.callsign,
payload.len()
);
send_payload(ctx, payload);
"Sending Route CoT to TCP server"
}
+7 -2
View File
@@ -6,8 +6,8 @@ use std::fs::File;
use std::io::BufReader;
use std::io::Cursor;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::time::Duration;
use std::sync::Arc;
use std::time::Duration;
use crate::tcp::transport::TransportStream;
@@ -65,7 +65,12 @@ fn resolve_address(address: &str) -> Result<SocketAddr, String> {
.to_socket_addrs()
.map_err(|e| format!("failed to resolve {}: {}", address, e))?
.next()
.ok_or_else(|| format!("failed to resolve {}: no socket addresses returned", address))
.ok_or_else(|| {
format!(
"failed to resolve {}: no socket addresses returned",
address
)
})
}
fn connect_tcp(address: &str) -> Result<TcpStream, String> {
+22 -63
View File
@@ -1,10 +1,14 @@
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_RSA_SHA256};
use log::info;
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_RSA_SHA256};
use reqwest::blocking::Client;
use serde::Deserialize;
use uuid::Uuid;
use super::connector::connect_mtls_from_pem;
use super::protocol::{
enrollment_config_url, enrollment_sign_url, normalize_certificate_pem,
parse_enrollment_config, EnrollmentConfig,
};
use crate::tcp::transport::TransportStream;
#[derive(Deserialize)]
@@ -14,34 +18,6 @@ struct EnrollmentResponse {
ca0: String,
}
struct EnrollmentConfig {
server_port: String,
enroll_path: String,
}
fn extract_tag_value(xml: &str, tag_name: &str) -> Option<String> {
let open_tag = format!("<{}>", tag_name);
let close_tag = format!("</{}>", tag_name);
let start = xml.find(&open_tag)? + open_tag.len();
let end = xml[start..].find(&close_tag)? + start;
Some(xml[start..end].trim().to_string())
}
fn wrap_pem_body(base64_body: &str, begin: &str, end: &str) -> String {
let mut wrapped = String::new();
let normalized = base64_body.trim().replace(['\r', '\n'], "");
wrapped.push_str(begin);
wrapped.push('\n');
for chunk in normalized.as_bytes().chunks(64) {
wrapped.push_str(std::str::from_utf8(chunk).unwrap_or_default());
wrapped.push('\n');
}
wrapped.push_str(end);
wrapped.push('\n');
wrapped
}
fn enrollment_http_client() -> Result<Client, String> {
Client::builder()
.danger_accept_invalid_certs(true)
@@ -64,16 +40,18 @@ fn response_error_details(response: reqwest::blocking::Response) -> String {
}
}
fn fetch_enrollment_config(host: &str, enroll_port: &str) -> Result<EnrollmentConfig, String> {
let url = format!(
"https://{}:{}/Marti/api/tls/config",
host.trim(),
enroll_port.trim()
);
fn fetch_enrollment_config(
host: &str,
enroll_port: &str,
username: &str,
password: &str,
) -> Result<EnrollmentConfig, String> {
let url = enrollment_config_url(host, enroll_port);
info!("Fetching TAK enrollment config from {}", url);
let response = enrollment_http_client()?
.get(&url)
.basic_auth(username.trim(), Some(password.to_string()))
.send()
.map_err(|e| format!("failed to fetch {}: {}", url, e))?;
@@ -88,21 +66,14 @@ fn fetch_enrollment_config(host: &str, enroll_port: &str) -> Result<EnrollmentCo
let response_text = response
.text()
.map_err(|e| format!("failed to read config response from {}: {}", url, e))?;
let server_port = extract_tag_value(&response_text, "serverPort")
.ok_or_else(|| "missing serverPort in /Marti/api/tls/config response".to_string())?;
let enroll_path = extract_tag_value(&response_text, "enrollPath")
.ok_or_else(|| "missing enrollPath in /Marti/api/tls/config response".to_string())?;
let config = parse_enrollment_config(&response_text);
info!(
"Enrollment config received: server_port={} enroll_path={}",
server_port, enroll_path
config.server_port, config.enroll_path
);
Ok(EnrollmentConfig {
server_port,
enroll_path,
})
Ok(config)
}
fn enroll_client_certificate(
@@ -134,13 +105,7 @@ fn enroll_client_certificate(
.map_err(|e| format!("failed to generate CSR: {}", e))?;
let csr_der = csr.der().as_ref().to_vec();
let url = format!(
"https://{}:{}{}?clientUid={}",
host.trim(),
enroll_port.trim(),
enroll_path.trim(),
client_uid.trim()
);
let url = enrollment_sign_url(host, enroll_port, enroll_path, client_uid);
info!(
"Submitting client certificate enrollment request for {} to {}",
client_uid, url
@@ -173,14 +138,11 @@ fn enroll_client_certificate(
enrollment.ca0.len()
);
let cert_pem = wrap_pem_body(
&enrollment.signed_cert,
"-----BEGIN CERTIFICATE-----",
"-----END CERTIFICATE-----",
);
let ca_cert_pem = normalize_certificate_pem(&enrollment.ca0);
let cert_pem = normalize_certificate_pem(&enrollment.signed_cert);
let key_pem = key_pair.serialize_pem();
Ok((enrollment.ca0, cert_pem, key_pem))
Ok((ca_cert_pem, cert_pem, key_pem))
}
pub fn enroll_and_connect(
@@ -198,13 +160,10 @@ pub fn enroll_and_connect(
};
info!(
"Starting enroll_and_connect for host={} enroll_port={} server_name={} client_uid={}",
host,
enroll_port,
server_name,
normalized_client_uid
host, enroll_port, server_name, normalized_client_uid
);
let enrollment_config = fetch_enrollment_config(host, enroll_port)?;
let enrollment_config = fetch_enrollment_config(host, enroll_port, username, password)?;
let (ca_cert_pem, client_cert_pem, client_key_pem) = enroll_client_certificate(
host,
enroll_port,
+1
View File
@@ -1,5 +1,6 @@
mod connector;
mod enrollment;
mod protocol;
pub use connector::connect_mtls;
pub use enrollment::enroll_and_connect;
+201
View File
@@ -0,0 +1,201 @@
pub(crate) const DEFAULT_MTLS_SERVER_PORT: &str = "8089";
pub(crate) const DEFAULT_ENROLL_PATH: &str = "/Marti/api/tls/signClient/v2";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EnrollmentConfig {
pub server_port: String,
pub enroll_path: String,
}
pub(crate) fn enrollment_config_url(host: &str, enroll_port: &str) -> String {
format!(
"https://{}:{}/Marti/api/tls/config",
host.trim(),
enroll_port.trim()
)
}
pub(crate) fn enrollment_sign_url(
host: &str,
enroll_port: &str,
enroll_path: &str,
client_uid: &str,
) -> String {
let path = normalize_enroll_path(enroll_path);
format!(
"https://{}:{}{}?clientUid={}",
host.trim(),
enroll_port.trim(),
path,
client_uid.trim()
)
}
pub(crate) fn parse_enrollment_config(xml: &str) -> EnrollmentConfig {
EnrollmentConfig {
server_port: extract_tag_value(xml, "serverPort")
.unwrap_or_else(|| DEFAULT_MTLS_SERVER_PORT.to_string()),
enroll_path: extract_tag_value(xml, "enrollPath")
.map(|path| normalize_enroll_path(&path))
.unwrap_or_else(|| DEFAULT_ENROLL_PATH.to_string()),
}
}
pub(crate) fn normalize_certificate_pem(certificate: &str) -> String {
let trimmed = certificate.trim();
if trimmed.contains("-----BEGIN CERTIFICATE-----") {
format!("{}\n", trimmed)
} else {
wrap_pem_body(
trimmed,
"-----BEGIN CERTIFICATE-----",
"-----END CERTIFICATE-----",
)
}
}
fn normalize_enroll_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{}", trimmed)
}
}
fn extract_tag_value(xml: &str, tag_name: &str) -> Option<String> {
let open_tag = format!("<{}>", tag_name);
let close_tag = format!("</{}>", tag_name);
let start = xml.find(&open_tag)? + open_tag.len();
let end = xml[start..].find(&close_tag)? + start;
Some(xml[start..end].trim().to_string())
}
fn wrap_pem_body(base64_body: &str, begin: &str, end: &str) -> String {
let mut wrapped = String::new();
let normalized = base64_body.trim().replace(['\r', '\n'], "");
wrapped.push_str(begin);
wrapped.push('\n');
for chunk in normalized.as_bytes().chunks(64) {
wrapped.push_str(std::str::from_utf8(chunk).unwrap_or_default());
wrapped.push('\n');
}
wrapped.push_str(end);
wrapped.push('\n');
wrapped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uses_official_tak_defaults() {
assert_eq!(DEFAULT_MTLS_SERVER_PORT, "8089");
assert_eq!(DEFAULT_ENROLL_PATH, "/Marti/api/tls/signClient/v2");
}
#[test]
fn builds_enrollment_config_url_from_trimmed_values() {
assert_eq!(
enrollment_config_url(" tak.example.test ", " 8446 "),
"https://tak.example.test:8446/Marti/api/tls/config"
);
}
#[test]
fn builds_enrollment_sign_url_and_normalizes_path() {
assert_eq!(
enrollment_sign_url(
" tak.example.test ",
" 8446 ",
" Marti/api/tls/signClient/v2 ",
" client-1 "
),
"https://tak.example.test:8446/Marti/api/tls/signClient/v2?clientUid=client-1"
);
assert_eq!(
enrollment_sign_url(
"tak.example.test",
"8446",
"/custom/enroll",
"client-2"
),
"https://tak.example.test:8446/custom/enroll?clientUid=client-2"
);
}
#[test]
fn parses_explicit_tak_enrollment_config() {
let xml = "<certificateConfig><serverPort>9443</serverPort><enrollPath>Marti/api/tls/signClient/v2</enrollPath></certificateConfig>";
assert_eq!(
parse_enrollment_config(xml),
EnrollmentConfig {
server_port: "9443".to_string(),
enroll_path: "/Marti/api/tls/signClient/v2".to_string(),
}
);
}
#[test]
fn falls_back_when_optional_config_values_are_missing() {
let xml = "<certificateConfig><nameEntries /></certificateConfig>";
assert_eq!(
parse_enrollment_config(xml),
EnrollmentConfig {
server_port: DEFAULT_MTLS_SERVER_PORT.to_string(),
enroll_path: DEFAULT_ENROLL_PATH.to_string(),
}
);
}
#[test]
fn returns_none_when_config_closing_tag_is_missing() {
assert_eq!(extract_tag_value("<serverPort>9443", "serverPort"), None);
}
#[test]
fn trims_extracted_config_values() {
let xml = "<certificateConfig><serverPort> 8089 </serverPort><enrollPath> /Marti/api/tls/signClient/v2 </enrollPath></certificateConfig>";
assert_eq!(
parse_enrollment_config(xml),
EnrollmentConfig {
server_port: "8089".to_string(),
enroll_path: DEFAULT_ENROLL_PATH.to_string(),
}
);
}
#[test]
fn normalizes_raw_certificate_body_to_pem() {
let raw = format!("{}\r\n{}", "A".repeat(64), "B".repeat(8));
let pem = normalize_certificate_pem(&raw);
assert_eq!(
pem,
format!(
"-----BEGIN CERTIFICATE-----\n{}\n{}\n-----END CERTIFICATE-----\n",
"A".repeat(64),
"B".repeat(8)
)
);
}
#[test]
fn preserves_existing_pem_and_adds_a_single_trailing_newline() {
let with_newline =
"-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n";
let without_newline = "-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----";
assert_eq!(normalize_certificate_pem(with_newline), with_newline);
assert_eq!(
normalize_certificate_pem(without_newline),
format!("{}\n", without_newline)
);
}
}
+6 -1
View File
@@ -35,7 +35,12 @@ fn connect_plain(address: &str) -> Result<TransportStream, String> {
.to_socket_addrs()
.map_err(|e| format!("failed to resolve {}: {}", address, e))?
.next()
.ok_or_else(|| format!("failed to resolve {}: no socket addresses returned", address))?;
.ok_or_else(|| {
format!(
"failed to resolve {}: no socket addresses returned",
address
)
})?;
info!(
"Opening plain TCP connection to {} (resolved={}) with timeout {:?}",
+6 -14
View File
@@ -1,25 +1,17 @@
#[cfg(test)]
mod tests {
use crate::init;
use std::vec;
use uuid::Uuid;
#[test]
fn uuid_output_is_uuid4_identifier() {
fn uuid_command_accepts_no_arguments_and_rejects_arguments() {
let extension = init().testing();
let (output, _) = extension.call("uuid", None);
assert!(Uuid::parse_str(&output).is_ok());
let validation = Uuid::parse_str(&output);
assert!(validation.is_ok())
}
#[test]
fn uuid_output_throws_if_passed_args() {
let extension = init().testing();
let args: Vec<String> = vec![1.to_string(), 2.to_string()];
let (output, _) = extension.call("uuid", Some(args));
assert_eq!(output, "")
let args = vec!["1".to_string(), "2".to_string()];
let (output_with_args, _) = extension.call("uuid", Some(args));
assert_eq!(output_with_args, "");
}
}
+67
View File
@@ -100,3 +100,70 @@ pub(crate) fn build_v2_packet(
packet.push((crc >> 8) as u8);
packet
}
#[cfg(test)]
mod tests {
use super::{build_v1_packet, build_v2_packet, calculate_crc_extra, mavlink_crc, FieldSpec};
fn assert_packet_crc(packet: &[u8], crc_extra: u8) {
let checksum_offset = packet.len() - 2;
let expected = mavlink_crc(&packet[1..checksum_offset], crc_extra);
let actual = u16::from_le_bytes([packet[checksum_offset], packet[checksum_offset + 1]]);
assert_eq!(actual, expected);
}
#[test]
fn crc_extra_is_deterministic_for_scalar_and_array_fields() {
let fields = [
FieldSpec {
ty: "uint32_t",
name: "time_boot_ms",
array_len: 0,
},
FieldSpec {
ty: "char",
name: "name",
array_len: 16,
},
];
let first = calculate_crc_extra("ARMATAK_TEST", &fields);
let second = calculate_crc_extra("ARMATAK_TEST", &fields);
let no_fields = calculate_crc_extra("ARMATAK_TEST", &[]);
assert_eq!(first, second);
assert_ne!(first, no_fields);
}
#[test]
fn builds_valid_mavlink_v1_packet() {
let payload = [0x10, 0x20, 0x30];
let packet = build_v1_packet(7, 9, 42, &payload, 77);
assert_eq!(packet[0], 0xFE);
assert_eq!(packet[1], payload.len() as u8);
assert_eq!(packet[3], 7);
assert_eq!(packet[4], 9);
assert_eq!(packet[5], 42);
assert_eq!(&packet[6..9], &payload);
assert_eq!(packet.len(), payload.len() + 8);
assert_packet_crc(&packet, 77);
}
#[test]
fn builds_valid_mavlink_v2_packet_with_three_byte_message_id() {
let payload = [0xAA, 0xBB];
let packet = build_v2_packet(11, 13, 0x01_02_03, &payload, 99);
assert_eq!(packet[0], 0xFD);
assert_eq!(packet[1], payload.len() as u8);
assert_eq!(packet[2], 0);
assert_eq!(packet[3], 0);
assert_eq!(packet[5], 11);
assert_eq!(packet[6], 13);
assert_eq!(&packet[7..10], &[0x03, 0x02, 0x01]);
assert_eq!(&packet[10..12], &payload);
assert_eq!(packet.len(), payload.len() + 12);
assert_packet_crc(&packet, 99);
}
}
+96
View File
@@ -93,3 +93,99 @@ pub(crate) fn should_send_video_stream_information(video_uri: &str) -> bool {
|| trimmed.starts_with("mpegts://")
|| trimmed.starts_with("tcp://")
}
#[cfg(test)]
mod tests {
use super::*;
const UUID: &str = "00112233-4455-6677-8899-aabbccddeeff";
#[test]
fn maps_arma_vehicle_types_to_mavlink_types() {
assert_eq!(map_vehicle_type(1), MAV_TYPE_FIXED_WING);
assert_eq!(map_vehicle_type(2), MAV_TYPE_QUADROTOR);
assert_eq!(map_vehicle_type(3), MAV_TYPE_HELICOPTER);
assert_eq!(map_vehicle_type(99), 99);
}
#[test]
fn normalizes_headings_to_zero_through_360() {
assert_eq!(normalize_heading_deg(0.0), 0.0);
assert_eq!(normalize_heading_deg(370.0), 10.0);
assert_eq!(normalize_heading_deg(-10.0), 350.0);
assert_eq!(normalize_heading_deg(720.0), 0.0);
}
#[test]
fn stable_system_id_is_deterministic_and_in_mavlink_range() {
let first = stable_system_id(UUID);
let second = stable_system_id(UUID);
assert_eq!(first, second);
assert!((1..=250).contains(&first));
assert!((1..=250).contains(&stable_system_id("")));
}
#[test]
fn stable_identity_trims_runtime_status_suffixes_and_falls_back_to_uuid() {
assert_eq!(stable_mavlink_identity(" Falcon ", UUID), "Falcon");
assert_eq!(stable_mavlink_identity("Falcon [ON]", UUID), "Falcon");
assert_eq!(stable_mavlink_identity("Falcon [OFF]", UUID), "Falcon");
assert_eq!(stable_mavlink_identity(" ", UUID), UUID);
}
#[test]
fn converts_uuid_to_fixed_binary_identifiers() {
assert_eq!(
uid64_from_uuid(UUID),
u64::from_le_bytes([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
);
let uid2 = uid2_from_uuid(UUID);
assert_eq!(
&uid2[..16],
&[
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff,
]
);
let checksum = UUID
.as_bytes()
.iter()
.fold(0u16, |acc, value| acc.wrapping_add(*value as u16));
assert_eq!(&uid2[16..], &checksum.to_le_bytes());
}
#[test]
fn malformed_or_short_uuid_components_are_zero_filled() {
let invalid = uuid16("gg");
assert_eq!(invalid, [0; 16]);
let short = uuid16("01");
assert_eq!(short[0], 1);
assert!(short[1..].iter().all(|byte| *byte == 0));
}
#[test]
fn fixed_string_reserves_a_nul_terminator_and_handles_zero_length() {
assert_eq!(fixed_string::<5>("abcdef"), [b'a', b'b', b'c', b'd', 0]);
assert_eq!(fixed_string::<5>("ab"), [b'a', b'b', 0, 0, 0]);
assert_eq!(fixed_string::<0>("ab"), [0u8; 0]);
}
#[test]
fn identifies_supported_video_stream_schemes_case_insensitively() {
for uri in [
" RTSP://host/live ",
"rtp://host/live",
"udp://239.1.1.1:1234",
"mpegts://host/live",
"tcp://host:1234",
] {
assert!(should_send_video_stream_information(uri), "{uri}");
}
assert!(!should_send_video_stream_information("https://host/live"));
assert!(!should_send_video_stream_information(""));
}
}
+66
View File
@@ -188,3 +188,69 @@ impl FromArma for UasSystemPayload {
})
}
}
#[cfg(test)]
mod tests {
use super::{UasSystemPayload, UasTelemetryPayload};
use arma_rs::FromArma;
#[test]
fn parses_and_clamps_uas_telemetry_payload() {
let payload = UasTelemetryPayload::from_arma(
r#"["127.0.0.1:14550",0,999,-1,-30.1,-51.2,100.5,25.25,361.0,12.5,1.0,2.0,3.0,1]"#
.to_string(),
)
.expect("telemetry payload should parse");
assert_eq!(payload.address, "127.0.0.1:14550");
assert_eq!(payload.system_id, 1);
assert_eq!(payload.component_id, 255);
assert_eq!(payload.vehicle_type, 0);
assert_eq!(payload.lat_deg, -30.1);
assert_eq!(payload.lon_deg, -51.2);
assert_eq!(payload.alt_msl_m, 100.5);
assert_eq!(payload.rel_alt_m, 25.25);
assert_eq!(payload.heading_deg, 361.0);
assert_eq!(payload.groundspeed_mps, 12.5);
assert_eq!(payload.roll_deg, 1.0);
assert_eq!(payload.pitch_deg, 2.0);
assert_eq!(payload.yaw_deg, 3.0);
assert!(payload.flying);
}
#[test]
fn parses_and_clamps_full_uas_system_payload() {
let payload = UasSystemPayload::from_arma(
r#"["10.0.0.1:14550","00112233-4455-6677-8899-aabbccddeeff","Falcon",300,-30.1,-51.2,100.5,25.25,90.0,12.5,1.0,2.0,3.0,0,1,4.0,5.0,6.0,"rtsp://video.example.test:8554/live",60.0,40.0,-30.2,-51.3,50.0,1,200]"#
.to_string(),
)
.expect("system payload should parse");
assert_eq!(payload.address, "10.0.0.1:14550");
assert_eq!(payload.entity_uuid, "00112233-4455-6677-8899-aabbccddeeff");
assert_eq!(payload.callsign, "Falcon");
assert_eq!(payload.vehicle_type, 255);
assert_eq!(payload.lat_deg, -30.1);
assert_eq!(payload.lon_deg, -51.2);
assert_eq!(payload.alt_msl_m, 100.5);
assert_eq!(payload.rel_alt_m, 25.25);
assert_eq!(payload.heading_deg, 90.0);
assert_eq!(payload.groundspeed_mps, 12.5);
assert_eq!(payload.roll_deg, 1.0);
assert_eq!(payload.pitch_deg, 2.0);
assert_eq!(payload.yaw_deg, 3.0);
assert!(!payload.flying);
assert!(payload.landed);
assert_eq!(payload.gimbal_roll_deg, 4.0);
assert_eq!(payload.gimbal_pitch_deg, 5.0);
assert_eq!(payload.gimbal_yaw_deg, 6.0);
assert_eq!(payload.video_uri, "rtsp://video.example.test:8554/live");
assert_eq!(payload.hfov_deg, 60.0);
assert_eq!(payload.vfov_deg, 40.0);
assert_eq!(payload.image_lat_deg, -30.2);
assert_eq!(payload.image_lon_deg, -51.3);
assert_eq!(payload.image_alt_msl_m, 50.0);
assert!(payload.has_turret_camera);
assert_eq!(payload.battery_remaining_pct, 100);
}
}
+135 -4
View File
@@ -93,6 +93,8 @@ impl UdpClient {
lazy_static! {
static ref UDP_CLIENT: Arc<Mutex<Option<UdpClient>>> = Arc::new(Mutex::new(None));
static ref LRF_CLIENT: Arc<Mutex<Option<UdpClient>>> = Arc::new(Mutex::new(None));
static ref COT_CLIENT: Arc<Mutex<Option<UdpClient>>> = Arc::new(Mutex::new(None));
}
pub fn start(ctx: Context, address: String) -> &'static str {
@@ -125,13 +127,22 @@ pub fn start(ctx: Context, address: String) -> &'static str {
"Starting UDP Client"
}
pub fn send_payload(ctx: Context, payload: String) -> &'static str {
if let Some(ref client) = *UDP_CLIENT.lock().unwrap() {
fn send_with_client(
client_slot: &Arc<Mutex<Option<UdpClient>>>,
ctx: Context,
payload: String,
missing_message: &'static str,
) {
if let Some(ref client) = *client_slot.lock().unwrap() {
client.send_payload(ctx, payload);
} else {
let _ = ctx.callback_null("UDP SOCKET ERROR", "UDP Socket is not running");
info!("UDP send requested while socket was not running");
let _ = ctx.callback_null("UDP SOCKET ERROR", missing_message);
info!("UDP send requested while target socket was not running");
}
}
pub fn send_payload(ctx: Context, payload: String) -> &'static str {
send_with_client(&UDP_CLIENT, ctx, payload, "UDP Socket is not running");
"Sending payload to UDP server"
}
@@ -146,6 +157,116 @@ pub fn send_gps_cot(
"Sending GPS Cursor Over Time to UDP server"
}
pub fn send_eud_cot(
ctx: Context,
cursor_over_time: cot::gps::ExternalPositionPayload,
) -> &'static str {
send_with_client(
&COT_CLIENT,
ctx,
cursor_over_time.to_cot().convert_to_xml(),
"CoT UDP Socket is not running",
);
"Sending EUD Cursor Over Time to CoT UDP server"
}
pub fn start_lrf(ctx: Context, address: String) -> &'static str {
info!("LRF UDP socket start requested for {}", address);
let (tx, rx): (Sender<UdpCommand>, Receiver<UdpCommand>) = mpsc::channel();
let client = UdpClient {
tx,
address: address.clone(),
};
{
let mut client_guard = LRF_CLIENT.lock().unwrap();
if let Some(ref existing_client) = *client_guard {
info!(
"Stopping previous LRF UDP client {} before starting {}",
existing_client.address, address
);
existing_client.stop();
}
*client_guard = Some(UdpClient {
tx: client.tx.clone(),
address: client.address.clone(),
});
}
client.start(address, rx, ctx);
"Starting LRF UDP Client"
}
pub fn start_cot(ctx: Context, address: String) -> &'static str {
info!("CoT UDP socket start requested for {}", address);
let (tx, rx): (Sender<UdpCommand>, Receiver<UdpCommand>) = mpsc::channel();
let client = UdpClient {
tx,
address: address.clone(),
};
{
let mut client_guard = COT_CLIENT.lock().unwrap();
if let Some(ref existing_client) = *client_guard {
info!(
"Stopping previous CoT UDP client {} before starting {}",
existing_client.address, address
);
existing_client.stop();
}
*client_guard = Some(UdpClient {
tx: client.tx.clone(),
address: client.address.clone(),
});
}
client.start(address, rx, ctx);
"Starting CoT UDP Client"
}
pub fn send_lrf(ctx: Context, payload: cot::lrf::LaserRangeFinderPayload) -> &'static str {
send_with_client(
&LRF_CLIENT,
ctx,
payload.to_lrf_message(),
"LRF UDP Socket is not running",
);
"Sending Laser Range Finder payload to UDP server"
}
pub fn clear_lrf(ctx: Context, payload: cot::lrf::LaserRangeFinderClearPayload) -> &'static str {
send_with_client(
&LRF_CLIENT,
ctx,
payload.to_lrf_message(),
"LRF UDP Socket is not running",
);
"Clearing Laser Range Finder payload on UDP server"
}
pub fn send_digital_pointer_cot(
ctx: Context,
payload: cot::digital_pointer::DigitalPointerPayload,
) -> &'static str {
send_with_client(
&COT_CLIENT,
ctx,
payload.to_cot().convert_to_xml(),
"CoT UDP Socket is not running",
);
"Sending Digital Pointer CoT to UDP server"
}
pub fn stop(ctx: Context) -> &'static str {
if let Some(ref client) = *UDP_CLIENT.lock().unwrap() {
info!("UDP socket stop requested for {}", client.address);
@@ -156,5 +277,15 @@ pub fn stop(ctx: Context) -> &'static str {
info!("UDP stop requested while socket was not running");
}
if let Some(ref client) = *LRF_CLIENT.lock().unwrap() {
info!("LRF UDP socket stop requested for {}", client.address);
client.stop();
}
if let Some(ref client) = *COT_CLIENT.lock().unwrap() {
info!("CoT UDP socket stop requested for {}", client.address);
client.stop();
}
"Stopping UDP Client"
}
+4 -1
View File
@@ -70,7 +70,10 @@ pub fn start_stream(
let rtsp_url = if username.is_empty() || password.is_empty() {
format!("rtsp://{}:{}/{}", address, port, stream_path)
} else {
format!("rtsp://{}:{}@{}:{}/{}", username, password, address, port, stream_path)
format!(
"rtsp://{}:{}@{}:{}/{}",
username, password, address, port, stream_path
)
};
let mut cmd = Command::new("ffmpeg");