# acars: library for Hoppie's ACARS.
# https://www.hoppie.nl/acars
# https://www.hoppie.nl/psx/python/

# Jeroen Hoppenbrouwers <hoppie@hoppie.nl> November 2024

VERSION = "AA-beta-01"
ACARS_URL = "http://www.hoppie.nl/acars/system/connect.html"


##### Modules ############################################################

import asyncio
import sys
import random

try:
  import aiohttp
except ModuleNotFoundError:
  print("Module 'aiohttp' not found. Install it with:")
  print("python -m pip install aiohttp")
  exit(1)


##### ACARS Client #######################################################

class Client:

  ##### Published API #####

  def __init__(self, logoncode):
    """Creates an ACARS object but does not go online."""
    self.logon     = logoncode
    self.callsign  = None
    self.polltask  = None
    self.acars     = None
    self.stop      = asyncio.Event()
    self.agent     = "python-acars/"+VERSION
    self.onUplink  = lambda: None      # Replace these with functions.
    self.logger    = lambda msg: None
  # init()


  def __enter__(self):
    """ Nifty helper to allow "with acars" contexts. """
    return self
  # enter()


  def __exit__(self, exc_type, exc_val, exc_tb):
    """ Nifty helper to allow "with acars" contexts. """
    pass
  # exit()


  async def install(self, receiver=None):
    """Installs the ACARS subsystem. Does not go online.
       receiver is the callback function after uplinks.
    """
    # Creates a HTTP object that could be used to make requests. This is how
    # aiohttp wants it.
    self.onUplink = receiver
    self.acars = aiohttp.ClientSession()
    self.logger("ACARS installed")
    # We keep this coroutine open to allow a gather() to wait on our
    # completion. This is the equivalent of keeping a process running.
    try:
      # This is to cleanly get out of the waiting loop on request.
      await self.stop.wait()
    except:
      # This is when the waiting loop was forcefully terminated by Ctrl-C.
      await self.uninstall()
  # install()


  async def uninstall(self):
    """Uninstalls the ACARS subsystem."""
    self.callsign = None
    if self.polltask is not None:
      self.polltask.cancel()
      self.polltask = None
    if self.acars is not None:
      await self.acars.close()
      self.acars = None
    self.onUplink = lambda: None
    self.stop.set()
    self.logger("ACARS uninstalled")
  # uninstall()


  def set_callsign(self, callsign):
    """Activate the radio with the callsign. We can now transmit downlinks.
       Take care that uplinks are only received after one downlink, the
       ground must know we are online."""
    if self.acars is None:
      return "error {ACARS not installed}"
    if (callsign is not None) and (callsign!=""):
      self.callsign = callsign
      self.logger(f"ACARS activated as {callsign}")
    else:
      self.callsign = None
      self.logger("ACARS deactivated")
  # set_callsign()


  async def ping_server(self):
    """Basic check for connection health. Note this needs to be awaited."""
    self.logger("Testing link...")
    r = await self._connect("server","ping","")
    self.logger(f"  response: {r}")
    return r.startswith("ok")
  # ping_server()


  def downlink_telex(self, to, text):
    """Queue a downlink for transmission when able.
    You can retain the returned task and query it for .done() and .result
    later. Result = True means got sent. Does not mean got delivered."""
    return asyncio.create_task(self._downlink_telex(to, text))


  def downlink_inforeq(self, request, icao):
    """Queue a downlink for transmission when able.
    You can retain the returned task and query it for .done() and .result
    later. Result = True means got sent. Does not mean got delivered."""
    return asyncio.create_task(self._downlink_inforeq(request, icao))


  ##### Internal API, not for application use #####

  async def _connect(self, to, type, packet):
    """Execute one connect call to the ACARS server.
       Returns the server response as-is, or a higher level error message.
    """
    if self.acars is None:
      return "error {ACARS not installed}"
    if self.callsign is None:
      return "error {no callsign set}"
    headers = {
      "User-Agent": self.agent
    }
    data = {
      "logon":  self.logon,
      "from":   self.callsign,
      "to":     to,
      "type":   type,
      "packet": packet
    }
    self.logger(f"  downlinking {type}...")
    try:
      async with self.acars.post(ACARS_URL,
                                 headers=headers,
                                 data=data) as response:
        text = await response.text()
        if type!="ping" and self.polltask is None:
          self.polltask = asyncio.create_task(self._poll())
        if response.status != 200:
          return f"error {{ACARS server unreachable HTTP {response.status}}}"
        else:
          return await response.text()
    except Exception as ex:
      return f"{ex}"
  # _connect()


  async def _poll(self):
    """Start the periodic poll to the ACARS server for new uplinks."""
    self.logger("Started polling")
    await asyncio.sleep(5)
    try:
      while True:
        r = await self._connect("server", "poll", "");
        for (frm,typ,txt) in self._decode_uplink(r):
          acars.onUplink(frm, typ, txt)
        wait = random.randint(45,75)  # www.hoppie.nl/acars/system/tech.html
        self.logger(f"  next poll in {wait} seconds")
        await asyncio.sleep(wait)
    except:
      self.set_callsign("")
      self.logger("Stopped polling")
  # _poll()


  async def _downlink_telex(self, to, text):
    """Downlink a telex to a station. True if downlink successful."""
    r = await self._connect(to, "telex", text)
    if not r.startswith("ok"):
      self.logger(r)
      return False
    else:
      return True
  # _downlink_telex()


  async def _downlink_inforeq(self, request, icao):
    """Downlink an information request. True if downlink successful.
       Request can be one of "metar", "taf", "shorttaf", "vatatis",
       or "peatis". All also need an ICAO code.
    """
    r = await self._connect("server", "inforeq", f"{request} {icao}")
    if r.startswith("ok"):
      # TODO decode the received thing and deliver it explicitly.
      # Intentionally delay the delivery of the result. This should have
      # been done in ACARS as an asynchronous request-response pair, but it
      # was built in 2003 as a synchronous call...
      Client._after(5, lambda:self.onUplink("server", request, r[4:-1]))
      return True
    else:
      self.logger(r)
      return False
  # _downlink_inforeq()


  def _decode_uplink(self, packet):
    # A packet is:  ok {HOPPIE telex {THIS IS A TEST. I LIKE TESTS.}}
    #                  {HOPPIE telex {THIS IS A TEST. I LIKE TESTS}}
    #                  ...
    #      or    error {what}
    # returns now: [ [msg1], [msg2], ...]
    # with msg = [from, type, text]
    if packet.startswith("ok"):
      # Look for opening brace.
      pos = packet.find("{", 2)
      if pos==-1:
        # No message at all.
        return []
      messages = []
      while True:
        f_pos = packet.find(" ", pos+1)
        frm = packet[pos+1:f_pos]
        t_pos = packet.find(" ", f_pos+1)
        typ = packet[f_pos+1:t_pos]
        o_pos = packet.find("{", t_pos+1)
        c_pos = packet.find("}", o_pos+1)
        msg = packet[o_pos+1:c_pos]
        messages.append([frm, typ, msg])
        # Just skip the 2nd closing brace and look for the next opening.
        pos = packet.find("{", c_pos)
        if pos==-1:
          return messages
      # while True
    else:
      # Not ok.
      self.logger(packet)
      return []
  # _decode_uplink()


  def _after(delay, function):
    """Schedule the (synchronous!) function to be called after delay seconds.
       If the function has parameters, wrap it in a lambda:
       _after(5, lambda: yourfunction(a,b,c))
    """
    if not callable(function):
      raise TypeError("Please insert 'lambda:' before the function argument " 
                      "in the call to _after()")
    async def wrapper():
      await asyncio.sleep(delay)
      return function()
    return asyncio.create_task(wrapper())   # can check this later if needed
  # _after()

# class Client


##### SELF TESTS #########################################################

""" The self test/demo is run when you execute this module as if it were a
    toplevel script. """

if __name__ == "__main__":
  """ Set up a connection to Hoppie's ACARS and play a bit. """

  async def demo_acars():
    acars.set_callsign(callsign)
    # Basic health check.
    if not (await acars.ping_server()):
      print(f"{rd}Problem:{df} no contact with ground system, see above")
      await acars.uninstall()
      return
    acars.downlink_inforeq("metar", "lppt")
    # Some messages to yourself.
    acars.downlink_telex(callsign, "this is a test. I like tests 1")
    acars.downlink_telex(callsign, "this is a test. I like tests 2")
    acars.downlink_telex(callsign, "this is a test. I like tests 3")
    acars.downlink_inforeq("shorttaf", "eham")
    # Demo of how to know when a downlink has completed.
    lppr = acars.downlink_inforeq("metar", "lppr")
    while not lppr.done():
      await asyncio.sleep(0.1)
    if lppr.result():
      print("metar lppr request downlink completed")
    else:
      print("metar lppr request downlink failed")
    # The demo ends here, but the ACARS subsystem remains installed.
  # demo_acars()


  def demo_receive(frm, typ, text):
    """This thing receives all message uplinks. Not tech stuff like ping."""
    print("\nACARS UPLINK")
    print("From: ", frm)
    print("Type: ", typ)
    print(text)
  # demo_receive()


  async def supervisor():
    """This is the inittab, so to say. It runs all tasks until they end."""
    await asyncio.gather(
      acars.install(demo_receive),
      demo_acars()
    )
  # supervisor()


  ##### MAIN #############################################################

  try:
    # To be fancy, we use colours. Windows terminals understand ANSI by now.
    df = "\033[0m"  # default colour
    bk = "\033[30m"
    rd = "\033[31m"
    gr = "\033[32m"
    yl = "\033[33m"
    bl = "\033[34m"
    mg = "\033[35m"
    cy = "\033[36m"
    wh = "\033[37m"

    print(f"{df}{yl}Self-test for acars.py, version {VERSION}{df}\n")

    if len(sys.argv)<3:
      print("No command line arguments given. You can provide your logon\n"
            "code and callsign directly as arguments if you want.\n")
      logon_code = input("Please give your Hoppie's ACARS logon code: ")
      callsign   = input("  and your call sign for this demo: ")
    else:
      logon_code = sys.argv[1]
      callsign   = sys.argv[2]

    # Create an ACARS radio and install a custom logger, then run the test.
    with Client(logon_code) as acars:
      acars.logger = lambda msg: print(f"  {gr}acars{df}: {msg}")
      asyncio.run(supervisor())
  except KeyboardInterrupt:
    print(f"\n\n{yl}Stopped by keyboard interrupt (Ctrl-C){df}")

# EOF
