diff --git a/BUK-M2/Nasal/GeoBridgedTransmitter.nas b/BUK-M2/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/BUK-M2/Nasal/GeoBridgedTransmitter.nas +++ b/BUK-M2/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/BUK-M2/Nasal/radar-logic.nas b/BUK-M2/Nasal/radar-logic.nas index 6b92c8b..2882a9e 100644 --- a/BUK-M2/Nasal/radar-logic.nas +++ b/BUK-M2/Nasal/radar-logic.nas @@ -160,7 +160,9 @@ var Contact = { if(e == nil or e == 0) { # AI/MP has no radar properties var self = geo.aircraft_position(); - self.set_alt(self.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + self.set_alt(self.alt()+elev); me.get_Coord(); var angleInv = clamp(self.distance_to(me.coord)/self.direct_distance_to(me.coord), -1, 1); e = (self.alt()>me.coord.alt()?-1:1)*math.acos(angleInv)*R2D; @@ -391,7 +393,9 @@ var isNotBehindTerrain = func( mp ) { var myPos = geo.aircraft_position(); - myPos.set_alt(myPos.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + myPos.set_alt(myPos.alt()+elev); var target_distance = myPos.direct_distance_to(aircraftPos); diff --git a/Frigate/Nasal/GeoBridgedTransmitter.nas b/Frigate/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/Frigate/Nasal/GeoBridgedTransmitter.nas +++ b/Frigate/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/MIM-104D/Nasal/GeoBridgedTransmitter.nas b/MIM-104D/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/MIM-104D/Nasal/GeoBridgedTransmitter.nas +++ b/MIM-104D/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/MIM-104D/Nasal/radar-logic.nas b/MIM-104D/Nasal/radar-logic.nas index 6b92c8b..2882a9e 100644 --- a/MIM-104D/Nasal/radar-logic.nas +++ b/MIM-104D/Nasal/radar-logic.nas @@ -160,7 +160,9 @@ var Contact = { if(e == nil or e == 0) { # AI/MP has no radar properties var self = geo.aircraft_position(); - self.set_alt(self.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + self.set_alt(self.alt()+elev); me.get_Coord(); var angleInv = clamp(self.distance_to(me.coord)/self.direct_distance_to(me.coord), -1, 1); e = (self.alt()>me.coord.alt()?-1:1)*math.acos(angleInv)*R2D; @@ -391,7 +393,9 @@ var isNotBehindTerrain = func( mp ) { var myPos = geo.aircraft_position(); - myPos.set_alt(myPos.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + myPos.set_alt(myPos.alt()+elev); var target_distance = myPos.direct_distance_to(aircraftPos); diff --git a/S-200/Nasal/ArmamentNotification.nas b/S-200/Nasal/ArmamentNotification.nas index e0d2bb5..5bcb62a 100644 --- a/S-200/Nasal/ArmamentNotification.nas +++ b/S-200/Nasal/ArmamentNotification.nas @@ -87,6 +87,48 @@ var ArmamentInFlightNotification = new_class.Flags = 0; # 8 bits for whatever. new_class.UniqueIdentity = _unique; + new_class.isValid = func { + var valid = ""; + var limitMin = -124; + var limitMax = 123; + # 1 byte integer + if (int(me.Kind) > limitMax or int(me.Kind) < limitMin) { + valid ~= "|ArmamentInFlight.Kind is "~me.Kind; + } + # 1 byte integer + if (int(me.SecondaryKind) > limitMax or int(me.SecondaryKind) < limitMin) { + valid ~= "|ArmamentInFlight.SecondaryKind is "~me.SecondaryKind; + } + # 1 byte float + if (int((me.u_fps-3348)*0.03703) > limitMax or int((me.u_fps-3348)*0.03703) < limitMin) { + valid ~= "|ArmamentInFlight.u_fps is "~me.u_fps; + } + # 1 byte float + if (int(me.Pitch*1.38) > limitMax or int(me.Pitch*1.38) < limitMin) { + valid ~= "|ArmamentInFlight.Pitch is "~me.Pitch; + } + # 1 byte float + if (int(geo.normdeg180(me.Heading)/1.54) > limitMax or int(geo.normdeg180(me.Heading)/1.54) < limitMin) { + valid ~= "|ArmamentInFlight.Heading is "~me.Heading; + } + # 1 byte integer + if (int(me.Flags) > limitMax or int(me.Flags) < limitMin) { + valid ~= "|ArmamentInFlight.Flags is "~me.Flags; + } + # 1 byte integer + if (int(me.UniqueIdentity) > limitMax or int(me.UniqueIdentity) < limitMin) { + valid ~= "|ArmamentInFlight.UniqueIdentity is "~me.UniqueIdentity; + } + # 7 byte string + if (size(me.RemoteCallsign) > 7) { + valid ~= "|ArmamentInFlight.RemoteCallsign is "~me.RemoteCallsign; + } + if (valid != "") { + print("\nEMESARY ENCODING WILL FAIL:"); + print(" "~valid); + } + }; + new_class.GetBridgeMessageNotificationTypeKey = func { return new_class.NotificationType~"."~new_class.Ident~"."~new_class.UniqueIndex; }; @@ -257,4 +299,4 @@ var ObjectInFlightNotification = }; return new_class; }, -}; \ No newline at end of file +}; diff --git a/S-200/Nasal/GeoBridgedTransmitter.nas b/S-200/Nasal/GeoBridgedTransmitter.nas index 903884a..6dfa84f 100644 --- a/S-200/Nasal/GeoBridgedTransmitter.nas +++ b/S-200/Nasal/GeoBridgedTransmitter.nas @@ -27,18 +27,18 @@ #i.e. change to [notifications.ArmamentInFlightNotification.new(nil), notifications.ArmamentNotification.new(nil)]; var geoRoutedNotifications = [notifications.ArmamentInFlightNotification.new()]; var geoBridgedTransmitter = emesary.Transmitter.new("geoOutgoingBridge"); -var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("sa5mp.geo",geoRoutedNotifications, 18, "", geoBridgedTransmitter); +var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoutedNotifications, 18, "", geoBridgedTransmitter); # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); #----- bridge hit (armament) notifications var hitRoutedNotifications = [notifications.ArmamentNotification.new(),notifications.StaticNotification.new()]; var hitBridgedTransmitter = emesary.Transmitter.new("armamentNotificationBridge"); -var hitoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("sa5mp.hit",hitRoutedNotifications, 19, "", hitBridgedTransmitter); +var hitoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.hit",hitRoutedNotifications, 19, "", hitBridgedTransmitter); hitoutgoingBridge.TransmitFrequencySeconds = 1.5; hitoutgoingBridge.MPStringMaxLen = 120; emesary_mp_bridge.IncomingMPBridge.startMPBridge(hitRoutedNotifications, 19, emesary.GlobalTransmitter); @@ -46,8 +46,38 @@ emesary_mp_bridge.IncomingMPBridge.startMPBridge(hitRoutedNotifications, 19, eme #----- bridge object notifications var objectRoutedNotifications = [notifications.ObjectInFlightNotification.new()]; var objectBridgedTransmitter = emesary.Transmitter.new("objectNotificationBridge"); -var objectoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("sa5mp.object",objectRoutedNotifications, 17, "", objectBridgedTransmitter); +var objectoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.object",objectRoutedNotifications, 17, "", objectBridgedTransmitter); objectoutgoingBridge.TransmitFrequencySeconds = 0.2; objectoutgoingBridge.MessageLifeTime = 1; objectoutgoingBridge.MPStringMaxLen = 150; emesary_mp_bridge.IncomingMPBridge.startMPBridge(objectRoutedNotifications, 17, emesary.GlobalTransmitter); + +# +# debug all messages - this can be removed when testing isn't required. +var debugRecipient = emesary.Recipient.new("Debug"); +debugRecipient.Receive = func(notification) +{ + if (notification.NotificationType != "FrameNotification") { + print ("recv(0): type=",notification.NotificationType, " fromIncoming=",notification.FromIncomingBridge); + + if (notification.NotificationType == "ArmamentInFlightNotification") { + print("recv(1): ",notification.NotificationType, " ", notification.Ident); + debug.dump(notification); + + } else if (notification.NotificationType == "ArmamentNotification") { + if (notification.FromIncomingBridge) { + print("recv(2): ",notification.NotificationType, " ", notification.Ident, + " Kind=",notification.Kind, + " SecondaryKind=",notification.SecondaryKind, + " RelativeAltitude=",notification.RelativeAltitude, + " Distance=",notification.Distance, + " Bearing=",notification.Bearing, + " RemoteCallsign=",notification.RemoteCallsign); + debug.dump(notification); + } + } + } + return emesary.Transmitter.ReceiptStatus_NotProcessed; # we're not processing it, just looking +} +# uncomment next line to activate debug recipient. +#emesary.GlobalTransmitter.Register(debugRecipient); \ No newline at end of file diff --git a/S-200/Nasal/datalink.nas b/S-200/Nasal/datalink.nas new file mode 100644 index 0000000..0f01161 --- /dev/null +++ b/S-200/Nasal/datalink.nas @@ -0,0 +1,681 @@ +#### Datalink + +# Copyright 2020-2021 Colin Geniet. +# Licensed under the GNU General Public License 2.0 or any later version. + + +#### Usage + +### Generalities +# +# The datalink protocol consists of a core protocol which implements a notion +# of datalink channel, and extensions which allow transmitting actual data. + +### Core protocol usage: +# +# Define the following properties (must be defined at nasal loading time). +# * Mandatory +# /instrumentation/datalink/power_prop path to property indicating if datalink is on +# /instrumentation/datalink/channel_prop path to property containing datalink channel +# (the channel property can contain anything, and is transmitted/compared as a string). +# * Optional +# /instrumentation/datalink/receive_period = 1 receiving loop update rate +# +# Optional: Re-define the function +# datalink.can_transmit(callsign, mp_prop, mp_index) +# +# This function should return 'true' when the given aircraft is able to transmit over datalink to us. +# For instance, it can be used to check line of sight and maximum range. +# The default implementation always returns true (always able to transmit). +# Arguments are callsign, property node /ai/models/multiplayer[i], index of the former node. +# +# +# API: +# - get_data(callsign) +# Returns all datalink information about 'callsign' as an object, or nil if there is none. +# This object must not be modified. +# It contains the following methods: +# callsign(): The aircraft callsign (same as the argument of get_data()). +# index(): The aircraft index in /ai/models/multiplayer[i]. +# on_link(): Returns a bool indicating whether 'callsign' is connected to this aircraft through datalink. +# +# Extensions can define other methods in this object. +# +# - get_connected_callsigns() / get_connected_indices() +# Returns a vector containing all callsigns, resp. indices +# in /ai/models/multiplayer[i], of aircrafts connected on datalink. +# Both vectors use the same order, i.e. get_connected_callsigns()[i] +# and get_connected_indices()[i] correspond to the same aircraft. +# Furthermore this order is stable (the relative order of two aircrafts +# does not change as long as neither disconnects from multiplayer). +# +# - get_all_callsigns() +# Returns a vector containing all callsigns of aircraft with any associated data. +# There is no guarantee on the order of callsigns. +# +# - send_data(data, timeout=nil) +# Send data on the datalink. 'data' is a hash of the form +# { +# : , +# ... +# } +# If 'timeout' is set, clear_data() will be called after this delay. +# Data sent with send_data() is deleted at the next call of send_data(), or by clear_data(). +# +# - clear_data() +# Clear data transmitted by this aircraft. +# +# Important note: +# After a send_data(), and until the next send_data() or clear_data(), +# the datalink behaves as if you are continuously sending the same data. +# Thus, it is important to +# 1. either call send_data() regularly +# 2. or set the timeout argument of send_data() + +### Extensions + +### Aircraft contacts (extension name: "contacts") +# +# This extension allows to simulate an aircraft transmitting information about +# another aircraft (typically one tracked on radar). The position data is not +# actually transmitted (since everyone can access it from simulator internals). +# +## Receiving data +# This extension adds the following methods to the result of get_data("A"): +# tracked(): A bool indicating that some aircraft "B" connected on datalink +# is transmitting information about aircraft "A". +# iff(): One of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY, or nil if tracked() is false. +# Indicates the result of IFF interrogation of "A" by "B" +# IFF_UNKNOWN means that e.g. no IFF interrogation was performed. +# tracked_by(): The callsign of the transmitting aircraft ("A"), or nil if tracked() is false. +# tracked_by_index(): The index of the transmitting aircraft, or nil if tracked() is false. +# The index refers to property nodes /ai/models/multiplayer[i]. +# is_known(): Equivalent to (on_link() or tracked()). +# Indicates if the position of this aircraft is supposed to be known +# (i.e. whether or not it should be displayed on a HSD or whatever). +# is_friendly(): Equivalent to (on_link() or iff() == IFF_FRIENDLY). +# is_hostile(): Equivalent to (!on_link() and iff() == IFF_HOSTILE). +# +## Sending data +# usage: send_data({ contacts: , ...}, ...) +# where is a vector of hashes of the form { callsign: , [iff: ,] }. +# is the multiplayer callsign of the tracked aircraft. +# (optional) is one of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY + +### Datalink identifier (extension name: "identifier") +# +# This extension allows each aircraft on datalink to transmit a personal +# identifier, e.g. the number of the aircraft in a flight. +# +## Receiving data +# This extension adds the method identifier() to the result of get_data(), +# which returns the identifier, or nil if there is none). +# +## Sending data +# Set the identifier with send_data({"identifier": , ...}); +# The identifier must be a string. It must not contain '!'. + +### Coordinate transmission (extension name: "point") +# +# This extension allows each aircraft to broadcast a coordinate (geo.Coord object). +# +## Receiving data +# This extension adds the method point() to the result of get_data(), +# which results the transmitted geo.Coord object, or nil if there is none. +# +## Sending data +# Transmit a geo.Coord object with send_data({"point": , ...}); + + +#### Protocol: +# +# Data is transmitted on MP generic string[7], with the following format: +# (!)+ +# +# is a hash of the datalink channel. See hash_channel() and check_channel_hash(). +# Each block corresponds to data sent by an extension. +# It starts with a prefix uniquely defining the extension. +# The rest of the block can contain any character (including non-ascii) except '!'. +# +# Remark: '!' as separator is specifically chosen to allow encoding with emesary.Transfer. +# +# The current extension prefixes are the following: +# contacts: C +# identifier: I +# point: P + +#### Extensions API +# +# Creating a new extension is done with +# register_extension(name, prefix, object, encode, decode) +# name the extension name, used as key in the 'data' argument of send_data(). +# prefix the protocol prefix. +# class contact class parent. +# A class from which all contact objects will inherit. +# It must have an init() method, which is called whenever a contact is created. +# +# encode(data) extension encoding function. +# Must return the encoding of the extension data (i.e. when calling +# send_data({name: })) into a string, which may use any character except '!'. +# The extension prefix must not be part of the encoded string. +# +# decode(aircrafts_data, callsign, index, string) extension decoding function. +# 'aircrafts_data' is a hash from callsigns to contact objects (see below). +# 'callsign' is the callsign of the aircraft which transmitted this data. +# 'index' is the index of the aircraft which transmitted this data. +# 'string' is the data encoded by encode() and transmitted through datalink. + +# Each contact in 'data' inherits from the core 'Contact' class, and the extension 'class'. +# decode() is expected to modify 'aircrafts_data', by possibly editing +# existing contacts and adding new ones. It should be careful when +# overwriting existing data in these contacts, including its own: decode() +# will be called several time on the same 'aircrafts_data' (once for each +# transmitting aircraft). +# The modified 'aircrafts_data' hash must be returned. +# +# decode() may use the following helper functions: +# add_if_missing(aircrafts_data, callsign): +# Create a new contact object for 'callsign' and add it to 'aircrafts_data', +# unless an entry for 'callsign' already exists. Returns the modified hash. + + + +#### Version and changelog +# current: v1.1.0, minimum compatible: v1.0.0 +# +## v1.1.0: +# Allow external transmission restrictions +# Make transmitting contact IFF optional +# Ensure personal identifier has no '!' +# '\n' is redundant for printf() +# Fix separator character in documentation +# Fix error when sending unknown extension +# +## v1.0.1: +# Add is_known(), is_friendly(), is_hostile() helpers to extension "contacts". +# +## v1.0.0: Initial version +# - Core protocol for datalink channel. +# - Extensions "contacts", "identifier", and "point". + + + +### Parameters +# +# Remark: most parameters need to be the same on all aircrafts. + +# Index of multiplayer string used to transmit datalink info. +# Must be the same for all aircrafts. +var mp_string = 7; +var mp_path = "sim/multiplay/generic/string["~mp_string~"]"; + +var channel_hash_period = 600; + +var receive_period = getprop("/instrumentation/datalink/receive_period") or 1; + +# Should be overwitten to add transmission restrictions. +var can_transmit = func(contact, mp_prop, mp_index) { + return 1; +} + +### Properties + +var input = { + power: getprop("/instrumentation/datalink/power_prop"), + channel: getprop("/instrumentation/datalink/channel_prop"), + ident: getprop("/instrumentation/datalink/identifier_prop"), + mp: mp_path, + models: "/ai/models", + callsign: "/sim/multiplay/callsign", +}; + +foreach (var name; keys(input)) { + if (input[name] != nil) { + input[name] = props.globals.getNode(input[name], 1); + } +} + + + +#### Core protocol implementation + +### Channel hash (based on iff.nas) +# +# Channel is hashed with current time (rounded to 10min) and own callsign. + +var clean_callsign = func(callsign) { + return damage.processCallsign(callsign); +} + +var my_callsign = func { + return clean_callsign(input.callsign.getValue()); +} + +# Time, rounded to 'channel_hash_period'. This is used to hash channel. +var get_time = func { + return int(math.floor(systime() / channel_hash_period) * channel_hash_period); +} + +# Previous / next time (with channel_hash_period interval). +# This is used to give a bit of margin on the time check. +# (will work if system clocks are coordinated within 10min). +var get_prev_time = func { return get_time() - channel_hash_period; } +var get_next_time = func { return get_time() + channel_hash_period; } + +var parse_hexadecimal = func(str) { + var res = 0; + for (var i=0; i= 48 and c < 58) { + # digit + res += c - 48; + } elsif (c >= 65 and c < 71) { + # upper case letter + res += c - 55; + } elsif (c >= 97 and c < 103) { + # lower case letter + res += c - 87; + } + } + return res; +} + +var _hash_channel = func(time, callsign, channel) { + # 5 hex digits (2^20) fit in 3 chars for emesary int encoding. + var hash = parse_hexadecimal(left(md5(time ~ callsign ~ channel), 5)); + return emesary.TransferInt.encode(hash, 3); +} + +# Hash channel (when sending). +var encode_channel = func(channel) { + return _hash_channel(get_time(), my_callsign(), channel); +} + +# Check that the hash transmitted by aircraft 'callsign' is correct for 'channel'. +var check_channel = func(hash, callsign, channel) { + return hash == _hash_channel(get_time(), callsign, channel) + or hash == _hash_channel(get_prev_time(), callsign, channel) + or hash == _hash_channel(get_next_time(), callsign, channel); +} + +### Contact object +var Contact = { + new: func(callsign) { + var c = { + # contact_parents is the list of all classes from which contacts inherit (for extensions). + parents: contact_parents, + _callsign: callsign, + }; + # Initialize all inherited classes. + foreach (var class; contact_parents) { + call(class.init, [], c, nil, nil); + } + return c; + }, + init: func { me._on_link = 0; }, + + callsign: func { return me._callsign; }, + index: func { return callsign_to_index[me._callsign]; }, + on_link: func { return me._on_link; }, + set_on_link: func(b) { me._on_link = b; }, +}; + +### Extensions +var extensions = {}; +var extension_prefixes = {}; +var max_prefix_length = 0; +var contact_parents = [Contact]; + +var register_extension = func(name, prefix, class, encode, decode) { + if (contains(extensions, name)) { + printf("Datalink: double registration of extension '%s'. Skipping.", name); + return -1; + } + if (contains(extension_prefixes, prefix)) { + printf("Datalink: double registration of extension prefix '%s'. Skipping.", name); + return -1; + } + extensions[name] = { prefix: prefix, encode: encode, decode: decode, }; + extension_prefixes[prefix] = name; + max_prefix_length = math.max(max_prefix_length, size(prefix)); + append(contact_parents, class); + + return 0; +} + + +var data_separator = "!"; + + +### Transmission + +var clear_data = func { + send_data({}); +} + +var clear_timer = maketimer(1, clear_data); +clear_timer.singleShot = 1; + +# Send data through datalink. +# +# timeout: if set, sent data will be cleared after this time (other aircrafts +# won't receive it anymore). Useful if 'send_data' is not called often. +var send_data = func(data, timeout=nil) { + if (!input.power.getBoolValue()) { + last_data = {}; + input.mp.setValue(""); + return; + } + + # First encode channel + var str = encode_channel(input.channel.getValue()); + + # Then all extensions + last_data = data; + foreach(var ext; keys(data)) { + # Skip missing extensions with a warning + if (!contains(extensions, ext)) { + printf("Warning: unknown datalink extension %s in send_data().", ext); + continue; + } + str = str ~ data_separator ~ extensions[ext].prefix ~ extensions[ext].encode(data[ext]); + } + + input.mp.setValue(str); + + if (timeout != nil) { + clear_timer.restart(timeout); + } +} + +# Used internally to update the channel/identifier while keeping the same data. +# Does not touch timeout. +var last_data = {}; +var resend_data = func { + send_data(last_data); +} + +# Very slow timer to ensure the channel hash is updated regularly. +# Only relevant if you never call send_data(); +var hash_update_timer = maketimer(channel_hash_period/2, resend_data); +hash_update_timer.start(); + + +### Receiving + +# callsign to data hash +var aircrafts_data = {}; +# List of callsigns / indices connected on datalink (index is for /ai/models/multiplayer[i]). +var connected_callsigns = []; +var connected_indices = []; + +# Maintain callsign to multiplayer index hash +# (doesn't cost much since we already iterate over MP models). +var callsign_to_index = {}; + + +var get_data = func(callsign) { + return aircrafts_data[callsign]; +} + +var get_connected_callsigns = func { + return connected_callsigns; +} + +var get_connected_indices = func { + return connected_indices; +} + +var get_all_callsigns = func { + return keys(aircrafts_data); +} + +# Helper for modifying aircrafts_data. +var add_if_missing = func(aircrafts_data, callsign) { + if (!contains(aircrafts_data, callsign)) { + aircrafts_data[callsign] = Contact.new(callsign); + } + return aircrafts_data; +} + +var receive_loop = func { + var my_channel = input.channel.getValue(); + + aircrafts_data = {}; + connected_callsigns = []; + connected_indices = []; + + var mp_models = input.models.getChildren("multiplayer"); + foreach(var mp; mp_models) { + var idx = mp.getIndex(); + if (!mp.getValue("valid")) continue; + var callsign = mp.getValue("callsign"); + if (callsign == nil) continue; + + callsign_to_index[callsign] = idx; + + var data = mp.getValue(mp_path); + if (data == nil) continue; + + # Split channel part and data part + var tokens = split(data_separator, data); + + # Check channel + if (!check_channel(tokens[0], callsign, my_channel)) continue; + + # We check this _after_ the channel. Checking the channel is quite cheap, + # and we don't know how slow this function is, it might have a get_cart_ground_intersection() + if (!can_transmit(callsign, mp, idx)) continue; + + # Add to list of connected aircrafts. + append(connected_callsigns, callsign); + append(connected_indices, idx); + # Add to data + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_on_link(1); + + # Parse extensions data + for (var i=1; i size(tokens)) break; + var prefix = left(tokens[i], len); + if (contains(extension_prefixes, prefix)) { + extension = extension_prefixes[prefix]; + break; + } + } + # Unknown extension, skip + if (extension == nil) continue; + # Remove prefix + var data = substr(tokens[i], len); + # Decode + aircrafts_data = extensions[extension].decode(aircrafts_data, callsign, data); + } + } +} + +var receive_timer = maketimer(receive_period, receive_loop); + + +# Start / stop listener +setlistener(input.power, func (node) { + if (node.getBoolValue()) { + receive_timer.start(); + resend_data(); # Sets channel/identifier + } else { + receive_timer.stop(); + aircrafts_data = {}; + clear_data(); + } +}, 1, 0); + +# Listener to resend data so as to update the channel. +setlistener(input.channel, resend_data); + + + +#### Extensions + +## Identifier + +var ContactIdentifier = { + init: func { + me._identifier = nil; + }, + set_identifier: func(ident) { + me._identifier = ident; + }, + identifier: func { + return me._identifier; + }, +}; + +var encode_identifier = func(ident) { + # Force string conversion + ident = ""~ident; + + if (find("!", ident) >= 0) { + printf("Datalink: Identifier is not allowed to contain '!': %s.", ident); + return ""; + } else { + return ident; + } +} + +var decode_identifier = func(aircrafts_data, callsign, str) { + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_identifier(str); + return aircrafts_data; +} + +register_extension("identifier", "I", ContactIdentifier, encode_identifier, decode_identifier); + + + +## Contacts + +# IFF status transmitted over datalink. +var IFF_UNKNOWN = 0; # Unknown status +var IFF_HOSTILE = 1; # Considered hostile (no response to IFF). +var IFF_FRIENDLY = 2; # Friendly, because positive IFF identification. +# This is also the priority order for IFF reports in case of conflicts: +# e.g. a contact will be reported as friendly if anyone on datalink reports it as friendly. + +var ContactTracked = { + init: func { + me._tracked_by = nil; + me._iff = IFF_UNKNOWN; + }, + set_tracked_by: func(callsign) { + me._tracked_by = callsign; + }, + set_iff: func(iff) { + # Priority order on IFF values (friendly, then hostile, then no data). + me._iff = math.max(me._iff, iff); + }, + tracked: func { + return me._tracked_by != nil; + }, + tracked_by: func { + return me._tracked_by; + }, + tracked_by_index: func { + return (me._tracked_by != nil) ? callsign_to_index[me._tracked_by] : nil; + }, + iff: func { + return me._iff; + }, + is_known: func { + return me.on_link() or me.tracked(); + }, + is_friendly: func { + return me.on_link() or me.iff() == IFF_FRIENDLY; + }, + is_hostile: func { + return !me.on_link() and me.iff() == IFF_HOSTILE; + }, +}; + +# Contact encoding: callsign + bits +# callsign: the callsign encoded with emesary.TransferString +# bits: bitfield |xxxxxxff| (left is most significant) +# f: IFF, x: unused +# encoded with emesary.TransferByte +# Additional values may be appended for extensions. + +var encode_contact = func(contact) { + # Encode bitfield + var bits = contact["iff"] != nil ? contact.iff : IFF_UNKNOWN; + + return emesary.TransferString.encode(clean_callsign(contact.callsign)) + ~ emesary.TransferByte.encode(bits); +} + +var decode_contact = func(str) { + var res = {}; + var dv = emesary.TransferString.decode(str, 0); + res.callsign = dv.value; + dv = emesary.TransferByte.decode(str, dv.pos); + var bits = dv.value; + res.iff = math.mod(bits, 4); + return res; +} + +# Special character, won't be used by emesary encoding. +var contacts_separator = "#"; + +var encode_contacts = func(contacts) { + var str = ""; + foreach (var contact; contacts) { + str = str~encode_contact(contact)~contacts_separator; + } + return str; +} + +var decode_contacts = func(aircrafts_data, callsign, str) { + var contacts = split(contacts_separator, str); + foreach (var contact; contacts) { + if (contact == "") continue; + var res = decode_contact(contact); + aircrafts_data = add_if_missing(aircrafts_data, res.callsign); + aircrafts_data[res.callsign].set_iff(res.iff); + aircrafts_data[res.callsign].set_tracked_by(callsign); + } + return aircrafts_data; +} + +register_extension("contacts", "C", ContactTracked, encode_contacts, decode_contacts); + + + +## Coordinate + +var ContactPoint = { + init: func { + me._point = nil; + }, + set_point: func(point) { + me._point = point; + }, + point: func { + return me._point; + }, +}; + +var encode_point = func(coord) { + return emesary.TransferCoord.encode(coord); +} + +var decode_point = func(aircrafts_data, callsign, str) { + var coord = emesary.TransferCoord.decode(str, 0).value; + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_point(coord); + return aircrafts_data; +} + +register_extension("point", "P", ContactPoint, encode_point, decode_point); diff --git a/S-300/Nasal/ArmamentNotification.nas b/S-300/Nasal/ArmamentNotification.nas index e0d2bb5..5bcb62a 100644 --- a/S-300/Nasal/ArmamentNotification.nas +++ b/S-300/Nasal/ArmamentNotification.nas @@ -87,6 +87,48 @@ var ArmamentInFlightNotification = new_class.Flags = 0; # 8 bits for whatever. new_class.UniqueIdentity = _unique; + new_class.isValid = func { + var valid = ""; + var limitMin = -124; + var limitMax = 123; + # 1 byte integer + if (int(me.Kind) > limitMax or int(me.Kind) < limitMin) { + valid ~= "|ArmamentInFlight.Kind is "~me.Kind; + } + # 1 byte integer + if (int(me.SecondaryKind) > limitMax or int(me.SecondaryKind) < limitMin) { + valid ~= "|ArmamentInFlight.SecondaryKind is "~me.SecondaryKind; + } + # 1 byte float + if (int((me.u_fps-3348)*0.03703) > limitMax or int((me.u_fps-3348)*0.03703) < limitMin) { + valid ~= "|ArmamentInFlight.u_fps is "~me.u_fps; + } + # 1 byte float + if (int(me.Pitch*1.38) > limitMax or int(me.Pitch*1.38) < limitMin) { + valid ~= "|ArmamentInFlight.Pitch is "~me.Pitch; + } + # 1 byte float + if (int(geo.normdeg180(me.Heading)/1.54) > limitMax or int(geo.normdeg180(me.Heading)/1.54) < limitMin) { + valid ~= "|ArmamentInFlight.Heading is "~me.Heading; + } + # 1 byte integer + if (int(me.Flags) > limitMax or int(me.Flags) < limitMin) { + valid ~= "|ArmamentInFlight.Flags is "~me.Flags; + } + # 1 byte integer + if (int(me.UniqueIdentity) > limitMax or int(me.UniqueIdentity) < limitMin) { + valid ~= "|ArmamentInFlight.UniqueIdentity is "~me.UniqueIdentity; + } + # 7 byte string + if (size(me.RemoteCallsign) > 7) { + valid ~= "|ArmamentInFlight.RemoteCallsign is "~me.RemoteCallsign; + } + if (valid != "") { + print("\nEMESARY ENCODING WILL FAIL:"); + print(" "~valid); + } + }; + new_class.GetBridgeMessageNotificationTypeKey = func { return new_class.NotificationType~"."~new_class.Ident~"."~new_class.UniqueIndex; }; @@ -257,4 +299,4 @@ var ObjectInFlightNotification = }; return new_class; }, -}; \ No newline at end of file +}; diff --git a/S-300/Nasal/GeoBridgedTransmitter.nas b/S-300/Nasal/GeoBridgedTransmitter.nas index 4136d64..6dfa84f 100644 --- a/S-300/Nasal/GeoBridgedTransmitter.nas +++ b/S-300/Nasal/GeoBridgedTransmitter.nas @@ -27,18 +27,18 @@ #i.e. change to [notifications.ArmamentInFlightNotification.new(nil), notifications.ArmamentNotification.new(nil)]; var geoRoutedNotifications = [notifications.ArmamentInFlightNotification.new()]; var geoBridgedTransmitter = emesary.Transmitter.new("geoOutgoingBridge"); -var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("s300mp.geo",geoRoutedNotifications, 18, "", geoBridgedTransmitter); +var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoutedNotifications, 18, "", geoBridgedTransmitter); # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); #----- bridge hit (armament) notifications var hitRoutedNotifications = [notifications.ArmamentNotification.new(),notifications.StaticNotification.new()]; var hitBridgedTransmitter = emesary.Transmitter.new("armamentNotificationBridge"); -var hitoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("s300mp.hit",hitRoutedNotifications, 19, "", hitBridgedTransmitter); +var hitoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.hit",hitRoutedNotifications, 19, "", hitBridgedTransmitter); hitoutgoingBridge.TransmitFrequencySeconds = 1.5; hitoutgoingBridge.MPStringMaxLen = 120; emesary_mp_bridge.IncomingMPBridge.startMPBridge(hitRoutedNotifications, 19, emesary.GlobalTransmitter); @@ -46,8 +46,38 @@ emesary_mp_bridge.IncomingMPBridge.startMPBridge(hitRoutedNotifications, 19, eme #----- bridge object notifications var objectRoutedNotifications = [notifications.ObjectInFlightNotification.new()]; var objectBridgedTransmitter = emesary.Transmitter.new("objectNotificationBridge"); -var objectoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("s300mp.object",objectRoutedNotifications, 17, "", objectBridgedTransmitter); +var objectoutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.object",objectRoutedNotifications, 17, "", objectBridgedTransmitter); objectoutgoingBridge.TransmitFrequencySeconds = 0.2; objectoutgoingBridge.MessageLifeTime = 1; objectoutgoingBridge.MPStringMaxLen = 150; -emesary_mp_bridge.IncomingMPBridge.startMPBridge(objectRoutedNotifications, 17, emesary.GlobalTransmitter); \ No newline at end of file +emesary_mp_bridge.IncomingMPBridge.startMPBridge(objectRoutedNotifications, 17, emesary.GlobalTransmitter); + +# +# debug all messages - this can be removed when testing isn't required. +var debugRecipient = emesary.Recipient.new("Debug"); +debugRecipient.Receive = func(notification) +{ + if (notification.NotificationType != "FrameNotification") { + print ("recv(0): type=",notification.NotificationType, " fromIncoming=",notification.FromIncomingBridge); + + if (notification.NotificationType == "ArmamentInFlightNotification") { + print("recv(1): ",notification.NotificationType, " ", notification.Ident); + debug.dump(notification); + + } else if (notification.NotificationType == "ArmamentNotification") { + if (notification.FromIncomingBridge) { + print("recv(2): ",notification.NotificationType, " ", notification.Ident, + " Kind=",notification.Kind, + " SecondaryKind=",notification.SecondaryKind, + " RelativeAltitude=",notification.RelativeAltitude, + " Distance=",notification.Distance, + " Bearing=",notification.Bearing, + " RemoteCallsign=",notification.RemoteCallsign); + debug.dump(notification); + } + } + } + return emesary.Transmitter.ReceiptStatus_NotProcessed; # we're not processing it, just looking +} +# uncomment next line to activate debug recipient. +#emesary.GlobalTransmitter.Register(debugRecipient); \ No newline at end of file diff --git a/S-300/Nasal/datalink.nas b/S-300/Nasal/datalink.nas new file mode 100644 index 0000000..0f01161 --- /dev/null +++ b/S-300/Nasal/datalink.nas @@ -0,0 +1,681 @@ +#### Datalink + +# Copyright 2020-2021 Colin Geniet. +# Licensed under the GNU General Public License 2.0 or any later version. + + +#### Usage + +### Generalities +# +# The datalink protocol consists of a core protocol which implements a notion +# of datalink channel, and extensions which allow transmitting actual data. + +### Core protocol usage: +# +# Define the following properties (must be defined at nasal loading time). +# * Mandatory +# /instrumentation/datalink/power_prop path to property indicating if datalink is on +# /instrumentation/datalink/channel_prop path to property containing datalink channel +# (the channel property can contain anything, and is transmitted/compared as a string). +# * Optional +# /instrumentation/datalink/receive_period = 1 receiving loop update rate +# +# Optional: Re-define the function +# datalink.can_transmit(callsign, mp_prop, mp_index) +# +# This function should return 'true' when the given aircraft is able to transmit over datalink to us. +# For instance, it can be used to check line of sight and maximum range. +# The default implementation always returns true (always able to transmit). +# Arguments are callsign, property node /ai/models/multiplayer[i], index of the former node. +# +# +# API: +# - get_data(callsign) +# Returns all datalink information about 'callsign' as an object, or nil if there is none. +# This object must not be modified. +# It contains the following methods: +# callsign(): The aircraft callsign (same as the argument of get_data()). +# index(): The aircraft index in /ai/models/multiplayer[i]. +# on_link(): Returns a bool indicating whether 'callsign' is connected to this aircraft through datalink. +# +# Extensions can define other methods in this object. +# +# - get_connected_callsigns() / get_connected_indices() +# Returns a vector containing all callsigns, resp. indices +# in /ai/models/multiplayer[i], of aircrafts connected on datalink. +# Both vectors use the same order, i.e. get_connected_callsigns()[i] +# and get_connected_indices()[i] correspond to the same aircraft. +# Furthermore this order is stable (the relative order of two aircrafts +# does not change as long as neither disconnects from multiplayer). +# +# - get_all_callsigns() +# Returns a vector containing all callsigns of aircraft with any associated data. +# There is no guarantee on the order of callsigns. +# +# - send_data(data, timeout=nil) +# Send data on the datalink. 'data' is a hash of the form +# { +# : , +# ... +# } +# If 'timeout' is set, clear_data() will be called after this delay. +# Data sent with send_data() is deleted at the next call of send_data(), or by clear_data(). +# +# - clear_data() +# Clear data transmitted by this aircraft. +# +# Important note: +# After a send_data(), and until the next send_data() or clear_data(), +# the datalink behaves as if you are continuously sending the same data. +# Thus, it is important to +# 1. either call send_data() regularly +# 2. or set the timeout argument of send_data() + +### Extensions + +### Aircraft contacts (extension name: "contacts") +# +# This extension allows to simulate an aircraft transmitting information about +# another aircraft (typically one tracked on radar). The position data is not +# actually transmitted (since everyone can access it from simulator internals). +# +## Receiving data +# This extension adds the following methods to the result of get_data("A"): +# tracked(): A bool indicating that some aircraft "B" connected on datalink +# is transmitting information about aircraft "A". +# iff(): One of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY, or nil if tracked() is false. +# Indicates the result of IFF interrogation of "A" by "B" +# IFF_UNKNOWN means that e.g. no IFF interrogation was performed. +# tracked_by(): The callsign of the transmitting aircraft ("A"), or nil if tracked() is false. +# tracked_by_index(): The index of the transmitting aircraft, or nil if tracked() is false. +# The index refers to property nodes /ai/models/multiplayer[i]. +# is_known(): Equivalent to (on_link() or tracked()). +# Indicates if the position of this aircraft is supposed to be known +# (i.e. whether or not it should be displayed on a HSD or whatever). +# is_friendly(): Equivalent to (on_link() or iff() == IFF_FRIENDLY). +# is_hostile(): Equivalent to (!on_link() and iff() == IFF_HOSTILE). +# +## Sending data +# usage: send_data({ contacts: , ...}, ...) +# where is a vector of hashes of the form { callsign: , [iff: ,] }. +# is the multiplayer callsign of the tracked aircraft. +# (optional) is one of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY + +### Datalink identifier (extension name: "identifier") +# +# This extension allows each aircraft on datalink to transmit a personal +# identifier, e.g. the number of the aircraft in a flight. +# +## Receiving data +# This extension adds the method identifier() to the result of get_data(), +# which returns the identifier, or nil if there is none). +# +## Sending data +# Set the identifier with send_data({"identifier": , ...}); +# The identifier must be a string. It must not contain '!'. + +### Coordinate transmission (extension name: "point") +# +# This extension allows each aircraft to broadcast a coordinate (geo.Coord object). +# +## Receiving data +# This extension adds the method point() to the result of get_data(), +# which results the transmitted geo.Coord object, or nil if there is none. +# +## Sending data +# Transmit a geo.Coord object with send_data({"point": , ...}); + + +#### Protocol: +# +# Data is transmitted on MP generic string[7], with the following format: +# (!)+ +# +# is a hash of the datalink channel. See hash_channel() and check_channel_hash(). +# Each block corresponds to data sent by an extension. +# It starts with a prefix uniquely defining the extension. +# The rest of the block can contain any character (including non-ascii) except '!'. +# +# Remark: '!' as separator is specifically chosen to allow encoding with emesary.Transfer. +# +# The current extension prefixes are the following: +# contacts: C +# identifier: I +# point: P + +#### Extensions API +# +# Creating a new extension is done with +# register_extension(name, prefix, object, encode, decode) +# name the extension name, used as key in the 'data' argument of send_data(). +# prefix the protocol prefix. +# class contact class parent. +# A class from which all contact objects will inherit. +# It must have an init() method, which is called whenever a contact is created. +# +# encode(data) extension encoding function. +# Must return the encoding of the extension data (i.e. when calling +# send_data({name: })) into a string, which may use any character except '!'. +# The extension prefix must not be part of the encoded string. +# +# decode(aircrafts_data, callsign, index, string) extension decoding function. +# 'aircrafts_data' is a hash from callsigns to contact objects (see below). +# 'callsign' is the callsign of the aircraft which transmitted this data. +# 'index' is the index of the aircraft which transmitted this data. +# 'string' is the data encoded by encode() and transmitted through datalink. + +# Each contact in 'data' inherits from the core 'Contact' class, and the extension 'class'. +# decode() is expected to modify 'aircrafts_data', by possibly editing +# existing contacts and adding new ones. It should be careful when +# overwriting existing data in these contacts, including its own: decode() +# will be called several time on the same 'aircrafts_data' (once for each +# transmitting aircraft). +# The modified 'aircrafts_data' hash must be returned. +# +# decode() may use the following helper functions: +# add_if_missing(aircrafts_data, callsign): +# Create a new contact object for 'callsign' and add it to 'aircrafts_data', +# unless an entry for 'callsign' already exists. Returns the modified hash. + + + +#### Version and changelog +# current: v1.1.0, minimum compatible: v1.0.0 +# +## v1.1.0: +# Allow external transmission restrictions +# Make transmitting contact IFF optional +# Ensure personal identifier has no '!' +# '\n' is redundant for printf() +# Fix separator character in documentation +# Fix error when sending unknown extension +# +## v1.0.1: +# Add is_known(), is_friendly(), is_hostile() helpers to extension "contacts". +# +## v1.0.0: Initial version +# - Core protocol for datalink channel. +# - Extensions "contacts", "identifier", and "point". + + + +### Parameters +# +# Remark: most parameters need to be the same on all aircrafts. + +# Index of multiplayer string used to transmit datalink info. +# Must be the same for all aircrafts. +var mp_string = 7; +var mp_path = "sim/multiplay/generic/string["~mp_string~"]"; + +var channel_hash_period = 600; + +var receive_period = getprop("/instrumentation/datalink/receive_period") or 1; + +# Should be overwitten to add transmission restrictions. +var can_transmit = func(contact, mp_prop, mp_index) { + return 1; +} + +### Properties + +var input = { + power: getprop("/instrumentation/datalink/power_prop"), + channel: getprop("/instrumentation/datalink/channel_prop"), + ident: getprop("/instrumentation/datalink/identifier_prop"), + mp: mp_path, + models: "/ai/models", + callsign: "/sim/multiplay/callsign", +}; + +foreach (var name; keys(input)) { + if (input[name] != nil) { + input[name] = props.globals.getNode(input[name], 1); + } +} + + + +#### Core protocol implementation + +### Channel hash (based on iff.nas) +# +# Channel is hashed with current time (rounded to 10min) and own callsign. + +var clean_callsign = func(callsign) { + return damage.processCallsign(callsign); +} + +var my_callsign = func { + return clean_callsign(input.callsign.getValue()); +} + +# Time, rounded to 'channel_hash_period'. This is used to hash channel. +var get_time = func { + return int(math.floor(systime() / channel_hash_period) * channel_hash_period); +} + +# Previous / next time (with channel_hash_period interval). +# This is used to give a bit of margin on the time check. +# (will work if system clocks are coordinated within 10min). +var get_prev_time = func { return get_time() - channel_hash_period; } +var get_next_time = func { return get_time() + channel_hash_period; } + +var parse_hexadecimal = func(str) { + var res = 0; + for (var i=0; i= 48 and c < 58) { + # digit + res += c - 48; + } elsif (c >= 65 and c < 71) { + # upper case letter + res += c - 55; + } elsif (c >= 97 and c < 103) { + # lower case letter + res += c - 87; + } + } + return res; +} + +var _hash_channel = func(time, callsign, channel) { + # 5 hex digits (2^20) fit in 3 chars for emesary int encoding. + var hash = parse_hexadecimal(left(md5(time ~ callsign ~ channel), 5)); + return emesary.TransferInt.encode(hash, 3); +} + +# Hash channel (when sending). +var encode_channel = func(channel) { + return _hash_channel(get_time(), my_callsign(), channel); +} + +# Check that the hash transmitted by aircraft 'callsign' is correct for 'channel'. +var check_channel = func(hash, callsign, channel) { + return hash == _hash_channel(get_time(), callsign, channel) + or hash == _hash_channel(get_prev_time(), callsign, channel) + or hash == _hash_channel(get_next_time(), callsign, channel); +} + +### Contact object +var Contact = { + new: func(callsign) { + var c = { + # contact_parents is the list of all classes from which contacts inherit (for extensions). + parents: contact_parents, + _callsign: callsign, + }; + # Initialize all inherited classes. + foreach (var class; contact_parents) { + call(class.init, [], c, nil, nil); + } + return c; + }, + init: func { me._on_link = 0; }, + + callsign: func { return me._callsign; }, + index: func { return callsign_to_index[me._callsign]; }, + on_link: func { return me._on_link; }, + set_on_link: func(b) { me._on_link = b; }, +}; + +### Extensions +var extensions = {}; +var extension_prefixes = {}; +var max_prefix_length = 0; +var contact_parents = [Contact]; + +var register_extension = func(name, prefix, class, encode, decode) { + if (contains(extensions, name)) { + printf("Datalink: double registration of extension '%s'. Skipping.", name); + return -1; + } + if (contains(extension_prefixes, prefix)) { + printf("Datalink: double registration of extension prefix '%s'. Skipping.", name); + return -1; + } + extensions[name] = { prefix: prefix, encode: encode, decode: decode, }; + extension_prefixes[prefix] = name; + max_prefix_length = math.max(max_prefix_length, size(prefix)); + append(contact_parents, class); + + return 0; +} + + +var data_separator = "!"; + + +### Transmission + +var clear_data = func { + send_data({}); +} + +var clear_timer = maketimer(1, clear_data); +clear_timer.singleShot = 1; + +# Send data through datalink. +# +# timeout: if set, sent data will be cleared after this time (other aircrafts +# won't receive it anymore). Useful if 'send_data' is not called often. +var send_data = func(data, timeout=nil) { + if (!input.power.getBoolValue()) { + last_data = {}; + input.mp.setValue(""); + return; + } + + # First encode channel + var str = encode_channel(input.channel.getValue()); + + # Then all extensions + last_data = data; + foreach(var ext; keys(data)) { + # Skip missing extensions with a warning + if (!contains(extensions, ext)) { + printf("Warning: unknown datalink extension %s in send_data().", ext); + continue; + } + str = str ~ data_separator ~ extensions[ext].prefix ~ extensions[ext].encode(data[ext]); + } + + input.mp.setValue(str); + + if (timeout != nil) { + clear_timer.restart(timeout); + } +} + +# Used internally to update the channel/identifier while keeping the same data. +# Does not touch timeout. +var last_data = {}; +var resend_data = func { + send_data(last_data); +} + +# Very slow timer to ensure the channel hash is updated regularly. +# Only relevant if you never call send_data(); +var hash_update_timer = maketimer(channel_hash_period/2, resend_data); +hash_update_timer.start(); + + +### Receiving + +# callsign to data hash +var aircrafts_data = {}; +# List of callsigns / indices connected on datalink (index is for /ai/models/multiplayer[i]). +var connected_callsigns = []; +var connected_indices = []; + +# Maintain callsign to multiplayer index hash +# (doesn't cost much since we already iterate over MP models). +var callsign_to_index = {}; + + +var get_data = func(callsign) { + return aircrafts_data[callsign]; +} + +var get_connected_callsigns = func { + return connected_callsigns; +} + +var get_connected_indices = func { + return connected_indices; +} + +var get_all_callsigns = func { + return keys(aircrafts_data); +} + +# Helper for modifying aircrafts_data. +var add_if_missing = func(aircrafts_data, callsign) { + if (!contains(aircrafts_data, callsign)) { + aircrafts_data[callsign] = Contact.new(callsign); + } + return aircrafts_data; +} + +var receive_loop = func { + var my_channel = input.channel.getValue(); + + aircrafts_data = {}; + connected_callsigns = []; + connected_indices = []; + + var mp_models = input.models.getChildren("multiplayer"); + foreach(var mp; mp_models) { + var idx = mp.getIndex(); + if (!mp.getValue("valid")) continue; + var callsign = mp.getValue("callsign"); + if (callsign == nil) continue; + + callsign_to_index[callsign] = idx; + + var data = mp.getValue(mp_path); + if (data == nil) continue; + + # Split channel part and data part + var tokens = split(data_separator, data); + + # Check channel + if (!check_channel(tokens[0], callsign, my_channel)) continue; + + # We check this _after_ the channel. Checking the channel is quite cheap, + # and we don't know how slow this function is, it might have a get_cart_ground_intersection() + if (!can_transmit(callsign, mp, idx)) continue; + + # Add to list of connected aircrafts. + append(connected_callsigns, callsign); + append(connected_indices, idx); + # Add to data + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_on_link(1); + + # Parse extensions data + for (var i=1; i size(tokens)) break; + var prefix = left(tokens[i], len); + if (contains(extension_prefixes, prefix)) { + extension = extension_prefixes[prefix]; + break; + } + } + # Unknown extension, skip + if (extension == nil) continue; + # Remove prefix + var data = substr(tokens[i], len); + # Decode + aircrafts_data = extensions[extension].decode(aircrafts_data, callsign, data); + } + } +} + +var receive_timer = maketimer(receive_period, receive_loop); + + +# Start / stop listener +setlistener(input.power, func (node) { + if (node.getBoolValue()) { + receive_timer.start(); + resend_data(); # Sets channel/identifier + } else { + receive_timer.stop(); + aircrafts_data = {}; + clear_data(); + } +}, 1, 0); + +# Listener to resend data so as to update the channel. +setlistener(input.channel, resend_data); + + + +#### Extensions + +## Identifier + +var ContactIdentifier = { + init: func { + me._identifier = nil; + }, + set_identifier: func(ident) { + me._identifier = ident; + }, + identifier: func { + return me._identifier; + }, +}; + +var encode_identifier = func(ident) { + # Force string conversion + ident = ""~ident; + + if (find("!", ident) >= 0) { + printf("Datalink: Identifier is not allowed to contain '!': %s.", ident); + return ""; + } else { + return ident; + } +} + +var decode_identifier = func(aircrafts_data, callsign, str) { + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_identifier(str); + return aircrafts_data; +} + +register_extension("identifier", "I", ContactIdentifier, encode_identifier, decode_identifier); + + + +## Contacts + +# IFF status transmitted over datalink. +var IFF_UNKNOWN = 0; # Unknown status +var IFF_HOSTILE = 1; # Considered hostile (no response to IFF). +var IFF_FRIENDLY = 2; # Friendly, because positive IFF identification. +# This is also the priority order for IFF reports in case of conflicts: +# e.g. a contact will be reported as friendly if anyone on datalink reports it as friendly. + +var ContactTracked = { + init: func { + me._tracked_by = nil; + me._iff = IFF_UNKNOWN; + }, + set_tracked_by: func(callsign) { + me._tracked_by = callsign; + }, + set_iff: func(iff) { + # Priority order on IFF values (friendly, then hostile, then no data). + me._iff = math.max(me._iff, iff); + }, + tracked: func { + return me._tracked_by != nil; + }, + tracked_by: func { + return me._tracked_by; + }, + tracked_by_index: func { + return (me._tracked_by != nil) ? callsign_to_index[me._tracked_by] : nil; + }, + iff: func { + return me._iff; + }, + is_known: func { + return me.on_link() or me.tracked(); + }, + is_friendly: func { + return me.on_link() or me.iff() == IFF_FRIENDLY; + }, + is_hostile: func { + return !me.on_link() and me.iff() == IFF_HOSTILE; + }, +}; + +# Contact encoding: callsign + bits +# callsign: the callsign encoded with emesary.TransferString +# bits: bitfield |xxxxxxff| (left is most significant) +# f: IFF, x: unused +# encoded with emesary.TransferByte +# Additional values may be appended for extensions. + +var encode_contact = func(contact) { + # Encode bitfield + var bits = contact["iff"] != nil ? contact.iff : IFF_UNKNOWN; + + return emesary.TransferString.encode(clean_callsign(contact.callsign)) + ~ emesary.TransferByte.encode(bits); +} + +var decode_contact = func(str) { + var res = {}; + var dv = emesary.TransferString.decode(str, 0); + res.callsign = dv.value; + dv = emesary.TransferByte.decode(str, dv.pos); + var bits = dv.value; + res.iff = math.mod(bits, 4); + return res; +} + +# Special character, won't be used by emesary encoding. +var contacts_separator = "#"; + +var encode_contacts = func(contacts) { + var str = ""; + foreach (var contact; contacts) { + str = str~encode_contact(contact)~contacts_separator; + } + return str; +} + +var decode_contacts = func(aircrafts_data, callsign, str) { + var contacts = split(contacts_separator, str); + foreach (var contact; contacts) { + if (contact == "") continue; + var res = decode_contact(contact); + aircrafts_data = add_if_missing(aircrafts_data, res.callsign); + aircrafts_data[res.callsign].set_iff(res.iff); + aircrafts_data[res.callsign].set_tracked_by(callsign); + } + return aircrafts_data; +} + +register_extension("contacts", "C", ContactTracked, encode_contacts, decode_contacts); + + + +## Coordinate + +var ContactPoint = { + init: func { + me._point = nil; + }, + set_point: func(point) { + me._point = point; + }, + point: func { + return me._point; + }, +}; + +var encode_point = func(coord) { + return emesary.TransferCoord.encode(coord); +} + +var decode_point = func(aircrafts_data, callsign, str) { + var coord = emesary.TransferCoord.decode(str, 0).value; + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_point(coord); + return aircrafts_data; +} + +register_extension("point", "P", ContactPoint, encode_point, decode_point); diff --git a/S-75/Nasal/GeoBridgedTransmitter.nas b/S-75/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/S-75/Nasal/GeoBridgedTransmitter.nas +++ b/S-75/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/S-75/Nasal/radar-logic.nas b/S-75/Nasal/radar-logic.nas index 6b92c8b..2882a9e 100644 --- a/S-75/Nasal/radar-logic.nas +++ b/S-75/Nasal/radar-logic.nas @@ -160,7 +160,9 @@ var Contact = { if(e == nil or e == 0) { # AI/MP has no radar properties var self = geo.aircraft_position(); - self.set_alt(self.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + self.set_alt(self.alt()+elev); me.get_Coord(); var angleInv = clamp(self.distance_to(me.coord)/self.direct_distance_to(me.coord), -1, 1); e = (self.alt()>me.coord.alt()?-1:1)*math.acos(angleInv)*R2D; @@ -391,7 +393,9 @@ var isNotBehindTerrain = func( mp ) { var myPos = geo.aircraft_position(); - myPos.set_alt(myPos.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + myPos.set_alt(myPos.alt()+elev); var target_distance = myPos.direct_distance_to(aircraftPos); diff --git a/SA-6/Nasal/GeoBridgedTransmitter.nas b/SA-6/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/SA-6/Nasal/GeoBridgedTransmitter.nas +++ b/SA-6/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/SA-6/Nasal/radar-logic.nas b/SA-6/Nasal/radar-logic.nas index 6b92c8b..2882a9e 100644 --- a/SA-6/Nasal/radar-logic.nas +++ b/SA-6/Nasal/radar-logic.nas @@ -160,7 +160,9 @@ var Contact = { if(e == nil or e == 0) { # AI/MP has no radar properties var self = geo.aircraft_position(); - self.set_alt(self.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + self.set_alt(self.alt()+elev); me.get_Coord(); var angleInv = clamp(self.distance_to(me.coord)/self.direct_distance_to(me.coord), -1, 1); e = (self.alt()>me.coord.alt()?-1:1)*math.acos(angleInv)*R2D; @@ -391,7 +393,9 @@ var isNotBehindTerrain = func( mp ) { var myPos = geo.aircraft_position(); - myPos.set_alt(myPos.alt()+fire_control.radar_elevation_above_terrain_m); + var elev = 0; + call(func {elev = fire_control.radar_elevation_above_terrain_m;},[],nil,nil, var err = []); + myPos.set_alt(myPos.alt()+elev); var target_distance = myPos.direct_distance_to(aircraftPos); diff --git a/ZSU-23/Nasal/ArmamentNotification.nas b/ZSU-23/Nasal/ArmamentNotification.nas index e0d2bb5..5bcb62a 100644 --- a/ZSU-23/Nasal/ArmamentNotification.nas +++ b/ZSU-23/Nasal/ArmamentNotification.nas @@ -87,6 +87,48 @@ var ArmamentInFlightNotification = new_class.Flags = 0; # 8 bits for whatever. new_class.UniqueIdentity = _unique; + new_class.isValid = func { + var valid = ""; + var limitMin = -124; + var limitMax = 123; + # 1 byte integer + if (int(me.Kind) > limitMax or int(me.Kind) < limitMin) { + valid ~= "|ArmamentInFlight.Kind is "~me.Kind; + } + # 1 byte integer + if (int(me.SecondaryKind) > limitMax or int(me.SecondaryKind) < limitMin) { + valid ~= "|ArmamentInFlight.SecondaryKind is "~me.SecondaryKind; + } + # 1 byte float + if (int((me.u_fps-3348)*0.03703) > limitMax or int((me.u_fps-3348)*0.03703) < limitMin) { + valid ~= "|ArmamentInFlight.u_fps is "~me.u_fps; + } + # 1 byte float + if (int(me.Pitch*1.38) > limitMax or int(me.Pitch*1.38) < limitMin) { + valid ~= "|ArmamentInFlight.Pitch is "~me.Pitch; + } + # 1 byte float + if (int(geo.normdeg180(me.Heading)/1.54) > limitMax or int(geo.normdeg180(me.Heading)/1.54) < limitMin) { + valid ~= "|ArmamentInFlight.Heading is "~me.Heading; + } + # 1 byte integer + if (int(me.Flags) > limitMax or int(me.Flags) < limitMin) { + valid ~= "|ArmamentInFlight.Flags is "~me.Flags; + } + # 1 byte integer + if (int(me.UniqueIdentity) > limitMax or int(me.UniqueIdentity) < limitMin) { + valid ~= "|ArmamentInFlight.UniqueIdentity is "~me.UniqueIdentity; + } + # 7 byte string + if (size(me.RemoteCallsign) > 7) { + valid ~= "|ArmamentInFlight.RemoteCallsign is "~me.RemoteCallsign; + } + if (valid != "") { + print("\nEMESARY ENCODING WILL FAIL:"); + print(" "~valid); + } + }; + new_class.GetBridgeMessageNotificationTypeKey = func { return new_class.NotificationType~"."~new_class.Ident~"."~new_class.UniqueIndex; }; @@ -257,4 +299,4 @@ var ObjectInFlightNotification = }; return new_class; }, -}; \ No newline at end of file +}; diff --git a/ZSU-23/Nasal/GeoBridgedTransmitter.nas b/ZSU-23/Nasal/GeoBridgedTransmitter.nas index 973e4a4..6dfa84f 100644 --- a/ZSU-23/Nasal/GeoBridgedTransmitter.nas +++ b/ZSU-23/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); @@ -51,3 +51,33 @@ objectoutgoingBridge.TransmitFrequencySeconds = 0.2; objectoutgoingBridge.MessageLifeTime = 1; objectoutgoingBridge.MPStringMaxLen = 150; emesary_mp_bridge.IncomingMPBridge.startMPBridge(objectRoutedNotifications, 17, emesary.GlobalTransmitter); + +# +# debug all messages - this can be removed when testing isn't required. +var debugRecipient = emesary.Recipient.new("Debug"); +debugRecipient.Receive = func(notification) +{ + if (notification.NotificationType != "FrameNotification") { + print ("recv(0): type=",notification.NotificationType, " fromIncoming=",notification.FromIncomingBridge); + + if (notification.NotificationType == "ArmamentInFlightNotification") { + print("recv(1): ",notification.NotificationType, " ", notification.Ident); + debug.dump(notification); + + } else if (notification.NotificationType == "ArmamentNotification") { + if (notification.FromIncomingBridge) { + print("recv(2): ",notification.NotificationType, " ", notification.Ident, + " Kind=",notification.Kind, + " SecondaryKind=",notification.SecondaryKind, + " RelativeAltitude=",notification.RelativeAltitude, + " Distance=",notification.Distance, + " Bearing=",notification.Bearing, + " RemoteCallsign=",notification.RemoteCallsign); + debug.dump(notification); + } + } + } + return emesary.Transmitter.ReceiptStatus_NotProcessed; # we're not processing it, just looking +} +# uncomment next line to activate debug recipient. +#emesary.GlobalTransmitter.Register(debugRecipient); \ No newline at end of file diff --git a/ZSU-23/Nasal/datalink.nas b/ZSU-23/Nasal/datalink.nas new file mode 100644 index 0000000..0f01161 --- /dev/null +++ b/ZSU-23/Nasal/datalink.nas @@ -0,0 +1,681 @@ +#### Datalink + +# Copyright 2020-2021 Colin Geniet. +# Licensed under the GNU General Public License 2.0 or any later version. + + +#### Usage + +### Generalities +# +# The datalink protocol consists of a core protocol which implements a notion +# of datalink channel, and extensions which allow transmitting actual data. + +### Core protocol usage: +# +# Define the following properties (must be defined at nasal loading time). +# * Mandatory +# /instrumentation/datalink/power_prop path to property indicating if datalink is on +# /instrumentation/datalink/channel_prop path to property containing datalink channel +# (the channel property can contain anything, and is transmitted/compared as a string). +# * Optional +# /instrumentation/datalink/receive_period = 1 receiving loop update rate +# +# Optional: Re-define the function +# datalink.can_transmit(callsign, mp_prop, mp_index) +# +# This function should return 'true' when the given aircraft is able to transmit over datalink to us. +# For instance, it can be used to check line of sight and maximum range. +# The default implementation always returns true (always able to transmit). +# Arguments are callsign, property node /ai/models/multiplayer[i], index of the former node. +# +# +# API: +# - get_data(callsign) +# Returns all datalink information about 'callsign' as an object, or nil if there is none. +# This object must not be modified. +# It contains the following methods: +# callsign(): The aircraft callsign (same as the argument of get_data()). +# index(): The aircraft index in /ai/models/multiplayer[i]. +# on_link(): Returns a bool indicating whether 'callsign' is connected to this aircraft through datalink. +# +# Extensions can define other methods in this object. +# +# - get_connected_callsigns() / get_connected_indices() +# Returns a vector containing all callsigns, resp. indices +# in /ai/models/multiplayer[i], of aircrafts connected on datalink. +# Both vectors use the same order, i.e. get_connected_callsigns()[i] +# and get_connected_indices()[i] correspond to the same aircraft. +# Furthermore this order is stable (the relative order of two aircrafts +# does not change as long as neither disconnects from multiplayer). +# +# - get_all_callsigns() +# Returns a vector containing all callsigns of aircraft with any associated data. +# There is no guarantee on the order of callsigns. +# +# - send_data(data, timeout=nil) +# Send data on the datalink. 'data' is a hash of the form +# { +# : , +# ... +# } +# If 'timeout' is set, clear_data() will be called after this delay. +# Data sent with send_data() is deleted at the next call of send_data(), or by clear_data(). +# +# - clear_data() +# Clear data transmitted by this aircraft. +# +# Important note: +# After a send_data(), and until the next send_data() or clear_data(), +# the datalink behaves as if you are continuously sending the same data. +# Thus, it is important to +# 1. either call send_data() regularly +# 2. or set the timeout argument of send_data() + +### Extensions + +### Aircraft contacts (extension name: "contacts") +# +# This extension allows to simulate an aircraft transmitting information about +# another aircraft (typically one tracked on radar). The position data is not +# actually transmitted (since everyone can access it from simulator internals). +# +## Receiving data +# This extension adds the following methods to the result of get_data("A"): +# tracked(): A bool indicating that some aircraft "B" connected on datalink +# is transmitting information about aircraft "A". +# iff(): One of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY, or nil if tracked() is false. +# Indicates the result of IFF interrogation of "A" by "B" +# IFF_UNKNOWN means that e.g. no IFF interrogation was performed. +# tracked_by(): The callsign of the transmitting aircraft ("A"), or nil if tracked() is false. +# tracked_by_index(): The index of the transmitting aircraft, or nil if tracked() is false. +# The index refers to property nodes /ai/models/multiplayer[i]. +# is_known(): Equivalent to (on_link() or tracked()). +# Indicates if the position of this aircraft is supposed to be known +# (i.e. whether or not it should be displayed on a HSD or whatever). +# is_friendly(): Equivalent to (on_link() or iff() == IFF_FRIENDLY). +# is_hostile(): Equivalent to (!on_link() and iff() == IFF_HOSTILE). +# +## Sending data +# usage: send_data({ contacts: , ...}, ...) +# where is a vector of hashes of the form { callsign: , [iff: ,] }. +# is the multiplayer callsign of the tracked aircraft. +# (optional) is one of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY + +### Datalink identifier (extension name: "identifier") +# +# This extension allows each aircraft on datalink to transmit a personal +# identifier, e.g. the number of the aircraft in a flight. +# +## Receiving data +# This extension adds the method identifier() to the result of get_data(), +# which returns the identifier, or nil if there is none). +# +## Sending data +# Set the identifier with send_data({"identifier": , ...}); +# The identifier must be a string. It must not contain '!'. + +### Coordinate transmission (extension name: "point") +# +# This extension allows each aircraft to broadcast a coordinate (geo.Coord object). +# +## Receiving data +# This extension adds the method point() to the result of get_data(), +# which results the transmitted geo.Coord object, or nil if there is none. +# +## Sending data +# Transmit a geo.Coord object with send_data({"point": , ...}); + + +#### Protocol: +# +# Data is transmitted on MP generic string[7], with the following format: +# (!)+ +# +# is a hash of the datalink channel. See hash_channel() and check_channel_hash(). +# Each block corresponds to data sent by an extension. +# It starts with a prefix uniquely defining the extension. +# The rest of the block can contain any character (including non-ascii) except '!'. +# +# Remark: '!' as separator is specifically chosen to allow encoding with emesary.Transfer. +# +# The current extension prefixes are the following: +# contacts: C +# identifier: I +# point: P + +#### Extensions API +# +# Creating a new extension is done with +# register_extension(name, prefix, object, encode, decode) +# name the extension name, used as key in the 'data' argument of send_data(). +# prefix the protocol prefix. +# class contact class parent. +# A class from which all contact objects will inherit. +# It must have an init() method, which is called whenever a contact is created. +# +# encode(data) extension encoding function. +# Must return the encoding of the extension data (i.e. when calling +# send_data({name: })) into a string, which may use any character except '!'. +# The extension prefix must not be part of the encoded string. +# +# decode(aircrafts_data, callsign, index, string) extension decoding function. +# 'aircrafts_data' is a hash from callsigns to contact objects (see below). +# 'callsign' is the callsign of the aircraft which transmitted this data. +# 'index' is the index of the aircraft which transmitted this data. +# 'string' is the data encoded by encode() and transmitted through datalink. + +# Each contact in 'data' inherits from the core 'Contact' class, and the extension 'class'. +# decode() is expected to modify 'aircrafts_data', by possibly editing +# existing contacts and adding new ones. It should be careful when +# overwriting existing data in these contacts, including its own: decode() +# will be called several time on the same 'aircrafts_data' (once for each +# transmitting aircraft). +# The modified 'aircrafts_data' hash must be returned. +# +# decode() may use the following helper functions: +# add_if_missing(aircrafts_data, callsign): +# Create a new contact object for 'callsign' and add it to 'aircrafts_data', +# unless an entry for 'callsign' already exists. Returns the modified hash. + + + +#### Version and changelog +# current: v1.1.0, minimum compatible: v1.0.0 +# +## v1.1.0: +# Allow external transmission restrictions +# Make transmitting contact IFF optional +# Ensure personal identifier has no '!' +# '\n' is redundant for printf() +# Fix separator character in documentation +# Fix error when sending unknown extension +# +## v1.0.1: +# Add is_known(), is_friendly(), is_hostile() helpers to extension "contacts". +# +## v1.0.0: Initial version +# - Core protocol for datalink channel. +# - Extensions "contacts", "identifier", and "point". + + + +### Parameters +# +# Remark: most parameters need to be the same on all aircrafts. + +# Index of multiplayer string used to transmit datalink info. +# Must be the same for all aircrafts. +var mp_string = 7; +var mp_path = "sim/multiplay/generic/string["~mp_string~"]"; + +var channel_hash_period = 600; + +var receive_period = getprop("/instrumentation/datalink/receive_period") or 1; + +# Should be overwitten to add transmission restrictions. +var can_transmit = func(contact, mp_prop, mp_index) { + return 1; +} + +### Properties + +var input = { + power: getprop("/instrumentation/datalink/power_prop"), + channel: getprop("/instrumentation/datalink/channel_prop"), + ident: getprop("/instrumentation/datalink/identifier_prop"), + mp: mp_path, + models: "/ai/models", + callsign: "/sim/multiplay/callsign", +}; + +foreach (var name; keys(input)) { + if (input[name] != nil) { + input[name] = props.globals.getNode(input[name], 1); + } +} + + + +#### Core protocol implementation + +### Channel hash (based on iff.nas) +# +# Channel is hashed with current time (rounded to 10min) and own callsign. + +var clean_callsign = func(callsign) { + return damage.processCallsign(callsign); +} + +var my_callsign = func { + return clean_callsign(input.callsign.getValue()); +} + +# Time, rounded to 'channel_hash_period'. This is used to hash channel. +var get_time = func { + return int(math.floor(systime() / channel_hash_period) * channel_hash_period); +} + +# Previous / next time (with channel_hash_period interval). +# This is used to give a bit of margin on the time check. +# (will work if system clocks are coordinated within 10min). +var get_prev_time = func { return get_time() - channel_hash_period; } +var get_next_time = func { return get_time() + channel_hash_period; } + +var parse_hexadecimal = func(str) { + var res = 0; + for (var i=0; i= 48 and c < 58) { + # digit + res += c - 48; + } elsif (c >= 65 and c < 71) { + # upper case letter + res += c - 55; + } elsif (c >= 97 and c < 103) { + # lower case letter + res += c - 87; + } + } + return res; +} + +var _hash_channel = func(time, callsign, channel) { + # 5 hex digits (2^20) fit in 3 chars for emesary int encoding. + var hash = parse_hexadecimal(left(md5(time ~ callsign ~ channel), 5)); + return emesary.TransferInt.encode(hash, 3); +} + +# Hash channel (when sending). +var encode_channel = func(channel) { + return _hash_channel(get_time(), my_callsign(), channel); +} + +# Check that the hash transmitted by aircraft 'callsign' is correct for 'channel'. +var check_channel = func(hash, callsign, channel) { + return hash == _hash_channel(get_time(), callsign, channel) + or hash == _hash_channel(get_prev_time(), callsign, channel) + or hash == _hash_channel(get_next_time(), callsign, channel); +} + +### Contact object +var Contact = { + new: func(callsign) { + var c = { + # contact_parents is the list of all classes from which contacts inherit (for extensions). + parents: contact_parents, + _callsign: callsign, + }; + # Initialize all inherited classes. + foreach (var class; contact_parents) { + call(class.init, [], c, nil, nil); + } + return c; + }, + init: func { me._on_link = 0; }, + + callsign: func { return me._callsign; }, + index: func { return callsign_to_index[me._callsign]; }, + on_link: func { return me._on_link; }, + set_on_link: func(b) { me._on_link = b; }, +}; + +### Extensions +var extensions = {}; +var extension_prefixes = {}; +var max_prefix_length = 0; +var contact_parents = [Contact]; + +var register_extension = func(name, prefix, class, encode, decode) { + if (contains(extensions, name)) { + printf("Datalink: double registration of extension '%s'. Skipping.", name); + return -1; + } + if (contains(extension_prefixes, prefix)) { + printf("Datalink: double registration of extension prefix '%s'. Skipping.", name); + return -1; + } + extensions[name] = { prefix: prefix, encode: encode, decode: decode, }; + extension_prefixes[prefix] = name; + max_prefix_length = math.max(max_prefix_length, size(prefix)); + append(contact_parents, class); + + return 0; +} + + +var data_separator = "!"; + + +### Transmission + +var clear_data = func { + send_data({}); +} + +var clear_timer = maketimer(1, clear_data); +clear_timer.singleShot = 1; + +# Send data through datalink. +# +# timeout: if set, sent data will be cleared after this time (other aircrafts +# won't receive it anymore). Useful if 'send_data' is not called often. +var send_data = func(data, timeout=nil) { + if (!input.power.getBoolValue()) { + last_data = {}; + input.mp.setValue(""); + return; + } + + # First encode channel + var str = encode_channel(input.channel.getValue()); + + # Then all extensions + last_data = data; + foreach(var ext; keys(data)) { + # Skip missing extensions with a warning + if (!contains(extensions, ext)) { + printf("Warning: unknown datalink extension %s in send_data().", ext); + continue; + } + str = str ~ data_separator ~ extensions[ext].prefix ~ extensions[ext].encode(data[ext]); + } + + input.mp.setValue(str); + + if (timeout != nil) { + clear_timer.restart(timeout); + } +} + +# Used internally to update the channel/identifier while keeping the same data. +# Does not touch timeout. +var last_data = {}; +var resend_data = func { + send_data(last_data); +} + +# Very slow timer to ensure the channel hash is updated regularly. +# Only relevant if you never call send_data(); +var hash_update_timer = maketimer(channel_hash_period/2, resend_data); +hash_update_timer.start(); + + +### Receiving + +# callsign to data hash +var aircrafts_data = {}; +# List of callsigns / indices connected on datalink (index is for /ai/models/multiplayer[i]). +var connected_callsigns = []; +var connected_indices = []; + +# Maintain callsign to multiplayer index hash +# (doesn't cost much since we already iterate over MP models). +var callsign_to_index = {}; + + +var get_data = func(callsign) { + return aircrafts_data[callsign]; +} + +var get_connected_callsigns = func { + return connected_callsigns; +} + +var get_connected_indices = func { + return connected_indices; +} + +var get_all_callsigns = func { + return keys(aircrafts_data); +} + +# Helper for modifying aircrafts_data. +var add_if_missing = func(aircrafts_data, callsign) { + if (!contains(aircrafts_data, callsign)) { + aircrafts_data[callsign] = Contact.new(callsign); + } + return aircrafts_data; +} + +var receive_loop = func { + var my_channel = input.channel.getValue(); + + aircrafts_data = {}; + connected_callsigns = []; + connected_indices = []; + + var mp_models = input.models.getChildren("multiplayer"); + foreach(var mp; mp_models) { + var idx = mp.getIndex(); + if (!mp.getValue("valid")) continue; + var callsign = mp.getValue("callsign"); + if (callsign == nil) continue; + + callsign_to_index[callsign] = idx; + + var data = mp.getValue(mp_path); + if (data == nil) continue; + + # Split channel part and data part + var tokens = split(data_separator, data); + + # Check channel + if (!check_channel(tokens[0], callsign, my_channel)) continue; + + # We check this _after_ the channel. Checking the channel is quite cheap, + # and we don't know how slow this function is, it might have a get_cart_ground_intersection() + if (!can_transmit(callsign, mp, idx)) continue; + + # Add to list of connected aircrafts. + append(connected_callsigns, callsign); + append(connected_indices, idx); + # Add to data + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_on_link(1); + + # Parse extensions data + for (var i=1; i size(tokens)) break; + var prefix = left(tokens[i], len); + if (contains(extension_prefixes, prefix)) { + extension = extension_prefixes[prefix]; + break; + } + } + # Unknown extension, skip + if (extension == nil) continue; + # Remove prefix + var data = substr(tokens[i], len); + # Decode + aircrafts_data = extensions[extension].decode(aircrafts_data, callsign, data); + } + } +} + +var receive_timer = maketimer(receive_period, receive_loop); + + +# Start / stop listener +setlistener(input.power, func (node) { + if (node.getBoolValue()) { + receive_timer.start(); + resend_data(); # Sets channel/identifier + } else { + receive_timer.stop(); + aircrafts_data = {}; + clear_data(); + } +}, 1, 0); + +# Listener to resend data so as to update the channel. +setlistener(input.channel, resend_data); + + + +#### Extensions + +## Identifier + +var ContactIdentifier = { + init: func { + me._identifier = nil; + }, + set_identifier: func(ident) { + me._identifier = ident; + }, + identifier: func { + return me._identifier; + }, +}; + +var encode_identifier = func(ident) { + # Force string conversion + ident = ""~ident; + + if (find("!", ident) >= 0) { + printf("Datalink: Identifier is not allowed to contain '!': %s.", ident); + return ""; + } else { + return ident; + } +} + +var decode_identifier = func(aircrafts_data, callsign, str) { + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_identifier(str); + return aircrafts_data; +} + +register_extension("identifier", "I", ContactIdentifier, encode_identifier, decode_identifier); + + + +## Contacts + +# IFF status transmitted over datalink. +var IFF_UNKNOWN = 0; # Unknown status +var IFF_HOSTILE = 1; # Considered hostile (no response to IFF). +var IFF_FRIENDLY = 2; # Friendly, because positive IFF identification. +# This is also the priority order for IFF reports in case of conflicts: +# e.g. a contact will be reported as friendly if anyone on datalink reports it as friendly. + +var ContactTracked = { + init: func { + me._tracked_by = nil; + me._iff = IFF_UNKNOWN; + }, + set_tracked_by: func(callsign) { + me._tracked_by = callsign; + }, + set_iff: func(iff) { + # Priority order on IFF values (friendly, then hostile, then no data). + me._iff = math.max(me._iff, iff); + }, + tracked: func { + return me._tracked_by != nil; + }, + tracked_by: func { + return me._tracked_by; + }, + tracked_by_index: func { + return (me._tracked_by != nil) ? callsign_to_index[me._tracked_by] : nil; + }, + iff: func { + return me._iff; + }, + is_known: func { + return me.on_link() or me.tracked(); + }, + is_friendly: func { + return me.on_link() or me.iff() == IFF_FRIENDLY; + }, + is_hostile: func { + return !me.on_link() and me.iff() == IFF_HOSTILE; + }, +}; + +# Contact encoding: callsign + bits +# callsign: the callsign encoded with emesary.TransferString +# bits: bitfield |xxxxxxff| (left is most significant) +# f: IFF, x: unused +# encoded with emesary.TransferByte +# Additional values may be appended for extensions. + +var encode_contact = func(contact) { + # Encode bitfield + var bits = contact["iff"] != nil ? contact.iff : IFF_UNKNOWN; + + return emesary.TransferString.encode(clean_callsign(contact.callsign)) + ~ emesary.TransferByte.encode(bits); +} + +var decode_contact = func(str) { + var res = {}; + var dv = emesary.TransferString.decode(str, 0); + res.callsign = dv.value; + dv = emesary.TransferByte.decode(str, dv.pos); + var bits = dv.value; + res.iff = math.mod(bits, 4); + return res; +} + +# Special character, won't be used by emesary encoding. +var contacts_separator = "#"; + +var encode_contacts = func(contacts) { + var str = ""; + foreach (var contact; contacts) { + str = str~encode_contact(contact)~contacts_separator; + } + return str; +} + +var decode_contacts = func(aircrafts_data, callsign, str) { + var contacts = split(contacts_separator, str); + foreach (var contact; contacts) { + if (contact == "") continue; + var res = decode_contact(contact); + aircrafts_data = add_if_missing(aircrafts_data, res.callsign); + aircrafts_data[res.callsign].set_iff(res.iff); + aircrafts_data[res.callsign].set_tracked_by(callsign); + } + return aircrafts_data; +} + +register_extension("contacts", "C", ContactTracked, encode_contacts, decode_contacts); + + + +## Coordinate + +var ContactPoint = { + init: func { + me._point = nil; + }, + set_point: func(point) { + me._point = point; + }, + point: func { + return me._point; + }, +}; + +var encode_point = func(coord) { + return emesary.TransferCoord.encode(coord); +} + +var decode_point = func(aircrafts_data, callsign, str) { + var coord = emesary.TransferCoord.decode(str, 0).value; + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_point(coord); + return aircrafts_data; +} + +register_extension("point", "P", ContactPoint, encode_point, decode_point); diff --git a/depot/Nasal/GeoBridgedTransmitter.nas b/depot/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/depot/Nasal/GeoBridgedTransmitter.nas +++ b/depot/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/fleet/Nasal/ArmamentNotification.nas b/fleet/Nasal/ArmamentNotification.nas index e0d2bb5..5bcb62a 100644 --- a/fleet/Nasal/ArmamentNotification.nas +++ b/fleet/Nasal/ArmamentNotification.nas @@ -87,6 +87,48 @@ var ArmamentInFlightNotification = new_class.Flags = 0; # 8 bits for whatever. new_class.UniqueIdentity = _unique; + new_class.isValid = func { + var valid = ""; + var limitMin = -124; + var limitMax = 123; + # 1 byte integer + if (int(me.Kind) > limitMax or int(me.Kind) < limitMin) { + valid ~= "|ArmamentInFlight.Kind is "~me.Kind; + } + # 1 byte integer + if (int(me.SecondaryKind) > limitMax or int(me.SecondaryKind) < limitMin) { + valid ~= "|ArmamentInFlight.SecondaryKind is "~me.SecondaryKind; + } + # 1 byte float + if (int((me.u_fps-3348)*0.03703) > limitMax or int((me.u_fps-3348)*0.03703) < limitMin) { + valid ~= "|ArmamentInFlight.u_fps is "~me.u_fps; + } + # 1 byte float + if (int(me.Pitch*1.38) > limitMax or int(me.Pitch*1.38) < limitMin) { + valid ~= "|ArmamentInFlight.Pitch is "~me.Pitch; + } + # 1 byte float + if (int(geo.normdeg180(me.Heading)/1.54) > limitMax or int(geo.normdeg180(me.Heading)/1.54) < limitMin) { + valid ~= "|ArmamentInFlight.Heading is "~me.Heading; + } + # 1 byte integer + if (int(me.Flags) > limitMax or int(me.Flags) < limitMin) { + valid ~= "|ArmamentInFlight.Flags is "~me.Flags; + } + # 1 byte integer + if (int(me.UniqueIdentity) > limitMax or int(me.UniqueIdentity) < limitMin) { + valid ~= "|ArmamentInFlight.UniqueIdentity is "~me.UniqueIdentity; + } + # 7 byte string + if (size(me.RemoteCallsign) > 7) { + valid ~= "|ArmamentInFlight.RemoteCallsign is "~me.RemoteCallsign; + } + if (valid != "") { + print("\nEMESARY ENCODING WILL FAIL:"); + print(" "~valid); + } + }; + new_class.GetBridgeMessageNotificationTypeKey = func { return new_class.NotificationType~"."~new_class.Ident~"."~new_class.UniqueIndex; }; @@ -257,4 +299,4 @@ var ObjectInFlightNotification = }; return new_class; }, -}; \ No newline at end of file +}; diff --git a/fleet/Nasal/GeoBridgedTransmitter.nas b/fleet/Nasal/GeoBridgedTransmitter.nas index 973e4a4..6dfa84f 100644 --- a/fleet/Nasal/GeoBridgedTransmitter.nas +++ b/fleet/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); @@ -51,3 +51,33 @@ objectoutgoingBridge.TransmitFrequencySeconds = 0.2; objectoutgoingBridge.MessageLifeTime = 1; objectoutgoingBridge.MPStringMaxLen = 150; emesary_mp_bridge.IncomingMPBridge.startMPBridge(objectRoutedNotifications, 17, emesary.GlobalTransmitter); + +# +# debug all messages - this can be removed when testing isn't required. +var debugRecipient = emesary.Recipient.new("Debug"); +debugRecipient.Receive = func(notification) +{ + if (notification.NotificationType != "FrameNotification") { + print ("recv(0): type=",notification.NotificationType, " fromIncoming=",notification.FromIncomingBridge); + + if (notification.NotificationType == "ArmamentInFlightNotification") { + print("recv(1): ",notification.NotificationType, " ", notification.Ident); + debug.dump(notification); + + } else if (notification.NotificationType == "ArmamentNotification") { + if (notification.FromIncomingBridge) { + print("recv(2): ",notification.NotificationType, " ", notification.Ident, + " Kind=",notification.Kind, + " SecondaryKind=",notification.SecondaryKind, + " RelativeAltitude=",notification.RelativeAltitude, + " Distance=",notification.Distance, + " Bearing=",notification.Bearing, + " RemoteCallsign=",notification.RemoteCallsign); + debug.dump(notification); + } + } + } + return emesary.Transmitter.ReceiptStatus_NotProcessed; # we're not processing it, just looking +} +# uncomment next line to activate debug recipient. +#emesary.GlobalTransmitter.Register(debugRecipient); \ No newline at end of file diff --git a/fleet/Nasal/datalink.nas b/fleet/Nasal/datalink.nas new file mode 100644 index 0000000..0f01161 --- /dev/null +++ b/fleet/Nasal/datalink.nas @@ -0,0 +1,681 @@ +#### Datalink + +# Copyright 2020-2021 Colin Geniet. +# Licensed under the GNU General Public License 2.0 or any later version. + + +#### Usage + +### Generalities +# +# The datalink protocol consists of a core protocol which implements a notion +# of datalink channel, and extensions which allow transmitting actual data. + +### Core protocol usage: +# +# Define the following properties (must be defined at nasal loading time). +# * Mandatory +# /instrumentation/datalink/power_prop path to property indicating if datalink is on +# /instrumentation/datalink/channel_prop path to property containing datalink channel +# (the channel property can contain anything, and is transmitted/compared as a string). +# * Optional +# /instrumentation/datalink/receive_period = 1 receiving loop update rate +# +# Optional: Re-define the function +# datalink.can_transmit(callsign, mp_prop, mp_index) +# +# This function should return 'true' when the given aircraft is able to transmit over datalink to us. +# For instance, it can be used to check line of sight and maximum range. +# The default implementation always returns true (always able to transmit). +# Arguments are callsign, property node /ai/models/multiplayer[i], index of the former node. +# +# +# API: +# - get_data(callsign) +# Returns all datalink information about 'callsign' as an object, or nil if there is none. +# This object must not be modified. +# It contains the following methods: +# callsign(): The aircraft callsign (same as the argument of get_data()). +# index(): The aircraft index in /ai/models/multiplayer[i]. +# on_link(): Returns a bool indicating whether 'callsign' is connected to this aircraft through datalink. +# +# Extensions can define other methods in this object. +# +# - get_connected_callsigns() / get_connected_indices() +# Returns a vector containing all callsigns, resp. indices +# in /ai/models/multiplayer[i], of aircrafts connected on datalink. +# Both vectors use the same order, i.e. get_connected_callsigns()[i] +# and get_connected_indices()[i] correspond to the same aircraft. +# Furthermore this order is stable (the relative order of two aircrafts +# does not change as long as neither disconnects from multiplayer). +# +# - get_all_callsigns() +# Returns a vector containing all callsigns of aircraft with any associated data. +# There is no guarantee on the order of callsigns. +# +# - send_data(data, timeout=nil) +# Send data on the datalink. 'data' is a hash of the form +# { +# : , +# ... +# } +# If 'timeout' is set, clear_data() will be called after this delay. +# Data sent with send_data() is deleted at the next call of send_data(), or by clear_data(). +# +# - clear_data() +# Clear data transmitted by this aircraft. +# +# Important note: +# After a send_data(), and until the next send_data() or clear_data(), +# the datalink behaves as if you are continuously sending the same data. +# Thus, it is important to +# 1. either call send_data() regularly +# 2. or set the timeout argument of send_data() + +### Extensions + +### Aircraft contacts (extension name: "contacts") +# +# This extension allows to simulate an aircraft transmitting information about +# another aircraft (typically one tracked on radar). The position data is not +# actually transmitted (since everyone can access it from simulator internals). +# +## Receiving data +# This extension adds the following methods to the result of get_data("A"): +# tracked(): A bool indicating that some aircraft "B" connected on datalink +# is transmitting information about aircraft "A". +# iff(): One of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY, or nil if tracked() is false. +# Indicates the result of IFF interrogation of "A" by "B" +# IFF_UNKNOWN means that e.g. no IFF interrogation was performed. +# tracked_by(): The callsign of the transmitting aircraft ("A"), or nil if tracked() is false. +# tracked_by_index(): The index of the transmitting aircraft, or nil if tracked() is false. +# The index refers to property nodes /ai/models/multiplayer[i]. +# is_known(): Equivalent to (on_link() or tracked()). +# Indicates if the position of this aircraft is supposed to be known +# (i.e. whether or not it should be displayed on a HSD or whatever). +# is_friendly(): Equivalent to (on_link() or iff() == IFF_FRIENDLY). +# is_hostile(): Equivalent to (!on_link() and iff() == IFF_HOSTILE). +# +## Sending data +# usage: send_data({ contacts: , ...}, ...) +# where is a vector of hashes of the form { callsign: , [iff: ,] }. +# is the multiplayer callsign of the tracked aircraft. +# (optional) is one of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY + +### Datalink identifier (extension name: "identifier") +# +# This extension allows each aircraft on datalink to transmit a personal +# identifier, e.g. the number of the aircraft in a flight. +# +## Receiving data +# This extension adds the method identifier() to the result of get_data(), +# which returns the identifier, or nil if there is none). +# +## Sending data +# Set the identifier with send_data({"identifier": , ...}); +# The identifier must be a string. It must not contain '!'. + +### Coordinate transmission (extension name: "point") +# +# This extension allows each aircraft to broadcast a coordinate (geo.Coord object). +# +## Receiving data +# This extension adds the method point() to the result of get_data(), +# which results the transmitted geo.Coord object, or nil if there is none. +# +## Sending data +# Transmit a geo.Coord object with send_data({"point": , ...}); + + +#### Protocol: +# +# Data is transmitted on MP generic string[7], with the following format: +# (!)+ +# +# is a hash of the datalink channel. See hash_channel() and check_channel_hash(). +# Each block corresponds to data sent by an extension. +# It starts with a prefix uniquely defining the extension. +# The rest of the block can contain any character (including non-ascii) except '!'. +# +# Remark: '!' as separator is specifically chosen to allow encoding with emesary.Transfer. +# +# The current extension prefixes are the following: +# contacts: C +# identifier: I +# point: P + +#### Extensions API +# +# Creating a new extension is done with +# register_extension(name, prefix, object, encode, decode) +# name the extension name, used as key in the 'data' argument of send_data(). +# prefix the protocol prefix. +# class contact class parent. +# A class from which all contact objects will inherit. +# It must have an init() method, which is called whenever a contact is created. +# +# encode(data) extension encoding function. +# Must return the encoding of the extension data (i.e. when calling +# send_data({name: })) into a string, which may use any character except '!'. +# The extension prefix must not be part of the encoded string. +# +# decode(aircrafts_data, callsign, index, string) extension decoding function. +# 'aircrafts_data' is a hash from callsigns to contact objects (see below). +# 'callsign' is the callsign of the aircraft which transmitted this data. +# 'index' is the index of the aircraft which transmitted this data. +# 'string' is the data encoded by encode() and transmitted through datalink. + +# Each contact in 'data' inherits from the core 'Contact' class, and the extension 'class'. +# decode() is expected to modify 'aircrafts_data', by possibly editing +# existing contacts and adding new ones. It should be careful when +# overwriting existing data in these contacts, including its own: decode() +# will be called several time on the same 'aircrafts_data' (once for each +# transmitting aircraft). +# The modified 'aircrafts_data' hash must be returned. +# +# decode() may use the following helper functions: +# add_if_missing(aircrafts_data, callsign): +# Create a new contact object for 'callsign' and add it to 'aircrafts_data', +# unless an entry for 'callsign' already exists. Returns the modified hash. + + + +#### Version and changelog +# current: v1.1.0, minimum compatible: v1.0.0 +# +## v1.1.0: +# Allow external transmission restrictions +# Make transmitting contact IFF optional +# Ensure personal identifier has no '!' +# '\n' is redundant for printf() +# Fix separator character in documentation +# Fix error when sending unknown extension +# +## v1.0.1: +# Add is_known(), is_friendly(), is_hostile() helpers to extension "contacts". +# +## v1.0.0: Initial version +# - Core protocol for datalink channel. +# - Extensions "contacts", "identifier", and "point". + + + +### Parameters +# +# Remark: most parameters need to be the same on all aircrafts. + +# Index of multiplayer string used to transmit datalink info. +# Must be the same for all aircrafts. +var mp_string = 7; +var mp_path = "sim/multiplay/generic/string["~mp_string~"]"; + +var channel_hash_period = 600; + +var receive_period = getprop("/instrumentation/datalink/receive_period") or 1; + +# Should be overwitten to add transmission restrictions. +var can_transmit = func(contact, mp_prop, mp_index) { + return 1; +} + +### Properties + +var input = { + power: getprop("/instrumentation/datalink/power_prop"), + channel: getprop("/instrumentation/datalink/channel_prop"), + ident: getprop("/instrumentation/datalink/identifier_prop"), + mp: mp_path, + models: "/ai/models", + callsign: "/sim/multiplay/callsign", +}; + +foreach (var name; keys(input)) { + if (input[name] != nil) { + input[name] = props.globals.getNode(input[name], 1); + } +} + + + +#### Core protocol implementation + +### Channel hash (based on iff.nas) +# +# Channel is hashed with current time (rounded to 10min) and own callsign. + +var clean_callsign = func(callsign) { + return damage.processCallsign(callsign); +} + +var my_callsign = func { + return clean_callsign(input.callsign.getValue()); +} + +# Time, rounded to 'channel_hash_period'. This is used to hash channel. +var get_time = func { + return int(math.floor(systime() / channel_hash_period) * channel_hash_period); +} + +# Previous / next time (with channel_hash_period interval). +# This is used to give a bit of margin on the time check. +# (will work if system clocks are coordinated within 10min). +var get_prev_time = func { return get_time() - channel_hash_period; } +var get_next_time = func { return get_time() + channel_hash_period; } + +var parse_hexadecimal = func(str) { + var res = 0; + for (var i=0; i= 48 and c < 58) { + # digit + res += c - 48; + } elsif (c >= 65 and c < 71) { + # upper case letter + res += c - 55; + } elsif (c >= 97 and c < 103) { + # lower case letter + res += c - 87; + } + } + return res; +} + +var _hash_channel = func(time, callsign, channel) { + # 5 hex digits (2^20) fit in 3 chars for emesary int encoding. + var hash = parse_hexadecimal(left(md5(time ~ callsign ~ channel), 5)); + return emesary.TransferInt.encode(hash, 3); +} + +# Hash channel (when sending). +var encode_channel = func(channel) { + return _hash_channel(get_time(), my_callsign(), channel); +} + +# Check that the hash transmitted by aircraft 'callsign' is correct for 'channel'. +var check_channel = func(hash, callsign, channel) { + return hash == _hash_channel(get_time(), callsign, channel) + or hash == _hash_channel(get_prev_time(), callsign, channel) + or hash == _hash_channel(get_next_time(), callsign, channel); +} + +### Contact object +var Contact = { + new: func(callsign) { + var c = { + # contact_parents is the list of all classes from which contacts inherit (for extensions). + parents: contact_parents, + _callsign: callsign, + }; + # Initialize all inherited classes. + foreach (var class; contact_parents) { + call(class.init, [], c, nil, nil); + } + return c; + }, + init: func { me._on_link = 0; }, + + callsign: func { return me._callsign; }, + index: func { return callsign_to_index[me._callsign]; }, + on_link: func { return me._on_link; }, + set_on_link: func(b) { me._on_link = b; }, +}; + +### Extensions +var extensions = {}; +var extension_prefixes = {}; +var max_prefix_length = 0; +var contact_parents = [Contact]; + +var register_extension = func(name, prefix, class, encode, decode) { + if (contains(extensions, name)) { + printf("Datalink: double registration of extension '%s'. Skipping.", name); + return -1; + } + if (contains(extension_prefixes, prefix)) { + printf("Datalink: double registration of extension prefix '%s'. Skipping.", name); + return -1; + } + extensions[name] = { prefix: prefix, encode: encode, decode: decode, }; + extension_prefixes[prefix] = name; + max_prefix_length = math.max(max_prefix_length, size(prefix)); + append(contact_parents, class); + + return 0; +} + + +var data_separator = "!"; + + +### Transmission + +var clear_data = func { + send_data({}); +} + +var clear_timer = maketimer(1, clear_data); +clear_timer.singleShot = 1; + +# Send data through datalink. +# +# timeout: if set, sent data will be cleared after this time (other aircrafts +# won't receive it anymore). Useful if 'send_data' is not called often. +var send_data = func(data, timeout=nil) { + if (!input.power.getBoolValue()) { + last_data = {}; + input.mp.setValue(""); + return; + } + + # First encode channel + var str = encode_channel(input.channel.getValue()); + + # Then all extensions + last_data = data; + foreach(var ext; keys(data)) { + # Skip missing extensions with a warning + if (!contains(extensions, ext)) { + printf("Warning: unknown datalink extension %s in send_data().", ext); + continue; + } + str = str ~ data_separator ~ extensions[ext].prefix ~ extensions[ext].encode(data[ext]); + } + + input.mp.setValue(str); + + if (timeout != nil) { + clear_timer.restart(timeout); + } +} + +# Used internally to update the channel/identifier while keeping the same data. +# Does not touch timeout. +var last_data = {}; +var resend_data = func { + send_data(last_data); +} + +# Very slow timer to ensure the channel hash is updated regularly. +# Only relevant if you never call send_data(); +var hash_update_timer = maketimer(channel_hash_period/2, resend_data); +hash_update_timer.start(); + + +### Receiving + +# callsign to data hash +var aircrafts_data = {}; +# List of callsigns / indices connected on datalink (index is for /ai/models/multiplayer[i]). +var connected_callsigns = []; +var connected_indices = []; + +# Maintain callsign to multiplayer index hash +# (doesn't cost much since we already iterate over MP models). +var callsign_to_index = {}; + + +var get_data = func(callsign) { + return aircrafts_data[callsign]; +} + +var get_connected_callsigns = func { + return connected_callsigns; +} + +var get_connected_indices = func { + return connected_indices; +} + +var get_all_callsigns = func { + return keys(aircrafts_data); +} + +# Helper for modifying aircrafts_data. +var add_if_missing = func(aircrafts_data, callsign) { + if (!contains(aircrafts_data, callsign)) { + aircrafts_data[callsign] = Contact.new(callsign); + } + return aircrafts_data; +} + +var receive_loop = func { + var my_channel = input.channel.getValue(); + + aircrafts_data = {}; + connected_callsigns = []; + connected_indices = []; + + var mp_models = input.models.getChildren("multiplayer"); + foreach(var mp; mp_models) { + var idx = mp.getIndex(); + if (!mp.getValue("valid")) continue; + var callsign = mp.getValue("callsign"); + if (callsign == nil) continue; + + callsign_to_index[callsign] = idx; + + var data = mp.getValue(mp_path); + if (data == nil) continue; + + # Split channel part and data part + var tokens = split(data_separator, data); + + # Check channel + if (!check_channel(tokens[0], callsign, my_channel)) continue; + + # We check this _after_ the channel. Checking the channel is quite cheap, + # and we don't know how slow this function is, it might have a get_cart_ground_intersection() + if (!can_transmit(callsign, mp, idx)) continue; + + # Add to list of connected aircrafts. + append(connected_callsigns, callsign); + append(connected_indices, idx); + # Add to data + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_on_link(1); + + # Parse extensions data + for (var i=1; i size(tokens)) break; + var prefix = left(tokens[i], len); + if (contains(extension_prefixes, prefix)) { + extension = extension_prefixes[prefix]; + break; + } + } + # Unknown extension, skip + if (extension == nil) continue; + # Remove prefix + var data = substr(tokens[i], len); + # Decode + aircrafts_data = extensions[extension].decode(aircrafts_data, callsign, data); + } + } +} + +var receive_timer = maketimer(receive_period, receive_loop); + + +# Start / stop listener +setlistener(input.power, func (node) { + if (node.getBoolValue()) { + receive_timer.start(); + resend_data(); # Sets channel/identifier + } else { + receive_timer.stop(); + aircrafts_data = {}; + clear_data(); + } +}, 1, 0); + +# Listener to resend data so as to update the channel. +setlistener(input.channel, resend_data); + + + +#### Extensions + +## Identifier + +var ContactIdentifier = { + init: func { + me._identifier = nil; + }, + set_identifier: func(ident) { + me._identifier = ident; + }, + identifier: func { + return me._identifier; + }, +}; + +var encode_identifier = func(ident) { + # Force string conversion + ident = ""~ident; + + if (find("!", ident) >= 0) { + printf("Datalink: Identifier is not allowed to contain '!': %s.", ident); + return ""; + } else { + return ident; + } +} + +var decode_identifier = func(aircrafts_data, callsign, str) { + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_identifier(str); + return aircrafts_data; +} + +register_extension("identifier", "I", ContactIdentifier, encode_identifier, decode_identifier); + + + +## Contacts + +# IFF status transmitted over datalink. +var IFF_UNKNOWN = 0; # Unknown status +var IFF_HOSTILE = 1; # Considered hostile (no response to IFF). +var IFF_FRIENDLY = 2; # Friendly, because positive IFF identification. +# This is also the priority order for IFF reports in case of conflicts: +# e.g. a contact will be reported as friendly if anyone on datalink reports it as friendly. + +var ContactTracked = { + init: func { + me._tracked_by = nil; + me._iff = IFF_UNKNOWN; + }, + set_tracked_by: func(callsign) { + me._tracked_by = callsign; + }, + set_iff: func(iff) { + # Priority order on IFF values (friendly, then hostile, then no data). + me._iff = math.max(me._iff, iff); + }, + tracked: func { + return me._tracked_by != nil; + }, + tracked_by: func { + return me._tracked_by; + }, + tracked_by_index: func { + return (me._tracked_by != nil) ? callsign_to_index[me._tracked_by] : nil; + }, + iff: func { + return me._iff; + }, + is_known: func { + return me.on_link() or me.tracked(); + }, + is_friendly: func { + return me.on_link() or me.iff() == IFF_FRIENDLY; + }, + is_hostile: func { + return !me.on_link() and me.iff() == IFF_HOSTILE; + }, +}; + +# Contact encoding: callsign + bits +# callsign: the callsign encoded with emesary.TransferString +# bits: bitfield |xxxxxxff| (left is most significant) +# f: IFF, x: unused +# encoded with emesary.TransferByte +# Additional values may be appended for extensions. + +var encode_contact = func(contact) { + # Encode bitfield + var bits = contact["iff"] != nil ? contact.iff : IFF_UNKNOWN; + + return emesary.TransferString.encode(clean_callsign(contact.callsign)) + ~ emesary.TransferByte.encode(bits); +} + +var decode_contact = func(str) { + var res = {}; + var dv = emesary.TransferString.decode(str, 0); + res.callsign = dv.value; + dv = emesary.TransferByte.decode(str, dv.pos); + var bits = dv.value; + res.iff = math.mod(bits, 4); + return res; +} + +# Special character, won't be used by emesary encoding. +var contacts_separator = "#"; + +var encode_contacts = func(contacts) { + var str = ""; + foreach (var contact; contacts) { + str = str~encode_contact(contact)~contacts_separator; + } + return str; +} + +var decode_contacts = func(aircrafts_data, callsign, str) { + var contacts = split(contacts_separator, str); + foreach (var contact; contacts) { + if (contact == "") continue; + var res = decode_contact(contact); + aircrafts_data = add_if_missing(aircrafts_data, res.callsign); + aircrafts_data[res.callsign].set_iff(res.iff); + aircrafts_data[res.callsign].set_tracked_by(callsign); + } + return aircrafts_data; +} + +register_extension("contacts", "C", ContactTracked, encode_contacts, decode_contacts); + + + +## Coordinate + +var ContactPoint = { + init: func { + me._point = nil; + }, + set_point: func(point) { + me._point = point; + }, + point: func { + return me._point; + }, +}; + +var encode_point = func(coord) { + return emesary.TransferCoord.encode(coord); +} + +var decode_point = func(aircrafts_data, callsign, str) { + var coord = emesary.TransferCoord.decode(str, 0).value; + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_point(coord); + return aircrafts_data; +} + +register_extension("point", "P", ContactPoint, encode_point, decode_point); diff --git a/gci-radar/Nasal/GeoBridgedTransmitter.nas b/gci-radar/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/gci-radar/Nasal/GeoBridgedTransmitter.nas +++ b/gci-radar/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/groundtarget/Nasal/GeoBridgedTransmitter.nas b/groundtarget/Nasal/GeoBridgedTransmitter.nas index 2e73fbb..6dfa84f 100644 --- a/groundtarget/Nasal/GeoBridgedTransmitter.nas +++ b/groundtarget/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); diff --git a/missile-frigate/Nasal/ArmamentNotification.nas b/missile-frigate/Nasal/ArmamentNotification.nas index e0d2bb5..5bcb62a 100644 --- a/missile-frigate/Nasal/ArmamentNotification.nas +++ b/missile-frigate/Nasal/ArmamentNotification.nas @@ -87,6 +87,48 @@ var ArmamentInFlightNotification = new_class.Flags = 0; # 8 bits for whatever. new_class.UniqueIdentity = _unique; + new_class.isValid = func { + var valid = ""; + var limitMin = -124; + var limitMax = 123; + # 1 byte integer + if (int(me.Kind) > limitMax or int(me.Kind) < limitMin) { + valid ~= "|ArmamentInFlight.Kind is "~me.Kind; + } + # 1 byte integer + if (int(me.SecondaryKind) > limitMax or int(me.SecondaryKind) < limitMin) { + valid ~= "|ArmamentInFlight.SecondaryKind is "~me.SecondaryKind; + } + # 1 byte float + if (int((me.u_fps-3348)*0.03703) > limitMax or int((me.u_fps-3348)*0.03703) < limitMin) { + valid ~= "|ArmamentInFlight.u_fps is "~me.u_fps; + } + # 1 byte float + if (int(me.Pitch*1.38) > limitMax or int(me.Pitch*1.38) < limitMin) { + valid ~= "|ArmamentInFlight.Pitch is "~me.Pitch; + } + # 1 byte float + if (int(geo.normdeg180(me.Heading)/1.54) > limitMax or int(geo.normdeg180(me.Heading)/1.54) < limitMin) { + valid ~= "|ArmamentInFlight.Heading is "~me.Heading; + } + # 1 byte integer + if (int(me.Flags) > limitMax or int(me.Flags) < limitMin) { + valid ~= "|ArmamentInFlight.Flags is "~me.Flags; + } + # 1 byte integer + if (int(me.UniqueIdentity) > limitMax or int(me.UniqueIdentity) < limitMin) { + valid ~= "|ArmamentInFlight.UniqueIdentity is "~me.UniqueIdentity; + } + # 7 byte string + if (size(me.RemoteCallsign) > 7) { + valid ~= "|ArmamentInFlight.RemoteCallsign is "~me.RemoteCallsign; + } + if (valid != "") { + print("\nEMESARY ENCODING WILL FAIL:"); + print(" "~valid); + } + }; + new_class.GetBridgeMessageNotificationTypeKey = func { return new_class.NotificationType~"."~new_class.Ident~"."~new_class.UniqueIndex; }; @@ -257,4 +299,4 @@ var ObjectInFlightNotification = }; return new_class; }, -}; \ No newline at end of file +}; diff --git a/missile-frigate/Nasal/GeoBridgedTransmitter.nas b/missile-frigate/Nasal/GeoBridgedTransmitter.nas index 973e4a4..6dfa84f 100644 --- a/missile-frigate/Nasal/GeoBridgedTransmitter.nas +++ b/missile-frigate/Nasal/GeoBridgedTransmitter.nas @@ -31,7 +31,7 @@ var geooutgoingBridge = emesary_mp_bridge.OutgoingMPBridge.new("mp.geo",geoRoute # bridge should be tuned to be around 90% of the packet size full. geooutgoingBridge.TransmitFrequencySeconds = 0.75; -geooutgoingBridge.MPStringMaxLen = 150; +geooutgoingBridge.MPStringMaxLen = 175; # each is 34 bytes emesary_mp_bridge.IncomingMPBridge.startMPBridge(geoRoutedNotifications, 18, emesary.GlobalTransmitter); @@ -51,3 +51,33 @@ objectoutgoingBridge.TransmitFrequencySeconds = 0.2; objectoutgoingBridge.MessageLifeTime = 1; objectoutgoingBridge.MPStringMaxLen = 150; emesary_mp_bridge.IncomingMPBridge.startMPBridge(objectRoutedNotifications, 17, emesary.GlobalTransmitter); + +# +# debug all messages - this can be removed when testing isn't required. +var debugRecipient = emesary.Recipient.new("Debug"); +debugRecipient.Receive = func(notification) +{ + if (notification.NotificationType != "FrameNotification") { + print ("recv(0): type=",notification.NotificationType, " fromIncoming=",notification.FromIncomingBridge); + + if (notification.NotificationType == "ArmamentInFlightNotification") { + print("recv(1): ",notification.NotificationType, " ", notification.Ident); + debug.dump(notification); + + } else if (notification.NotificationType == "ArmamentNotification") { + if (notification.FromIncomingBridge) { + print("recv(2): ",notification.NotificationType, " ", notification.Ident, + " Kind=",notification.Kind, + " SecondaryKind=",notification.SecondaryKind, + " RelativeAltitude=",notification.RelativeAltitude, + " Distance=",notification.Distance, + " Bearing=",notification.Bearing, + " RemoteCallsign=",notification.RemoteCallsign); + debug.dump(notification); + } + } + } + return emesary.Transmitter.ReceiptStatus_NotProcessed; # we're not processing it, just looking +} +# uncomment next line to activate debug recipient. +#emesary.GlobalTransmitter.Register(debugRecipient); \ No newline at end of file diff --git a/missile-frigate/Nasal/datalink.nas b/missile-frigate/Nasal/datalink.nas new file mode 100644 index 0000000..0f01161 --- /dev/null +++ b/missile-frigate/Nasal/datalink.nas @@ -0,0 +1,681 @@ +#### Datalink + +# Copyright 2020-2021 Colin Geniet. +# Licensed under the GNU General Public License 2.0 or any later version. + + +#### Usage + +### Generalities +# +# The datalink protocol consists of a core protocol which implements a notion +# of datalink channel, and extensions which allow transmitting actual data. + +### Core protocol usage: +# +# Define the following properties (must be defined at nasal loading time). +# * Mandatory +# /instrumentation/datalink/power_prop path to property indicating if datalink is on +# /instrumentation/datalink/channel_prop path to property containing datalink channel +# (the channel property can contain anything, and is transmitted/compared as a string). +# * Optional +# /instrumentation/datalink/receive_period = 1 receiving loop update rate +# +# Optional: Re-define the function +# datalink.can_transmit(callsign, mp_prop, mp_index) +# +# This function should return 'true' when the given aircraft is able to transmit over datalink to us. +# For instance, it can be used to check line of sight and maximum range. +# The default implementation always returns true (always able to transmit). +# Arguments are callsign, property node /ai/models/multiplayer[i], index of the former node. +# +# +# API: +# - get_data(callsign) +# Returns all datalink information about 'callsign' as an object, or nil if there is none. +# This object must not be modified. +# It contains the following methods: +# callsign(): The aircraft callsign (same as the argument of get_data()). +# index(): The aircraft index in /ai/models/multiplayer[i]. +# on_link(): Returns a bool indicating whether 'callsign' is connected to this aircraft through datalink. +# +# Extensions can define other methods in this object. +# +# - get_connected_callsigns() / get_connected_indices() +# Returns a vector containing all callsigns, resp. indices +# in /ai/models/multiplayer[i], of aircrafts connected on datalink. +# Both vectors use the same order, i.e. get_connected_callsigns()[i] +# and get_connected_indices()[i] correspond to the same aircraft. +# Furthermore this order is stable (the relative order of two aircrafts +# does not change as long as neither disconnects from multiplayer). +# +# - get_all_callsigns() +# Returns a vector containing all callsigns of aircraft with any associated data. +# There is no guarantee on the order of callsigns. +# +# - send_data(data, timeout=nil) +# Send data on the datalink. 'data' is a hash of the form +# { +# : , +# ... +# } +# If 'timeout' is set, clear_data() will be called after this delay. +# Data sent with send_data() is deleted at the next call of send_data(), or by clear_data(). +# +# - clear_data() +# Clear data transmitted by this aircraft. +# +# Important note: +# After a send_data(), and until the next send_data() or clear_data(), +# the datalink behaves as if you are continuously sending the same data. +# Thus, it is important to +# 1. either call send_data() regularly +# 2. or set the timeout argument of send_data() + +### Extensions + +### Aircraft contacts (extension name: "contacts") +# +# This extension allows to simulate an aircraft transmitting information about +# another aircraft (typically one tracked on radar). The position data is not +# actually transmitted (since everyone can access it from simulator internals). +# +## Receiving data +# This extension adds the following methods to the result of get_data("A"): +# tracked(): A bool indicating that some aircraft "B" connected on datalink +# is transmitting information about aircraft "A". +# iff(): One of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY, or nil if tracked() is false. +# Indicates the result of IFF interrogation of "A" by "B" +# IFF_UNKNOWN means that e.g. no IFF interrogation was performed. +# tracked_by(): The callsign of the transmitting aircraft ("A"), or nil if tracked() is false. +# tracked_by_index(): The index of the transmitting aircraft, or nil if tracked() is false. +# The index refers to property nodes /ai/models/multiplayer[i]. +# is_known(): Equivalent to (on_link() or tracked()). +# Indicates if the position of this aircraft is supposed to be known +# (i.e. whether or not it should be displayed on a HSD or whatever). +# is_friendly(): Equivalent to (on_link() or iff() == IFF_FRIENDLY). +# is_hostile(): Equivalent to (!on_link() and iff() == IFF_HOSTILE). +# +## Sending data +# usage: send_data({ contacts: , ...}, ...) +# where is a vector of hashes of the form { callsign: , [iff: ,] }. +# is the multiplayer callsign of the tracked aircraft. +# (optional) is one of IFF_UNKNOWN, IFF_HOSTILE, IFF_FRIENDLY + +### Datalink identifier (extension name: "identifier") +# +# This extension allows each aircraft on datalink to transmit a personal +# identifier, e.g. the number of the aircraft in a flight. +# +## Receiving data +# This extension adds the method identifier() to the result of get_data(), +# which returns the identifier, or nil if there is none). +# +## Sending data +# Set the identifier with send_data({"identifier": , ...}); +# The identifier must be a string. It must not contain '!'. + +### Coordinate transmission (extension name: "point") +# +# This extension allows each aircraft to broadcast a coordinate (geo.Coord object). +# +## Receiving data +# This extension adds the method point() to the result of get_data(), +# which results the transmitted geo.Coord object, or nil if there is none. +# +## Sending data +# Transmit a geo.Coord object with send_data({"point": , ...}); + + +#### Protocol: +# +# Data is transmitted on MP generic string[7], with the following format: +# (!)+ +# +# is a hash of the datalink channel. See hash_channel() and check_channel_hash(). +# Each block corresponds to data sent by an extension. +# It starts with a prefix uniquely defining the extension. +# The rest of the block can contain any character (including non-ascii) except '!'. +# +# Remark: '!' as separator is specifically chosen to allow encoding with emesary.Transfer. +# +# The current extension prefixes are the following: +# contacts: C +# identifier: I +# point: P + +#### Extensions API +# +# Creating a new extension is done with +# register_extension(name, prefix, object, encode, decode) +# name the extension name, used as key in the 'data' argument of send_data(). +# prefix the protocol prefix. +# class contact class parent. +# A class from which all contact objects will inherit. +# It must have an init() method, which is called whenever a contact is created. +# +# encode(data) extension encoding function. +# Must return the encoding of the extension data (i.e. when calling +# send_data({name: })) into a string, which may use any character except '!'. +# The extension prefix must not be part of the encoded string. +# +# decode(aircrafts_data, callsign, index, string) extension decoding function. +# 'aircrafts_data' is a hash from callsigns to contact objects (see below). +# 'callsign' is the callsign of the aircraft which transmitted this data. +# 'index' is the index of the aircraft which transmitted this data. +# 'string' is the data encoded by encode() and transmitted through datalink. + +# Each contact in 'data' inherits from the core 'Contact' class, and the extension 'class'. +# decode() is expected to modify 'aircrafts_data', by possibly editing +# existing contacts and adding new ones. It should be careful when +# overwriting existing data in these contacts, including its own: decode() +# will be called several time on the same 'aircrafts_data' (once for each +# transmitting aircraft). +# The modified 'aircrafts_data' hash must be returned. +# +# decode() may use the following helper functions: +# add_if_missing(aircrafts_data, callsign): +# Create a new contact object for 'callsign' and add it to 'aircrafts_data', +# unless an entry for 'callsign' already exists. Returns the modified hash. + + + +#### Version and changelog +# current: v1.1.0, minimum compatible: v1.0.0 +# +## v1.1.0: +# Allow external transmission restrictions +# Make transmitting contact IFF optional +# Ensure personal identifier has no '!' +# '\n' is redundant for printf() +# Fix separator character in documentation +# Fix error when sending unknown extension +# +## v1.0.1: +# Add is_known(), is_friendly(), is_hostile() helpers to extension "contacts". +# +## v1.0.0: Initial version +# - Core protocol for datalink channel. +# - Extensions "contacts", "identifier", and "point". + + + +### Parameters +# +# Remark: most parameters need to be the same on all aircrafts. + +# Index of multiplayer string used to transmit datalink info. +# Must be the same for all aircrafts. +var mp_string = 7; +var mp_path = "sim/multiplay/generic/string["~mp_string~"]"; + +var channel_hash_period = 600; + +var receive_period = getprop("/instrumentation/datalink/receive_period") or 1; + +# Should be overwitten to add transmission restrictions. +var can_transmit = func(contact, mp_prop, mp_index) { + return 1; +} + +### Properties + +var input = { + power: getprop("/instrumentation/datalink/power_prop"), + channel: getprop("/instrumentation/datalink/channel_prop"), + ident: getprop("/instrumentation/datalink/identifier_prop"), + mp: mp_path, + models: "/ai/models", + callsign: "/sim/multiplay/callsign", +}; + +foreach (var name; keys(input)) { + if (input[name] != nil) { + input[name] = props.globals.getNode(input[name], 1); + } +} + + + +#### Core protocol implementation + +### Channel hash (based on iff.nas) +# +# Channel is hashed with current time (rounded to 10min) and own callsign. + +var clean_callsign = func(callsign) { + return damage.processCallsign(callsign); +} + +var my_callsign = func { + return clean_callsign(input.callsign.getValue()); +} + +# Time, rounded to 'channel_hash_period'. This is used to hash channel. +var get_time = func { + return int(math.floor(systime() / channel_hash_period) * channel_hash_period); +} + +# Previous / next time (with channel_hash_period interval). +# This is used to give a bit of margin on the time check. +# (will work if system clocks are coordinated within 10min). +var get_prev_time = func { return get_time() - channel_hash_period; } +var get_next_time = func { return get_time() + channel_hash_period; } + +var parse_hexadecimal = func(str) { + var res = 0; + for (var i=0; i= 48 and c < 58) { + # digit + res += c - 48; + } elsif (c >= 65 and c < 71) { + # upper case letter + res += c - 55; + } elsif (c >= 97 and c < 103) { + # lower case letter + res += c - 87; + } + } + return res; +} + +var _hash_channel = func(time, callsign, channel) { + # 5 hex digits (2^20) fit in 3 chars for emesary int encoding. + var hash = parse_hexadecimal(left(md5(time ~ callsign ~ channel), 5)); + return emesary.TransferInt.encode(hash, 3); +} + +# Hash channel (when sending). +var encode_channel = func(channel) { + return _hash_channel(get_time(), my_callsign(), channel); +} + +# Check that the hash transmitted by aircraft 'callsign' is correct for 'channel'. +var check_channel = func(hash, callsign, channel) { + return hash == _hash_channel(get_time(), callsign, channel) + or hash == _hash_channel(get_prev_time(), callsign, channel) + or hash == _hash_channel(get_next_time(), callsign, channel); +} + +### Contact object +var Contact = { + new: func(callsign) { + var c = { + # contact_parents is the list of all classes from which contacts inherit (for extensions). + parents: contact_parents, + _callsign: callsign, + }; + # Initialize all inherited classes. + foreach (var class; contact_parents) { + call(class.init, [], c, nil, nil); + } + return c; + }, + init: func { me._on_link = 0; }, + + callsign: func { return me._callsign; }, + index: func { return callsign_to_index[me._callsign]; }, + on_link: func { return me._on_link; }, + set_on_link: func(b) { me._on_link = b; }, +}; + +### Extensions +var extensions = {}; +var extension_prefixes = {}; +var max_prefix_length = 0; +var contact_parents = [Contact]; + +var register_extension = func(name, prefix, class, encode, decode) { + if (contains(extensions, name)) { + printf("Datalink: double registration of extension '%s'. Skipping.", name); + return -1; + } + if (contains(extension_prefixes, prefix)) { + printf("Datalink: double registration of extension prefix '%s'. Skipping.", name); + return -1; + } + extensions[name] = { prefix: prefix, encode: encode, decode: decode, }; + extension_prefixes[prefix] = name; + max_prefix_length = math.max(max_prefix_length, size(prefix)); + append(contact_parents, class); + + return 0; +} + + +var data_separator = "!"; + + +### Transmission + +var clear_data = func { + send_data({}); +} + +var clear_timer = maketimer(1, clear_data); +clear_timer.singleShot = 1; + +# Send data through datalink. +# +# timeout: if set, sent data will be cleared after this time (other aircrafts +# won't receive it anymore). Useful if 'send_data' is not called often. +var send_data = func(data, timeout=nil) { + if (!input.power.getBoolValue()) { + last_data = {}; + input.mp.setValue(""); + return; + } + + # First encode channel + var str = encode_channel(input.channel.getValue()); + + # Then all extensions + last_data = data; + foreach(var ext; keys(data)) { + # Skip missing extensions with a warning + if (!contains(extensions, ext)) { + printf("Warning: unknown datalink extension %s in send_data().", ext); + continue; + } + str = str ~ data_separator ~ extensions[ext].prefix ~ extensions[ext].encode(data[ext]); + } + + input.mp.setValue(str); + + if (timeout != nil) { + clear_timer.restart(timeout); + } +} + +# Used internally to update the channel/identifier while keeping the same data. +# Does not touch timeout. +var last_data = {}; +var resend_data = func { + send_data(last_data); +} + +# Very slow timer to ensure the channel hash is updated regularly. +# Only relevant if you never call send_data(); +var hash_update_timer = maketimer(channel_hash_period/2, resend_data); +hash_update_timer.start(); + + +### Receiving + +# callsign to data hash +var aircrafts_data = {}; +# List of callsigns / indices connected on datalink (index is for /ai/models/multiplayer[i]). +var connected_callsigns = []; +var connected_indices = []; + +# Maintain callsign to multiplayer index hash +# (doesn't cost much since we already iterate over MP models). +var callsign_to_index = {}; + + +var get_data = func(callsign) { + return aircrafts_data[callsign]; +} + +var get_connected_callsigns = func { + return connected_callsigns; +} + +var get_connected_indices = func { + return connected_indices; +} + +var get_all_callsigns = func { + return keys(aircrafts_data); +} + +# Helper for modifying aircrafts_data. +var add_if_missing = func(aircrafts_data, callsign) { + if (!contains(aircrafts_data, callsign)) { + aircrafts_data[callsign] = Contact.new(callsign); + } + return aircrafts_data; +} + +var receive_loop = func { + var my_channel = input.channel.getValue(); + + aircrafts_data = {}; + connected_callsigns = []; + connected_indices = []; + + var mp_models = input.models.getChildren("multiplayer"); + foreach(var mp; mp_models) { + var idx = mp.getIndex(); + if (!mp.getValue("valid")) continue; + var callsign = mp.getValue("callsign"); + if (callsign == nil) continue; + + callsign_to_index[callsign] = idx; + + var data = mp.getValue(mp_path); + if (data == nil) continue; + + # Split channel part and data part + var tokens = split(data_separator, data); + + # Check channel + if (!check_channel(tokens[0], callsign, my_channel)) continue; + + # We check this _after_ the channel. Checking the channel is quite cheap, + # and we don't know how slow this function is, it might have a get_cart_ground_intersection() + if (!can_transmit(callsign, mp, idx)) continue; + + # Add to list of connected aircrafts. + append(connected_callsigns, callsign); + append(connected_indices, idx); + # Add to data + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_on_link(1); + + # Parse extensions data + for (var i=1; i size(tokens)) break; + var prefix = left(tokens[i], len); + if (contains(extension_prefixes, prefix)) { + extension = extension_prefixes[prefix]; + break; + } + } + # Unknown extension, skip + if (extension == nil) continue; + # Remove prefix + var data = substr(tokens[i], len); + # Decode + aircrafts_data = extensions[extension].decode(aircrafts_data, callsign, data); + } + } +} + +var receive_timer = maketimer(receive_period, receive_loop); + + +# Start / stop listener +setlistener(input.power, func (node) { + if (node.getBoolValue()) { + receive_timer.start(); + resend_data(); # Sets channel/identifier + } else { + receive_timer.stop(); + aircrafts_data = {}; + clear_data(); + } +}, 1, 0); + +# Listener to resend data so as to update the channel. +setlistener(input.channel, resend_data); + + + +#### Extensions + +## Identifier + +var ContactIdentifier = { + init: func { + me._identifier = nil; + }, + set_identifier: func(ident) { + me._identifier = ident; + }, + identifier: func { + return me._identifier; + }, +}; + +var encode_identifier = func(ident) { + # Force string conversion + ident = ""~ident; + + if (find("!", ident) >= 0) { + printf("Datalink: Identifier is not allowed to contain '!': %s.", ident); + return ""; + } else { + return ident; + } +} + +var decode_identifier = func(aircrafts_data, callsign, str) { + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_identifier(str); + return aircrafts_data; +} + +register_extension("identifier", "I", ContactIdentifier, encode_identifier, decode_identifier); + + + +## Contacts + +# IFF status transmitted over datalink. +var IFF_UNKNOWN = 0; # Unknown status +var IFF_HOSTILE = 1; # Considered hostile (no response to IFF). +var IFF_FRIENDLY = 2; # Friendly, because positive IFF identification. +# This is also the priority order for IFF reports in case of conflicts: +# e.g. a contact will be reported as friendly if anyone on datalink reports it as friendly. + +var ContactTracked = { + init: func { + me._tracked_by = nil; + me._iff = IFF_UNKNOWN; + }, + set_tracked_by: func(callsign) { + me._tracked_by = callsign; + }, + set_iff: func(iff) { + # Priority order on IFF values (friendly, then hostile, then no data). + me._iff = math.max(me._iff, iff); + }, + tracked: func { + return me._tracked_by != nil; + }, + tracked_by: func { + return me._tracked_by; + }, + tracked_by_index: func { + return (me._tracked_by != nil) ? callsign_to_index[me._tracked_by] : nil; + }, + iff: func { + return me._iff; + }, + is_known: func { + return me.on_link() or me.tracked(); + }, + is_friendly: func { + return me.on_link() or me.iff() == IFF_FRIENDLY; + }, + is_hostile: func { + return !me.on_link() and me.iff() == IFF_HOSTILE; + }, +}; + +# Contact encoding: callsign + bits +# callsign: the callsign encoded with emesary.TransferString +# bits: bitfield |xxxxxxff| (left is most significant) +# f: IFF, x: unused +# encoded with emesary.TransferByte +# Additional values may be appended for extensions. + +var encode_contact = func(contact) { + # Encode bitfield + var bits = contact["iff"] != nil ? contact.iff : IFF_UNKNOWN; + + return emesary.TransferString.encode(clean_callsign(contact.callsign)) + ~ emesary.TransferByte.encode(bits); +} + +var decode_contact = func(str) { + var res = {}; + var dv = emesary.TransferString.decode(str, 0); + res.callsign = dv.value; + dv = emesary.TransferByte.decode(str, dv.pos); + var bits = dv.value; + res.iff = math.mod(bits, 4); + return res; +} + +# Special character, won't be used by emesary encoding. +var contacts_separator = "#"; + +var encode_contacts = func(contacts) { + var str = ""; + foreach (var contact; contacts) { + str = str~encode_contact(contact)~contacts_separator; + } + return str; +} + +var decode_contacts = func(aircrafts_data, callsign, str) { + var contacts = split(contacts_separator, str); + foreach (var contact; contacts) { + if (contact == "") continue; + var res = decode_contact(contact); + aircrafts_data = add_if_missing(aircrafts_data, res.callsign); + aircrafts_data[res.callsign].set_iff(res.iff); + aircrafts_data[res.callsign].set_tracked_by(callsign); + } + return aircrafts_data; +} + +register_extension("contacts", "C", ContactTracked, encode_contacts, decode_contacts); + + + +## Coordinate + +var ContactPoint = { + init: func { + me._point = nil; + }, + set_point: func(point) { + me._point = point; + }, + point: func { + return me._point; + }, +}; + +var encode_point = func(coord) { + return emesary.TransferCoord.encode(coord); +} + +var decode_point = func(aircrafts_data, callsign, str) { + var coord = emesary.TransferCoord.decode(str, 0).value; + aircrafts_data = add_if_missing(aircrafts_data, callsign); + aircrafts_data[callsign].set_point(coord); + return aircrafts_data; +} + +register_extension("point", "P", ContactPoint, encode_point, decode_point); diff --git a/oprf-versions.json b/oprf-versions.json index 8aee6cb..bbc0ff1 100644 --- a/oprf-versions.json +++ b/oprf-versions.json @@ -1,313 +1,313 @@ { "vector": [ { - "version": null, + "version": "2.0.3", "path": "BUK-M2/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "depot/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "fleet/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "Frigate/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "gci-radar/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "groundtarget/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "MIM-104D/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "missile-frigate/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "S-200/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "S-300/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "S-75/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "SA-6/Nasal/vector.nas" }, { - "version": null, + "version": "2.0.3", "path": "ZSU-23/Nasal/vector.nas" } ], "missile-code": [ { - "version": null, + "version": "1.1.1", "path": "BUK-M2/Nasal/guided-missiles.nas" }, { - "version": null, + "version": "1.1.1", "path": "MIM-104D/Nasal/guided-missiles.nas" }, { - "version": null, + "version": "1.1.1", "path": "S-75/Nasal/guided-missiles.nas" }, { - "version": null, + "version": "1.1.1", "path": "SA-6/Nasal/guided-missiles.nas" } ], "damage": [ { - "version": null, + "version": "1.2.1", "path": "BUK-M2/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "depot/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "fleet/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "Frigate/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "gci-radar/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "groundtarget/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "MIM-104D/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "missile-frigate/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "S-200/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "S-300/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "S-75/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "SA-6/Nasal/damage.nas" }, { - "version": null, + "version": "1.2.1", "path": "ZSU-23/Nasal/damage.nas" } ], "datalink": [ { - "version": null, + "version": "1.1.0", "path": "BUK-M2/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "fleet/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "MIM-104D/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "missile-frigate/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "S-200/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "S-300/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "S-75/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "SA-6/Nasal/datalink.nas" }, { - "version": null, + "version": "1.1.0", "path": "ZSU-23/Nasal/datalink.nas" } ], "armament-notification": [ { - "version": null, + "version": "1.1.0", "path": "BUK-M2/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "depot/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "fleet/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "Frigate/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "gci-radar/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "groundtarget/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "MIM-104D/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "missile-frigate/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "S-200/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "S-300/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "S-75/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "SA-6/Nasal/ArmamentNotification.nas" }, { - "version": null, + "version": "1.1.0", "path": "ZSU-23/Nasal/ArmamentNotification.nas" } ], "geo-bridged-transmitter": [ { - "version": null, + "version": "1.0.1", "path": "BUK-M2/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "depot/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "fleet/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "Frigate/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "gci-radar/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "groundtarget/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "MIM-104D/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "missile-frigate/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "S-200/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "S-300/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "S-75/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "SA-6/Nasal/GeoBridgedTransmitter.nas" }, { - "version": null, + "version": "1.0.1", "path": "ZSU-23/Nasal/GeoBridgedTransmitter.nas" } ], "asset-fire-control": [ { - "version": null, + "version": "1.0.0", "path": "BUK-M2/Nasal/fire-control.nas" }, { - "version": null, + "version": "1.0.0", "path": "MIM-104D/Nasal/fire-control.nas" }, { - "version": null, + "version": "1.0.0", "path": "S-75/Nasal/fire-control.nas" }, { - "version": null, + "version": "1.0.0", "path": "SA-6/Nasal/fire-control.nas" } ], "asset-radar-logic": [ { - "version": null, + "version": "1.1.0", "path": "BUK-M2/Nasal/radar-logic.nas" }, { - "version": null, + "version": "1.1.0", "path": "MIM-104D/Nasal/radar-logic.nas" }, { - "version": null, + "version": "1.1.0", "path": "S-75/Nasal/radar-logic.nas" }, { - "version": null, + "version": "1.1.0", "path": "SA-6/Nasal/radar-logic.nas" }, { - "version": null, + "version": "1.1.0", "path": "gci-radar/Nasal/radar-logic.nas" } ]