#! /usr/bin/env python3
# -*- coding: utf-8 -*-

# flo-joystick-handler --- Translate joystick input into mouse clicks and/or
#                          Mumble actions
# Copyright (c) 2015, 2016, 2019, 2021, Florent Rougon
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and documentation are
# those of the authors and should not be interpreted as representing official
# policies, either expressed or implied, of the flo-joystick-handler project.

import sys
import locale
import argparse
import enum
import ctypes
from sdl2 import SDL_Init, SDL_INIT_JOYSTICK, SDL_NumJoysticks, \
    SDL_JoystickNameForIndex, SDL_JoystickOpen, SDL_JoystickClose, SDL_Quit, \
    SDL_Event, SDL_WaitEvent, SDL_JoystickInstanceID, SDL_JOYBUTTONUP, \
    SDL_JOYBUTTONDOWN, SDL_QUIT
import sdl2.ext
from pydbus import SessionBus
from gi.repository import GLib
# An alternative that doesn't require write access to /dev/uinput might
# be PyAutoGUI. It relies on python-xlib (for Python 2) or python3-Xlib
# (for Python 3). However, my previous attempts with Xlib-or-XTest-based
# solutions (XSendEvent, XTestFakeKeyEvent) used through C++, xdo...
# have not been very successful.
# <http://stackoverflow.com/questions/3545230/simulate-mouse-clicks-on-python>
# also indicates other libraries for the same purpose.
from evdev import UInput, ecodes

@enum.unique
class Mode(enum.Enum):
    click = 0
    mumble = 1

joysticks = {"DragonRise":
             {"name": "DragonRise Inc.   Generic   USB  Joystick  ",
              "mainButton": 0,
              "clickModeButton": 1,
              "mumbleModeButton": 2,
              "middleClickButton": 2,
              "rightClickButton": 3},
             "Cyborg":
             {"name": "SAITEK CYBORG 3D USB", # "Saitek PLC Cyborg Gold Joystick"
              "mainButton": 0,
              "clickModeButton": 8, # previously: 1
              "mumbleModeButton": 2,
              "middleClickButton": 2,
              "rightClickButton": 3},
             "Attack3":
             {"name": "Logitech Logitech Attack 3",
              "mumbleTalkButton": 3}}

heliJoystickName = "Attack3"
joystick = None

virtualMouseCapa = {
        ecodes.EV_REL : (ecodes.REL_X, ecodes.REL_Y),
        ecodes.EV_KEY : (ecodes.BTN_LEFT, ecodes.BTN_MIDDLE, ecodes.BTN_RIGHT),
    }

class Button:
    def __init__(self, name, number, state=-1):
        self.name = name
        self.number = number
        self.signals = ("onUp", "onDown", "onClicked")
        self.reset(state=state)

    def connect(self, sigName, callback, *args, **kwargs):
        self.callbacks[sigName].append((callback, args, kwargs))

    def reset(self, state=-1):
        # Protects against unwanted clicks when changing mode while the button
        # is held down
        self.state = state
        self.callbacks = { sigName: [] for sigName in self.signals }

    def onDown(self, event):
        self.state = 1
        for cb, args, kwargs in self.callbacks["onDown"]:
            cb(event, *args, **kwargs)

    def onUp(self, event):
        if self.state == 1:
            self.state = 0      # Do it first in case a callback checks this
            signalsToFire = ("onUp", "onClicked")
        else:
            self.state = 0
            signalsToFire = ("onUp",)

        for sigName in signalsToFire:
            for cb, args, kwargs in self.callbacks[sigName]:
                cb(event, *args, **kwargs)


def getMumbleService():
    if params.disable_mumble_integration:
        mumbleService = None
    else:
        bus = SessionBus()
        try:
            mumbleService = bus.get("net.sourceforge.mumble.mumble", "/")
        except GLib.Error as e:
            print("Warning: unable to connect to the Mumble D-Bus service: {}"
                  .format(e))
            mumbleService = None

    return mumbleService


class App:
    def __init__(self, mumbleService, ui):
        self.mumbleService = mumbleService
        self.ui = ui
        self.initSDL()

        if params.old_layout:
            joysticks["Cyborg"]["clickModeButton"] = 1

        buttonNames = ["mainButton", "clickModeButton", "rightClickButton"]
        buttonNames.append("mumbleModeButton" if params.enable_mumble_mode
                           else "middleClickButton")

        self.buttons = {}
        for buttonName in buttonNames:
            button = Button(buttonName, joysticks[params.joyname][buttonName])
            setattr(self, buttonName, button)
            self.buttons[button.number] = button

        self.clickModeButton.connect("onClicked", self.setModeCallback,
                                     Mode.click)
        if params.emulate_right_click:
            self.rightClickButton.connect("onDown", self.sendMouseEventCallback,
                                          ecodes.BTN_RIGHT, 1)
            self.rightClickButton.connect("onUp", self.sendMouseEventCallback,
                                          ecodes.BTN_RIGHT, 0)
        if not params.enable_mumble_mode:
            self.middleClickButton.connect("onDown", self.sendMouseEventCallback,
                                          ecodes.BTN_MIDDLE, 1)
            self.middleClickButton.connect("onUp", self.sendMouseEventCallback,
                                          ecodes.BTN_MIDDLE, 0)
        if params.heli_mode:
            self.heliJoystickButtons = {}

            for buttonName in ("mumbleTalkButton",):
                button = Button(buttonName,
                                joysticks[heliJoystickName][buttonName])
                self.heliJoystickButtons[button.number] = button

            mumbleTalkButtonNum = \
                                joysticks[heliJoystickName]["mumbleTalkButton"]
            self.heliJoystickButtons[mumbleTalkButtonNum].connect(
                "onDown", self.mumbleSetPushToTalkStatusCallback, True)
            self.heliJoystickButtons[mumbleTalkButtonNum].connect(
                "onUp", self.mumbleSetPushToTalkStatusCallback, False)
        elif params.enable_mumble_mode:
            self.mumbleModeButton.connect("onClicked", self.setModeCallback,
                                          Mode.mumble)
        self.setMode(Mode.click) # default mode

    def initSDL(self):
        SDL_Init(SDL_INIT_JOYSTICK)
        self.joystick = None
        joyName = joysticks[params.joyname]["name"]

        for i in range(SDL_NumJoysticks()):
            name = SDL_JoystickNameForIndex(i)
            if name is None:
                raise sdl2.ext.SDLError()
            name = name.decode("utf-8")

            if params.heli_mode and name == joysticks[heliJoystickName]["name"]:
                print("Opened heli joystick: '{}' (id {})".format(name, i))
                self.heliJoystick = SDL_JoystickOpen(i)
            elif name == joyName:
                self.joystick = SDL_JoystickOpen(i)
                print("Opened joystick '{}' (id {})".format(name, i))
                break
        else:
            print("Can't find joystick '{}'! Aborting.".format(joyName))

    def cleanup(self):
        if hasattr(self, "joystick") and self.joystick is not None:
            SDL_JoystickClose(self.joystick)
        SDL_Quit()

    def setMode(self, mode):
        if hasattr(self, "currentMode") and self.currentMode == mode:
            pass
        else:
            self.mainButton.reset()
            if mode == Mode.mumble:
                # Leaving the 'click' mode → stop holding mouse button(s)
                self.sendMouseEvent(ecodes.BTN_LEFT, 0)

                self.mainButton.connect(
                    "onDown", self.mumbleSetPushToTalkStatusCallback, True)
                self.mainButton.connect(
                    "onUp", self.mumbleSetPushToTalkStatusCallback, False)
            elif mode == Mode.click:
                # Leaving the 'mumble' mode → stop talking
                self.mumbleSetPushToTalkStatus(False)

                self.mainButton.connect("onDown", self.sendMouseEventCallback,
                                        ecodes.BTN_LEFT, 1)
                self.mainButton.connect("onUp", self.sendMouseEventCallback,
                                        ecodes.BTN_LEFT, 0)
            self.currentMode = mode
            print("Current mode: {}".format(self.currentMode.name))

    def setModeCallback(self, event, mode):
        return self.setMode(mode)

    def mumbleSetPushToTalkStatus(self, status):
        if self.mumbleService is None:
            self.mumbleService = getMumbleService()
            if self.mumbleService is None:
                return

        for attempt in range(2):
            try:
                self.mumbleService.setPushToTalkStatus(status)
            except GLib.Error:
                if attempt == 0:
                    self.mumbleService = getMumbleService()
            else:
                break

            if self.mumbleService is None:
                break

    def mumbleSetPushToTalkStatusCallback(self, event, status):
        return self.mumbleSetPushToTalkStatus(status)

    def sendMouseEvent(self, button, buttonState):
        self.ui.write(ecodes.EV_KEY, button, buttonState)
        self.ui.syn()

    def sendMouseEventCallback(self, event, button, buttonState):
        return self.sendMouseEvent(button, buttonState)

    def mainLoop(self):
        while True:
            event = SDL_Event()
            SDL_WaitEvent(ctypes.byref(event))
            if not event:
                raise sdl2.ext.SDLError()

            if event.type == SDL_QUIT:
                break
            elif event.type in (SDL_JOYBUTTONUP, SDL_JOYBUTTONDOWN):
                if event.jbutton.which == SDL_JoystickInstanceID(self.joystick):
                    buttonsDict = self.buttons
                elif (params.heli_mode and
                      event.jbutton.which == SDL_JoystickInstanceID(
                          self.heliJoystick)):
                    buttonsDict = self.heliJoystickButtons

                methName = "onUp" if event.type == SDL_JOYBUTTONUP else "onDown"
                try:
                    button = buttonsDict[event.jbutton.button]
                except KeyError:
                    pass        # Unhandled button → ignore
                else:
                    getattr(button, methName)(event) # Fire the callback


def processCommandLine():
    params = argparse.Namespace()

    parser = argparse.ArgumentParser(
        usage="""\
%(prog)s [OPTION ...]
Translate joystick input into mouse clicks and/or Mumble actions.""",
        description="""\
This program reads input from the joystick specified with --joyname and
translates it into mouse clicks and/or Mumble actions.

There are 2 buttons to set 'click' or 'mumble' mode. The third button
managed by this program either simulates a left mouse button or
activates Push-To-Talk in Mumble, depending on the current mode.

This program requires PySDL2 for joystick input, Python-evdev to
simulate mouse clicks and pydbus to communicate with Mumble. In order
for Python-evdev to work properly here, you need:
  - the 'uinput' Linux kernel module loaded;
  - write access to '/dev/uinput'.""",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        # I want --help but not -h (it might be useful for something else)
        add_help=False)

    parser.add_argument('-j', "--joyname", choices=("Cyborg", "DragonRise"),
                        default="Cyborg", help="joystick name")
    parser.add_argument('-r', '--emulate-right-click', action='store_true',
                        help="""\
      Enable right click emulation.""")
    parser.add_argument('--disable-mumble-integration', action='store_true',
                        help="""\
      Completely disable all interaction with Mumble. Don't even try to
      get the DBus Mumble service.""")
    parser.add_argument('-m', '--enable-mumble-mode', action='store_true',
                        help="""\
      Allow activating Mumble mode with the corresponding button.""")
    parser.add_argument('-h', '--heli-mode', action='store_true',
                        help="""\
      Helicopter mode. Mumble talk button is on a specific joystick.""")
    parser.add_argument('-o', '--old-layout', action='store_true',
                        help="""\
      Use the old layout for the Saitek PLC Cyborg Gold Joystick
      (aka SAITEK CYBORG 3D USB).""")
    parser.add_argument('--help', action="help",
                        help="display this message and exit")

    params = parser.parse_args(namespace=params)

    return params


def main():
    global params

    locale.setlocale(locale.LC_ALL, '')
    params = processCommandLine()

    with UInput(virtualMouseCapa) as ui:
        app = App(getMumbleService(), ui)
        try:
            app.mainLoop()
        finally:
            app.cleanup()

    return 0

if __name__ == "__main__":
    sys.exit(main())
