# version 1.2.0
import platform    
import subprocess
import configparser
import requests
import json
import string
import time
import argparse
import sys
import random
from base64 import b64encode,b64decode
from Crypto.Hash import SHA, HMAC
from requests.auth import HTTPDigestAuth
import paho.mqtt.client as mqttc
import os 

# Suppress "Unverified HTTPS request is being made" error message
requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()
session.verify = False
session.mount('https://', requests.adapters.HTTPAdapter(pool_connections=1))

# Key used for generated the HMAC signature
secret_key="JCqdN5AcnAHgJYseUn7ER5k3qgtemfUvMRghQpTfTZq7Cvv8EPQPqfz6dDxPQPSu4gKFPWkJGw32zyASgJkHwCjU"

parser = argparse.ArgumentParser(description="Control Philips TV API (versions 5 and 6)")
parser.add_argument("--host", dest="host", help="TV's ip address")
parser.add_argument("--user", dest="user", help="Username")
parser.add_argument("--pass", dest="password", help="Password")
parser.add_argument("--command", help="Command to run", default="")
parser.add_argument("--path", dest="path", help="API's endpoint path")
parser.add_argument("--body", dest="body", help="Body for post requests")
parser.add_argument("--verbose", dest="verbose", help="Display feedback", default="1")
parser.add_argument("--apiv", dest="apiv", help="Api version", default="")
parser.add_argument("--config", dest="config", help="Path to config file", default=os.path.dirname(os.path.realpath(__file__))+os.path.sep+"settings.ini")

args = parser.parse_args()

class Pylips:
    def __init__(self, ini_file):
        # read config file
        self.config = configparser.ConfigParser()

        if os.path.isfile(ini_file) == False:
            return print("Config file", ini_file, "not found")

        try:
            self.config.read(ini_file)
        except:
            return print("Config file", ini_file, "found, but cannot be read")

        if args.host is None and self.config["TV"]["host"]=="":
            return print("Please set your TV's IP-address with a --host parameter or in [TV] section in settings.ini")
            
        # override config with passed args
        if len(sys.argv)>1:
            if args.verbose=="1" or args.verbose.lower()=="true":
                self.config["DEFAULT"]["verbose"]="True"
            else:
                self.config["DEFAULT"]["verbose"]="False"
            if args.host:
                self.config["TV"]["host"] = args.host
            if args.user and args.password:
                self.config["TV"]["user"] = args.user
                self.config["TV"]["pass"] = args.password
                self.config["TV"]["port"] = "1926"
                self.config["TV"]["protocol"] = "https://"
            elif (len(self.config["TV"]["user"])==0 or len(self.config["TV"]["pass"])==0) and self.config["TV"]["port"] == 1926:
                return print ("If you have an Android TV, please provide both a username and a password (--user and --pass)")
            if len(args.apiv) != 0:
                self.config["TV"]["apiv"]=args.apiv
                
        # check verbose option
        if self.config["DEFAULT"]["verbose"] == "True":
            self.verbose = True
        else:
            self.verbose = False
        # check API version
        if len(self.config["TV"]["apiv"])==0:
            if self.find_api_version(self.verbose):
                if self.check_if_paired() is False:
                    print("No valid credentials found, starting pairing process...")
                    self.pair()
                with open("settings.ini", "w") as configfile:
                    self.config.write(configfile)
            else:
                if self.is_online(self.config["TV"]["host"]):
                    return print("IP", self.config["TV"]["host"], "is online, but no known API is found. Exiting...")
                else:
                    return print("IP", self.config["TV"]["host"], "seems to be offline. Exiting...")

        # load API commands
        with open(os.path.dirname(os.path.realpath(__file__))+"/available_commands.json") as json_file:  
            self.available_commands = json.load(json_file)

        # start MQTT listener and updater if required
        if (len(sys.argv)==1 or (len(sys.argv)==3 and sys.argv[1] == "--config")) and self.config["DEFAULT"]["mqtt_listen"] == "True":
                if len(self.config["MQTT"]["host"])>0:
                    # listen for MQTT messages to run commands
                    self.start_mqtt_listener()
                    if self.config["DEFAULT"]["mqtt_update"] == "True":
                        # Update TV status and publish any changes
                        self.last_status = {"powerstate": None, "volume":None, "muted":False, "cur_app":None, "ambilight":None, "ambihue":False}
                        self.start_mqtt_updater(self.verbose)
                else:
                    print("Please specify host in MQTT section in settings.ini to use MQTT")
        elif len(sys.argv)>1:
            # parse the passed args and run required command
            body=args.body
            path=args.path
            if args.command == "get":
                self.get(path,self.verbose)
            elif args.command == "post":
                self.post(path, body, self.verbose)
            elif len(args.command)>0:
                self.run_command(args.command,body, self.verbose)
            else:
                print("Please provide a valid command with a '--command' argument")
        else:
            print("Please enable mqtt_listen in settings.ini or provide a valid command with a '--command' argument")
               
    def is_online(self, host):
        """
        Returns True if host (str) responds to a ping request.
        """
        # Option for the number of packets as a function of
        param = "-n" if platform.system().lower()=="windows" else "-c"

        # Building the command. Ex: "ping -c 1 google.com"
        command = ["ping", param, "1", host]

        return subprocess.call(command) == 0

    # finds API version, saves it to settings.ini (["TV"]["apiv"]) and returns True if successful.
    def find_api_version(self, verbose=True, possible_ports=[1925], possible_api_versions=[6,5,1]):
        if verbose:
            print ("Checking API version and port...")
        protocol="http://"
        for port in possible_ports:
            for api_version in possible_api_versions:
                try:
                    if verbose:
                        print("Trying", str(protocol) + str(self.config["TV"]["host"]) + ":" + str(port)+"/" + str(api_version)+"/system")
                    r = session.get(str(protocol) + str(self.config["TV"]["host"]) + ":" + str(port)+"/" + str(api_version)+"/system", verify=False, timeout=2)
                except requests.exceptions.ConnectionError:
                    print("Connection refused")
                    continue
                if r.status_code == 200:
                    print(r.json())

                    self.config["TV"]["apiv"]= str(r.json()["api_version"]["Major"])
                    if "featuring" in r.json() and "systemfeatures" in r.json()["featuring"] and "pairing_type" in r.json()["featuring"]["systemfeatures"] and r.json()["featuring"]["systemfeatures"]["pairing_type"] == "digest_auth_pairing":
                        self.config["TV"]["protocol"] = "https://"
                        self.config["TV"]["port"] = "1926"
                    else:
                        self.config["TV"]["protocol"] = "http://"
                        self.config["TV"]["port"] = "1925"
                    return True
        return False

    # returns True if already paired or using non-Android TVs.
    def check_if_paired(self):
        if str(self.config["TV"]["protocol"])=="https://" and (len(str(self.config["TV"]["user"]))==0 or len(str(self.config["TV"]["pass"]))==0):
            return False
        else:
            return True
        
    # creates random device id
    def createDeviceId(self):
        return "".join(random.SystemRandom().choice(string.ascii_uppercase + string.digits + string.ascii_lowercase) for _ in range(16))

    # creates signature
    def create_signature(self, secret_key, to_sign):
        sign = HMAC.new(secret_key, to_sign, SHA)
        return str(b64encode(sign.hexdigest().encode()))

    # creates device spec JSON
    def getDeviceSpecJson(self, config):
        device_spec =  { "device_name" : "heliotrope", "device_os" : "Android", "app_name" : "Pylips", "type" : "native"}
        device_spec["app_id"] = config["application_id"]
        device_spec["id"] = config["device_id"]
        return device_spec

    # initiates pairing with a TV
    def pair(self, err_count=0):
        payload = {}
        payload["application_id"] = "app.id"
        payload["device_id"] = self.createDeviceId()
        self.config["TV"]["user"] = payload["device_id"]
        data = { "scope" :  [ "read", "write", "control"] }
        data["device"]  = self.getDeviceSpecJson(payload)
        print("Sending pairing request")
        self.pair_request(data)

    # pairs with a TV
    def pair_request(self, data, err_count=0):
        print(data)
        print("https://" + str(self.config["TV"]["host"]) + ":1926/"+str(self.config["TV"]["apiv"])+"/pair/request")
        response={}
        r = session.post("https://" + str(self.config["TV"]["host"]) + ":1926/"+str(self.config["TV"]["apiv"])+"/pair/request", json=data, verify=False, timeout=2)
        if r.json() is not None:
            if r.json()["error_id"] == "SUCCESS":
                response=r.json()
            else:
                return print("Error", r.json())
        else:
            return print("Can not reach the API")

        auth_Timestamp = response["timestamp"]
        self.config["TV"]["pass"] = response["auth_key"]
        data["device"]["auth_key"] = response["auth_key"]
        pin = input("Enter onscreen passcode: ")

        auth = { "auth_AppId" : "1"}
        auth["pin"] = str(pin)
        auth["auth_timestamp"] = auth_Timestamp
        auth["auth_signature"] = self.create_signature(b64decode(secret_key), str(auth_Timestamp).encode() + str(pin).encode())

        grant_request = {}
        grant_request["auth"] = auth
        data["application_id"]="app.id"
        data["device_id"]=self.config["TV"]["user"]
        grant_request["device"]  = self.getDeviceSpecJson(data)

        print("Attempting to pair")
        self.pair_confirm(grant_request)

    # confirms pairing with a TV
    def pair_confirm(self, data, err_count=0):
        while err_count < 10:
            if err_count > 0:
                print("Resending pair confirm request")
            try:
                # print(data)
                r = session.post("https://" + str(self.config["TV"]["host"]) +":1926/"+str(self.config["TV"]["apiv"])+"/pair/grant", json=data, verify=False, auth=HTTPDigestAuth(self.config["TV"]["user"], self.config["TV"]["pass"]),timeout=2)
                print (r.request.headers)
                print (r.request.body)
                print("Username for subsequent calls is: " + str(self.config["TV"]["user"]))
                print("Password for subsequent calls is: " + str(self.config["TV"]["pass"]))
                return print("The credentials are saved in the settings.ini file.")
            except Exception:
                # try again
                err_count += 1
                continue
        else:
            return print("The API is unreachable. Try restarting your TV and pairing again")

    # sends a general GET request
    def get(self, path, verbose=True, err_count=0):
        while err_count < int(self.config["DEFAULT"]["num_retries"]):
            if verbose:
                print("Sending GET request to", str(self.config["TV"]["protocol"]) + str(self.config["TV"]["host"]) + ":" + str(self.config["TV"]["port"]) + "/" + str(self.config["TV"]["apiv"]) + "/" + str(path))
            try:
                r = session.get(str(self.config["TV"]["protocol"]) + str(self.config["TV"]["host"]) + ":" + str(self.config["TV"]["port"]) + "/" + str(self.config["TV"]["apiv"]) + "/" + str(path), verify=False, auth=HTTPDigestAuth(str(self.config["TV"]["user"]), str(self.config["TV"]["pass"])), timeout=2)
            except Exception:
                err_count += 1
                continue
            if verbose:
                print("Request sent!")
            if len(r.text) > 0:
                print(r.text)
                return r.text
        else:
            if self.config["DEFAULT"]["mqtt_listen"].lower()=="true":
                self.mqtt_update_status({"powerstate":"Off", "volume":None, "muted":False, "cur_app":None, "ambilight":None, "ambihue":False})
            return json.dumps({"error":"Can not reach the API"})

    # sends a general POST request
    def post(self, path, body, verbose=True, callback=True, err_count=0):
        while err_count < int(self.config["DEFAULT"]["num_retries"]):
            if type(body) is str:
                body = json.loads(body)
            if verbose:
                print("Sending POST request to", str(self.config["TV"]["protocol"]) + str(self.config["TV"]["host"]) + ":" + str(self.config["TV"]["port"]) + "/" + str(self.config["TV"]["apiv"]) + "/" + str(path)) 
            try:
                r = session.post(str(self.config["TV"]["protocol"]) + str(self.config["TV"]["host"]) + ":" + str(self.config["TV"]["port"]) + "/" + str(self.config["TV"]["apiv"]) + "/" + str(path), json=body, verify=False, auth=HTTPDigestAuth(str(self.config["TV"]["user"]), str(self.config["TV"]["pass"])), timeout=2)
            except Exception:
                err_count += 1
                continue
            if verbose:
                print("Request sent!")
            if callback and self.config["DEFAULT"]["mqtt_listen"].lower()=="true" and len(sys.argv)==1:
                # run mqtt callback to update the status (only in MQTT mode)
                self.mqtt_callback(path)
            if len(r.text) > 0:
                print(r.text)
                return r.text
            elif r.status_code == 200:
                print(json.dumps({"response":"OK"}))
                return json.dumps({"response":"OK"})
        else:
            if self.config["DEFAULT"]["mqtt_listen"].lower()=="true" and len(sys.argv)==1:
                self.mqtt_update_status({"powerstate":"Off", "volume":None, "muted":False, "cur_app":None, "ambilight":None, "ambihue":False})
            print(json.dumps({"error":"Can not reach the API"}))
            return json.dumps({"error":"Can not reach the API"})

    # runs a command
    def run_command(self, command, body=None, verbose=True, callback=True):
        if command in self.available_commands["get"]:
            return self.get(self.available_commands["get"][command]["path"],verbose)
        elif command in self.available_commands["post"]:
            if "body" in self.available_commands["post"][command] and body is None:
                return self.post(self.available_commands["post"][command]["path"],self.available_commands["post"][command]["body"],verbose, callback)
            if "body" in self.available_commands["post"][command] and body is not None:
                new_body = self.available_commands["post"][command]["body"]
                if command == "ambilight_brightness":
                    if type(body) is str:
                        body = json.loads(body)
                    new_body["values"][0]["value"]["data"] = body
                elif command == "ambilight_color":
                    if type(body) is str:
                        body = json.loads(body)
                    new_body["colorSettings"]["color"]["hue"] = int(body["hue"]*(255/360))
                    new_body["colorSettings"]["color"]["saturation"]=int(body["saturation"]*(255/100))
                    new_body["colorSettings"]["color"]["brightness"]=int(body["brightness"])
                return self.post(self.available_commands["post"][command]["path"],new_body,verbose, callback)
            else:
                return self.post(self.available_commands["post"][command]["path"], body,verbose, callback)
        elif command in self.available_commands["power"]:
            return session.post("http://" + str(self.config["TV"]["host"]) +":8008/"+self.available_commands["power"][command]["path"], verify=False, timeout=2)
        else:
            print("Unknown command")

    # updates status immediately after sending a POST request. Currently works only for ambilight and ambihue.        
    def mqtt_callback(self, path):
        if "ambilight" or "ambihue" in path:
            self.mqtt_update_ambilight()
            self.mqtt_update_ambihue()

    # starts MQTT listener that accepts Pylips commands               
    def start_mqtt_listener(self):
        def on_connect(client, userdata, flags, rc):
            print("Connected to MQTT broker at", self.config["MQTT"]["host"])
            client.subscribe(self.config["MQTT"]["topic_pylips"])
        def on_message(client, userdata, msg):
            if str(msg.topic)==self.config["MQTT"]["topic_pylips"]:
                message = json.loads(msg.payload.decode('utf-8'))
                if "status" in message:
                    self.mqtt_update_status(message["status"])
                if "command" in message:
                    body=None
                    path=""
                    if "body" in message:
                        body = message["body"]
                    if "path" in message:
                        path = message["path"]
                    if message["command"] == "get":
                        if len(path)==0:
                            return print("Please provide a 'path' argument")
                        self.get(path,self.verbose)
                    elif message["command"] == "post":
                        if len(path)==0:
                            return print("Please provide a 'path' argument")
                        self.post(path, body, self.verbose)
                    elif message["command"] != "post" and message["command"] != "get":
                        self.run_command(message["command"],body, self.verbose)

        self.mqtt = mqttc.Client()
        self.mqtt.on_connect = on_connect
        self.mqtt.on_message = on_message

        if len(self.config["MQTT"]["user"])>0 and len(self.config["MQTT"]["pass"])>0:
            self.mqtt.username_pw_set(self.config["MQTT"]["user"], self.config["MQTT"]["pass"])
        if self.config["MQTT"]["TLS"].lower()=="true":
            if len(self.config["MQTT"]["cert_path"].strip())>0:
                self.mqtt.tls_set(self.config["MQTT"]["cert_path"])
            else:
                self.mqtt.tls_set()
        self.mqtt.connect(str(self.config["MQTT"]["host"]), int(self.config["MQTT"]["port"]), 60)
        if self.config["DEFAULT"]["mqtt_listen"] == "True" and self.config["DEFAULT"]["mqtt_update"] == "False":
            self.mqtt.loop_forever()
        else:
            self.mqtt.loop_start()

    # publishes an update with TV status over MQTT
    def mqtt_update_status(self, update):
        new_status = dict(self.last_status, **update)
        if json.dumps(new_status) != json.dumps(self.last_status):
            self.last_status = new_status
            self.mqtt.publish(str(self.config["MQTT"]["topic_status"]), json.dumps(self.last_status), retain = True)
    
    # updates powerstate for MQTT status and returns True if TV is on
    def mqtt_update_powerstate(self):
        powerstate_status = self.get("powerstate",self.verbose)
        if powerstate_status is not None and powerstate_status[0]=='{':
            powerstate_status = json.loads(powerstate_status)
            if "powerstate" in powerstate_status:
                if "powerstate" in self.last_status and self.last_status["powerstate"] != powerstate_status['powerstate']:
                    self.mqtt.publish(str(self.config["MQTT"]["topic_pylips"]), json.dumps({"status":{"powerstate":powerstate_status['powerstate']}}), retain = False)
                if powerstate_status['powerstate'].lower()=="on":
                    return True
            else:
                self.mqtt_update_status({"powerstate":"Off", "volume":None, "muted":False, "cur_app":None, "ambilight":None, "ambihue":False})
        else:
                self.mqtt_update_status({"powerstate":"Off", "volume":None, "muted":False, "cur_app":None, "ambilight":None, "ambihue":False})
        return False

    # updates ambilight for MQTT status
    def mqtt_update_ambilight(self):
        ambilight_status = self.get("ambilight/currentconfiguration",self.verbose)
        if ambilight_status is not None and ambilight_status[0]=='{':
            ambilight_status = json.loads(ambilight_status)
            if "styleName" in ambilight_status:
                ambilight = ambilight_status
                if json.dumps(self.last_status["ambilight"]) != json.dumps(ambilight):
                    self.mqtt.publish(str(self.config["MQTT"]["topic_pylips"]), json.dumps({"status":{"ambilight":ambilight}}), retain = False)
    
    # updates ambihue for MQTT status
    def mqtt_update_ambihue(self):
        ambihue_status = self.run_command("ambihue_status",None,self.verbose, False)
        if ambihue_status is not None and ambihue_status[0]=='{':
            ambihue_status = json.loads(ambihue_status)
            if "values" in ambihue_status:
                ambihue = ambihue_status["values"][0]["value"]["data"]["value"]
                if self.last_status["ambihue"] != ambihue:
                    self.mqtt.publish(str(self.config["MQTT"]["topic_pylips"]), json.dumps({"status":{"ambihue":ambihue}}), retain = False)

    # updates current app for MQTT status
    def mqtt_update_app(self):
        actv_status = self.run_command("current_app",None,self.verbose, False)
        if actv_status is not None and actv_status[0]=='{':
            actv_status=json.loads(actv_status)
            if "component" in actv_status:
                if actv_status["component"]["packageName"] == "org.droidtv.zapster" or actv_status["component"]["packageName"] =="NA":
                    self.mqtt_update_channel()
                else:
                    if self.last_status["cur_app"] is None or self.last_status["cur_app"] != actv_status["component"]["packageName"]:
                        self.mqtt.publish(str(self.config["MQTT"]["topic_pylips"]), json.dumps({"status":{"cur_app":actv_status["component"]["packageName"]}}), retain = False)

    # updates current channel for MQTT status
    def mqtt_update_channel(self):
        channel = self.run_command("current_channel",None,self.verbose, False)
        if channel is not None and channel[0]=='{':
            channel=json.loads(channel)
            if "channel" in channel:
                if json.dumps(self.last_status["cur_app"]) != json.dumps({"app":"TV","channel":channel}):
                    self.mqtt.publish(str(self.config["MQTT"]["topic_pylips"]), json.dumps({"status":{"cur_app":{"app":"TV","channel":channel}}}), retain = False)
    
    # updates volume and mute state for MQTT status
    def mqtt_update_volume(self):
        vol_status = self.run_command("volume",None,self.verbose, False)
        if vol_status is not None:
            vol_status = json.loads(vol_status)
            if "muted" in vol_status:
                muted = vol_status["muted"]
                volume = vol_status["current"]
                if self.last_status["muted"] != muted or self.last_status["volume"] != volume:
                    self.mqtt.publish(str(self.config["MQTT"]["topic_pylips"]), json.dumps({"status":{"muted":muted, "volume":volume}}), retain = False)

    # runs MQTT update functions with a specified update interval
    def start_mqtt_updater(self, verbose=True):
        print("Started MQTT status updater")
        while True:
            if self.mqtt_update_powerstate():
                self.mqtt_update_volume()
                self.mqtt_update_app()
                self.mqtt_update_ambilight()
                self.mqtt_update_ambihue()
            time.sleep(int(self.config["DEFAULT"]["update_interval"]))

if __name__ == '__main__':
    pylips = Pylips(args.config)