56 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
valmojr 9b05bcccc2 Added UAS Tool active actions from ATAK 2026-05-10 01:19:39 -03:00
valmojr 80320c0f2c added land and home storage 2026-05-10 01:19:13 -03:00
valmojr b43a6c9748 added undefined uri 2026-05-10 01:18:11 -03:00
valmojr dcc9e1d469 Added handler for turreted drones to share multiple camera components to UAS Tool 2026-05-09 11:58:44 -03:00
valmojr 3c37185c1a Fixed UAS stream URI resolver 2026-05-09 11:58:08 -03:00
79 changed files with 5644 additions and 367 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 {
};
};
};
+15 -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", []];
@@ -61,6 +64,14 @@ addMissionEventHandler ["ExtensionCallback", {
case "COMMAND_LONG";
case "COMMAND_INT";
case "COMMAND_ACK";
case "MISSION_COUNT";
case "MISSION_ITEM";
case "MISSION_ITEM_INT";
case "MISSION_CLEAR_ALL";
case "MISSION_SET_CURRENT";
case "SET_HOME_POSITION";
case "SET_MODE";
case "SET_POSITION_TARGET_GLOBAL_INT";
case "MANUAL_CONTROL": {
"armatak" callExtension ["log", [["info", format ["MAVLINK UDP CALLBACK %1 %2", _function, _data]]]];
[_function, _data] call EFUNC(uav,handleMavlinkCallback);
@@ -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];
@@ -6,13 +6,20 @@ params["_unit"];
private _callsign = "";
private _displayName = localize (getText (configOf _unit >> "displayName"));
private _markerCallsignOverride = _unit getVariable ["armatak_attribute_marker_callsign", ""];
if (_markerCallsignOverride isNotEqualTo "") exitWith {
_markerCallsignOverride
};
if (_displayName isEqualTo "") then {
_displayName = typeOf _unit;
};
private _vehicleName = vehicleVarName _unit;
if ((([_unit] call BIS_fnc_objectType) select 0) == "Vehicle") then {
_callsign = _displayName;
_callsign = [_displayName, _vehicleName] select (_vehicleName isNotEqualTo "");
if (!isNull driver _unit) then {
_callsign = _displayName + " | " + ([name (driver _unit)] call armatak_fnc_shorten_name);
@@ -20,7 +27,13 @@ if ((([_unit] call BIS_fnc_objectType) select 0) == "Vehicle") then {
};
if (unitIsUAV _unit) then {
_callsign = _displayName;
_callsign = [_displayName, _vehicleName] select (_vehicleName isNotEqualTo "");
private _uavControl = UAVControl _unit;
private _controller = _uavControl param [0, objNull];
if (!isNull _controller) then {
_callsign = _callsign + " | " + ([name _controller] call armatak_fnc_shorten_name);
};
if (isUAVConnected _unit) then {
_callsign = _callsign + " [ON]";
@@ -29,10 +42,8 @@ if (unitIsUAV _unit) then {
}
};
private _markerCallsignOverride = _unit getVariable ["armatak_attribute_marker_callsign", ""];
if (_markerCallsignOverride isNotEqualTo "") then {
_callsign = _markerCallsignOverride;
if (_callsign isEqualTo "") then {
_callsign = _displayName;
};
_callsign
@@ -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);
{
@@ -1,4 +1,4 @@
params ["_drone"];
params ["_drone", ["_cameraMode", "turret"]];
private _override = _drone getVariable ["armatak_uas_camera_data_override", []];
private _isLocalController = hasInterface && {!isNull player} && {(getConnectedUAV player) isEqualTo _drone};
@@ -22,16 +22,18 @@ private _cameraDir = [];
private _spiASL = [];
private _slantRange = 0;
private _laserTarget = laserTarget _drone;
if (!isNull _laserTarget) then {
private _laserTargetWorld = getPosWorld _laserTarget;
private _laserTargetAslZ = (getPosASL _laserTarget) select 2;
_spiASL = [_laserTargetWorld select 0, _laserTargetWorld select 1, _laserTargetAslZ];
_cameraDir = _spiASL vectorDiff _originASL;
_slantRange = _originASL vectorDistance _spiASL;
if (_cameraMode isNotEqualTo "fpv") then {
private _laserTarget = laserTarget _drone;
if (!isNull _laserTarget) then {
private _laserTargetWorld = getPosWorld _laserTarget;
private _laserTargetAslZ = (getPosASL _laserTarget) select 2;
_spiASL = [_laserTargetWorld select 0, _laserTargetWorld select 1, _laserTargetAslZ];
_cameraDir = _spiASL vectorDiff _originASL;
_slantRange = _originASL vectorDistance _spiASL;
};
};
if (_cameraDir isEqualTo []) then {
if (_cameraDir isEqualTo [] && {_cameraMode isNotEqualTo "fpv"}) then {
private _uavControl = UAVControl _drone;
private _controlledTurretPath = _uavControl param [1, []];
private _candidateTurrets = [];
+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
@@ -6,42 +6,281 @@ if (!hasInterface) exitWith {};
private _payload = [_data] call FUNC(parseMavlinkCallbackData);
private _uav = getConnectedUAV player;
if (isNull _uav) then {
_uav = player getVariable [QGVAR(broadcastingUav), objNull];
};
if (isNull _uav) exitWith {
"armatak" callExtension ["log", [["warn", format ["Ignoring MAVLINK UDP callback %1 because no UAV is connected: %2", _function, _data]]]];
};
private _command = parseNumber (_payload getOrDefault ["command", "-1"]);
private _number = {
params ["_key", ["_default", 0]];
private _raw = _payload getOrDefault [_key, str _default];
private _value = parseNumber _raw;
if (!finite _value) exitWith {_default};
_value
};
private _uavGroup = {
params ["_vehicle"];
private _crew = crew _vehicle;
if (_crew isEqualTo []) exitWith {grpNull};
group (_crew select 0)
};
private _clearWaypoints = {
params ["_group"];
if (isNull _group) exitWith {};
for "_i" from ((count waypoints _group) - 1) to 0 step -1 do {
deleteWaypoint [_group, _i];
};
};
private _clearUavRoute = {
private _group = [_uav] call _uavGroup;
if (isNull _group) exitWith {false};
[_group] call _clearWaypoints;
_uav setVariable ["armatak_uas_mission_items", [], true];
true
};
private _geoToAtl = {
params ["_vehicle", "_lat", "_lon", ["_alt", -1]];
private _current = [_vehicle] call EFUNC(client,extractClientPosition);
private _currentLat = _current select 1;
private _currentLon = _current select 2;
private _currentAtl = getPosATL _vehicle;
private _northM = (_lat - _currentLat) * 111320;
private _eastM = (_lon - _currentLon) * (111320 * (cos _currentLat));
[
(_currentAtl select 0) + _eastM,
(_currentAtl select 1) + _northM,
if (_alt >= 0) then {_alt} else {_currentAtl select 2}
]
};
private _commandMove = {
params ["_vehicle", "_positionAtl", ["_type", "MOVE"], ["_radius", 80], ["_completion", 50]];
private _group = [_vehicle] call _uavGroup;
if (isNull _group) exitWith {false};
[_group] call _clearWaypoints;
_vehicle engineOn true;
_vehicle setVariable ["armatak_uas_armed", true, true];
_vehicle setFuel ((fuel _vehicle) max 0.1);
_vehicle flyInHeight ((_positionAtl select 2) max 10);
_vehicle doMove _positionAtl;
private _wp = _group addWaypoint [_positionAtl, 0];
_wp setWaypointType _type;
_wp setWaypointBehaviour "CARELESS";
_wp setWaypointCombatMode "BLUE";
_wp setWaypointSpeed "NORMAL";
_wp setWaypointCompletionRadius _completion;
if (_type == "LOITER") then {
_wp setWaypointLoiterRadius (_radius max 25);
_wp setWaypointLoiterType "CIRCLE_L";
};
true
};
private _appendMissionWaypoint = {
params ["_vehicle", "_positionAtl", "_command", "_seq", ["_radius", 80]];
private _group = [_vehicle] call _uavGroup;
if (isNull _group) exitWith {false};
private _type = switch (_command) do {
case 17;
case 18;
case 19;
case 31: {"LOITER"};
case 21: {"MOVE"};
default {"MOVE"};
};
private _wp = _group addWaypoint [_positionAtl, 0];
_wp setWaypointType _type;
_wp setWaypointBehaviour "CARELESS";
_wp setWaypointCombatMode "BLUE";
_wp setWaypointSpeed "NORMAL";
_wp setWaypointCompletionRadius 35;
if (_type == "LOITER") then {
_wp setWaypointLoiterRadius (_radius max 25);
_wp setWaypointLoiterType "CIRCLE_L";
};
if (_command == 21) then {
_wp setWaypointStatements ["true", "(vehicle this) land 'LAND'"];
};
private _items = _vehicle getVariable ["armatak_uas_mission_items", []];
_items pushBack [_seq, _command, _positionAtl];
_vehicle setVariable ["armatak_uas_mission_items", _items, true];
true
};
private _commandName = _payload getOrDefault ["command_name", "UNKNOWN"];
private _command = [_payload getOrDefault ["command", "-1"]] call BIS_fnc_parseNumber;
private _callsign = [_uav] call armatak_fnc_extract_marker_callsign;
private _applySpeed = {
params ["_speed"];
if (_speed <= 0) exitWith {false};
_uav limitSpeed _speed;
systemChat format ["ATAK SPEED %1m/s %2", round _speed, _callsign];
true
};
private _applyMode = {
params ["_mode"];
switch (_mode) do {
case 4: {
_uav engineOn true;
_uav setVariable ["armatak_uas_armed", true, true];
_uav setFuel ((fuel _uav) max 0.1);
systemChat format ["ATAK GUIDED %1", _callsign];
};
case 5: {
private _pos = getPosATL _uav;
[_uav, _pos, "LOITER", 80, 25] call _commandMove;
systemChat format ["ATAK LOITER %1", _callsign];
};
case 6;
case 21;
case 27: {
private _home = _uav getVariable ["armatak_uas_home_atl", getPosATL _uav];
_home set [2, ((_home select 2) max 60)];
[_uav, _home, "MOVE", 80, 60] call _commandMove;
systemChat format ["ATAK RTL %1", _callsign];
};
case 9: {
private _pos = getPosATL _uav;
_pos set [2, 0];
[_uav, _pos, "MOVE", 30, 20] call _commandMove;
_uav flyInHeight 0;
systemChat format ["ATAK LAND %1", _callsign];
};
default {
"armatak" callExtension ["log", [["info", format ["Unhandled MAVLINK mode %1 for UAV %2", _mode, _uav]]]];
};
};
};
private _setHomeFromGeo = {
params ["_lat", "_lon", "_alt"];
if (_lat == 0 && {_lon == 0}) exitWith {false};
private _homeAtl = [_uav, _lat, _lon, _alt] call _geoToAtl;
_uav setVariable ["armatak_uas_home_atl", _homeAtl, true];
_uav setVariable ["armatak_uas_home_geo", [_lat, _lon, _alt], true];
systemChat format ["ATAK HOME %1", _callsign];
true
};
switch (_function) do {
case "COMMAND_LONG": {
switch (_command) do {
case 176: {
private _mode = ["param2", -1] call _number;
if (_mode < 0) then {
_mode = ["param1", -1] call _number;
};
[_mode] call _applyMode;
};
case 178: {
private _speed = ["param2", -1] call _number;
if (_speed <= 0) then {
_speed = ["param1", -1] call _number;
};
[_speed] call _applySpeed;
};
case 179: {
private _useCurrent = (["param1", 0] call _number) >= 1;
if (_useCurrent) then {
private _pos = [_uav] call EFUNC(client,extractClientPosition);
private _relAlt = ((getPosATL _uav) select 2) max 0;
private _homeAtl = getPosATL _uav;
_uav setVariable ["armatak_uas_home_atl", _homeAtl, true];
_uav setVariable ["armatak_uas_home_geo", [_pos select 1, _pos select 2, (_pos select 3) - _relAlt], true];
systemChat format ["ATAK HOME %1", _callsign];
} else {
[["param5", 0] call _number, ["param6", 0] call _number, ["param7", 0] call _number] call _setHomeFromGeo;
};
};
case 400: {
private _armValue = parseNumber (_payload getOrDefault ["param1", "0"]);
private _doArm = _armValue >= 1;
private _doArm = (["param1", 0] call _number) >= 1;
_uav engineOn _doArm;
_uav setVariable ["armatak_uas_armed", _doArm, true];
if (_doArm) then {
_uav setFuel ((fuel _uav) max 0.1);
};
systemChat format ["ATAK %1 %2", ["DISARM", "ARM"] select _doArm, [_uav] call armatak_fnc_extract_marker_callsign];
"armatak" callExtension ["log", [["info", format ["Applied MAVLINK COMMAND_LONG %1 (%2) to UAV %3", _command, _commandName, _uav]]]];
systemChat format ["ATAK %1 %2", ["DISARM", "ARM"] select _doArm, _callsign];
"armatak" callExtension ["log", [["info", format ["Applied MAVLINK ARM=%1 to UAV %2", _doArm, _uav]]]];
};
case 22: {
_uav engineOn true;
_uav setFuel ((fuel _uav) max 0.1);
_uav flyInHeight 75;
if (_uav isKindOf "Helicopter" || {_uav isKindOf "VTOL_Base_F"} || {_uav isKindOf "Quadbike_01_F"}) then {
private _velocity = velocityModelSpace _uav;
_uav setVelocityModelSpace [
_velocity select 0,
(_velocity select 1) max 15,
((_velocity select 2) max 0) + 8
];
private _alt = (["param7", 75] call _number) max 10;
private _pos = getPosATL _uav;
_pos set [2, _alt];
[_uav, _pos, "MOVE", 80, 25] call _commandMove;
_uav setVelocityModelSpace [0, 15, 8];
systemChat format ["ATAK TAKEOFF %1m %2", round _alt, _callsign];
};
case 21: {
[9] call _applyMode;
};
case 20: {
[6] call _applyMode;
};
case 16: {
private _lat = ["param5", 0] call _number;
private _lon = ["param6", 0] call _number;
private _alt = ["param7", -1] call _number;
private _pos = [_uav, _lat, _lon, _alt] call _geoToAtl;
[_uav, _pos, "MOVE", 80, 50] call _commandMove;
systemChat format ["ATAK MOVE %1", _callsign];
};
case 17: {
private _lat = ["param5", 0] call _number;
private _lon = ["param6", 0] call _number;
private _alt = ["param7", -1] call _number;
private _radius = abs (["param3", 80] call _number);
private _pos = [_uav, _lat, _lon, _alt] call _geoToAtl;
[_uav, _pos, "LOITER", _radius, 30] call _commandMove;
systemChat format ["ATAK LOITER %1", _callsign];
};
case 43000: {
private _speed = ["param2", -1] call _number;
if (_speed <= 0) then {
_speed = ["param1", -1] call _number;
};
[_speed] call _applySpeed;
};
case 43001: {
private _alt = ["param1", -1] call _number;
if (_alt < 0) then {
_alt = ["param7", -1] call _number;
};
private _pos = getPosATL _uav;
_pos set [2, _alt max 10];
[_uav, _pos, "MOVE", 80, 25] call _commandMove;
systemChat format ["ATAK ALT %1m %2", round (_pos select 2), _callsign];
};
case 43002: {
private _heading = ["param1", -1] call _number;
if (_heading >= 0) then {
_uav setDir _heading;
systemChat format ["ATAK HDG %1 %2", round _heading, _callsign];
};
systemChat format ["ATAK TAKEOFF %1", [_uav] call armatak_fnc_extract_marker_callsign];
"armatak" callExtension ["log", [["info", format ["Applied MAVLINK TAKEOFF to UAV %1", _uav]]]];
};
default {
"armatak" callExtension ["log", [["info", format ["Unhandled MAVLINK COMMAND_LONG %1 (%2): %3", _command, _commandName, _data]]]];
@@ -49,7 +288,87 @@ switch (_function) do {
};
};
case "COMMAND_INT": {
"armatak" callExtension ["log", [["info", format ["Received MAVLINK COMMAND_INT %1 (%2): %3", _command, _commandName, _data]]]];
private _lat = (["x", 0] call _number) / 1e7;
private _lon = (["y", 0] call _number) / 1e7;
private _alt = ["z", -1] call _number;
switch (_command) do {
case 16: {
private _pos = [_uav, _lat, _lon, _alt] call _geoToAtl;
[_uav, _pos, "MOVE", 80, 50] call _commandMove;
systemChat format ["ATAK MOVE %1", _callsign];
};
case 17;
case 192: {
private _radius = abs (["param3", 80] call _number);
private _direction = ["CIRCLE_L", "CIRCLE_R"] select ((["param4", 0] call _number) < 0);
private _pos = [_uav, _lat, _lon, _alt] call _geoToAtl;
private _type = ["MOVE", "LOITER"] select (_radius > 1);
[_uav, _pos, _type, _radius, 30] call _commandMove;
if (_type == "LOITER") then {
private _group = [_uav] call _uavGroup;
private _waypoints = waypoints _group;
if (_waypoints isNotEqualTo []) then {
(_waypoints select -1) setWaypointLoiterType _direction;
};
};
systemChat format ["ATAK %1 %2", _type, _callsign];
};
default {
"armatak" callExtension ["log", [["info", format ["Unhandled MAVLINK COMMAND_INT %1 (%2): %3", _command, _commandName, _data]]]];
};
};
};
case "MISSION_COUNT": {
private _count = ["count", 0] call _number;
[] call _clearUavRoute;
systemChat format ["ATAK ROUTE %1 pts %2", round _count, _callsign];
"armatak" callExtension ["log", [["info", format ["Receiving MAVLINK mission count=%1 for UAV %2", _count, _uav]]]];
};
case "MISSION_CLEAR_ALL": {
[] call _clearUavRoute;
systemChat format ["ATAK ROUTE CLEAR %1", _callsign];
};
case "MISSION_SET_CURRENT": {
private _seq = ["seq", 0] call _number;
"armatak" callExtension ["log", [["info", format ["MAVLINK mission set current seq=%1 for UAV %2", _seq, _uav]]]];
};
case "MISSION_ITEM";
case "MISSION_ITEM_INT": {
private _seq = ["seq", 0] call _number;
private _missionCommand = ["command", -1] call _number;
private _lat = ["lat", 0] call _number;
private _lon = ["lon", 0] call _number;
private _alt = ["alt", -1] call _number;
if (_lat == 0 && {_lon == 0}) exitWith {
"armatak" callExtension ["log", [["warn", format ["Ignoring MAVLINK mission item at zero coordinate: %1", _data]]]];
};
private _pos = [_uav, _lat, _lon, _alt] call _geoToAtl;
private _radius = abs (["param3", 80] call _number);
[_uav, _pos, _missionCommand, _seq, _radius] call _appendMissionWaypoint;
_uav engineOn true;
_uav setVariable ["armatak_uas_armed", true, true];
_uav setFuel ((fuel _uav) max 0.1);
_uav flyInHeight ((_pos select 2) max 10);
systemChat format ["ATAK ROUTE WP %1 %2", round _seq, _callsign];
"armatak" callExtension ["log", [["info", format ["Added MAVLINK mission item seq=%1 command=%2 posATL=%3 for UAV %4", _seq, _missionCommand, _pos, _uav]]]];
};
case "SET_HOME_POSITION": {
[["lat", 0] call _number, ["lon", 0] call _number, ["alt", 0] call _number] call _setHomeFromGeo;
};
case "SET_POSITION_TARGET_GLOBAL_INT": {
private _lat = ["lat", 0] call _number;
private _lon = ["lon", 0] call _number;
private _alt = ["alt", -1] call _number;
private _pos = [_uav, _lat, _lon, _alt] call _geoToAtl;
[_uav, _pos, "MOVE", 80, 40] call _commandMove;
systemChat format ["ATAK GUIDED MOVE %1", _callsign];
};
case "SET_MODE": {
private _mode = ["custom_mode", -1] call _number;
[_mode] call _applyMode;
};
case "COMMAND_ACK": {
"armatak" callExtension ["log", [["info", format ["Received MAVLINK COMMAND_ACK %1 (%2): %3", _command, _commandName, _data]]]];
+8 -4
View File
@@ -2,7 +2,7 @@
params [["_uav", objNull, [objNull]]];
private _defaultVideoUri = "rtsp://irontak.com:554/fpv";
private _defaultVideoUri = "rtsp://undefined:554/fpv";
private _activelyControlledUav = if (!isNull player) then {getConnectedUAV player} else {objNull};
private _normalize = {
@@ -20,15 +20,19 @@ private _normalize = {
format ["rtp://%1", _url]
};
if (!isNull _uav && {_activelyControlledUav isEqualTo _uav}) then {
if (!isNull _uav) then {
private _objectVideoUrl = [_uav] call armatak_fnc_extract_marker_video_url;
private _normalizedObjectVideoUrl = [_objectVideoUrl] call _normalize;
if (_normalizedObjectVideoUrl isNotEqualTo "") exitWith {
_normalizedObjectVideoUrl
};
};
if (!isNull _uav) exitWith {
private _activeSessionVideoUrl = player getVariable [QEGVAR(client,video_feed_url), ""];
private _normalizedActiveSessionVideoUrl = [_activeSessionVideoUrl] call _normalize;
if (_normalizedActiveSessionVideoUrl isNotEqualTo "") exitWith {
_normalizedActiveSessionVideoUrl
};
_defaultVideoUri
};
@@ -40,6 +40,11 @@ private _mavlinkAddress = player getVariable [QEGVAR(client,mavlink_address), ""
if (_mavlinkAddress isEqualTo "") exitWith {};
private _pos = [_uav] call EFUNC(client,extractClientPosition);
private _relAlt = ((getPosATL _uav) select 2) max 0;
if (isNil {_uav getVariable "armatak_uas_home_atl"}) then {
_uav setVariable ["armatak_uas_home_atl", getPosATL _uav, true];
_uav setVariable ["armatak_uas_home_geo", [_pos select 1, _pos select 2, (_pos select 3) - _relAlt], true];
};
private _uuid = [_uav] call armatak_fnc_extract_uuid;
private _callsign = [_uav] call armatak_fnc_extract_marker_callsign;
private _videoUri = [_uav] call FUNC(resolveVideoUri);
@@ -48,8 +53,15 @@ private _up = vectorUp _uav;
private _yaw = getDir _uav;
private _pitch = asin (((_dir select 2) max -1) min 1);
private _roll = asin (((_up select 0) max -1) min 1);
private _relAlt = ((getPosATL _uav) select 2) max 0;
private _uavType = if (_uav isKindOf "Plane") then {1} else {[2, 3] select (_uav isKindOf "Helicopter")};
private _armed = _uav getVariable ["armatak_uas_armed", isEngineOn _uav];
if !(isEngineOn _uav) then {
_armed = false;
_uav setVariable ["armatak_uas_armed", false, true];
};
private _groundSpeed = abs (_pos select 6);
private _landed = (_relAlt <= 1.5) && {_groundSpeed <= 0.5};
private _batteryRemaining = round ((((fuel _uav) max 0) min 1) * 100);
private _gimbalRoll = 0;
private _gimbalPitch = _pitch;
@@ -59,34 +71,22 @@ private _vfov = _uav getVariable ["armatak_uas_vfov", (_hfov * 0.5625)];
private _imageLat = _pos select 1;
private _imageLon = _pos select 2;
private _imageAlt = _pos select 3;
private _cameraData = [_uav, "turret"] call armatak_fnc_extract_uas_camera_data;
private _uavControl = UAVControl _uav;
private _controlledTurretPath = _uavControl param [1, []];
private _hasTurretCamera = ((_controlledTurretPath isEqualType []) && {_controlledTurretPath isNotEqualTo []}) || {(allTurrets _uav) isNotEqualTo []};
private _laser = laserTarget _uav;
if (!isNull _laser) then {
private _originASL = getPosASL _uav;
private _targetWorld = getPosWorld _laser;
private _targetAslZ = (getPosASL _laser) select 2;
private _spiASL = [_targetWorld select 0, _targetWorld select 1, _targetAslZ];
if (_cameraData isEqualType [] && {(count _cameraData) >= 6}) then {
_gimbalYaw = _cameraData param [0, _yaw];
_gimbalPitch = _cameraData param [1, _pitch];
_hfov = _cameraData param [2, _hfov];
_vfov = _uav getVariable ["armatak_uas_vfov", (_hfov * 0.5625)];
private _los = _spiASL vectorDiff _originASL;
private _losMag = vectorMagnitude _los;
if (_losMag > 0) then {
_los = _los vectorMultiply (1 / _losMag);
private _dirX = _los select 0;
private _dirY = _los select 1;
private _dirZ = _los select 2;
private _horizontalMag = sqrt ((_dirX * _dirX) + (_dirY * _dirY));
private _camYaw = ((_dirX atan2 _dirY) + 360) mod 360;
private _camPitch = _dirZ atan2 (_horizontalMag max 0.001);
_gimbalPitch = _camPitch;
_gimbalYaw = _camYaw;
private _imageGeo = [_targetWorld select 0, _targetWorld select 1, _targetAslZ] call EFUNC(client,convertClientLocation);
_imageLat = _imageGeo select 0;
_imageLon = _imageGeo select 1;
_imageAlt = _imageGeo select 2;
private _spiGeo = _cameraData param [5, []];
if (_spiGeo isEqualType [] && {(count _spiGeo) >= 3}) then {
_imageLat = _spiGeo select 0;
_imageLon = _spiGeo select 1;
_imageAlt = _spiGeo select 2;
};
};
@@ -104,7 +104,8 @@ private _systemPayload = [
_roll,
_pitch,
_yaw,
parseNumber isEngineOn _uav,
parseNumber _armed,
parseNumber _landed,
_gimbalRoll,
_gimbalPitch,
_gimbalYaw,
@@ -113,7 +114,9 @@ private _systemPayload = [
_vfov,
_imageLat,
_imageLon,
_imageAlt
_imageAlt,
parseNumber _hasTurretCamera,
_batteryRemaining
];
"armatak" callExtension ["uas:send_uas_system", [_systemPayload]];
+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(
+17 -14
View File
@@ -54,17 +54,15 @@ 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| {
let message = e.to_string();
let _ = context.callback_data(
"TCP SOCKET ERROR",
"TAK Socket disconnected",
message.clone(),
);
message
})
stream.write_message(message.as_bytes()).map_err(|e| {
let message = e.to_string();
let _ = context.callback_data(
"TCP SOCKET ERROR",
"TAK Socket disconnected",
message.clone(),
);
message
})
}
fn flush_pending_messages(
@@ -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, "");
}
}
+792 -10
View File
@@ -3,8 +3,38 @@ pub(crate) struct MavlinkCallbackEvent {
pub data: String,
}
use super::constants::{AUTOPILOT_COMPONENT_ID, CAMERA_COMPONENT_ID, TURRET_CAMERA_COMPONENT_ID};
use super::identity::should_send_video_stream_information;
use super::packets::{
autopilot_version_packet, camera_fov_status_packet_for_component,
camera_information_packet_for_component, command_ack_packet, gimbal_manager_information_packet,
home_position_packet, mission_ack_packet, mission_request_int_packet,
mount_orientation_packet_for_component, mount_status_packet,
video_stream_information_packet_for_component, video_stream_status_packet_for_component,
};
use super::state::{latest_system, set_active_camera, set_home};
use log::info;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Copy)]
struct MissionUploadState {
count: u16,
next_seq: u16,
mission_type: u8,
gcs_system: u8,
gcs_component: u8,
}
static MISSION_UPLOADS: OnceLock<Mutex<HashMap<u8, MissionUploadState>>> = OnceLock::new();
fn mission_uploads() -> &'static Mutex<HashMap<u8, MissionUploadState>> {
MISSION_UPLOADS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(crate) fn hex_preview(bytes: &[u8], max_len: usize) -> String {
bytes.iter()
bytes
.iter()
.take(max_len)
.map(|byte| format!("{:02X}", byte))
.collect::<Vec<_>>()
@@ -13,19 +43,42 @@ pub(crate) fn hex_preview(bytes: &[u8], max_len: usize) -> String {
pub(crate) fn mav_cmd_name(command_id: u16) -> &'static str {
match command_id {
16 => "NAV_WAYPOINT",
17 => "NAV_LOITER_UNLIM",
20 => "NAV_RETURN_TO_LAUNCH",
21 => "NAV_LAND",
22 => "NAV_TAKEOFF",
176 => "DO_SET_MODE",
178 => "DO_CHANGE_SPEED",
179 => "DO_SET_HOME",
192 => "DO_REPOSITION",
205 => "DO_MOUNT_CONTROL",
43000 => "GUIDED_CHANGE_SPEED",
43001 => "GUIDED_CHANGE_ALTITUDE",
43002 => "GUIDED_CHANGE_HEADING",
200 => "IMAGE_START_CAPTURE",
201 => "IMAGE_STOP_CAPTURE",
250 => "VIDEO_START_CAPTURE",
251 => "VIDEO_STOP_CAPTURE",
252 => "DO_CONTROL_VIDEO",
400 => "COMPONENT_ARM_DISARM",
521 => "REQUEST_MESSAGE",
511 => "SET_MESSAGE_INTERVAL",
512 => "REQUEST_MESSAGE",
521 => "REQUEST_CAMERA_INFORMATION",
2502 => "VIDEO_START_STREAMING",
2503 => "VIDEO_STOP_STREAMING",
2504 => "REQUEST_VIDEO_STREAM_INFORMATION",
2505 => "REQUEST_VIDEO_STREAM_STATUS",
_ => "UNKNOWN",
}
}
fn read_i32(payload: &[u8], offset: usize) -> Option<i32> {
payload
.get(offset..offset + 4)
.map(|bytes| i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
fn read_i16(payload: &[u8], offset: usize) -> Option<i16> {
payload
.get(offset..offset + 2)
@@ -46,10 +99,10 @@ fn read_f32(payload: &[u8], offset: usize) -> Option<f32> {
fn mavlink_message_detail(msg_id: u8, payload: &[u8]) -> String {
match msg_id {
76 if payload.len() >= 33 => {
76 if payload.len() >= 31 => {
let command = u16::from_le_bytes([payload[28], payload[29]]);
let target_system = payload[30];
let target_component = payload[31];
let target_component = payload.get(31).copied().unwrap_or(0);
format!(
" command={}({}) target={}:{}",
command,
@@ -86,7 +139,8 @@ pub(crate) fn mavlink_packet_summary(bytes: &[u8]) -> String {
let system_id = bytes[3];
let component_id = bytes[4];
let msg_id = bytes[5];
let detail = mavlink_message_detail(msg_id, bytes.get(6..6 + payload_len).unwrap_or(&[]));
let detail =
mavlink_message_detail(msg_id, bytes.get(6..6 + payload_len).unwrap_or(&[]));
format!(
"MAVLink v1 msgid={}{} sysid={} compid={} seq={} payload_len={} preview={}",
msg_id,
@@ -104,7 +158,10 @@ pub(crate) fn mavlink_packet_summary(bytes: &[u8]) -> String {
let system_id = bytes[5];
let component_id = bytes[6];
let msg_id = bytes[7] as u32 | ((bytes[8] as u32) << 8) | ((bytes[9] as u32) << 16);
let detail = mavlink_message_detail(msg_id as u8, bytes.get(10..10 + payload_len).unwrap_or(&[]));
let detail = mavlink_message_detail(
msg_id as u8,
bytes.get(10..10 + payload_len).unwrap_or(&[]),
);
format!(
"MAVLink v2 msgid={}{} sysid={} compid={} seq={} payload_len={} preview={}",
msg_id,
@@ -163,11 +220,11 @@ pub(crate) fn mavlink_callback_event(bytes: &[u8], source: &str) -> Option<Mavli
),
})
}
76 if payload.len() >= 33 => {
76 if payload.len() >= 31 => {
let command = read_u16(payload, 28)?;
let target_system = *payload.get(30)?;
let target_component = *payload.get(31)?;
let confirmation = *payload.get(32)?;
let target_component = payload.get(31).copied().unwrap_or(0);
let confirmation = payload.get(32).copied().unwrap_or(0);
Some(MavlinkCallbackEvent {
function: "COMMAND_LONG",
data: format!(
@@ -200,7 +257,7 @@ pub(crate) fn mavlink_callback_event(bytes: &[u8], source: &str) -> Option<Mavli
Some(MavlinkCallbackEvent {
function: "COMMAND_INT",
data: format!(
"source={};sysid={};compid={};command={};command_name={};target_system={};target_component={};frame={};current={};autocontinue={};param1={:.3};param2={:.3};param3={:.3};param4={:.3}",
"source={};sysid={};compid={};command={};command_name={};target_system={};target_component={};frame={};current={};autocontinue={};param1={:.3};param2={:.3};param3={:.3};param4={:.3};x={};y={};z={:.3}",
source,
system_id,
component_id,
@@ -215,6 +272,181 @@ pub(crate) fn mavlink_callback_event(bytes: &[u8], source: &str) -> Option<Mavli
read_f32(payload, 4).unwrap_or(0.0),
read_f32(payload, 8).unwrap_or(0.0),
read_f32(payload, 12).unwrap_or(0.0),
read_i32(payload, 16).unwrap_or(0),
read_i32(payload, 20).unwrap_or(0),
read_f32(payload, 24).unwrap_or(0.0),
),
})
}
44 if payload.len() >= 4 => {
let count = read_u16(payload, 0)?;
let target_system = *payload.get(2)?;
let target_component = *payload.get(3)?;
let mission_type = payload.get(4).copied().unwrap_or(0);
Some(MavlinkCallbackEvent {
function: "MISSION_COUNT",
data: format!(
"source={};sysid={};compid={};target_system={};target_component={};count={};mission_type={}",
source, system_id, component_id, target_system, target_component, count, mission_type
),
})
}
39 if payload.len() >= 37 => {
let seq = read_u16(payload, 28)?;
let command = read_u16(payload, 30)?;
let target_system = *payload.get(32)?;
let target_component = *payload.get(33)?;
let frame = *payload.get(34)?;
let current = *payload.get(35)?;
let autocontinue = *payload.get(36)?;
let mission_type = payload.get(37).copied().unwrap_or(0);
Some(MavlinkCallbackEvent {
function: "MISSION_ITEM",
data: format!(
"source={};sysid={};compid={};seq={};command={};command_name={};target_system={};target_component={};frame={};current={};autocontinue={};mission_type={};param1={:.3};param2={:.3};param3={:.3};param4={:.3};lat={:.7};lon={:.7};alt={:.3}",
source,
system_id,
component_id,
seq,
command,
mav_cmd_name(command),
target_system,
target_component,
frame,
current,
autocontinue,
mission_type,
read_f32(payload, 0).unwrap_or(0.0),
read_f32(payload, 4).unwrap_or(0.0),
read_f32(payload, 8).unwrap_or(0.0),
read_f32(payload, 12).unwrap_or(0.0),
read_f32(payload, 16).unwrap_or(0.0),
read_f32(payload, 20).unwrap_or(0.0),
read_f32(payload, 24).unwrap_or(0.0),
),
})
}
73 if payload.len() >= 37 => {
let seq = read_u16(payload, 28)?;
let command = read_u16(payload, 30)?;
let target_system = *payload.get(32)?;
let target_component = *payload.get(33)?;
let frame = *payload.get(34)?;
let current = *payload.get(35)?;
let autocontinue = *payload.get(36)?;
let mission_type = payload.get(37).copied().unwrap_or(0);
Some(MavlinkCallbackEvent {
function: "MISSION_ITEM_INT",
data: format!(
"source={};sysid={};compid={};seq={};command={};command_name={};target_system={};target_component={};frame={};current={};autocontinue={};mission_type={};param1={:.3};param2={:.3};param3={:.3};param4={:.3};x={};y={};z={:.3};lat={:.7};lon={:.7};alt={:.3}",
source,
system_id,
component_id,
seq,
command,
mav_cmd_name(command),
target_system,
target_component,
frame,
current,
autocontinue,
mission_type,
read_f32(payload, 0).unwrap_or(0.0),
read_f32(payload, 4).unwrap_or(0.0),
read_f32(payload, 8).unwrap_or(0.0),
read_f32(payload, 12).unwrap_or(0.0),
read_i32(payload, 16).unwrap_or(0),
read_i32(payload, 20).unwrap_or(0),
read_f32(payload, 24).unwrap_or(0.0),
read_i32(payload, 16).unwrap_or(0) as f64 / 1e7,
read_i32(payload, 20).unwrap_or(0) as f64 / 1e7,
read_f32(payload, 24).unwrap_or(0.0),
),
})
}
45 if payload.len() >= 2 => {
let target_system = *payload.get(0)?;
let target_component = *payload.get(1)?;
let mission_type = payload.get(2).copied().unwrap_or(0);
Some(MavlinkCallbackEvent {
function: "MISSION_CLEAR_ALL",
data: format!(
"source={};sysid={};compid={};target_system={};target_component={};mission_type={}",
source, system_id, component_id, target_system, target_component, mission_type
),
})
}
41 if payload.len() >= 4 => {
let seq = read_u16(payload, 0)?;
let target_system = *payload.get(2)?;
let target_component = *payload.get(3)?;
Some(MavlinkCallbackEvent {
function: "MISSION_SET_CURRENT",
data: format!(
"source={};sysid={};compid={};target_system={};target_component={};seq={}",
source, system_id, component_id, target_system, target_component, seq
),
})
}
243 if payload.len() >= 53 => {
let lat_int = read_i32(payload, 0)?;
let lon_int = read_i32(payload, 4)?;
let alt_mm = read_i32(payload, 8)?;
let target_system = *payload.get(52)?;
Some(MavlinkCallbackEvent {
function: "SET_HOME_POSITION",
data: format!(
"source={};sysid={};compid={};target_system={};lat={:.7};lon={:.7};alt={:.3}",
source,
system_id,
component_id,
target_system,
lat_int as f64 / 1e7,
lon_int as f64 / 1e7,
alt_mm as f32 / 1000.0,
),
})
}
86 if payload.len() >= 53 => {
let lat_int = read_i32(payload, 4)?;
let lon_int = read_i32(payload, 8)?;
let alt = read_f32(payload, 12)?;
let type_mask = read_u16(payload, 48)?;
let target_system = *payload.get(50)?;
let target_component = *payload.get(51)?;
let coordinate_frame = *payload.get(52)?;
Some(MavlinkCallbackEvent {
function: "SET_POSITION_TARGET_GLOBAL_INT",
data: format!(
"source={};sysid={};compid={};target_system={};target_component={};coordinate_frame={};type_mask={};lat={:.7};lon={:.7};alt={:.3};vx={:.3};vy={:.3};vz={:.3};yaw={:.3}",
source,
system_id,
component_id,
target_system,
target_component,
coordinate_frame,
type_mask,
lat_int as f64 / 1e7,
lon_int as f64 / 1e7,
alt,
read_f32(payload, 16).unwrap_or(0.0),
read_f32(payload, 20).unwrap_or(0.0),
read_f32(payload, 24).unwrap_or(0.0),
read_f32(payload, 40).unwrap_or(0.0),
),
})
}
11 if payload.len() >= 6 => {
let custom_mode = payload
.get(0..4)
.map(|bytes| u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))?;
let target_system = *payload.get(4)?;
let base_mode = *payload.get(5)?;
Some(MavlinkCallbackEvent {
function: "SET_MODE",
data: format!(
"source={};sysid={};compid={};target_system={};base_mode={};custom_mode={}",
source, system_id, component_id, target_system, base_mode, custom_mode
),
})
}
@@ -237,3 +469,553 @@ pub(crate) fn mavlink_callback_event(bytes: &[u8], source: &str) -> Option<Mavli
_ => None,
}
}
pub(crate) fn mavlink_response_packets(bytes: &[u8]) -> Vec<Vec<u8>> {
if bytes.len() < 8 {
return Vec::new();
}
let (msg_id, system_id, component_id, payload) = match bytes[0] {
0xFE if bytes.len() >= 8 => {
let payload_len = bytes[1] as usize;
(
bytes[5] as u32,
bytes[3],
bytes[4],
bytes.get(6..6 + payload_len).unwrap_or(&[]),
)
}
0xFD if bytes.len() >= 12 => {
let payload_len = bytes[1] as usize;
(
bytes[7] as u32 | ((bytes[8] as u32) << 8) | ((bytes[9] as u32) << 16),
bytes[5],
bytes[6],
bytes.get(10..10 + payload_len).unwrap_or(&[]),
)
}
_ => return Vec::new(),
};
match msg_id {
76 if payload.len() >= 31 => command_long_response_packets(payload),
75 if payload.len() >= 35 => command_int_response_packets(payload),
44 if payload.len() >= 4 => {
mission_count_response_packets(system_id, component_id, payload)
}
39 | 73 if payload.len() >= 37 => mission_item_response_packets(payload),
45 if payload.len() >= 2 => mission_clear_all_response_packets(payload),
243 if payload.len() >= 53 => set_home_position_response_packets(payload),
_ => Vec::new(),
}
}
fn command_long_response_packets(payload: &[u8]) -> Vec<Vec<u8>> {
let command = match read_u16(payload, 28) {
Some(command) => command,
None => return Vec::new(),
};
let target_system = *payload.get(30).unwrap_or(&0);
let target_component = *payload.get(31).unwrap_or(&0);
if target_system == 0 {
return Vec::new();
}
let ack_component = if target_component == 0 {
AUTOPILOT_COMPONENT_ID
} else {
target_component
};
let mut packets = vec![command_ack_packet(target_system, ack_component, command, 0)];
match command {
512 => {
let requested_message = read_f32(payload, 0).unwrap_or(0.0).round() as u32;
packets.extend(requested_message_packets(target_system, requested_message));
}
179 => {
if target_system != 0 {
let use_current = read_f32(payload, 0).unwrap_or(0.0) >= 1.0;
let lat = read_f32(payload, 16).unwrap_or(0.0) as f64;
let lon = read_f32(payload, 20).unwrap_or(0.0) as f64;
let alt = read_f32(payload, 24).unwrap_or(0.0);
if use_current {
if let Some(system) = latest_system(target_system) {
set_home(
target_system,
system.lat_deg,
system.lon_deg,
system.alt_msl_m - system.rel_alt_m,
);
}
} else if lat != 0.0 || lon != 0.0 {
set_home(target_system, lat, lon, alt);
}
}
}
521 => {
if let Some(system) = latest_system(target_system) {
let camera_component =
camera_component_for_target(target_component, system.has_turret_camera);
set_active_camera(target_system, camera_component);
info!(
"MAVLink camera selection command={} target_system={} target_component={} resolved_camera_component={}",
command, target_system, target_component, camera_component
);
packets.push(camera_information_packet_for_component(
target_system,
camera_component,
&camera_name(&system.callsign, camera_component, system.has_turret_camera),
gimbal_device_for_target(target_component, system.has_turret_camera),
));
}
}
2502 | 2503 | 2505 => {
if let Some(system) = latest_system(target_system) {
let camera_component =
camera_component_for_target(target_component, system.has_turret_camera);
set_active_camera(target_system, camera_component);
info!(
"MAVLink camera selection command={} target_system={} target_component={} resolved_camera_component={}",
command, target_system, target_component, camera_component
);
packets.push(video_stream_status_packet_for_component(
target_system,
camera_component,
system.hfov_deg,
1,
false,
));
}
}
2504 => {
if let Some(system) = latest_system(target_system) {
if should_send_video_stream_information(&system.video_uri) {
let camera_component =
camera_component_for_target(target_component, system.has_turret_camera);
set_active_camera(target_system, camera_component);
info!(
"MAVLink camera selection command={} target_system={} target_component={} resolved_camera_component={}",
command, target_system, target_component, camera_component
);
packets.push(video_stream_information_packet_for_component(
target_system,
camera_component,
&camera_name(&system.callsign, camera_component, system.has_turret_camera),
&system.video_uri,
system.hfov_deg,
1,
1,
false,
));
}
}
}
_ => {}
}
packets
}
fn command_int_response_packets(payload: &[u8]) -> Vec<Vec<u8>> {
let command = match read_u16(payload, 28) {
Some(command) => command,
None => return Vec::new(),
};
let target_system = *payload.get(30).unwrap_or(&0);
let target_component = *payload.get(31).unwrap_or(&AUTOPILOT_COMPONENT_ID);
if target_system == 0 {
return Vec::new();
}
vec![command_ack_packet(
target_system,
if target_component == 0 {
AUTOPILOT_COMPONENT_ID
} else {
target_component
},
command,
0,
)]
}
fn mission_count_response_packets(
gcs_system: u8,
gcs_component: u8,
payload: &[u8],
) -> Vec<Vec<u8>> {
let count = match read_u16(payload, 0) {
Some(count) => count,
None => return Vec::new(),
};
let target_system = *payload.get(2).unwrap_or(&0);
let target_component = *payload.get(3).unwrap_or(&AUTOPILOT_COMPONENT_ID);
let mission_type = payload.get(4).copied().unwrap_or(0);
if target_system == 0 {
return Vec::new();
}
let ack_component = if target_component == 0 {
AUTOPILOT_COMPONENT_ID
} else {
target_component
};
let Ok(mut uploads) = mission_uploads().lock() else {
return Vec::new();
};
if count == 0 {
uploads.remove(&target_system);
return vec![mission_ack_packet(
target_system,
ack_component,
gcs_system,
gcs_component,
mission_type,
)];
}
uploads.insert(
target_system,
MissionUploadState {
count,
next_seq: 0,
mission_type,
gcs_system,
gcs_component,
},
);
vec![mission_request_int_packet(
target_system,
ack_component,
gcs_system,
gcs_component,
0,
mission_type,
)]
}
fn mission_item_response_packets(payload: &[u8]) -> Vec<Vec<u8>> {
let seq = match read_u16(payload, 28) {
Some(seq) => seq,
None => return Vec::new(),
};
let target_system = *payload.get(32).unwrap_or(&0);
let target_component = *payload.get(33).unwrap_or(&AUTOPILOT_COMPONENT_ID);
if target_system == 0 {
return Vec::new();
}
let ack_component = if target_component == 0 {
AUTOPILOT_COMPONENT_ID
} else {
target_component
};
let Ok(mut uploads) = mission_uploads().lock() else {
return Vec::new();
};
let Some(state) = uploads.get_mut(&target_system) else {
return vec![mission_ack_packet(
target_system,
ack_component,
255,
190,
payload.get(37).copied().unwrap_or(0),
)];
};
state.next_seq = seq.saturating_add(1);
if state.next_seq < state.count {
vec![mission_request_int_packet(
target_system,
ack_component,
state.gcs_system,
state.gcs_component,
state.next_seq,
state.mission_type,
)]
} else {
let ack = mission_ack_packet(
target_system,
ack_component,
state.gcs_system,
state.gcs_component,
state.mission_type,
);
uploads.remove(&target_system);
vec![ack]
}
}
fn mission_clear_all_response_packets(payload: &[u8]) -> Vec<Vec<u8>> {
let target_system = *payload.get(0).unwrap_or(&0);
let target_component = *payload.get(1).unwrap_or(&AUTOPILOT_COMPONENT_ID);
if target_system == 0 {
return Vec::new();
}
if let Ok(mut uploads) = mission_uploads().lock() {
uploads.remove(&target_system);
}
vec![mission_ack_packet(
target_system,
if target_component == 0 {
AUTOPILOT_COMPONENT_ID
} else {
target_component
},
255,
190,
payload.get(2).copied().unwrap_or(0),
)]
}
fn set_home_position_response_packets(payload: &[u8]) -> Vec<Vec<u8>> {
let target_system = *payload.get(52).unwrap_or(&0);
if target_system == 0 {
return Vec::new();
}
let lat = read_i32(payload, 0).unwrap_or(0) as f64 / 1e7;
let lon = read_i32(payload, 4).unwrap_or(0) as f64 / 1e7;
let alt = read_i32(payload, 8).unwrap_or(0) as f32 / 1000.0;
if lat != 0.0 || lon != 0.0 {
set_home(target_system, lat, lon, alt);
}
Vec::new()
}
fn requested_message_packets(system_id: u8, requested_message: u32) -> Vec<Vec<u8>> {
let Some(system) = latest_system(system_id) else {
return Vec::new();
};
match requested_message {
148 => vec![autopilot_version_packet(
system_id,
&system.mavlink_identity,
)],
242 => vec![home_position_packet(
system_id,
system.home_lat_deg,
system.home_lon_deg,
system.home_alt_msl_m,
system.heading_deg,
)],
259 => {
let mut packets = vec![camera_information_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
&camera_name(
&system.callsign,
CAMERA_COMPONENT_ID,
system.has_turret_camera,
),
0,
)];
if system.has_turret_camera {
packets.push(camera_information_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
&camera_name(&system.callsign, TURRET_CAMERA_COMPONENT_ID, true),
super::constants::GIMBAL_COMPONENT_ID,
));
}
packets
}
269 => {
if should_send_video_stream_information(&system.video_uri) {
let mut packets = vec![video_stream_information_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
&camera_name(
&system.callsign,
CAMERA_COMPONENT_ID,
system.has_turret_camera,
),
&system.video_uri,
system.hfov_deg,
1,
1,
false,
)];
if system.has_turret_camera {
packets.push(video_stream_information_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
&camera_name(&system.callsign, TURRET_CAMERA_COMPONENT_ID, true),
&system.video_uri,
system.hfov_deg,
1,
1,
false,
));
}
packets
} else {
Vec::new()
}
}
270 => {
let mut packets = vec![video_stream_status_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
system.hfov_deg,
1,
false,
)];
if system.has_turret_camera {
packets.push(video_stream_status_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
system.hfov_deg,
1,
false,
));
}
packets
}
265 => {
let mut packets = vec![mount_orientation_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
system.fpv_pitch_deg,
system.fpv_yaw_deg,
)];
if system.has_turret_camera {
packets.push(mount_orientation_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
system.gimbal_pitch_deg,
system.gimbal_yaw_deg,
));
}
packets
}
271 => {
let (fpv_image_lat, fpv_image_lon, fpv_image_alt) = fpv_image_point(
system.lat_deg,
system.lon_deg,
system.alt_msl_m,
system.rel_alt_m,
system.fpv_pitch_deg,
system.fpv_yaw_deg,
);
let mut packets = vec![camera_fov_status_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
system.lat_deg,
system.lon_deg,
system.alt_msl_m,
fpv_image_lat,
fpv_image_lon,
fpv_image_alt,
0.0,
system.fpv_pitch_deg,
system.fpv_yaw_deg,
system.hfov_deg,
system.vfov_deg,
)];
if system.has_turret_camera {
packets.push(camera_fov_status_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
system.lat_deg,
system.lon_deg,
system.alt_msl_m,
system.image_lat_deg,
system.image_lon_deg,
system.image_alt_msl_m,
0.0,
system.gimbal_pitch_deg,
system.gimbal_yaw_deg,
system.hfov_deg,
system.vfov_deg,
));
}
packets
}
158 => {
let active_component = if system.has_turret_camera {
system.active_camera_component
} else {
CAMERA_COMPONENT_ID
};
let (pitch, roll, relative_yaw) = if active_component == TURRET_CAMERA_COMPONENT_ID {
(
system.gimbal_pitch_deg,
0.0,
normalize_signed_deg(system.gimbal_yaw_deg - system.fpv_yaw_deg),
)
} else {
(system.fpv_pitch_deg, 0.0, 0.0)
};
vec![mount_status_packet(system_id, pitch, roll, relative_yaw)]
}
280 => vec![gimbal_manager_information_packet(system_id)],
_ => Vec::new(),
}
}
fn camera_component_for_target(target_component: u8, has_turret_camera: bool) -> u8 {
if has_turret_camera && target_component == TURRET_CAMERA_COMPONENT_ID {
TURRET_CAMERA_COMPONENT_ID
} else {
CAMERA_COMPONENT_ID
}
}
fn gimbal_device_for_target(target_component: u8, has_turret_camera: bool) -> u8 {
if has_turret_camera && target_component == TURRET_CAMERA_COMPONENT_ID {
super::constants::GIMBAL_COMPONENT_ID
} else {
0
}
}
fn camera_name(callsign: &str, component_id: u8, has_turret_camera: bool) -> String {
if has_turret_camera && component_id == TURRET_CAMERA_COMPONENT_ID {
format!("{callsign} Turret")
} else {
format!("{callsign} FPV")
}
}
fn normalize_signed_deg(value: f32) -> f32 {
let normalized = ((value % 360.0) + 360.0) % 360.0;
if normalized > 180.0 {
normalized - 360.0
} else {
normalized
}
}
fn fpv_image_point(
lat_deg: f64,
lon_deg: f64,
alt_msl_m: f32,
rel_alt_m: f32,
pitch_deg: f32,
yaw_deg: f32,
) -> (f64, f64, f32) {
let pitch_rad = pitch_deg.to_radians();
let vertical = (-pitch_rad.sin()).max(0.01);
let slant_m = (rel_alt_m.max(1.0) / vertical).clamp(1.0, 15_000.0);
let ground_m = slant_m * pitch_rad.cos().abs();
let yaw_rad = yaw_deg.to_radians();
let north_m = ground_m * yaw_rad.cos();
let east_m = ground_m * yaw_rad.sin();
let lat_rad = lat_deg.to_radians();
let meters_per_degree_lat = 111_320.0;
let meters_per_degree_lon = (111_320.0 * lat_rad.cos().abs()).max(1.0);
(
lat_deg + north_m as f64 / meters_per_degree_lat,
lon_deg + east_m as f64 / meters_per_degree_lon,
alt_msl_m - rel_alt_m,
)
}
+8 -1
View File
@@ -1,5 +1,6 @@
pub const AUTOPILOT_COMPONENT_ID: u8 = 1;
pub const CAMERA_COMPONENT_ID: u8 = 100;
pub const TURRET_CAMERA_COMPONENT_ID: u8 = 101;
pub const GIMBAL_COMPONENT_ID: u8 = 154;
pub const MAV_TYPE_FIXED_WING: u8 = 1;
@@ -8,7 +9,7 @@ pub const MAV_TYPE_HELICOPTER: u8 = 4;
pub const MAV_TYPE_GIMBAL: u8 = 26;
pub const MAV_TYPE_CAMERA: u8 = 30;
pub const MAV_AUTOPILOT_GENERIC: u8 = 0;
pub const MAV_AUTOPILOT_ARDUPILOTMEGA: u8 = 3;
pub const MAV_AUTOPILOT_INVALID: u8 = 8;
pub const MAV_STATE_STANDBY: u8 = 3;
@@ -27,6 +28,10 @@ pub const MAV_PROTOCOL_CAPABILITY_SET_POSITION_TARGET_GLOBAL_INT: u64 = 256;
pub const MAV_PROTOCOL_CAPABILITY_MAVLINK2: u64 = 8192;
pub const MAV_PROTOCOL_CAPABILITY_COMPONENT_IMPLEMENTS_GIMBAL_MANAGER: u64 = 262_144;
pub const CAMERA_CAP_FLAGS_CAPTURE_VIDEO: u32 = 1;
pub const CAMERA_CAP_FLAGS_CAPTURE_IMAGE: u32 = 2;
pub const CAMERA_CAP_FLAGS_HAS_MODES: u32 = 4;
pub const CAMERA_CAP_FLAGS_HAS_BASIC_ZOOM: u32 = 64;
pub const CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM: u32 = 256;
pub const VIDEO_STREAM_STATUS_FLAGS_RUNNING: u16 = 1;
pub const VIDEO_STREAM_TYPE_RTSP: u8 = 0;
@@ -34,3 +39,5 @@ pub const VIDEO_STREAM_TYPE_RTPUDP: u8 = 1;
pub const VIDEO_STREAM_TYPE_TCP_MPEG: u8 = 2;
pub const VIDEO_STREAM_TYPE_MPEG_TS: u8 = 3;
pub const VIDEO_STREAM_ENCODING_H264: u8 = 1;
pub const GIMBAL_MANAGER_CAP_FLAGS_BASIC_PITCH_YAW: u32 = 32 | 128 | 256 | 1024 | 4096 | 131_072;
+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);
}
}
+22 -8
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use super::callbacks::{mavlink_callback_event, mavlink_packet_summary};
use super::callbacks::{mavlink_callback_event, mavlink_packet_summary, mavlink_response_packets};
pub(crate) struct MavlinkEndpoint {
pub socket: UdpSocket,
@@ -18,10 +18,11 @@ pub(crate) struct MavlinkEndpoint {
static MAVLINK_ENDPOINT: Mutex<Option<MavlinkEndpoint>> = Mutex::new(None);
pub(crate) fn socket_for_send() -> Option<UdpSocket> {
MAVLINK_ENDPOINT
.lock()
.ok()
.and_then(|endpoint| endpoint.as_ref().and_then(|entry| entry.socket.try_clone().ok()))
MAVLINK_ENDPOINT.lock().ok().and_then(|endpoint| {
endpoint
.as_ref()
.and_then(|entry| entry.socket.try_clone().ok())
})
}
fn stop_endpoint_internal() {
@@ -102,14 +103,27 @@ pub fn start_endpoint(ctx: Context, bind_port: i32) -> &'static str {
source,
mavlink_packet_summary(&buffer[..received])
);
if let Some(event) = mavlink_callback_event(&buffer[..received], &source_string) {
let _ = listener_ctx.callback_data("MAVLINK UDP", event.function, event.data);
if let Some(event) = mavlink_callback_event(&buffer[..received], &source_string)
{
let _ =
listener_ctx.callback_data("MAVLINK UDP", event.function, event.data);
}
for packet in mavlink_response_packets(&buffer[..received]) {
if let Err(error) = listener_socket.send_to(&packet, source) {
info!(
"MAVLink UDP endpoint failed sending response to {}: {}",
source, error
);
break;
}
}
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::ConnectionReset
) => {}
Err(error) => {
if listener_running.load(Ordering::Relaxed) {
+114 -3
View File
@@ -1,6 +1,4 @@
use super::constants::{
MAV_TYPE_FIXED_WING, MAV_TYPE_HELICOPTER, MAV_TYPE_QUADROTOR,
};
use super::constants::{MAV_TYPE_FIXED_WING, MAV_TYPE_HELICOPTER, MAV_TYPE_QUADROTOR};
pub(crate) fn map_vehicle_type(vehicle_type: u8) -> u8 {
match vehicle_type {
@@ -25,6 +23,23 @@ pub(crate) fn stable_system_id(entity_uuid: &str) -> u8 {
((hash % 250) as u8) + 1
}
pub(crate) fn stable_mavlink_identity(callsign: &str, entity_uuid: &str) -> String {
let mut identity = callsign.trim().to_string();
for suffix in [" [ON]", " [OFF]"] {
if identity.ends_with(suffix) {
identity.truncate(identity.len() - suffix.len());
break;
}
}
if identity.is_empty() {
entity_uuid.trim().to_string()
} else {
identity
}
}
fn uuid16(entity_uuid: &str) -> [u8; 16] {
let hex = entity_uuid.replace('-', "");
let mut bytes = [0u8; 16];
@@ -78,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(""));
}
}
+1
View File
@@ -6,6 +6,7 @@ mod identity;
mod packets;
mod payload;
mod send;
mod state;
pub use endpoint::{start_endpoint, stop_endpoint};
#[allow(unused_imports)]
+410 -61
View File
@@ -1,16 +1,17 @@
use chrono::Utc;
use super::constants::{
AUTOPILOT_COMPONENT_ID, CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM, CAMERA_COMPONENT_ID,
GIMBAL_COMPONENT_ID, MAV_AUTOPILOT_GENERIC, MAV_AUTOPILOT_INVALID,
MAV_LANDED_STATE_IN_AIR, MAV_LANDED_STATE_ON_GROUND, MAV_LANDED_STATE_UNDEFINED,
MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, MAV_MODE_FLAG_SAFETY_ARMED,
MAV_PROTOCOL_CAPABILITY_COMMAND_INT, MAV_PROTOCOL_CAPABILITY_COMPONENT_IMPLEMENTS_GIMBAL_MANAGER,
MAV_PROTOCOL_CAPABILITY_FTP, MAV_PROTOCOL_CAPABILITY_MAVLINK2,
MAV_PROTOCOL_CAPABILITY_MISSION_INT, MAV_PROTOCOL_CAPABILITY_SET_POSITION_TARGET_GLOBAL_INT,
MAV_STATE_ACTIVE, MAV_STATE_STANDBY, VIDEO_STREAM_ENCODING_H264,
VIDEO_STREAM_STATUS_FLAGS_RUNNING, VIDEO_STREAM_TYPE_MPEG_TS, VIDEO_STREAM_TYPE_RTPUDP,
VIDEO_STREAM_TYPE_RTSP, VIDEO_STREAM_TYPE_TCP_MPEG,
AUTOPILOT_COMPONENT_ID, CAMERA_CAP_FLAGS_CAPTURE_IMAGE, CAMERA_CAP_FLAGS_CAPTURE_VIDEO,
CAMERA_CAP_FLAGS_HAS_BASIC_ZOOM, CAMERA_CAP_FLAGS_HAS_MODES, CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM,
GIMBAL_COMPONENT_ID, GIMBAL_MANAGER_CAP_FLAGS_BASIC_PITCH_YAW, MAV_AUTOPILOT_ARDUPILOTMEGA,
MAV_AUTOPILOT_INVALID, MAV_LANDED_STATE_IN_AIR, MAV_LANDED_STATE_ON_GROUND,
MAV_LANDED_STATE_UNDEFINED, MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, MAV_MODE_FLAG_SAFETY_ARMED,
MAV_PROTOCOL_CAPABILITY_COMMAND_INT,
MAV_PROTOCOL_CAPABILITY_COMPONENT_IMPLEMENTS_GIMBAL_MANAGER, MAV_PROTOCOL_CAPABILITY_FTP,
MAV_PROTOCOL_CAPABILITY_MAVLINK2, MAV_PROTOCOL_CAPABILITY_MISSION_INT,
MAV_PROTOCOL_CAPABILITY_SET_POSITION_TARGET_GLOBAL_INT, MAV_STATE_ACTIVE, MAV_STATE_STANDBY,
VIDEO_STREAM_ENCODING_H264, VIDEO_STREAM_STATUS_FLAGS_RUNNING, VIDEO_STREAM_TYPE_MPEG_TS,
VIDEO_STREAM_TYPE_RTPUDP, VIDEO_STREAM_TYPE_RTSP, VIDEO_STREAM_TYPE_TCP_MPEG,
};
use super::crc::{build_v1_packet, build_v2_packet, calculate_crc_extra, FieldSpec};
use super::identity::{fixed_string, normalize_heading_deg, uid2_from_uuid, uid64_from_uuid};
@@ -18,9 +19,10 @@ use super::payload::UasTelemetryPayload;
pub(crate) fn heartbeat_packet(payload: &UasTelemetryPayload) -> Vec<u8> {
let mut msg = Vec::with_capacity(9);
msg.extend_from_slice(&0u32.to_le_bytes());
let custom_mode = if payload.flying { 5u32 } else { 0u32 };
msg.extend_from_slice(&custom_mode.to_le_bytes());
msg.push(payload.vehicle_type);
msg.push(MAV_AUTOPILOT_GENERIC);
msg.push(MAV_AUTOPILOT_ARDUPILOTMEGA);
msg.push(if payload.flying {
MAV_MODE_FLAG_CUSTOM_MODE_ENABLED | MAV_MODE_FLAG_SAFETY_ARMED
} else {
@@ -35,6 +37,49 @@ pub(crate) fn heartbeat_packet(payload: &UasTelemetryPayload) -> Vec<u8> {
build_v1_packet(payload.system_id, payload.component_id, 0, &msg, 50)
}
pub(crate) fn command_ack_packet(
system_id: u8,
component_id: u8,
command: u16,
result: u8,
) -> Vec<u8> {
let mut msg = Vec::with_capacity(3);
msg.extend_from_slice(&command.to_le_bytes());
msg.push(result);
build_v2_packet(system_id, component_id, 77, &msg, 143)
}
pub(crate) fn mission_request_int_packet(
system_id: u8,
component_id: u8,
target_system: u8,
target_component: u8,
seq: u16,
mission_type: u8,
) -> Vec<u8> {
let mut msg = Vec::with_capacity(5);
msg.extend_from_slice(&seq.to_le_bytes());
msg.push(target_system);
msg.push(target_component);
msg.push(mission_type);
build_v2_packet(system_id, component_id, 51, &msg, 196)
}
pub(crate) fn mission_ack_packet(
system_id: u8,
component_id: u8,
target_system: u8,
target_component: u8,
mission_type: u8,
) -> Vec<u8> {
let mut msg = Vec::with_capacity(4);
msg.push(target_system);
msg.push(target_component);
msg.push(0);
msg.push(mission_type);
build_v2_packet(system_id, component_id, 47, &msg, 153)
}
pub(crate) fn gps_raw_int_packet(payload: &UasTelemetryPayload) -> Vec<u8> {
let time_usec = (Utc::now().timestamp_millis().max(0) as u64) * 1_000;
let fix_type = if payload.flying { 3u8 } else { 2u8 };
@@ -55,8 +100,10 @@ pub(crate) fn gps_raw_int_packet(payload: &UasTelemetryPayload) -> Vec<u8> {
pub(crate) fn global_position_int_packet(payload: &UasTelemetryPayload) -> Vec<u8> {
let time_boot_ms = Utc::now().timestamp_millis().max(0) as u32;
let vx = (payload.groundspeed_mps * payload.heading_deg.to_radians().sin() * 100.0).round() as i16;
let vy = (payload.groundspeed_mps * payload.heading_deg.to_radians().cos() * 100.0).round() as i16;
let vx =
(payload.groundspeed_mps * payload.heading_deg.to_radians().sin() * 100.0).round() as i16;
let vy =
(payload.groundspeed_mps * payload.heading_deg.to_radians().cos() * 100.0).round() as i16;
let heading = (normalize_heading_deg(payload.heading_deg) * 100.0).round() as u16;
let mut msg = Vec::with_capacity(28);
@@ -103,21 +150,73 @@ pub(crate) fn vfr_hud_packet(payload: &UasTelemetryPayload) -> Vec<u8> {
build_v1_packet(payload.system_id, payload.component_id, 74, &msg, 20)
}
pub(crate) fn system_status_packet(system_id: u8) -> Vec<u8> {
pub(crate) fn system_status_packet(system_id: u8, battery_remaining_pct: i8) -> Vec<u8> {
let fields = [
FieldSpec { ty: "uint32_t", name: "onboard_control_sensors_present", array_len: 0 },
FieldSpec { ty: "uint32_t", name: "onboard_control_sensors_enabled", array_len: 0 },
FieldSpec { ty: "uint32_t", name: "onboard_control_sensors_health", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "load", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "voltage_battery", array_len: 0 },
FieldSpec { ty: "int16_t", name: "current_battery", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "drop_rate_comm", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "errors_comm", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "errors_count1", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "errors_count2", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "errors_count3", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "errors_count4", array_len: 0 },
FieldSpec { ty: "int8_t", name: "battery_remaining", array_len: 0 },
FieldSpec {
ty: "uint32_t",
name: "onboard_control_sensors_present",
array_len: 0,
},
FieldSpec {
ty: "uint32_t",
name: "onboard_control_sensors_enabled",
array_len: 0,
},
FieldSpec {
ty: "uint32_t",
name: "onboard_control_sensors_health",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "load",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "voltage_battery",
array_len: 0,
},
FieldSpec {
ty: "int16_t",
name: "current_battery",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "drop_rate_comm",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "errors_comm",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "errors_count1",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "errors_count2",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "errors_count3",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "errors_count4",
array_len: 0,
},
FieldSpec {
ty: "int8_t",
name: "battery_remaining",
array_len: 0,
},
];
let crc_extra = calculate_crc_extra("SYS_STATUS", &fields);
@@ -135,24 +234,32 @@ pub(crate) fn system_status_packet(system_id: u8) -> Vec<u8> {
msg.extend_from_slice(&0u16.to_le_bytes());
msg.extend_from_slice(&0u16.to_le_bytes());
msg.extend_from_slice(&0u16.to_le_bytes());
msg.push(100u8);
msg.push(battery_remaining_pct.clamp(0, 100) as u8);
build_v2_packet(system_id, AUTOPILOT_COMPONENT_ID, 1, &msg, crc_extra)
}
pub(crate) fn extended_sys_state_packet(system_id: u8, flying: bool) -> Vec<u8> {
pub(crate) fn extended_sys_state_packet(system_id: u8, landed: bool) -> Vec<u8> {
let fields = [
FieldSpec { ty: "uint8_t", name: "vtol_state", array_len: 0 },
FieldSpec { ty: "uint8_t", name: "landed_state", array_len: 0 },
FieldSpec {
ty: "uint8_t",
name: "vtol_state",
array_len: 0,
},
FieldSpec {
ty: "uint8_t",
name: "landed_state",
array_len: 0,
},
];
let crc_extra = calculate_crc_extra("EXTENDED_SYS_STATE", &fields);
let mut msg = Vec::with_capacity(2);
msg.push(MAV_LANDED_STATE_UNDEFINED);
msg.push(if flying {
MAV_LANDED_STATE_IN_AIR
} else {
msg.push(if landed {
MAV_LANDED_STATE_ON_GROUND
} else {
MAV_LANDED_STATE_IN_AIR
});
build_v2_packet(system_id, AUTOPILOT_COMPONENT_ID, 245, &msg, crc_extra)
@@ -160,17 +267,61 @@ pub(crate) fn extended_sys_state_packet(system_id: u8, flying: bool) -> Vec<u8>
pub(crate) fn autopilot_version_packet(system_id: u8, entity_uuid: &str) -> Vec<u8> {
let fields = [
FieldSpec { ty: "uint64_t", name: "capabilities", array_len: 0 },
FieldSpec { ty: "uint32_t", name: "flight_sw_version", array_len: 0 },
FieldSpec { ty: "uint32_t", name: "middleware_sw_version", array_len: 0 },
FieldSpec { ty: "uint32_t", name: "os_sw_version", array_len: 0 },
FieldSpec { ty: "uint32_t", name: "board_version", array_len: 0 },
FieldSpec { ty: "uint8_t", name: "flight_custom_version", array_len: 8 },
FieldSpec { ty: "uint8_t", name: "middleware_custom_version", array_len: 8 },
FieldSpec { ty: "uint8_t", name: "os_custom_version", array_len: 8 },
FieldSpec { ty: "uint16_t", name: "vendor_id", array_len: 0 },
FieldSpec { ty: "uint16_t", name: "product_id", array_len: 0 },
FieldSpec { ty: "uint64_t", name: "uid", array_len: 0 },
FieldSpec {
ty: "uint64_t",
name: "capabilities",
array_len: 0,
},
FieldSpec {
ty: "uint32_t",
name: "flight_sw_version",
array_len: 0,
},
FieldSpec {
ty: "uint32_t",
name: "middleware_sw_version",
array_len: 0,
},
FieldSpec {
ty: "uint32_t",
name: "os_sw_version",
array_len: 0,
},
FieldSpec {
ty: "uint32_t",
name: "board_version",
array_len: 0,
},
FieldSpec {
ty: "uint8_t",
name: "flight_custom_version",
array_len: 8,
},
FieldSpec {
ty: "uint8_t",
name: "middleware_custom_version",
array_len: 8,
},
FieldSpec {
ty: "uint8_t",
name: "os_custom_version",
array_len: 8,
},
FieldSpec {
ty: "uint16_t",
name: "vendor_id",
array_len: 0,
},
FieldSpec {
ty: "uint16_t",
name: "product_id",
array_len: 0,
},
FieldSpec {
ty: "uint64_t",
name: "uid",
array_len: 0,
},
];
let crc_extra = calculate_crc_extra("AUTOPILOT_VERSION", &fields);
@@ -200,7 +351,38 @@ pub(crate) fn autopilot_version_packet(system_id: u8, entity_uuid: &str) -> Vec<
build_v2_packet(system_id, AUTOPILOT_COMPONENT_ID, 148, &msg, crc_extra)
}
pub(crate) fn component_heartbeat_packet(system_id: u8, component_id: u8, component_type: u8) -> Vec<u8> {
pub(crate) fn home_position_packet(
system_id: u8,
lat_deg: f64,
lon_deg: f64,
alt_msl_m: f32,
heading_deg: f32,
) -> Vec<u8> {
let yaw = normalize_heading_deg(heading_deg).to_radians();
let q = [(yaw * 0.5).cos(), 0.0f32, 0.0f32, (yaw * 0.5).sin()];
let mut msg = Vec::with_capacity(52);
msg.extend_from_slice(&((lat_deg * 1e7).round() as i32).to_le_bytes());
msg.extend_from_slice(&((lon_deg * 1e7).round() as i32).to_le_bytes());
msg.extend_from_slice(&((alt_msl_m * 1000.0).round() as i32).to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
for value in q {
msg.extend_from_slice(&value.to_le_bytes());
}
msg.extend_from_slice(&0f32.to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
build_v2_packet(system_id, AUTOPILOT_COMPONENT_ID, 242, &msg, 85)
}
pub(crate) fn component_heartbeat_packet(
system_id: u8,
component_id: u8,
component_type: u8,
) -> Vec<u8> {
let mut msg = Vec::with_capacity(9);
msg.extend_from_slice(&0u32.to_le_bytes());
msg.push(component_type);
@@ -211,10 +393,20 @@ pub(crate) fn component_heartbeat_packet(system_id: u8, component_id: u8, compon
build_v2_packet(system_id, component_id, 0, &msg, 50)
}
pub(crate) fn camera_information_packet(system_id: u8, callsign: &str) -> Vec<u8> {
pub(crate) fn camera_information_packet_for_component(
system_id: u8,
component_id: u8,
callsign: &str,
gimbal_device_id: u8,
) -> Vec<u8> {
let vendor = fixed_string::<32>("ArmaTAK");
let model = fixed_string::<32>(callsign);
let cam_definition_uri = fixed_string::<140>("");
let flags = CAMERA_CAP_FLAGS_CAPTURE_VIDEO
| CAMERA_CAP_FLAGS_CAPTURE_IMAGE
| CAMERA_CAP_FLAGS_HAS_MODES
| CAMERA_CAP_FLAGS_HAS_BASIC_ZOOM
| CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM;
let mut msg = Vec::with_capacity(235);
msg.extend_from_slice(&(Utc::now().timestamp_millis().max(0) as u32).to_le_bytes());
@@ -222,7 +414,7 @@ pub(crate) fn camera_information_packet(system_id: u8, callsign: &str) -> Vec<u8
msg.extend_from_slice(&2.8f32.to_le_bytes());
msg.extend_from_slice(&6.4f32.to_le_bytes());
msg.extend_from_slice(&4.8f32.to_le_bytes());
msg.extend_from_slice(&CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM.to_le_bytes());
msg.extend_from_slice(&flags.to_le_bytes());
msg.extend_from_slice(&1280u16.to_le_bytes());
msg.extend_from_slice(&720u16.to_le_bytes());
msg.extend_from_slice(&0u16.to_le_bytes());
@@ -230,17 +422,21 @@ pub(crate) fn camera_information_packet(system_id: u8, callsign: &str) -> Vec<u8
msg.extend_from_slice(&model);
msg.push(0);
msg.extend_from_slice(&cam_definition_uri);
msg.push(GIMBAL_COMPONENT_ID);
msg.push(gimbal_device_id);
msg.push(0);
build_v2_packet(system_id, CAMERA_COMPONENT_ID, 259, &msg, 92)
build_v2_packet(system_id, component_id, 259, &msg, 92)
}
pub(crate) fn video_stream_information_packet(
pub(crate) fn video_stream_information_packet_for_component(
system_id: u8,
component_id: u8,
callsign: &str,
video_uri: &str,
hfov_deg: f32,
stream_id: u8,
stream_count: u8,
thermal: bool,
) -> Vec<u8> {
let stream_type = if video_uri.starts_with("rtsp://") {
VIDEO_STREAM_TYPE_RTSP
@@ -254,42 +450,195 @@ pub(crate) fn video_stream_information_packet(
VIDEO_STREAM_TYPE_RTSP
};
let name = fixed_string::<32>(&format!("{} Video", callsign));
let name = fixed_string::<32>(callsign);
let uri = fixed_string::<160>(video_uri);
let flags = VIDEO_STREAM_STATUS_FLAGS_RUNNING | if thermal { 2 } else { 0 };
let mut msg = Vec::with_capacity(208);
msg.extend_from_slice(&30f32.to_le_bytes());
msg.extend_from_slice(&4_000_000u32.to_le_bytes());
msg.extend_from_slice(&VIDEO_STREAM_STATUS_FLAGS_RUNNING.to_le_bytes());
msg.extend_from_slice(&flags.to_le_bytes());
msg.extend_from_slice(&1280u16.to_le_bytes());
msg.extend_from_slice(&720u16.to_le_bytes());
msg.extend_from_slice(&0u16.to_le_bytes());
msg.extend_from_slice(&(hfov_deg.clamp(1.0, 360.0).round() as u16).to_le_bytes());
msg.push(1);
msg.push(1);
msg.push(stream_id);
msg.push(stream_count);
msg.push(stream_type);
msg.extend_from_slice(&name);
msg.extend_from_slice(&uri);
msg.push(VIDEO_STREAM_ENCODING_H264);
msg.push(0);
build_v2_packet(system_id, CAMERA_COMPONENT_ID, 269, &msg, 109)
build_v2_packet(system_id, component_id, 269, &msg, 109)
}
pub(crate) fn video_stream_status_packet(
pub(crate) fn video_stream_status_packet_for_component(
system_id: u8,
component_id: u8,
hfov_deg: f32,
stream_id: u8,
thermal: bool,
) -> Vec<u8> {
let flags = VIDEO_STREAM_STATUS_FLAGS_RUNNING | if thermal { 2 } else { 0 };
let mut msg = Vec::with_capacity(19);
msg.extend_from_slice(&30f32.to_le_bytes());
msg.extend_from_slice(&4_000_000u32.to_le_bytes());
msg.extend_from_slice(&VIDEO_STREAM_STATUS_FLAGS_RUNNING.to_le_bytes());
msg.extend_from_slice(&flags.to_le_bytes());
msg.extend_from_slice(&1280u16.to_le_bytes());
msg.extend_from_slice(&720u16.to_le_bytes());
msg.extend_from_slice(&0u16.to_le_bytes());
msg.extend_from_slice(&(hfov_deg.clamp(1.0, 360.0).round() as u16).to_le_bytes());
msg.push(1);
msg.push(stream_id);
msg.push(0);
build_v2_packet(system_id, CAMERA_COMPONENT_ID, 270, &msg, 59)
build_v2_packet(system_id, component_id, 270, &msg, 59)
}
pub(crate) fn mount_orientation_packet_for_component(
system_id: u8,
component_id: u8,
pitch_deg: f32,
yaw_deg: f32,
) -> Vec<u8> {
let mut msg = Vec::with_capacity(16);
msg.extend_from_slice(&(Utc::now().timestamp_millis().max(0) as u32).to_le_bytes());
msg.extend_from_slice(&pitch_deg.to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
msg.extend_from_slice(&normalize_heading_deg(yaw_deg).to_le_bytes());
build_v2_packet(system_id, component_id, 265, &msg, 26)
}
pub(crate) fn camera_fov_status_packet_for_component(
system_id: u8,
component_id: u8,
lat_camera_deg: f64,
lon_camera_deg: f64,
alt_camera_msl_m: f32,
lat_image_deg: f64,
lon_image_deg: f64,
alt_image_msl_m: f32,
roll_deg: f32,
pitch_deg: f32,
yaw_deg: f32,
hfov_deg: f32,
vfov_deg: f32,
) -> Vec<u8> {
let q = attitude_quaternion(roll_deg, pitch_deg, yaw_deg);
let mut msg = Vec::with_capacity(53);
msg.extend_from_slice(&(Utc::now().timestamp_millis().max(0) as u32).to_le_bytes());
msg.extend_from_slice(&((lat_camera_deg * 1e7).round() as i32).to_le_bytes());
msg.extend_from_slice(&((lon_camera_deg * 1e7).round() as i32).to_le_bytes());
msg.extend_from_slice(&((alt_camera_msl_m * 1000.0).round() as i32).to_le_bytes());
msg.extend_from_slice(&((lat_image_deg * 1e7).round() as i32).to_le_bytes());
msg.extend_from_slice(&((lon_image_deg * 1e7).round() as i32).to_le_bytes());
msg.extend_from_slice(&((alt_image_msl_m * 1000.0).round() as i32).to_le_bytes());
for value in q {
msg.extend_from_slice(&value.to_le_bytes());
}
msg.extend_from_slice(&hfov_deg.to_le_bytes());
msg.extend_from_slice(&vfov_deg.to_le_bytes());
msg.push(0);
build_v2_packet(system_id, component_id, 271, &msg, 22)
}
pub(crate) fn mount_status_packet(
system_id: u8,
pitch_deg: f32,
roll_deg: f32,
relative_yaw_deg: f32,
) -> Vec<u8> {
let mut msg = Vec::with_capacity(15);
msg.extend_from_slice(&((pitch_deg * 100.0).round() as i32).to_le_bytes());
msg.extend_from_slice(&((roll_deg * 100.0).round() as i32).to_le_bytes());
msg.extend_from_slice(&((relative_yaw_deg * 100.0).round() as i32).to_le_bytes());
msg.push(system_id);
msg.push(GIMBAL_COMPONENT_ID);
msg.push(2);
build_v2_packet(system_id, AUTOPILOT_COMPONENT_ID, 158, &msg, 134)
}
pub(crate) fn gimbal_manager_information_packet(system_id: u8) -> Vec<u8> {
let fields = [
FieldSpec {
ty: "uint32_t",
name: "time_boot_ms",
array_len: 0,
},
FieldSpec {
ty: "uint32_t",
name: "cap_flags",
array_len: 0,
},
FieldSpec {
ty: "float",
name: "roll_min",
array_len: 0,
},
FieldSpec {
ty: "float",
name: "roll_max",
array_len: 0,
},
FieldSpec {
ty: "float",
name: "pitch_min",
array_len: 0,
},
FieldSpec {
ty: "float",
name: "pitch_max",
array_len: 0,
},
FieldSpec {
ty: "float",
name: "yaw_min",
array_len: 0,
},
FieldSpec {
ty: "float",
name: "yaw_max",
array_len: 0,
},
FieldSpec {
ty: "uint8_t",
name: "gimbal_device_id",
array_len: 0,
},
];
let crc_extra = calculate_crc_extra("GIMBAL_MANAGER_INFORMATION", &fields);
let mut msg = Vec::with_capacity(33);
msg.extend_from_slice(&(Utc::now().timestamp_millis().max(0) as u32).to_le_bytes());
msg.extend_from_slice(&GIMBAL_MANAGER_CAP_FLAGS_BASIC_PITCH_YAW.to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
msg.extend_from_slice(&0f32.to_le_bytes());
msg.extend_from_slice(&(-90f32).to_radians().to_le_bytes());
msg.extend_from_slice(&30f32.to_radians().to_le_bytes());
msg.extend_from_slice(&(-180f32).to_radians().to_le_bytes());
msg.extend_from_slice(&180f32.to_radians().to_le_bytes());
msg.push(GIMBAL_COMPONENT_ID);
build_v2_packet(system_id, GIMBAL_COMPONENT_ID, 280, &msg, crc_extra)
}
fn attitude_quaternion(roll_deg: f32, pitch_deg: f32, yaw_deg: f32) -> [f32; 4] {
let (roll, pitch, yaw) = (
roll_deg.to_radians(),
pitch_deg.to_radians(),
normalize_heading_deg(yaw_deg).to_radians(),
);
let (sr, cr) = (roll * 0.5).sin_cos();
let (sp, cp) = (pitch * 0.5).sin_cos();
let (sy, cy) = (yaw * 0.5).sin_cos();
[
cr * cp * cy + sr * sp * sy,
sr * cp * cy - cr * sp * sy,
cr * sp * cy + sr * cp * sy,
cr * cp * sy - sr * sp * cy,
]
}
+78
View File
@@ -33,6 +33,7 @@ pub struct UasSystemPayload {
pub pitch_deg: f32,
pub yaw_deg: f32,
pub flying: bool,
pub landed: bool,
pub gimbal_roll_deg: f32,
pub gimbal_pitch_deg: f32,
pub gimbal_yaw_deg: f32,
@@ -42,6 +43,8 @@ pub struct UasSystemPayload {
pub image_lat_deg: f64,
pub image_lon_deg: f64,
pub image_alt_msl_m: f32,
pub has_turret_camera: bool,
pub battery_remaining_pct: i8,
}
impl FromArma for UasTelemetryPayload {
@@ -114,6 +117,7 @@ impl FromArma for UasSystemPayload {
pitch_deg,
yaw_deg,
flying,
landed,
gimbal_roll_deg,
gimbal_pitch_deg,
gimbal_yaw_deg,
@@ -123,6 +127,8 @@ impl FromArma for UasSystemPayload {
image_lat_deg,
image_lon_deg,
image_alt_msl_m,
has_turret_camera,
battery_remaining_pct,
) = <(
String,
String,
@@ -138,6 +144,7 @@ impl FromArma for UasSystemPayload {
f32,
f32,
i32,
i32,
f32,
f32,
f32,
@@ -147,6 +154,8 @@ impl FromArma for UasSystemPayload {
f64,
f64,
f32,
i32,
i32,
)>::from_arma(data)?;
Ok(Self {
@@ -164,6 +173,7 @@ impl FromArma for UasSystemPayload {
pitch_deg,
yaw_deg,
flying: flying != 0,
landed: landed != 0,
gimbal_roll_deg,
gimbal_pitch_deg,
gimbal_yaw_deg,
@@ -173,6 +183,74 @@ impl FromArma for UasSystemPayload {
image_lat_deg,
image_lon_deg,
image_alt_msl_m,
has_turret_camera: has_turret_camera != 0,
battery_remaining_pct: battery_remaining_pct.clamp(0, 100) as i8,
})
}
}
#[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);
}
}
+262 -23
View File
@@ -2,16 +2,25 @@ use arma_rs::Context;
use log::info;
use std::net::UdpSocket;
use super::constants::{AUTOPILOT_COMPONENT_ID, CAMERA_COMPONENT_ID, GIMBAL_COMPONENT_ID, MAV_TYPE_CAMERA, MAV_TYPE_GIMBAL};
use super::constants::{
AUTOPILOT_COMPONENT_ID, CAMERA_COMPONENT_ID, GIMBAL_COMPONENT_ID, MAV_TYPE_CAMERA,
MAV_TYPE_GIMBAL, TURRET_CAMERA_COMPONENT_ID,
};
use super::endpoint::socket_for_send;
use super::identity::{map_vehicle_type, should_send_video_stream_information, stable_system_id};
use super::identity::{
map_vehicle_type, should_send_video_stream_information, stable_mavlink_identity,
stable_system_id,
};
use super::packets::{
attitude_packet, autopilot_version_packet, camera_information_packet,
component_heartbeat_packet, extended_sys_state_packet, global_position_int_packet,
gps_raw_int_packet, heartbeat_packet, system_status_packet, vfr_hud_packet,
video_stream_information_packet, video_stream_status_packet,
attitude_packet, autopilot_version_packet, camera_fov_status_packet_for_component,
camera_information_packet_for_component, component_heartbeat_packet, extended_sys_state_packet,
gimbal_manager_information_packet, global_position_int_packet, gps_raw_int_packet,
heartbeat_packet, home_position_packet, mount_orientation_packet_for_component,
mount_status_packet, system_status_packet, vfr_hud_packet,
video_stream_information_packet_for_component, video_stream_status_packet_for_component,
};
use super::payload::{UasSystemPayload, UasTelemetryPayload};
use super::state::{latest_system, record_system};
fn sending_socket(ctx: &Context, error_prefix: &str) -> Result<UdpSocket, &'static str> {
if let Some(socket) = socket_for_send() {
@@ -21,7 +30,11 @@ fn sending_socket(ctx: &Context, error_prefix: &str) -> Result<UdpSocket, &'stat
match UdpSocket::bind("0.0.0.0:0") {
Ok(socket) => Ok(socket),
Err(error) => {
let _ = ctx.callback_data("MAVLINK MOCK ERROR", "Failed to bind UDP socket", error.to_string());
let _ = ctx.callback_data(
"MAVLINK MOCK ERROR",
"Failed to bind UDP socket",
error.to_string(),
);
info!("{} failed to bind UDP socket: {}", error_prefix, error);
Err("Failed to bind MAVLink mock socket")
}
@@ -58,24 +71,54 @@ pub fn send_uas_telemetry(ctx: Context, payload: UasTelemetryPayload) -> &'stati
for (index, packet) in packets.iter().enumerate() {
if let Err(error) = socket.send_to(packet, &payload.address) {
let _ = ctx.callback_data("MAVLINK MOCK ERROR", "Failed to send MAVLink packet", error.to_string());
info!("MAVLink mock failed sending packet {} to {}: {}", index, payload.address, error);
let _ = ctx.callback_data(
"MAVLINK MOCK ERROR",
"Failed to send MAVLink packet",
error.to_string(),
);
info!(
"MAVLink mock failed sending packet {} to {}: {}",
index, payload.address, error
);
return "Failed to send MAVLink mock telemetry";
}
}
info!("MAVLink mock sent {} packets to {}", packets.len(), payload.address);
info!(
"MAVLink mock sent {} packets to {}",
packets.len(),
payload.address
);
"Sent MAVLink mock telemetry"
}
pub fn send_uas_system(ctx: Context, payload: UasSystemPayload) -> &'static str {
let system_id = stable_system_id(&payload.entity_uuid);
let mavlink_identity = stable_mavlink_identity(&payload.callsign, &payload.entity_uuid);
let system_id = stable_system_id(&mavlink_identity);
let vehicle_type = map_vehicle_type(payload.vehicle_type);
record_system(system_id, &mavlink_identity, &payload);
let active_camera_component = latest_system(system_id)
.map(|system| system.active_camera_component)
.unwrap_or(CAMERA_COMPONENT_ID);
let (home_lat_deg, home_lon_deg, home_alt_msl_m) = latest_system(system_id)
.map(|system| {
(
system.home_lat_deg,
system.home_lon_deg,
system.home_alt_msl_m,
)
})
.unwrap_or((
payload.lat_deg,
payload.lon_deg,
payload.alt_msl_m - payload.rel_alt_m,
));
info!(
"MAVLink system send requested to {} entity_uuid={} sysid={} callsign={} lat={} lon={} alt_msl={} rel_alt={} heading={} gimbal_pitch={} gimbal_yaw={} video_uri={}",
"MAVLink system send requested to {} entity_uuid={} mavlink_identity={} sysid={} callsign={} lat={} lon={} alt_msl={} rel_alt={} heading={} gimbal_pitch={} gimbal_yaw={} video_uri={}",
payload.address,
payload.entity_uuid,
mavlink_identity,
system_id,
payload.callsign,
payload.lat_deg,
@@ -92,6 +135,46 @@ pub fn send_uas_system(ctx: Context, payload: UasSystemPayload) -> &'static str
Ok(socket) => socket,
Err(message) => return message,
};
let (fpv_image_lat, fpv_image_lon, fpv_image_alt) = fpv_image_point(
payload.lat_deg,
payload.lon_deg,
payload.alt_msl_m,
payload.rel_alt_m,
payload.pitch_deg,
payload.yaw_deg,
);
let active_is_turret =
payload.has_turret_camera && active_camera_component == TURRET_CAMERA_COMPONENT_ID;
info!(
"MAVLink active camera sysid={} active_component={} has_turret={} active_is_turret={}",
system_id, active_camera_component, payload.has_turret_camera, active_is_turret
);
let (
primary_pitch,
primary_roll,
primary_yaw,
primary_image_lat,
primary_image_lon,
primary_image_alt,
) = if active_is_turret {
(
payload.gimbal_pitch_deg,
payload.gimbal_roll_deg,
payload.gimbal_yaw_deg,
payload.image_lat_deg,
payload.image_lon_deg,
payload.image_alt_msl_m,
)
} else {
(
payload.pitch_deg,
payload.roll_deg,
payload.yaw_deg,
fpv_image_lat,
fpv_image_lon,
fpv_image_alt,
)
};
let autopilot_payload = UasTelemetryPayload {
address: payload.address.clone(),
@@ -116,27 +199,141 @@ pub fn send_uas_system(ctx: Context, payload: UasSystemPayload) -> &'static str
global_position_int_packet(&autopilot_payload),
attitude_packet(&autopilot_payload),
vfr_hud_packet(&autopilot_payload),
system_status_packet(system_id),
extended_sys_state_packet(system_id, payload.flying),
autopilot_version_packet(system_id, &payload.entity_uuid),
system_status_packet(system_id, payload.battery_remaining_pct),
extended_sys_state_packet(system_id, payload.landed),
autopilot_version_packet(system_id, &mavlink_identity),
home_position_packet(
system_id,
home_lat_deg,
home_lon_deg,
home_alt_msl_m,
payload.heading_deg,
),
component_heartbeat_packet(system_id, CAMERA_COMPONENT_ID, MAV_TYPE_CAMERA),
component_heartbeat_packet(system_id, GIMBAL_COMPONENT_ID, MAV_TYPE_GIMBAL),
camera_information_packet(system_id, &payload.callsign),
camera_information_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
&format!("{} FPV", payload.callsign),
0,
),
mount_orientation_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
primary_pitch,
primary_yaw,
),
camera_fov_status_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
payload.lat_deg,
payload.lon_deg,
payload.alt_msl_m,
primary_image_lat,
primary_image_lon,
primary_image_alt,
primary_roll,
primary_pitch,
primary_yaw,
payload.hfov_deg,
payload.vfov_deg,
),
gimbal_manager_information_packet(system_id),
];
if payload.has_turret_camera {
packets.push(component_heartbeat_packet(
system_id,
TURRET_CAMERA_COMPONENT_ID,
MAV_TYPE_CAMERA,
));
packets.push(camera_information_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
&format!("{} Turret", payload.callsign),
GIMBAL_COMPONENT_ID,
));
packets.push(mount_orientation_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
payload.gimbal_pitch_deg,
payload.gimbal_yaw_deg,
));
packets.push(camera_fov_status_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
payload.lat_deg,
payload.lon_deg,
payload.alt_msl_m,
payload.image_lat_deg,
payload.image_lon_deg,
payload.image_alt_msl_m,
payload.gimbal_roll_deg,
payload.gimbal_pitch_deg,
payload.gimbal_yaw_deg,
payload.hfov_deg,
payload.vfov_deg,
));
}
let (active_pitch, active_roll, active_relative_yaw) = if active_is_turret {
(
payload.gimbal_pitch_deg,
payload.gimbal_roll_deg,
normalize_signed_deg(payload.gimbal_yaw_deg - payload.yaw_deg),
)
} else {
(payload.pitch_deg, payload.roll_deg, 0.0)
};
packets.push(mount_status_packet(
system_id,
active_pitch,
active_roll,
active_relative_yaw,
));
if should_send_video_stream_information(&payload.video_uri) {
info!(
"Sending VIDEO_STREAM_INFORMATION for sysid={} uri={}",
system_id,
payload.video_uri
system_id, payload.video_uri
);
packets.push(video_stream_information_packet(
packets.push(video_stream_information_packet_for_component(
system_id,
&payload.callsign,
CAMERA_COMPONENT_ID,
&format!("{} FPV", payload.callsign),
&payload.video_uri,
payload.hfov_deg,
1,
1,
false,
));
packets.push(video_stream_status_packet(system_id, payload.hfov_deg));
packets.push(video_stream_status_packet_for_component(
system_id,
CAMERA_COMPONENT_ID,
payload.hfov_deg,
1,
false,
));
if payload.has_turret_camera {
packets.push(video_stream_information_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
&format!("{} Turret", payload.callsign),
&payload.video_uri,
payload.hfov_deg,
1,
1,
false,
));
packets.push(video_stream_status_packet_for_component(
system_id,
TURRET_CAMERA_COMPONENT_ID,
payload.hfov_deg,
1,
false,
));
}
} else if !payload.video_uri.trim().is_empty() {
info!(
"Skipping VIDEO_STREAM_INFORMATION for sysid={} because URI is not a supported stream URI: {}",
@@ -146,8 +343,15 @@ pub fn send_uas_system(ctx: Context, payload: UasSystemPayload) -> &'static str
for (index, packet) in packets.iter().enumerate() {
if let Err(error) = socket.send_to(packet, &payload.address) {
let _ = ctx.callback_data("MAVLINK MOCK ERROR", "Failed to send MAVLink packet", error.to_string());
info!("MAVLink system failed sending packet {} to {}: {}", index, payload.address, error);
let _ = ctx.callback_data(
"MAVLINK MOCK ERROR",
"Failed to send MAVLink packet",
error.to_string(),
);
info!(
"MAVLink system failed sending packet {} to {}: {}",
index, payload.address, error
);
return "Failed to send MAVLink system telemetry";
}
}
@@ -162,3 +366,38 @@ pub fn send_uas_system(ctx: Context, payload: UasSystemPayload) -> &'static str
);
"Sent MAVLink system telemetry"
}
fn normalize_signed_deg(value: f32) -> f32 {
let normalized = ((value % 360.0) + 360.0) % 360.0;
if normalized > 180.0 {
normalized - 360.0
} else {
normalized
}
}
fn fpv_image_point(
lat_deg: f64,
lon_deg: f64,
alt_msl_m: f32,
rel_alt_m: f32,
pitch_deg: f32,
yaw_deg: f32,
) -> (f64, f64, f32) {
let pitch_rad = pitch_deg.to_radians();
let vertical = (-pitch_rad.sin()).max(0.01);
let slant_m = (rel_alt_m.max(1.0) / vertical).clamp(1.0, 15_000.0);
let ground_m = slant_m * pitch_rad.cos().abs();
let yaw_rad = yaw_deg.to_radians();
let north_m = ground_m * yaw_rad.cos();
let east_m = ground_m * yaw_rad.sin();
let lat_rad = lat_deg.to_radians();
let meters_per_degree_lat = 111_320.0;
let meters_per_degree_lon = (111_320.0 * lat_rad.cos().abs()).max(1.0);
(
lat_deg + north_m as f64 / meters_per_degree_lat,
lon_deg + east_m as f64 / meters_per_degree_lon,
alt_msl_m - rel_alt_m,
)
}
+112
View File
@@ -0,0 +1,112 @@
use std::collections::HashMap;
use std::sync::Mutex;
use lazy_static::lazy_static;
use super::payload::UasSystemPayload;
#[derive(Clone)]
pub(crate) struct LatestUasSystem {
pub mavlink_identity: String,
pub callsign: String,
pub lat_deg: f64,
pub lon_deg: f64,
pub alt_msl_m: f32,
pub rel_alt_m: f32,
pub heading_deg: f32,
pub fpv_pitch_deg: f32,
pub fpv_yaw_deg: f32,
pub gimbal_pitch_deg: f32,
pub gimbal_yaw_deg: f32,
pub video_uri: String,
pub hfov_deg: f32,
pub vfov_deg: f32,
pub image_lat_deg: f64,
pub image_lon_deg: f64,
pub image_alt_msl_m: f32,
pub has_turret_camera: bool,
pub active_camera_component: u8,
pub home_lat_deg: f64,
pub home_lon_deg: f64,
pub home_alt_msl_m: f32,
}
lazy_static! {
static ref LATEST_UAS_SYSTEMS: Mutex<HashMap<u8, LatestUasSystem>> = Mutex::new(HashMap::new());
}
pub(crate) fn record_system(system_id: u8, mavlink_identity: &str, payload: &UasSystemPayload) {
if let Ok(mut systems) = LATEST_UAS_SYSTEMS.lock() {
let active_camera_component = systems
.get(&system_id)
.map(|system| system.active_camera_component)
.unwrap_or(super::constants::CAMERA_COMPONENT_ID);
let home = systems
.get(&system_id)
.map(|system| {
(
system.home_lat_deg,
system.home_lon_deg,
system.home_alt_msl_m,
)
})
.unwrap_or((
payload.lat_deg,
payload.lon_deg,
payload.alt_msl_m - payload.rel_alt_m,
));
systems.insert(
system_id,
LatestUasSystem {
mavlink_identity: mavlink_identity.to_string(),
callsign: payload.callsign.clone(),
lat_deg: payload.lat_deg,
lon_deg: payload.lon_deg,
alt_msl_m: payload.alt_msl_m,
rel_alt_m: payload.rel_alt_m,
heading_deg: payload.heading_deg,
fpv_pitch_deg: payload.pitch_deg,
fpv_yaw_deg: payload.yaw_deg,
gimbal_pitch_deg: payload.gimbal_pitch_deg,
gimbal_yaw_deg: payload.gimbal_yaw_deg,
video_uri: payload.video_uri.clone(),
hfov_deg: payload.hfov_deg,
vfov_deg: payload.vfov_deg,
image_lat_deg: payload.image_lat_deg,
image_lon_deg: payload.image_lon_deg,
image_alt_msl_m: payload.image_alt_msl_m,
has_turret_camera: payload.has_turret_camera,
active_camera_component,
home_lat_deg: home.0,
home_lon_deg: home.1,
home_alt_msl_m: home.2,
},
);
}
}
pub(crate) fn set_home(system_id: u8, lat_deg: f64, lon_deg: f64, alt_msl_m: f32) {
if let Ok(mut systems) = LATEST_UAS_SYSTEMS.lock() {
if let Some(system) = systems.get_mut(&system_id) {
system.home_lat_deg = lat_deg;
system.home_lon_deg = lon_deg;
system.home_alt_msl_m = alt_msl_m;
}
}
}
pub(crate) fn latest_system(system_id: u8) -> Option<LatestUasSystem> {
LATEST_UAS_SYSTEMS
.lock()
.ok()
.and_then(|systems| systems.get(&system_id).cloned())
}
pub(crate) fn set_active_camera(system_id: u8, component_id: u8) {
if let Ok(mut systems) = LATEST_UAS_SYSTEMS.lock() {
if let Some(system) = systems.get_mut(&system_id) {
system.active_camera_component = component_id;
}
}
}
+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");