"""
   TEAMS-BUTTONS HANDLER
   ---------------------

   Companion Python script for the Teams Buttons device — a set of 4 physical
   push buttons with RGB LEDs, built on a Teensy 2.0 (or compatible Arduino).
   The Arduino sketch is in TeamsButtonsArduino.ino.

   OVERVIEW
   --------
   This script acts as a bridge between the Teams Buttons hardware and Microsoft
   Teams. It runs two async loops in parallel:
     1. WebSocket loop  — connects to the Teams local API, receives meeting-state
                          updates, and sends actions (mute, video, reactions, etc.)
     2. Serial loop     — receives button-press events from the Arduino and sends
                          the corresponding action to Teams via the WebSocket.
   A periodic heartbeat timer refreshes the LED state every 10 seconds as a
   fallback in case an update is missed.

   ENABLING THE TEAMS API
   ----------------------
   In Microsoft Teams, go to Settings > Privacy > Third-party app API and click
   "Manage API". Toggle "Enable API" to On.
   Note: if the Manage API button is greyed out, your IT administrator has
   disabled third-party device pairing for your account.

   PAIRING
   -------
   On first connect the script has limited access. Full pairing (which unlocks
   meeting controls) requires Teams to be in an active meeting. The script
   triggers pairing automatically by sending a toggle-mute action, which causes
   Teams to display an Allow/Block prompt. Once the user clicks Allow, Teams
   sends a meetingState block and the script is considered paired.
   Teams periodically issues a new token via a tokenRefresh message; the script
   stores it and includes it in the next outgoing request automatically.

   SERIAL PROTOCOL (Arduino <-> Python)
   -------------------------------------
   Button presses (Arduino -> Python), single byte per event:
     Short press:  byte = button index (0–3)
     Long press:   byte = button index + 128 (i.e. 128–131)
     Button assignments:  0 = Meeting, 1 = Audio, 2 = Video, 3 = Hand

   LED colors (Python -> Arduino), 13-byte packet:
     Byte 0:      command type (0 = set colors)
     Bytes 1–3:   R, G, B for button 0 (Meeting)
     Bytes 4–6:   R, G, B for button 1 (Audio)
     Bytes 7–9:   R, G, B for button 2 (Video)
     Bytes 10–12: R, G, B for button 3 (Hand)
   The Arduino has a watchdog: if no serial data is received for 60 seconds it
   displays a rainbow idle pattern until communication resumes.

   DEPENDENCIES
   ------------
   pip install websockets pyserial-asyncio

   CONFIGURATION
   -------------
   Edit the CONFIGURATION section below to set the correct serial port for your
   platform (e.g. "COM4" on Windows, "/dev/tty.usbmodem..." on macOS/Linux).

   MS TEAMS WEBSOCKET API REFERENCES
   ----------------------------------
   - v2.0.0: https://github.com/AntoineGS/teams-status-rs
   - v1.6.x: https://lostdomain.notion.site/Microsoft-Teams-WebSocket-API-5c042838bc3e4731bdfe679e864ab52a

   REVISION HISTORY
   ----------------
   - v1.0.0, May 2026, first public release

   Roger Meier

"""

# Set debug to True for verbose mode
debug = False

# ----------------------------------------------------------------------
# CONFIGURATION
# ----------------------------------------------------------------------
URL = "ws://localhost:8124/?protocol-version=2.0.0&manufacturer=RogerMeier&device=TeamsButtons&app=TeamsButtonsHandler&app-version=1.0.0"
#port = "COM4"
port = "/dev/tty.usbmodem123451"
numButtons = 4


# import necessary libaries
import asyncio
import websockets
from serial_asyncio import open_serial_connection
import json

# Status
ws_connected = False
canPair = False
isPaired = False
isPairing = False
serial_connected = False
isInMeeting = False
isMuted = False
isVideoOn = False
isHandRaised = False
isRecordingOn = False
isBackgroundBlurred = False
hasUnreadMessages = False
curRequestID = -1
token = ""
showPairingMessage = True
showIsPairedMessage = True


# Objects
reader = None
writer = None
websocket = None
ledColors = [[0 for _ in range(3)] for _ in range(numButtons)]

# Shutdown event — created inside main() once the event loop is running.
# Initialised to None here; all handlers access it as a global.
shutdown_event = None



# ----------------------------------------------------------------------
# WEBSOCKET HANDLER
# ----------------------------------------------------------------------
async def listen_websocket():
    # Globals that are assigned in this function
    global ws_connected, canPair, isPaired, isPairing
    global isInMeeting, isMuted, isVideoOn, isHandRaised
    global isRecordingOn, isBackgroundBlurred, hasUnreadMessages
    global curRequestID, websocket, token
    global showPairingMessage, showIsPairedMessage

    # Attempt to establish the WebSocket connection
    try:
        websocket_cm = websockets.connect(URL)
        ws = await websocket_cm.__aenter__()
    except OSError as e:
        print(f"ERROR: Could not connect to WebSocket at {URL}")
        print(f"       Is Microsoft Teams running and the API enabled?")
        print(f"       ({e})")
        shutdown_event.set()
        return
    except Exception as e:
        print(f"ERROR: Unexpected error connecting to WebSocket: {e}")
        shutdown_event.set()
        return

    websocket = ws
    ws_connected = True
    print("Connected to WebSocket")

    try:
        # Continuous loop to keep listening for messages
        async for message in websocket:
            if debug: print(f"WS Received: {message}")
            # process message
            js = json.loads(message)
            if "meetingUpdate" in js:
                # we got an update
                meetingUpdate = js["meetingUpdate"]
                canPair = meetingUpdate["meetingPermissions"]["canPair"]
                if "meetingState" in meetingUpdate:
                    if debug: print("Got meetingState")
                    meetingState = meetingUpdate["meetingState"]
                    # we're paired
                    isPaired = True
                    if showIsPairedMessage:
                        showIsPairedMessage = False
                        print("--> TeamsButtonHandler is paired to Teams.")
                    if isPairing:
                        isPairing = False
                        await sendAction("toggle-mute")
                    # we're possibly in a meeting
                    isInMeeting = meetingState["isInMeeting"]
                    isMuted = meetingState["isMuted"]
                    isVideoOn = meetingState["isVideoOn"]
                    isHandRaised = meetingState["isHandRaised"]
                    isRecordingOn = meetingState["isRecordingOn"]
                    isBackgroundBlurred = meetingState["isBackgroundBlurred"]
                    hasUnreadMessages = meetingState["hasUnreadMessages"]
                else:
                    # we're not paired
                    isPaired = False
                    if showPairingMessage:
                        showPairingMessage = False
                        print("--> Teams needs to be in a meeting before TeamsButtonHandler can fully pair with it.")
                    if canPair:
                        if not isPaired and not isPairing:
                            # attempt to pair
                            isPairing = True
                            await sendAction("toggle-mute")
                    
            elif "response" in js:
                # just a response to a command
                response = js["response"]
                if debug: print("Got response: " + response)
                if response != "Success":
                    print("Teams API Response: " + response)
                curRequestID = js["requestId"]
                
            elif "tokenRefresh" in js:
                if debug: print("Got tokenRefresh")
                # get the new token
                token = js["tokenRefresh"]
                # use the token with the next action request
            elif "errorMsg" in js:
                # show error message
            	print("Teams API Error Message: " + js["errorMsg"])
            else:
                # unexpected API response:
                print("Unexpected API Message: " + message)
            	
                
            # Update LEDs
            if debug: print("Update LEDs")
            updateLEDs(False)

    except websockets.exceptions.ConnectionClosedError as e:
        print(f"ERROR: WebSocket connection was closed unexpectedly. ({e})")
        shutdown_event.set()
    except websockets.exceptions.ConnectionClosedOK:
        print("WebSocket connection closed.")
        shutdown_event.set()
    except Exception as e:
        print(f"ERROR: Unexpected WebSocket error: {e}")
        shutdown_event.set()
    finally:
        ws_connected = False
        await websocket_cm.__aexit__(None, None, None)


# ----------------------------------------------------------------------
# SERIAL PORT HANDLER
# ----------------------------------------------------------------------
async def listen_serial():
    global serial_connected
    global ws_connected
    global reader
    global writer
    global websocket

    # Attempt to open the serial port
    try:
        reader, writer = await open_serial_connection(url=port, baudrate=9600)
    except Exception as e:
        print(f"ERROR: Could not open serial port '{port}'")
        print(f"       Is the Teams Buttons device connected?")
        print(f"       ({e})")
        shutdown_event.set()
        return

    serial_connected = True
    print("Connected to Serial Port")

    try:
        # Receive data from serial port
        while True:
            data = await reader.read(1)
            if not data:
                # EOF — device disconnected cleanly
                print("ERROR: Serial port closed (device disconnected?).")
                shutdown_event.set()
                break
            char = data[0]  # equivalent to ord(), but safe on empty bytes
            if debug: print(f"Serial Received: {char}")
            if char == 0:
                # MEETING BUTTON, short press
                if isPaired:
                    # leave the meeting
                    await sendAction("leave-call")
                else:
                    # attempt to pair teams
                    isPairing = True
                    await sendAction("toggle-mute")
            elif char == 1:
                # AUDIO BUTTON, short press
                if isPaired:
                    # toggle the mute on/off
                    await sendAction("toggle-mute")
                else:
                    # attempt to pair teams
                    isPairing = True
                    await sendAction("toggle-mute")
            elif char == 2:
                # VIDEO BUTTON, short press
                if isPaired:
                    # toggle video on/off
                    await sendAction("toggle-video")
                else:
                    # attempt to pair teams
                    isPairing = True
                    await sendAction("toggle-mute")
            elif char == 3:
                # HAND BUTTON, short press
                if isPaired:
                    # raise/lower hand
                    await sendAction("toggle-hand")
                else:
                    # attempt to pair teams
                    isPairing = True
                    await sendAction("toggle-mute")
            elif char == 128 + 0:
                # MEETING BUTTON, long press
                if isPaired:
                    # toggle recording
                    await sendAction("toggle-recording")
                else:
                    if ws_connected:
                        # disconnect websocket
                        print("Disconnecting websocket")
                        ws_connected = False
                        await websocket.close()
                        # updateLEDs() with no arg checks ws_connected internally,
                        # so this correctly turns all LEDs off now that we're disconnected
                        updateLEDs()
                    else:
                        # connect to websocket
                        print("Connecting to websocket")
                        shutdown_event.clear()
                        asyncio.create_task(listen_websocket())
                        updateLEDs()
            elif char == 128 + 1:
                # AUDIO BUTTON, long press
                if isPaired:
                    # send "like" reaction
                    await sendAction("send-reaction", "type=like")
                else:
                    # attempt to pair teams
                    isPairing = True
                    await sendAction("toggle-mute")
            elif char == 128 + 2:
                # VIDEO BUTTON, long press
                if isPaired:
                    # toggle background blur on/off
                    await sendAction("toggle-background-blur")
                else:
                    # attempt to pair teams
                    isPairing = True
                    await sendAction("toggle-mute")
            elif char == 128 + 3:
                # HAND BUTTON, long press
                if isPaired:
                    # send "applause" reaction
                    await sendAction("send-reaction", "type=applause")
                else:
                    # attempt to pair teams  
                    isPairing = True
                    await sendAction("toggle-mute")

    except asyncio.IncompleteReadError:
        print("ERROR: Serial connection lost (device disconnected?).")
        shutdown_event.set()
    except Exception as e:
        print(f"ERROR: Unexpected serial port error: {e}")
        shutdown_event.set()
    finally:
        serial_connected = False


# ----------------------------------------------------------------------
# PERIODIC TIMER EVENT
# ----------------------------------------------------------------------
async def heartbeat_timer(interval=10):
    """Fires a specific action every 10 seconds."""
    try:
        while True:
            await asyncio.sleep(interval)
            if debug: print("Refresh LEDs")
            updateLEDs(False)
    except asyncio.CancelledError:
        pass  # Clean shutdown — nothing to do

     
# ----------------------------------------------------------------------
# UTILITY METHODS
# ----------------------------------------------------------------------

# Send action to MS Teams API
async def sendAction(request, parameter = ""):
    global websocket
    global ws_connected
    action = makeAction(request, parameter)
    if ws_connected:
        await websocket.send(action)
    else:
        print("Websocket is disconnected")

def makeAction(request, parameter = ""):
    global curRequestID
    global token
    # add request to action    
    data = {"action": request}
    # add parameter to action if necessary
    if parameter != "":
        parts = parameter.split('=')
        parameters = {parts[0]: parts[1]}
        data["parameters"] = parameters
    # add request ID
    curRequestID = curRequestID + 1
    data["requestId"] = curRequestID
    # add API version
    data["apiVersion"] = "2.0.0"
    # add token if necessary
    if token != "":
        data["token"] = token
        token = ""
    json_string = json.dumps(data)
    if debug: print(json_string)
    return json_string
    
    
# Update the Teams Buttons LEDs    
def updateLEDs(clearLEDs = False):
    if serial_connected:
        if clearLEDs:
            ledColors[0] = [0,0,0]
            ledColors[1] = [0,0,0]
            ledColors[2] = [0,0,0]
            ledColors[3] = [0,0,0]
        else:
            if ws_connected:
                if isPaired:
                    if isInMeeting:
                        ledColors[0] = [0,255,0] # Meeting LED is GREEN
                        if isMuted:
                            ledColors[1] = [255,0,0] # AUDIO LED is RED
                        else:
                            ledColors[1] = [0,255,0] # AUDIO LED is GREEN
                        if isVideoOn:
                            ledColors[2] = [0,255,0] # VIDEO LED is GREEN
                        else:
                            ledColors[2] = [255,0,0] # VIDEO LED is RED
                        if isHandRaised:
                            ledColors[3] = [255,255,0] # HAND LED is YELLOW
                        else:
                            ledColors[3] = [0,0,0] # HAND LED is OFF
                    else:
                        # Not in meeting
                        # Meeting LED is RED, all others are OFF
                        ledColors[0] = [255,0,0]
                        ledColors[1] = [0,0,0]
                        ledColors[2] = [0,0,0]
                        ledColors[3] = [0,0,0]

                else:
                    # Connected, not paired
                    # Teams LED is BLUE
                    ledColors[0] = [0,0,255]
                    ledColors[1] = [0,0,0]
                    ledColors[2] = [0,0,0]
                    ledColors[3] = [0,0,0]
                    if canPair:
                        # in a meeting
                        ledColors[0] = [0,255,255] # Blue + Green
                    else:
                        # not in a meeting
                        ledColors[0] = [255,0,255] # Blue + Red
            else:
                # not connected to websocket
                # All LEDs off
                ledColors[0] = [0,0,0]
                ledColors[1] = [0,0,0]
                ledColors[2] = [0,0,0]
                ledColors[3] = [0,0,0]
                
        
        # Send colors to Buttons
        #if debug: print(ledColors)
        data = bytes([0] + ledColors[0] + ledColors[1] + ledColors[2] + ledColors[3])
        writer.write(data)        	



# ----------------------------------------------------------------------
# MAIN METHOD
# ----------------------------------------------------------------------
async def main():
    global shutdown_event
    # Create the shutdown event here, inside the running event loop
    shutdown_event = asyncio.Event()

    # Run async loop; cancel remaining tasks once shutdown_event is set
    # or any handler exits (e.g. on connection failure)
    tasks = [
        asyncio.create_task(listen_websocket()),
        asyncio.create_task(listen_serial()),
        asyncio.create_task(heartbeat_timer(10)),
    ]

    # Wait until the shutdown event fires or all tasks finish
    await shutdown_event.wait()

    # Cancel any still-running tasks
    for task in tasks:
        task.cancel()
    await asyncio.gather(*tasks, return_exceptions=True)
    print("Exiting.")

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("Closing application...")
