#!/usr/bin/env python3

# CaseLights Linux Qt System Tray client
# depends on:
# - python-pyqt5
# - python-pyserial

import subprocess
import sys
import os.path
import threading
import time
import colorsys
import serial, serial.tools, serial.tools.list_ports
from PyQt5 import QtWidgets, QtGui, QtCore
from PyQt5.QtWidgets import QSystemTrayIcon, QAction, QMenu
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtCore import QCoreApplication, QSettings

class CaseLights():
    name = "CaseLights"
    vendor = "xythobuz"
    version = "0.1"

    iconPath = "/usr/share/pixmaps/"
    iconName = "caselights_icon.png"

    staticColors = [
        [ "Off",     "0",   "0",   "0", None ],
        [ "Red",   "255",   "0",   "0", None ],
        [ "Green",   "0", "255",   "0", None ],
        [ "Blue",    "0",   "0", "255", None ],
        [ "White", "255", "255", "255", None ],
    ]

    slowFadeUpdateFreq = 5
    fastFadeUpdateFreq = 20
    cpuUsageUpdateFreq = 1
    fadeSaturation = 1.0
    fadeValue = 1.0
    fadeHueCounter = 0

    usedPort = None
    serial = None
    animation = None
    animationRunning = False
    menu = None
    portMenu = None
    portActions = None
    refreshAction = None
    quitAction = None

    def __init__(self):
        app = QtWidgets.QApplication(sys.argv)
        QCoreApplication.setApplicationName(self.name)

        if not QSystemTrayIcon.isSystemTrayAvailable():
            print("System Tray is not available on this platform!")
            sys.exit(0)

        self.readSettings()
        if self.usedPort is not None:
            self.connect()

        self.menu = QMenu()

        colorMenu = QMenu("&Colors")
        for color in self.staticColors:
            color[4] = QAction(color[0])
            colorMenu.addAction(color[4])
        colorMenu.triggered.connect(self.setStaticColor)
        self.menu.addMenu(colorMenu)

        animMenu = QMenu("&Animations")
        noFadeAction = QAction("Off")
        noFadeAction.triggered.connect(self.animOff)
        animMenu.addAction(noFadeAction)
        slowFadeAction = QAction("Slow Fade")
        slowFadeAction.triggered.connect(self.slowFadeOn)
        animMenu.addAction(slowFadeAction)
        fastFadeAction = QAction("Fast Fade")
        fastFadeAction.triggered.connect(self.fastFadeOn)
        animMenu.addAction(fastFadeAction)
        self.menu.addMenu(animMenu)

        visualMenu = QMenu("&Visualizations")
        noVisualAction = QAction("Off")
        noVisualAction.triggered.connect(self.animOff)
        visualMenu.addAction(noVisualAction)
        cpuUsageAction = QAction("CPU Usage")
        cpuUsageAction.triggered.connect(self.cpuUsageOn)
        visualMenu.addAction(cpuUsageAction)
        self.menu.addMenu(visualMenu)

        lightMenu = QMenu("&UV-Light")
        lightOnAction = QAction("O&n")
        lightOnAction.triggered.connect(self.lightsOn)
        lightMenu.addAction(lightOnAction)
        lightOffAction = QAction("O&ff")
        lightOffAction.triggered.connect(self.lightsOff)
        lightMenu.addAction(lightOffAction)
        self.menu.addMenu(lightMenu)

        self.refreshSerialPorts()

        self.quitAction = QAction("&Quit")
        self.quitAction.triggered.connect(self.exit)
        self.menu.addAction(self.quitAction)

        iconPathName = ""
        if os.path.isfile(self.iconName):
            iconPathName = self.iconName
        elif os.path.isfile(self.iconPath + self.iconName):
            iconPathName = self.iconPath + self.iconName
        else:
            print("no icon found")

        icon = QIcon()
        if iconPathName != "":
            pic = QPixmap(32, 32)
            pic.load(iconPathName)
            icon = QIcon(pic)

        trayIcon = QSystemTrayIcon(icon)
        trayIcon.setToolTip(self.name + " " + self.version)
        trayIcon.setContextMenu(self.menu)
        trayIcon.setVisible(True)

        sys.exit(app.exec_())

    def exit(self):
        if self.serial is not None:
            if self.serial.is_open:
                print("stopping animations")
                self.animOff()
                print("turning off lights")
                self.serial.write(b'RGB 0 0 0\n')
                self.serial.write(b'UV 0\n')
                print("closing connection")
                self.serial.close()
        QCoreApplication.quit()

    def readSettings(self):
        settings = QSettings(self.vendor, self.name)
        self.usedPort = settings.value("serial_port")
        if self.usedPort is not None:
            print("serial port stored: " + self.usedPort)
        else:
            print("no serial port stored")

    def writeSettings(self):
        settings = QSettings(self.vendor, self.name)
        settings.setValue("serial_port", self.usedPort)
        if self.usedPort is not None:
            print("storing serial port: " + self.usedPort)
        else:
            print("not storing any serial port")
        del settings

    def refreshSerialPorts(self):
        self.portMenu = QMenu("Port")
        ports = serial.tools.list_ports.comports()
        self.portActions = []
        for port in ports:
            action = QAction(port.device)
            self.portActions.append(action)
            self.portMenu.addAction(action)
        self.portMenu.triggered.connect(self.selectSerialPort)

        if self.refreshAction == None:
            self.refreshAction = QAction("&Refresh")
            self.refreshAction.triggered.connect(self.refreshSerialPorts)
        self.portMenu.addAction(self.refreshAction)
        self.menu.insertMenu(self.quitAction, self.portMenu)

    def selectSerialPort(self, action):
        self.usedPort = action.text()
        self.writeSettings()
        if self.connect():
            self.portMenu.setActiveAction(action)

    def connect(self):
        if self.usedPort is None:
            print("not connecting to any serial port")
            return False

        if self.serial is not None:
            print("closing previous port")
            self.serial.close()

        self.serial = serial.Serial()
        self.serial.port = self.usedPort
        self.serial.baudrate = 115200
        self.serial.open()
        if self.serial.is_open:
            print("connected to: " + self.usedPort)
        else:
            print("error connecting to: " + self.usedPort)
        return self.serial.is_open

    def printRGBStrings(self, rs, gs, bs):
        if self.serial.is_open:
            r = str.encode(rs)
            g = str.encode(gs)
            b = str.encode(bs)
            rgb = b'RGB ' + r + b' ' + g + b' ' + b + b'\n'
            self.serial.write(rgb)
        else:
            print("not connected")

    def setStaticColor(self, action):
        self.animOff()
        for color in self.staticColors:
            if color[4] is action:
                self.printRGBStrings(color[1], color[2], color[3])
                return True
        print("color not found")
        return False

    def hsvToRgb(self, h, s, v):
        (r, g, b) = colorsys.hsv_to_rgb(h, s, v)
        return (round(r * 255), round(g * 255), round(b * 255))

    def getCurrentCpuUsage(self):
        # https://stackoverflow.com/a/9229692
        # "top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1}'"
        # https://stackoverflow.com/a/4760517
        cmd = ["top -bn1 | grep \"Cpu(s)\" | sed \"s/.*, *\\([0-9.]*\\)%* id.*/\\1/\" | awk '{print 100 - $1}'"]
        result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE)
        num = result.stdout.decode('utf-8')
        return float(num)

    def cpuUsageRunner(self):
        while self.animationRunning is True:
            cpu = self.getCurrentCpuUsage()
            color = cpu / 100.0 * 120.0
            (r, g, b) = self.hsvToRgb((120.0 - color) / 360.0, self.fadeSaturation, self.fadeValue)
            self.printRGBStrings(str(r), str(g), str(b))
            time.sleep(1.0 / self.cpuUsageUpdateFreq)

    def cpuUsageOn(self):
        self.animOff()
        self.animationRunning = True
        self.animation = threading.Thread(target=self.cpuUsageRunner)
        self.animation.start()

    def fadeRunner(self, freq):
        while self.animationRunning is True:
            self.fadeHueCounter += 1
            if self.fadeHueCounter >= 360:
                self.fadeHueCounter = 0
            (r, g, b) = self.hsvToRgb(self.fadeHueCounter / 360.0, self.fadeSaturation, self.fadeValue)
            self.printRGBStrings(str(r), str(g), str(b))
            time.sleep(1.0 / freq)

    def slowFadeOn(self):
        self.animOff()
        self.animationRunning = True
        self.animation = threading.Thread(target=self.fadeRunner, args=[self.slowFadeUpdateFreq])
        self.animation.start()

    def fastFadeOn(self):
        self.animOff()
        self.animationRunning = True
        self.animation = threading.Thread(target=self.fadeRunner, args=[self.fastFadeUpdateFreq])
        self.animation.start()

    def animOff(self):
        self.animationRunning = False
        self.animation = None
        self.printRGBStrings("0", "0", "0")
        time.sleep(0.1)

    def lightsOn(self):
        if self.serial.is_open:
            self.serial.write(b'UV 1\n')
        else:
            print("not connected")

    def lightsOff(self):
        if self.serial.is_open:
            self.serial.write(b'UV 0\n')
        else:
            print("not connected")

if __name__ == "__main__":
    tray = CaseLights()