Browse Source

initial commit

boyska 3 years ago
commit
401a65cd32
4 changed files with 224 additions and 0 deletions
  1. 45 0
      README.md
  2. 168 0
      blututto.py
  3. 2 0
      pyproject.toml
  4. 9 0
      sample-config.json

+ 45 - 0
README.md

@@ -0,0 +1,45 @@
+blututto
+===========
+
+`blututto` is a simple python script to run commands from generic keypressure. Differently than other (better)
+tools like `xchainkeys`, `xbindkeys`, `sxhkd`, etc. It supports specifying the *device*.
+This is extremely useful in companion with special remotes like the `AB Shutter3` or probably many others
+
+Setup
+--------
+
+I will not digress on how to setup bluetooth, pairing, etc. Bluetooth is a mess, and I'm not the one
+understanding it. Try hard, and at some point you will find it
+
+
+Quick run
+-----------
+
+`sudo python3 ev.py -n 'AB Shutter3 Consumer Control' --debug`
+
+will tell you something. Please note that **not** all the shutters you will find online are programmed to send
+the same keycode. So look at the debug messages, and see what happens if you press any key. This is the output
+I have:
+
+```
+INFO:root:Found device: /dev/input/event7
+DEBUG:root:device /dev/input/event7, name "AB Shutter3 Consumer Control", phys "6C:71:D9:57:7F:48"
+INFO:root:start loop
+DEBUG:root:received key event at 1595169257.015336, 115 (KEY_VOLUMEUP), down
+DEBUG:root:received key event at 1595169257.195282, 115 (KEY_VOLUMEUP), up
+DEBUG:root:(115, 'short')
+```
+
+You can see that the shutter I bought sends the key "volume up", whose code is `115`. So that's what you need
+to put in your configuration file.
+
+
+Better run
+-------------
+
+You might have noticed that I ran the script with `sudo`. That's not good, and not strictly needed. First
+solution: `sudo useradd $USER input` and voilà! But maybe that's still too much...
+
+better adding a udevrule. Match on
+`    ATTRS{name}=="AB Shutter3 Consumer Control"`
+and apply mode 0640 and set the group to `$(id -gn)`

+ 168 - 0
blututto.py

@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+import json
+import logging
+import os
+import sys
+import time
+from argparse import ArgumentParser
+from subprocess import Popen
+
+import evdev
+
+INPUTDEV_BASEDIR = "/dev/input/"
+
+# TODO: handle sighup
+# TODO: prevent event to do other things
+
+
+def listall():
+    for f in os.listdir(INPUTDEV_BASEDIR):
+        try:
+            print(f, evdev.InputDevice(os.path.join(INPUTDEV_BASEDIR, f)))
+        except:
+            pass
+
+
+def find_dev_by_name(name):
+    for f in os.listdir(INPUTDEV_BASEDIR):
+        try:
+            ev = evdev.InputDevice(os.path.join(INPUTDEV_BASEDIR, f))
+        except Exception:
+            continue
+        if ev.name == name:
+            return os.path.join(INPUTDEV_BASEDIR, f)
+    raise ValueError("device with such name not found")
+
+
+class KeymapTable:
+    def __init__(self):
+        self.table = {}
+
+    @classmethod
+    def from_config_file(cls, data):
+        kt = cls()
+        kt.table = {int(key): value for key, value in data.items()}
+        kt.validate()
+        return kt
+
+    def validate(self):
+        errors = 0
+        for key in self.table:
+            if type(key) is not int:
+                logging.error("Keycode %r is not of valid type", key)
+                errors += 1
+            if type(self.table[key]) is str:
+                # Autofixing key key
+                self.table[key] = {
+                    "short": self.table[key],
+                    "long": self.table[key],
+                }
+            if type(self.table[key]) is not dict:
+                logging.error("Keycode %r is not of valid type", key)
+                errors += 1
+        if errors:
+            raise ValueError("%d validation errors found" % errors)
+
+    def __contains__(self, x):
+        code, pressure_type = x
+        return code in self.table and pressure_type in self.table[code]
+
+    def __getitem__(self, x):
+        code, pressure_type = x
+        return self.table[code][pressure_type]
+
+
+def loop(device, args):
+    pressed = None
+    for event in device.read_loop():
+        if event.type != evdev.ecodes.EV_KEY:
+            continue
+        # for attr in dir(event):
+        #    if not attr.startswith("_"):
+        #        logging.debug("   ", attr, str(getattr(event, attr)))
+        logging.debug("received %s", str(evdev.categorize(event)))
+        if event.value == 1:  # keydown
+            pressed = {"code": event.code, "ts": event.timestamp()}
+        if event.value == 0 and pressed is not None:
+            pressure_time = event.timestamp() - pressed["ts"]
+            if pressure_time * 1000 <= args.short_threshold:
+                pressure_type = "short"
+            else:
+                pressure_type = "long"
+            yield (pressed["code"], pressure_type)
+
+
+def main():
+    p = ArgumentParser()
+    p.add_argument("--config", type=open)
+    input_p = p.add_argument_group("input device options")
+    input_p.add_argument("-d", "--device")
+    input_p.add_argument(
+        "-n", "--name", help="Look at `xinput list` to have an idea"
+    )
+    input_p.add_argument(
+        "--short-threshold",
+        default=300,
+        help="Threshold to distinguish a short and a long press, "
+        "in milliseconds",
+    )
+    output_p = p.add_argument_group("output options")
+    output_p.add_argument(
+        "-v",
+        "--verbose",
+        dest="logging_level",
+        action="store_const",
+        const=logging.INFO,
+    )
+    output_p.add_argument(
+        "--debug",
+        dest="logging_level",
+        action="store_const",
+        const=logging.DEBUG,
+    )
+    args = p.parse_args()
+
+    logging.basicConfig(level=args.logging_level)
+    if args.config:
+        config = json.load(args.config)
+    else:
+        config = {}
+
+    keymaps = KeymapTable.from_config_file(config.get("keys", {}))
+
+    if args.name:
+        if args.device:
+            print("Cannot specify both -d and -n", file=sys.stderr)
+            sys.exit(1)
+        while True:
+            try:
+                args.device = find_dev_by_name(args.name)
+            except ValueError:
+                logging.debug("Could not find device; trying again")
+                time.sleep(2)
+                continue
+            else:
+                logging.info("Found device: %s", args.device)
+                break
+
+    device = evdev.InputDevice(args.device)
+    logging.debug("%s", device)
+    logging.info("start loop")
+    for highlevel_event in loop(device, args):
+        logging.debug(highlevel_event)
+        if highlevel_event not in keymaps:
+            continue
+
+        cmd = keymaps[highlevel_event]
+        if type(cmd) is str:
+            cmd = cmd.split()
+        cmd[0] = os.path.expanduser(cmd[0])
+        try:
+            Popen(cmd)
+        except Exception as exc:
+            logging.error("Error running %s: %s" % (cmd, str(exc)))
+
+
+if __name__ == "__main__":
+    # listall()
+    main()

+ 2 - 0
pyproject.toml

@@ -0,0 +1,2 @@
+[tool.black]
+line-length=79

+ 9 - 0
sample-config.json

@@ -0,0 +1,9 @@
+{
+    "keys": {
+        "115": "~/bin/on/bt-shutter-pressed-short",
+        "42": {
+            "short": ["~/bin/on/bt-answer-pressed", "short"],
+            "long": ["~/bin/on/bt-answer-pressed", "long"]
+        }
+    }
+}