#### 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);