# psx-python.demo.py: simple connector between a PSX Main Server and some
# virtual controls. Meant as a starting point for your own code.


##### Modules ############################################################

VERSION = "0.0-alpha-1"

import asyncio
import argparse
import sys


##### Coroutines, Classes, and Functions #################################

class PSX:
  """ There can only be one PSX connection at a time, so this class is not
      built to be instantiated into objects. It is only used to provide a
      convenient wrapper block for all PSX link related functions. """

  reader = None
  writer = None

  async def connect(host, port):
    """ Keep trying forever to open a connection and run it. """
    while True:
      await asyncio.sleep(2)
      print(f"Attempting to connect to PSX Main Server at {host}...")
      try:
        PSX.reader, PSX.writer = await asyncio.open_connection(host, port)
      except OSError:
        print("Oops, that failed. Has the PSX Main Server been started?")
        print("Will retry in 10 seconds")
        await asyncio.sleep(10)
        continue

      # We got a connection. Respond to incoming data in a loop, until the
      # connection breaks or we want to disconnect.
      try:
        while True:
          line = await PSX.reader.readline()
          if PSX.reader.at_eof():
            break
          # decode() converts bytes object to Unicode string object.
          key, sep, value = line.decode().strip().partition("=")
          if key=="exit":
            break                # Main Server is disconnecting us.
          if key[0]=="L":
            Db.lex(key, value)   # This is a lexicon mapping.
          else:
            Db.set(key, value)   # This is a normal Q-variable.
        PSX.writer.close()
        await PSX.writer.wait_closed()
        print("Disconnected from PSX Main Server")
      except asyncio.exceptions.CancelledError:
        # The connector task is being canceled; typically by a Ctrl-C.
        print("Disconnecting from PSX Main Server")
        PSX.writer.write("exit\n".encode())
        PSX.writer.close()
        await PSX.writer.wait_closed()
        break
  # connect()

  def send(key, value):
    """ Submit the given key,value combo to the PSX Main Server. """
    mapped = Db.mapToQ(key)
    if mapped!=None:
      key = mapped    # Found a matching Lexicon mapping.
    PSX.writer.write(f"{key}={value}\n".encode())
  # send()

# class PSX


class Db:
  """ One single Database class, no instances, only class vars/methods. """
  lexicon   = dict()    # (key, name)
  variables = dict()    # (key, value)
  callbacks = dict()    # (key, [callback, callback, ...])

  def lex(key, name):
    """ Process a lexicon entry. Only map those lexicon names that have been
        subscribed to. """
    if name in Db.variables:
      # Lexicon key syntax: "Li35(E)". We want the "i35" part.
      q, dummy, dummy = key[1:].partition("(")
      Db.lexicon["Q"+q] = name
      # print(f"Lex: {name} = Q{q}")

  def mapToQ(name):
    """ Translate a lexicon name to the Q code, for easy transmitting. """
    for k,n in Db.lexicon.items():
      if name==n:
        # print(f"Map: {name} to {k}")
        return k
    else:
      return None

  def set(key, value):
    """ Set the new key to the given value and call the subscribers.
        Only process variables that have been subscribed to. """
    if key in Db.lexicon:
      key = Db.lexicon[key]    # Rewrite the Q code with the name.
    if key in Db.variables:
      # print(f"Set: {key} = {value}")
      Db.variables[key] = value
      # See whether there are any callbacks registered on this key.
      if key in Db.callbacks:
        for callback in Db.callbacks[key]:
          # Call all subscribers with the key and new value as parameters.
          # The key is very useful for multi-key-handling callbacks.
          callback(key, value)

  def get(key):
    """ Retrieve the value stored on the given key. """
    if key in Db.variables:
      return Db.variables[key]
    else:
      print(f"Get {key} which is unknown; trying to return empty string")
      return ""

  def subscribe(key, cb=None):
    """ Add the key to the monitor list and optionally
        the given function to the key's callback list. """
    if key not in Db.variables:
      Db.variables[key] = None    # Do not overwrite already known values.
    if cb != None:
      if key in Db.callbacks:
        Db.callbacks[key].append(cb)
      else:
        Db.callbacks[key] = [cb]    # Remember to create a list.
# Db


def McpWdoAlt(key, value):
  """ Called when the MCP Altitude Window value changes. """
  altitude = int(value)*100
  print(f"MCP Window Altitude: {altitude} ft")
  # Play with the FMC selector as a demo.
  if altitude>=37000:
    PSX.send("FmcEicas",1)    # Right FMC
  else:
    PSX.send("FmcEicas",0)    # Left FMC


async def main(host, port):
  """ Main Async Event Loop.
      Start the concurrent coroutines and wait forever, until either they
      all end (should not happen) or any unhandled exception breaks out of
      its coroutine.
  """
  await asyncio.gather(
    PSX.connect(host, port)
  )
# main


##### MAIN ###############################################################

print(f"PSX Python Connector v{VERSION}, (C) Hoppie 2020\n")

# This script is only guaranteed to work on Python 3.8 and later, mostly
# because of the asyncio API and support.
if not (sys.version_info.major==3 and sys.version_info.minor>=8):
  print("This script needs at least Python 3.8 to run. You have:")
  print(sys.version)
  exit()

# Figure out the command line options and handle help requests etc.
p = argparse.ArgumentParser(description="""
       Connects to a PSX Main Server and shows a few techniques to
       communicate with the simulator via TCP/IP sockets.""")
p.add_argument("--host", help="the PSX Main Server host, "
                              "default 127.0.0.1",
               default="127.0.0.1")
p.add_argument("--port", help="the PSX Main Server port, default 10747",
               default=10747)
args = p.parse_args()

# Register all PSX variables we are interested in, and their callbacks if
# any.
Db.subscribe("id")
Db.subscribe("version", lambda key, value:
  print(f"Connected to PSX {value} as client #{Db.get('id')}"))
Db.subscribe("McpWdoAlt", McpWdoAlt)
Db.subscribe("FmcEicas")

# There can be at most one entry point to the Python asyncio Event Loop.
# When this one returns, the event loop has been "broken" by an unhandled
# exception, which usually means the whole program should be terminated.
try:
  asyncio.run(main(args.host, args.port))
except KeyboardInterrupt:
  """This only works properly from Python 3.8 onwards."""
  print("\nStopped by keyboard interrupt (Ctrl-C)")

# EOF
