From b9fc933b6d36bb01b361684405d13ea33221d3c0 Mon Sep 17 00:00:00 2001
From: Anton Sarukhanov <code@ant.sr>
Date: Sun, 10 Jun 2018 19:52:44 -0400
Subject: [PATCH] Handle button presses

---
 README.md             | 107 +++++++++++++++++++++--
 turntouch/__init__.py | 191 +++++++++++++++++++++++++++++++++++++++---
 2 files changed, 280 insertions(+), 18 deletions(-)

diff --git a/README.md b/README.md
index 5a84cdb..788e0d5 100644
--- a/README.md
+++ b/README.md
@@ -5,14 +5,13 @@ bluetooth smart home remote.
 
 It is written in Python 3, originally for use with [Home Assistant](https://www.home-assistant.io/).
 
-# Status
-
-This is currently pre-alpha status. It is not usable.
-
 # Usage
 
 ## Scanning for Turn Touch devices
 
+**Note:** Scanning requires root privileges on Linux. To avoid this, skip
+to the next section and connect to the device without scanning.
+
 ```python
 import turntouch
 
@@ -26,9 +25,103 @@ device = turntouch.scan(only_one=True)[0]
 devices = turntouch.scan(timeout=60)
 ```
 
-`turntouch.scan()` returns a list of `turntouch.TurnTouch` objects.
-
-## Interacting with a Turn Touch device
+`turntouch.scan()` returns a list of `turntouch.TurnTouch` objects. A connection
+is automatically opened to each device, so it is ready to use.
 
 `turntouch.TurnTouch` is a subclass of
 [`bluepy.btle.Peripheral`](http://ianharvey.github.io/bluepy-doc/peripheral.html).
+
+## Interacting with a Turn Touch device
+
+```python
+import turntouch
+
+# Connect to a device by MAC address
+tt = turntouch.TurnTouch('c0:ff:ee:c0:ff:ee')
+
+# Read the device nickname
+print(tt.name)
+
+# Update the device nickname (max. 32 characters)
+tt.name = 'Living Room Remote'
+```
+
+## Listening for button presses
+
+```python
+from turntouch import TurnTouch, DefaultButtonPressHandler
+
+class MyHandler(DefaultButtonPressHandler):
+    def button_north(self):
+        print("Up button pressed.")
+    def button_east_double_tap(self):
+        print("Right button double-tapped.")
+    def button_south_hold(self):
+        print("Down button held.")
+
+tt = TurnTouch('c0:ff:ee:c0:ff:ee')
+tt.set_handler(MyHandler())
+tt.listen_forever()
+
+# One-liner alternative (same as listen_forever)
+TurnTouch('c0:ff:ee:c0:ff:ee', handler=MyHandler(), listen=True)
+```
+
+## More advanced usage
+
+Here's a more complex example, triggering some existing functions.
+
+```python
+import turntouch
+
+# Define a handler
+class MyFancyHandler(turntouch.DefaultButtonPressHandler):
+
+    def __init__(some_object, other_function):
+        """Use the __init__ method to pass references to parts of your code,
+        such as objects, methods, or variables."""
+        self.thing_1 = some_object
+        self.other_func = other_function
+
+    def button_any(press_type):
+        """Special handler which is fired for ALL button presses.
+        `press_type` is an instance of turntouch.PressType."""
+        if press_type.name == "North":
+            self.thing_1.some_method()
+        elif press_type.name in ["South", "East", "West"]:
+            self.thing_1.other_method()
+        else:
+            self.other_func()
+
+    def button_south_hold():
+        print("You can combine per-button handlers with button_any!")
+
+
+# Instantiate the handler, passing some application data into it
+my_handler = MyFancyHandler(some_object_from_my_application, a_function)
+
+# Scan until we find a device
+devices = []
+while not devices:
+    devices = turntouch.scan(only_one=True)
+tt = devices[0]
+
+# Assign the handler to your device.
+tt.set_handler(my_handler)
+
+tt.listen_forever()
+```
+
+## Listening for just one button press
+
+If you don't want the listener to run forever, do this:
+
+```python
+tt = TurnTouch('c0:ff:ee:c0:ff:ee', handler=SomeHandler)
+tt.listen()  # Will return as soon as one button is pressed.
+```
+
+## Error handling
+
+Connection failures will raise `turntouch.TurnTouchException`. You may want to
+catch and ignore this exception to retry connecting.
diff --git a/turntouch/__init__.py b/turntouch/__init__.py
index a97771c..206f038 100644
--- a/turntouch/__init__.py
+++ b/turntouch/__init__.py
@@ -7,6 +7,74 @@ from typing import List, Union
 logger = logging.getLogger('TurnTouch')
 
 
+class PressType:
+    """A type of button press."""
+    def __init__(self, data: bytes, name: str, function_name: str,
+                 multi: bool = False):
+        self.data = data
+        self.name = name
+        self.function_name = function_name
+        self.multi = multi
+
+    def __repr__(self):
+        return '<PressType name="{name}">'.format(name=self.name)
+
+
+class DefaultButtonPressHandler:
+    """A callback handler class for button press events.
+    Create a subclass of this class to define your button press behavior."""
+
+    def button_any(self, press_type: PressType = None): pass
+
+    def button_off(self): pass
+
+    def button_north(self): pass
+
+    def button_north_double_tap(self): pass
+
+    def button_north_hold(self): pass
+
+    def button_east(self): pass
+
+    def button_east_double_tap(self): pass
+
+    def button_east_hold(self): pass
+
+    def button_west(self): pass
+
+    def button_west_double_tap(self): pass
+
+    def button_west_hold(self): pass
+
+    def button_south(self): pass
+
+    def button_south_double_tap(self): pass
+
+    def button_south_hold(self): pass
+
+    def button_multi_north_east(self): pass
+
+    def button_multi_north_west(self): pass
+
+    def button_multi_north_south(self): pass
+
+    def button_multi_east_west(self): pass
+
+    def button_multi_east_south(self): pass
+
+    def button_multi_west_south(self): pass
+
+    def button_multi_north_east_west(self): pass
+
+    def button_multi_north_east_south(self): pass
+
+    def button_multi_north_west_south(self): pass
+
+    def button_multi_east_west_south(self): pass
+
+    def button_multi_north_east_west_south(self): pass
+
+
 class TurnTouch(btle.Peripheral):
     """A Turn Touch smart home remote."""
 
@@ -14,27 +82,78 @@ class TurnTouch(btle.Peripheral):
     BATTERY_LEVEL_CHARACTERISTIC_UUID = '2a19'
     DEVICE_NAME_CHARACTERISTIC_UUID = '99c31526-dc4f-41b1-bb04-4e4deb81fadd'
     DEVICE_NAME_LENGTH = 32
+    PRESS_TYPES = {
+        0xFF00: PressType(0xFF00, 'Off', 'button_off'),
+
+        0xFE00: PressType(0xFE00, 'North', 'button_north'),
+        0xEF00: PressType(0xEF00, 'North double tap',
+                          'button_north_double_tap'),
+        0xFEFF: PressType(0xFEFF, 'North hold', 'button_north_hold'),
+
+        0xFD00: PressType(0xFD00, 'East', 'button_east'),
+        0xDF00: PressType(0xDF00, 'East double tap', 'button_east_double_tap'),
+        0xFDFF: PressType(0xFDFF, 'East hold', 'button_east_hold'),
+
+        0xFB00: PressType(0xFB00, 'West', 'button_west'),
+        0xBF00: PressType(0xBF00, 'West double tap', 'button_west_double_tap'),
+        0xFBFF: PressType(0xFBFF, 'West hold', 'button_west_hold'),
+
+        0xF700: PressType(0xF700, 'South', 'button_south'),
+        0x7F00: PressType(0x7F00, 'South double tap',
+                          'button_south_double_tap'),
+        0xF7FF: PressType(0xF7FF, 'South hold', 'button_south_hold'),
+
+        0xFC00: PressType(0xFC00, 'Multi North East',
+                          'button_multi_north_east', True),
+        0xFA00: PressType(0xFA00, 'Multi North West',
+                          'button_multi_north_west', True),
+        0xF600: PressType(0xF600, 'Multi North South',
+                          'button_multi_north_south', True),
+        0xF900: PressType(0xF900, 'Multi East West',
+                          'button_multi_east_west', True),
+        0xF500: PressType(0xF500, 'Multi East South',
+                          'button_multi_east_south', True),
+        0xF300: PressType(0xF300, 'Multi West South',
+                          'button_multi_west_south', True),
+
+        0xF800: PressType(0xF800, 'Multi North East West',
+                          'button_multi_north_east_west', True),
+        0xF400: PressType(0xF400, 'Multi North East South',
+                          'button_multi_north_east_south', True),
+        0xF200: PressType(0xF200, 'Multi North West South',
+                          'button_multi_north_west_south', True),
+        0xF100: PressType(0xF100, 'Multi East West South',
+                          'button_multi_east_west_south', True),
+
+        0xF000: PressType(0xF000, 'Multi North East West South',
+                          'button_multi_north_east_west_south', True),
+    }
 
     def __init__(self,
-                 device_address: Union[str, btle.ScanEntry],
-                 interface: int = None,
-                 enable_notifications: bool = True):
+                 address: Union[str, btle.ScanEntry],
+                 handler: DefaultButtonPressHandler = None,
+                 listen: bool = False,
+                 interface: int = None):
         """Connect to the Turn Touch remote.
         Set appropriate address type (overriding btle default).
-        :param device_address Union[str, btle.ScanEntry]:
+        :param address Union[str, btle.ScanEntry]:
             MAC address (or btle.ScanEntry object) of this device
-        :param interface int: Index of the bluetooth device (eg. 0 for hci0)
-        :param enable_notifications bool: Start listening for button presses"""
+        :param listen bool: Start listening for button presses
+        :param interface int: Index of the bluetooth device (eg. 0 for hci0)"""
         try:
-            logger.info("Connecting to device {address}...'".format(
-                address=self.addr))
+            logger.info("Connecting to device {address}...".format(
+                address=address))
             super(TurnTouch, self).__init__(
-                device_address, btle.ADDR_TYPE_RANDOM, interface)
-            logger.info("Successfully connected to device {address}.'".format(
+                address, btle.ADDR_TYPE_RANDOM, interface)
+            logger.info("Successfully connected to device {address}.".format(
                 address=self.addr))
         except btle.BTLEException:
             raise TurnTouchException("Failed to connect to device {address}"
-                                     .format(address=device_address))
+                                     .format(address=address))
+        self.withDelegate(self.NotificationDelegate(turn_touch=self))
+        self.set_handler(handler)
+        if listen:
+            self.listen_forever()
 
     @property
     def name(self) -> str:
@@ -58,6 +177,56 @@ class TurnTouch(btle.Peripheral):
         logger.debug("Set name for device {address} to '{name}'".format(
             address=self.addr, name=name_bytes))
 
+    def set_handler(self, handler: DefaultButtonPressHandler = None):
+        """Set the button press handler class for this remote."""
+        self._handler = handler or DefaultButtonPressHandler
+
+    def listen(self, timeout: int = 0, only_one: bool = True):
+        """Listen for a button press event.
+        Will listen indefinitely if `only_one` is False."""
+        self._enable_notifications()
+        if only_one:
+            self.waitForNotifications(timeout)
+        else:
+            while True:
+                self.waitForNotifications(timeout)
+        self._enable_notifications(enable=False)
+
+    def listen_forever(self):
+        """Listen for button press events indefinitely."""
+        self.listen(only_one=False)
+
+    def _enable_notifications(self, enabled=True):
+        """Tell the remote to start sending button press notifications."""
+        notification_handle = self.getCharacteristics(
+            uuid=self.BUTTON_STATUS_CHARACTERISTIC_UUID)[0].getHandle()
+        notification_enable_handle = notification_handle + 1
+        logger.debug("{action} notifications for device {address}...".format(
+            action="Enabling" if enabled else "Disabling", address=self.addr))
+        self.writeCharacteristic(notification_enable_handle,
+                                 bytes([0x01 if enabled else 0x00, 0x00]),
+                                 withResponse=True)
+        logger.debug("Notifications {action} for device {address}.".format(
+            action="enabled" if enabled else "disabled", address=self.addr))
+
+    class NotificationDelegate(btle.DefaultDelegate):
+        """Handle callbacks for notifications from the device."""
+        def __init__(self, turn_touch):
+            """Retain a reference to the calling object."""
+            self.turn_touch = turn_touch
+
+        def handleNotification(self, cHandle, data):
+            """Call the appropriate button press handler method(s)."""
+            logger.debug("Got notification {notification}".format(
+                notification=data))
+            type_int = int.from_bytes(data, byteorder='big')
+            # Call the generic (any button) callback.
+            self.turn_touch._handler.button_any(
+                self.turn_touch.PRESS_TYPES.get(type_int))
+            # Call the button-specific callback
+            getattr(self.turn_touch._handler,
+                    self.turn_touch.PRESS_TYPES.get(type_int).function_name)()
+
 
 class TurnTouchException(Exception):
     """An error related to the Turn Touch bluetooth smart home remote."""
-- 
GitLab