From c7b0440562259a72a8f815fd030f2a8c7b1bc414 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 29 Feb 2024 00:26:49 +0100 Subject: [PATCH] [#357] Migrated `backend.button.flic` to `flic` plugin. Closes: #357 --- docs/source/backends.rst | 1 - docs/source/events.rst | 2 +- docs/source/platypush/backend/button.flic.rst | 6 - docs/source/platypush/events/button.flic.rst | 6 - docs/source/platypush/events/flic.rst | 5 + docs/source/platypush/plugins/flic.rst | 5 + docs/source/plugins.rst | 1 + platypush/backend/button/flic/__init__.py | 131 ---- .../backend/button/flic/fliclib/__init__.py | 0 .../backend/button/flic/fliclib/fliclib.py | 609 ------------------ platypush/backend/button/flic/manifest.yaml | 9 - platypush/message/event/button/__init__.py | 0 .../event/{button => }/flic/__init__.py | 30 +- platypush/plugins/flic/__init__.py | 155 +++++ .../aioflic.py => plugins/flic/fliclib.py} | 528 +++++++++++---- platypush/plugins/flic/manifest.yaml | 7 + setup.cfg | 6 +- 17 files changed, 593 insertions(+), 908 deletions(-) delete mode 100644 docs/source/platypush/backend/button.flic.rst delete mode 100644 docs/source/platypush/events/button.flic.rst create mode 100644 docs/source/platypush/events/flic.rst create mode 100644 docs/source/platypush/plugins/flic.rst delete mode 100644 platypush/backend/button/flic/__init__.py delete mode 100644 platypush/backend/button/flic/fliclib/__init__.py delete mode 100644 platypush/backend/button/flic/fliclib/fliclib.py delete mode 100644 platypush/backend/button/flic/manifest.yaml delete mode 100644 platypush/message/event/button/__init__.py rename platypush/message/event/{button => }/flic/__init__.py (69%) create mode 100644 platypush/plugins/flic/__init__.py rename platypush/{backend/button/flic/fliclib/aioflic.py => plugins/flic/fliclib.py} (50%) create mode 100644 platypush/plugins/flic/manifest.yaml diff --git a/docs/source/backends.rst b/docs/source/backends.rst index c8c2facf6..cfe4c1edd 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -6,7 +6,6 @@ Backends :maxdepth: 1 :caption: Backends: - platypush/backend/button.flic.rst platypush/backend/chat.telegram.rst platypush/backend/http.rst platypush/backend/midi.rst diff --git a/docs/source/events.rst b/docs/source/events.rst index cf096d81e..e9ecfcee4 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -11,7 +11,6 @@ Events platypush/events/application.rst platypush/events/assistant.rst platypush/events/bluetooth.rst - platypush/events/button.flic.rst platypush/events/camera.rst platypush/events/chat.slack.rst platypush/events/chat.telegram.rst @@ -21,6 +20,7 @@ Events platypush/events/distance.rst platypush/events/entities.rst platypush/events/file.rst + platypush/events/flic.rst platypush/events/foursquare.rst platypush/events/geo.rst platypush/events/github.rst diff --git a/docs/source/platypush/backend/button.flic.rst b/docs/source/platypush/backend/button.flic.rst deleted file mode 100644 index 189cc6c4e..000000000 --- a/docs/source/platypush/backend/button.flic.rst +++ /dev/null @@ -1,6 +0,0 @@ -``button.flic`` -================================= - -.. automodule:: platypush.backend.button.flic - :members: - diff --git a/docs/source/platypush/events/button.flic.rst b/docs/source/platypush/events/button.flic.rst deleted file mode 100644 index 5f1cc2e54..000000000 --- a/docs/source/platypush/events/button.flic.rst +++ /dev/null @@ -1,6 +0,0 @@ -``button.flic`` -======================================= - -.. automodule:: platypush.message.event.button.flic - :members: - diff --git a/docs/source/platypush/events/flic.rst b/docs/source/platypush/events/flic.rst new file mode 100644 index 000000000..67b7aeba3 --- /dev/null +++ b/docs/source/platypush/events/flic.rst @@ -0,0 +1,5 @@ +``event.flic`` +============== + +.. automodule:: platypush.message.event.flic + :members: diff --git a/docs/source/platypush/plugins/flic.rst b/docs/source/platypush/plugins/flic.rst new file mode 100644 index 000000000..119431b35 --- /dev/null +++ b/docs/source/platypush/plugins/flic.rst @@ -0,0 +1,5 @@ +``flic`` +======== + +.. automodule:: platypush.plugins.flic + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 99e99c1ba..720b02014 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -35,6 +35,7 @@ Plugins platypush/plugins/ffmpeg.rst platypush/plugins/file.rst platypush/plugins/file.monitor.rst + platypush/plugins/flic.rst platypush/plugins/foursquare.rst platypush/plugins/github.rst platypush/plugins/google.calendar.rst diff --git a/platypush/backend/button/flic/__init__.py b/platypush/backend/button/flic/__init__.py deleted file mode 100644 index 038795be2..000000000 --- a/platypush/backend/button/flic/__init__.py +++ /dev/null @@ -1,131 +0,0 @@ -from threading import Timer -from time import time - -from platypush.backend import Backend -from platypush.message.event.button.flic import FlicButtonEvent - -from .fliclib.fliclib import FlicClient, ButtonConnectionChannel, ClickType - - -class ButtonFlicBackend(Backend): - """ - Backend that listen for events from the Flic (https://flic.io/) bluetooth - smart buttons. - - Requires: - - * **fliclib** (https://github.com/50ButtonsEach/fliclib-linux-hci). For - the backend to work properly you need to have the ``flicd`` daemon - from the fliclib running, and you have to first pair the buttons with - your device using any of the scanners provided by the library. - - """ - - _long_press_timeout = 0.3 - _btn_timeout = 0.5 - ShortPressEvent = "ShortPressEvent" - LongPressEvent = "LongPressEvent" - - def __init__( - self, - server='localhost', - long_press_timeout=_long_press_timeout, - btn_timeout=_btn_timeout, - **kwargs - ): - """ - :param server: flicd server host (default: localhost) - :type server: str - - :param long_press_timeout: How long you should press a button for a - press action to be considered "long press" (default: 0.3 secohds) - :type long_press_timeout: float - - :param btn_timeout: How long since the last button release before - considering the user interaction completed (default: 0.5 seconds) - :type btn_timeout: float - """ - - super().__init__(**kwargs) - - self.server = server - self.client = FlicClient(server) - self.client.get_info(self._received_info()) - self.client.on_new_verified_button = self._got_button() - - self._long_press_timeout = long_press_timeout - self._btn_timeout = btn_timeout - self._btn_timer = None - self._btn_addr = None - self._down_pressed_time = None - self._cur_sequence = [] - self.logger.info('Initialized Flic buttons backend on %s', self.server) - - def _got_button(self): - def _f(bd_addr): - cc = ButtonConnectionChannel(bd_addr) - cc.on_button_up_or_down = ( - lambda channel, click_type, was_queued, time_diff: self._on_event()( - bd_addr, channel, click_type, was_queued, time_diff - ) - ) - self.client.add_connection_channel(cc) - - return _f - - def _received_info(self): - def _f(items): - for bd_addr in items["bd_addr_of_verified_buttons"]: - self._got_button()(bd_addr) - - return _f - - def _on_btn_timeout(self): - def _f(): - self.logger.info( - 'Flic event triggered from %s: %s', self._btn_addr, self._cur_sequence - ) - - self.bus.post( - FlicButtonEvent(btn_addr=self._btn_addr, sequence=self._cur_sequence) - ) - - self._cur_sequence = [] - - return _f - - def _on_event(self): - # _ = channel - # __ = time_diff - def _f(bd_addr, _, click_type, was_queued, __): - if was_queued: - return - - if self._btn_timer: - self._btn_timer.cancel() - - if click_type == ClickType.ButtonDown: - self._down_pressed_time = time() - return - - btn_event = self.ShortPressEvent - if self._down_pressed_time: - if time() - self._down_pressed_time >= self._long_press_timeout: - btn_event = self.LongPressEvent - self._down_pressed_time = None - - self._cur_sequence.append(btn_event) - - self._btn_addr = bd_addr - self._btn_timer = Timer(self._btn_timeout, self._on_btn_timeout()) - self._btn_timer.start() - - return _f - - def run(self): - super().run() - - self.client.handle_events() - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/button/flic/fliclib/__init__.py b/platypush/backend/button/flic/fliclib/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/platypush/backend/button/flic/fliclib/fliclib.py b/platypush/backend/button/flic/fliclib/fliclib.py deleted file mode 100644 index 56263c88f..000000000 --- a/platypush/backend/button/flic/fliclib/fliclib.py +++ /dev/null @@ -1,609 +0,0 @@ -"""Flic client library for python - -Requires python 3.3 or higher. - -For detailed documentation, see the protocol documentation. - -Notes on the data type used in this python implementation compared to the protocol documentation: -All kind of integers are represented as python integers. -Booleans use the Boolean type. -Enums use the defined python enums below. -Bd addr are represented as standard python strings, e.g. "aa:bb:cc:dd:ee:ff". -""" - -from enum import Enum -from collections import namedtuple -import time -import socket -import select -import struct -import itertools -import queue -import threading - -class CreateConnectionChannelError(Enum): - NoError = 0 - MaxPendingConnectionsReached = 1 - -class ConnectionStatus(Enum): - Disconnected = 0 - Connected = 1 - Ready = 2 - -class DisconnectReason(Enum): - Unspecified = 0 - ConnectionEstablishmentFailed = 1 - TimedOut = 2 - BondingKeysMismatch = 3 - -class RemovedReason(Enum): - RemovedByThisClient = 0 - ForceDisconnectedByThisClient = 1 - ForceDisconnectedByOtherClient = 2 - - ButtonIsPrivate = 3 - VerifyTimeout = 4 - InternetBackendError = 5 - InvalidData = 6 - - CouldntLoadDevice = 7 - -class ClickType(Enum): - ButtonDown = 0 - ButtonUp = 1 - ButtonClick = 2 - ButtonSingleClick = 3 - ButtonDoubleClick = 4 - ButtonHold = 5 - -class BdAddrType(Enum): - PublicBdAddrType = 0 - RandomBdAddrType = 1 - -class LatencyMode(Enum): - NormalLatency = 0 - LowLatency = 1 - HighLatency = 2 - -class BluetoothControllerState(Enum): - Detached = 0 - Resetting = 1 - Attached = 2 - -class ScanWizardResult(Enum): - WizardSuccess = 0 - WizardCancelledByUser = 1 - WizardFailedTimeout = 2 - WizardButtonIsPrivate = 3 - WizardBluetoothUnavailable = 4 - WizardInternetBackendError = 5 - WizardInvalidData = 6 - -class ButtonScanner: - """ButtonScanner class. - - Usage: - scanner = ButtonScanner() - scanner.on_advertisement_packet = lambda scanner, bd_addr, name, rssi, is_private, already_verified: ... - client.add_scanner(scanner) - """ - - _cnt = itertools.count() - - def __init__(self): - self._scan_id = next(ButtonScanner._cnt) - self.on_advertisement_packet = lambda scanner, bd_addr, name, rssi, is_private, already_verified: None - -class ScanWizard: - """ScanWizard class - - Usage: - wizard = ScanWizard() - wizard.on_found_private_button = lambda scan_wizard: ... - wizard.on_found_public_button = lambda scan_wizard, bd_addr, name: ... - wizard.on_button_connected = lambda scan_wizard, bd_addr, name: ... - wizard.on_completed = lambda scan_wizard, result, bd_addr, name: ... - client.add_scan_wizard(wizard) - """ - - _cnt = itertools.count() - - def __init__(self): - self._scan_wizard_id = next(ScanWizard._cnt) - self._bd_addr = None - self._name = None - self.on_found_private_button = lambda scan_wizard: None - self.on_found_public_button = lambda scan_wizard, bd_addr, name: None - self.on_button_connected = lambda scan_wizard, bd_addr, name: None - self.on_completed = lambda scan_wizard, result, bd_addr, name: None - -class ButtonConnectionChannel: - """ButtonConnectionChannel class. - - This class represents a connection channel to a Flic button. - Add this button connection channel to a FlicClient by executing client.add_connection_channel(connection_channel). - You may only have this connection channel added to one FlicClient at a time. - - Before you add the connection channel to the client, you should set up your callback functions by assigning - the corresponding properties to this object with a function. Each callback function has a channel parameter as the first one, - referencing this object. - - Available properties and the function parameters are: - on_create_connection_channel_response: channel, error, connection_status - on_removed: channel, removed_reason - on_connection_status_changed: channel, connection_status, disconnect_reason - on_button_up_or_down / on_button_click_or_hold / on_button_single_or_double_click / on_button_single_or_double_click_or_hold: channel, click_type, was_queued, time_diff - """ - - _cnt = itertools.count() - - def __init__(self, bd_addr, latency_mode = LatencyMode.NormalLatency, auto_disconnect_time = 511): - self._conn_id = next(ButtonConnectionChannel._cnt) - self._bd_addr = bd_addr - self._latency_mode = latency_mode - self._auto_disconnect_time = auto_disconnect_time - self._client = None - - self.on_create_connection_channel_response = lambda channel, error, connection_status: None - self.on_removed = lambda channel, removed_reason: None - self.on_connection_status_changed = lambda channel, connection_status, disconnect_reason: None - self.on_button_up_or_down = lambda channel, click_type, was_queued, time_diff: None - self.on_button_click_or_hold = lambda channel, click_type, was_queued, time_diff: None - self.on_button_single_or_double_click = lambda channel, click_type, was_queued, time_diff: None - self.on_button_single_or_double_click_or_hold = lambda channel, click_type, was_queued, time_diff: None - - @property - def bd_addr(self): - return self._bd_addr - - @property - def latency_mode(self): - return self._latency_mode - - @latency_mode.setter - def latency_mode(self, latency_mode): - if self._client is None: - self._latency_mode = latency_mode - return - - with self._client._lock: - self._latency_mode = latency_mode - if not self._client._closed: - self._client._send_command("CmdChangeModeParameters", {"conn_id": self._conn_id, "latency_mode": self._latency_mode, "auto_disconnect_time": self._auto_disconnect_time}) - - @property - def auto_disconnect_time(self): - return self._auto_disconnect_time - - @auto_disconnect_time.setter - def auto_disconnect_time(self, auto_disconnect_time): - if self._client is None: - self._auto_disconnect_time = auto_disconnect_time - return - - with self._client._lock: - self._auto_disconnect_time = auto_disconnect_time - if not self._client._closed: - self._client._send_command("CmdChangeModeParameters", {"conn_id": self._conn_id, "latency_mode": self._latency_mode, "auto_disconnect_time": self._auto_disconnect_time}) - -class FlicClient: - """FlicClient class. - - When this class is constructed, a socket connection is established. - You may then send commands to the server and set timers. - Once you are ready with the initialization you must call the handle_events() method which is a main loop that never exits, unless the socket is closed. - For a more detailed description of all commands, events and enums, check the protocol specification. - - All commands are wrapped in more high level functions and events are reported using callback functions. - - All methods called on this class will take effect only if you eventually call the handle_events() method. - - The ButtonScanner is used to set up a handler for advertisement packets. - The ButtonConnectionChannel is used to interact with connections to flic buttons and receive their events. - - Other events are handled by the following callback functions that can be assigned to this object (and a list of the callback function parameters): - on_new_verified_button: bd_addr - on_no_space_for_new_connection: max_concurrently_connected_buttons - on_got_space_for_new_connection: max_concurrently_connected_buttons - on_bluetooth_controller_state_change: state - """ - - _EVENTS = [ - ("EvtAdvertisementPacket", "> 8 - bytes[2] = opcode - bytes += data_bytes - with self._lock: - if not self._closed: - self._sock.sendall(bytes) - - def _dispatch_event(self, data): - if len(data) == 0: - return - opcode = data[0] - - if opcode >= len(FlicClient._EVENTS) or FlicClient._EVENTS[opcode] is None: - return - - event_name = FlicClient._EVENTS[opcode][0] - data_tuple = FlicClient._EVENT_STRUCTS[opcode].unpack(data[1 : 1 + FlicClient._EVENT_STRUCTS[opcode].size]) - items = FlicClient._EVENT_NAMED_TUPLES[opcode]._make(data_tuple)._asdict() - - # Process some kind of items whose data type is not supported by struct - if "bd_addr" in items: - items["bd_addr"] = FlicClient._bdaddr_bytes_to_string(items["bd_addr"]) - - if "name" in items: - items["name"] = items["name"].decode("utf-8") - - if event_name == "EvtCreateConnectionChannelResponse": - items["error"] = CreateConnectionChannelError(items["error"]) - items["connection_status"] = ConnectionStatus(items["connection_status"]) - - if event_name == "EvtConnectionStatusChanged": - items["connection_status"] = ConnectionStatus(items["connection_status"]) - items["disconnect_reason"] = DisconnectReason(items["disconnect_reason"]) - - if event_name == "EvtConnectionChannelRemoved": - items["removed_reason"] = RemovedReason(items["removed_reason"]) - - if event_name.startswith("EvtButton"): - items["click_type"] = ClickType(items["click_type"]) - - if event_name == "EvtGetInfoResponse": - items["bluetooth_controller_state"] = BluetoothControllerState(items["bluetooth_controller_state"]) - items["my_bd_addr"] = FlicClient._bdaddr_bytes_to_string(items["my_bd_addr"]) - items["my_bd_addr_type"] = BdAddrType(items["my_bd_addr_type"]) - items["bd_addr_of_verified_buttons"] = [] - - pos = FlicClient._EVENT_STRUCTS[opcode].size - for i in range(items["nb_verified_buttons"]): - items["bd_addr_of_verified_buttons"].append(FlicClient._bdaddr_bytes_to_string(data[1 + pos : 1 + pos + 6])) - pos += 6 - - if event_name == "EvtBluetoothControllerStateChange": - items["state"] = BluetoothControllerState(items["state"]) - - if event_name == "EvtGetButtonUUIDResponse": - items["uuid"] = "".join(map(lambda x: "%02x" % x, items["uuid"])) - if items["uuid"] == "00000000000000000000000000000000": - items["uuid"] = None - - if event_name == "EvtScanWizardCompleted": - items["result"] = ScanWizardResult(items["result"]) - - # Process event - if event_name == "EvtAdvertisementPacket": - scanner = self._scanners.get(items["scan_id"]) - if scanner is not None: - scanner.on_advertisement_packet(scanner, items["bd_addr"], items["name"], items["rssi"], items["is_private"], items["already_verified"]) - - if event_name == "EvtCreateConnectionChannelResponse": - channel = self._connection_channels[items["conn_id"]] - if items["error"] != CreateConnectionChannelError.NoError: - del self._connection_channels[items["conn_id"]] - channel.on_create_connection_channel_response(channel, items["error"], items["connection_status"]) - - if event_name == "EvtConnectionStatusChanged": - channel = self._connection_channels[items["conn_id"]] - channel.on_connection_status_changed(channel, items["connection_status"], items["disconnect_reason"]) - - if event_name == "EvtConnectionChannelRemoved": - channel = self._connection_channels[items["conn_id"]] - del self._connection_channels[items["conn_id"]] - channel.on_removed(channel, items["removed_reason"]) - - if event_name == "EvtButtonUpOrDown": - channel = self._connection_channels[items["conn_id"]] - channel.on_button_up_or_down(channel, items["click_type"], items["was_queued"], items["time_diff"]) - if event_name == "EvtButtonClickOrHold": - channel = self._connection_channels[items["conn_id"]] - channel.on_button_click_or_hold(channel, items["click_type"], items["was_queued"], items["time_diff"]) - if event_name == "EvtButtonSingleOrDoubleClick": - channel = self._connection_channels[items["conn_id"]] - channel.on_button_single_or_double_click(channel, items["click_type"], items["was_queued"], items["time_diff"]) - if event_name == "EvtButtonSingleOrDoubleClickOrHold": - channel = self._connection_channels[items["conn_id"]] - channel.on_button_single_or_double_click_or_hold(channel, items["click_type"], items["was_queued"], items["time_diff"]) - - if event_name == "EvtNewVerifiedButton": - self.on_new_verified_button(items["bd_addr"]) - - if event_name == "EvtGetInfoResponse": - self._get_info_response_queue.get()(items) - - if event_name == "EvtNoSpaceForNewConnection": - self.on_no_space_for_new_connection(items["max_concurrently_connected_buttons"]) - - if event_name == "EvtGotSpaceForNewConnection": - self.on_got_space_for_new_connection(items["max_concurrently_connected_buttons"]) - - if event_name == "EvtBluetoothControllerStateChange": - self.on_bluetooth_controller_state_change(items["state"]) - - if event_name == "EvtGetButtonUUIDResponse": - self._get_button_uuid_queue.get()(items["bd_addr"], items["uuid"]) - - if event_name == "EvtScanWizardFoundPrivateButton": - scan_wizard = self._scan_wizards[items["scan_wizard_id"]] - scan_wizard.on_found_private_button(scan_wizard) - - if event_name == "EvtScanWizardFoundPublicButton": - scan_wizard = self._scan_wizards[items["scan_wizard_id"]] - scan_wizard._bd_addr = items["bd_addr"] - scan_wizard._name = items["name"] - scan_wizard.on_found_public_button(scan_wizard, scan_wizard._bd_addr, scan_wizard._name) - - if event_name == "EvtScanWizardButtonConnected": - scan_wizard = self._scan_wizards[items["scan_wizard_id"]] - scan_wizard.on_button_connected(scan_wizard, scan_wizard._bd_addr, scan_wizard._name) - - if event_name == "EvtScanWizardCompleted": - scan_wizard = self._scan_wizards[items["scan_wizard_id"]] - del self._scan_wizards[items["scan_wizard_id"]] - scan_wizard.on_completed(scan_wizard, items["result"], scan_wizard._bd_addr, scan_wizard._name) - - def _handle_one_event(self): - if len(self._timers.queue) > 0: - current_timer = self._timers.queue[0] - timeout = max(current_timer[0] - time.monotonic(), 0) - if timeout == 0: - self._timers.get()[1]() - return True - if len(select.select([self._sock], [], [], timeout)[0]) == 0: - return True - - len_arr = bytearray(2) - view = memoryview(len_arr) - - toread = 2 - while toread > 0: - nbytes = self._sock.recv_into(view, toread) - if nbytes == 0: - return False - view = view[nbytes:] - toread -= nbytes - - packet_len = len_arr[0] | (len_arr[1] << 8) - data = bytearray(packet_len) - view = memoryview(data) - toread = packet_len - while toread > 0: - nbytes = self._sock.recv_into(view, toread) - if nbytes == 0: - return False - view = view[nbytes:] - toread -= nbytes - - self._dispatch_event(data) - return True - - def handle_events(self): - """Start the main loop for this client. - - This method will not return until the socket has been closed. - Once it has returned, any use of this FlicClient is illegal. - """ - self._handle_event_thread_ident = threading.get_ident() - while not self._closed: - if not self._handle_one_event(): - break - self._sock.close() diff --git a/platypush/backend/button/flic/manifest.yaml b/platypush/backend/button/flic/manifest.yaml deleted file mode 100644 index b95026b9e..000000000 --- a/platypush/backend/button/flic/manifest.yaml +++ /dev/null @@ -1,9 +0,0 @@ -manifest: - events: - platypush.message.event.button.flic.FlicButtonEvent: when a button is pressed.The - event will also contain the press sequence(e.g. ``["ShortPressEvent", "LongPressEvent", - "ShortPressEvent"]``) - install: - pip: [] - package: platypush.backend.button.flic - type: backend diff --git a/platypush/message/event/button/__init__.py b/platypush/message/event/button/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/platypush/message/event/button/flic/__init__.py b/platypush/message/event/flic/__init__.py similarity index 69% rename from platypush/message/event/button/flic/__init__.py rename to platypush/message/event/flic/__init__.py index 48245a0f8..d98cbd244 100644 --- a/platypush/message/event/button/flic/__init__.py +++ b/platypush/message/event/flic/__init__.py @@ -1,34 +1,33 @@ +from typing import Sequence from platypush.message.event import Event, EventMatchResult class FlicButtonEvent(Event): """ Event triggered when a sequence of user short/long presses is detected on a - Flic button (https://flic.io). + `Flic button `_. """ - def __init__(self, btn_addr, sequence, *args, **kwargs): + def __init__(self, btn_addr: str, sequence: Sequence[str], *_, **kwargs): """ - :param btn_addr: Physical address of the button that originated the event - :type btn_addr: str - - :param sequence: Detected sequence, as a list of Flic button event types (either "ShortPressEvent" or - "LongPressEvent") - :type sequence: list[str] + :param btn_addr: Physical address of the button that originated the + event. + :param sequence: Detected sequence, as a list of Flic button event + types (either ``ShortPressEvent`` or ``LongPressEvent``). """ - - super().__init__(btn_addr=btn_addr, sequence=sequence, *args, **kwargs) + super().__init__(btn_addr=btn_addr, sequence=sequence, **kwargs) def matches_condition(self, condition): """ - :param condition: Condition to be checked against, as a sequence of button presses ("ShortPressEvent" and - "LongPressEvent") + :param condition: Condition to be checked against, as a sequence of + button presses (``ShortPressEvent`` and ``LongPressEvent``). """ - result = EventMatchResult(is_match=False) - if not isinstance(self, condition.type) \ - or self.args['btn_addr'] != condition.args['btn_addr']: + if ( + not isinstance(self, condition.type) + or self.args['btn_addr'] != condition.args['btn_addr'] + ): return result cond_sequence = list(condition.args['sequence']) @@ -47,4 +46,5 @@ class FlicButtonEvent(Event): result.is_match = len(cond_sequence) == 0 return result + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/flic/__init__.py b/platypush/plugins/flic/__init__.py new file mode 100644 index 000000000..45ed11001 --- /dev/null +++ b/platypush/plugins/flic/__init__.py @@ -0,0 +1,155 @@ +from multiprocessing import Process +from threading import RLock, Timer +from time import time + +from platypush.context import get_bus +from platypush.message.event.flic import FlicButtonEvent +from platypush.plugins import RunnablePlugin + +from .fliclib import FlicClient, ButtonConnectionChannel, ClickType + + +class FlicPlugin(RunnablePlugin): + """ + This integration listens for events from the `Flic `_ + smart buttons over Bluetooth. + + Requires: + + * **fliclib** (https://github.com/50ButtonsEach/fliclib-linux-hci). + + Clone the repository and follow the instructions in the README. This plugin + requires: + + * The ``flicd`` daemon to be running - either on the same machine or on + a machine that can be reached through the network. + + * The buttons to be paired with the device running the ``flicd`` daemon. + The repository provides several scanners, but the easiest way is + probably through the ``new_scan_wizard.py`` script. + + .. code-block:: bash + + # Clone the repository + $ git clone https://github.com/50ButtonsEach/fliclib-linux-hci.git + # Run the flid daemon + $ [sudo] fliclib-linux-hci/bin/$(uname -m)/flicd -f /path/to/flicd.db & + # Run the new_scan_wizard.py script to pair the buttons + $ cd fliclib-linux-hci/clientlib/python + $ python3 new_scan_wizard.py + + """ + + _long_press_timeout = 0.3 + _btn_timeout = 0.5 + ShortPressEvent = "ShortPressEvent" + LongPressEvent = "LongPressEvent" + + def __init__( + self, + server: str = 'localhost', + long_press_timeout: float = _long_press_timeout, + btn_timeout: float = _btn_timeout, + **kwargs, + ): + """ + :param server: flicd server host (default: localhost) + :param long_press_timeout: How long you should press a button for a + press action to be considered "long press" (default: 0.3 secohds) + :param btn_timeout: How long since the last button release before + considering the user interaction completed (default: 0.5 seconds) + """ + + super().__init__(**kwargs) + + self.server = server + self._client = None + self._client_lock = RLock() + + self._long_press_timeout = long_press_timeout + self._btn_timeout = btn_timeout + self._btn_timer = None + self._btn_addr = None + self._down_pressed_time = None + self._cur_sequence = [] + + @property + def client(self): + with self._client_lock: + if not self._client: + self._client = FlicClient(self.server) + self._client.get_info(self._received_info) + self._client.on_new_verified_button = self._got_button + + return self._client + + def _got_button(self, bd_addr): + cc = ButtonConnectionChannel(bd_addr) + cc.on_button_up_or_down = ( + lambda channel, click_type, was_queued, time_diff: self._on_event( + bd_addr, channel, click_type, was_queued, time_diff + ) + ) + self.client.add_connection_channel(cc) + + def _received_info(self, items): + for bd_addr in items["bd_addr_of_verified_buttons"]: + self._got_button(bd_addr) + + def _on_btn_timeout(self): + if self._btn_addr: + get_bus().post( + FlicButtonEvent( + btn_addr=self._btn_addr, + sequence=self._cur_sequence, + ) + ) + + self._cur_sequence = [] + + # _ = channel + # __ = time_diff + def _on_event(self, bd_addr, _, click_type, was_queued, __): + if was_queued and self._btn_addr: + return + + if self._btn_timer: + self._btn_timer.cancel() + + if click_type == ClickType.ButtonDown: + self._down_pressed_time = time() + return + + btn_event = self.ShortPressEvent + if self._down_pressed_time: + if time() - self._down_pressed_time >= self._long_press_timeout: + btn_event = self.LongPressEvent + self._down_pressed_time = None + + self._cur_sequence.append(btn_event) + self._btn_addr = bd_addr + self._btn_timer = Timer(self._btn_timeout, self._on_btn_timeout) + self._btn_timer.start() + + def _processor(self): + try: + self.client.handle_events() + except KeyboardInterrupt: + pass + + def main(self): + while not self.should_stop(): + proc = Process(target=self._processor, name='FlicProcessor') + proc.start() + self.wait_stop() + + if proc.is_alive(): + proc.terminate() + proc.join(2) + + if proc.is_alive(): + self.logger.warning('Flic processor still alive after termination') + proc.kill() + + +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/button/flic/fliclib/aioflic.py b/platypush/plugins/flic/fliclib.py similarity index 50% rename from platypush/backend/button/flic/fliclib/aioflic.py rename to platypush/plugins/flic/fliclib.py index 9099c0f11..8b4fbd510 100644 --- a/platypush/backend/button/flic/fliclib/aioflic.py +++ b/platypush/plugins/flic/fliclib.py @@ -10,11 +10,15 @@ Booleans use the Boolean type. Enums use the defined python enums below. Bd addr are represented as standard python strings, e.g. "aa:bb:cc:dd:ee:ff". """ -import asyncio + from enum import Enum from collections import namedtuple +import time +import socket +import select import struct import itertools +import queue import threading @@ -48,6 +52,11 @@ class RemovedReason(Enum): CouldntLoadDevice = 7 + DeletedByThisClient = 8 + DeletedByOtherClient = 9 + ButtonBelongsToOtherPartner = 10 + DeletedFromButton = 11 + class ClickType(Enum): ButtonDown = 0 @@ -83,6 +92,8 @@ class ScanWizardResult(Enum): WizardBluetoothUnavailable = 4 WizardInternetBackendError = 5 WizardInvalidData = 6 + WizardButtonBelongsToOtherPartner = 7 + WizardButtonAlreadyConnectedToOtherDevice = 8 class ButtonScanner: @@ -90,7 +101,7 @@ class ButtonScanner: Usage: scanner = ButtonScanner() - scanner.on_advertisement_packet = lambda scanner, bd_addr, name, rssi, is_private, already_verified: ... + scanner.on_advertisement_packet = lambda scanner, bd_addr, name, rssi, is_private, already_verified, already_connected_to_this_device, already_connected_to_other_device: ... client.add_scanner(scanner) """ @@ -98,7 +109,9 @@ class ButtonScanner: def __init__(self): self._scan_id = next(ButtonScanner._cnt) - self.on_advertisement_packet = lambda scanner, bd_addr, name, rssi, is_private, already_verified: None + self.on_advertisement_packet = ( + lambda scanner, bd_addr, name, rssi, is_private, already_verified, already_connected_to_this_device, already_connected_to_other_device: None + ) class ScanWizard: @@ -125,6 +138,29 @@ class ScanWizard: self.on_completed = lambda scan_wizard, result, bd_addr, name: None +class BatteryStatusListener: + """BatteryStatusListener class + + Usage: + listener = BatteryStatusListener(bd_addr) + listener.on_battery_status = lambda battery_status_listener, bd_addr, battery_percentage, timestamp: ... + client.add_battery_status_listener(listener) + """ + + _cnt = itertools.count() + + def __init__(self, bd_addr): + self._listener_id = next(BatteryStatusListener._cnt) + self._bd_addr = bd_addr + self.on_battery_status = ( + lambda battery_status_listener, battery_percentage, timestamp: None + ) + + @property + def bd_addr(self): + return self._bd_addr + + class ButtonConnectionChannel: """ButtonConnectionChannel class. @@ -145,20 +181,34 @@ class ButtonConnectionChannel: _cnt = itertools.count() - def __init__(self, bd_addr, latency_mode=LatencyMode.NormalLatency, auto_disconnect_time=511): + def __init__( + self, bd_addr, latency_mode=LatencyMode.NormalLatency, auto_disconnect_time=511 + ): self._conn_id = next(ButtonConnectionChannel._cnt) self._bd_addr = bd_addr self._latency_mode = latency_mode self._auto_disconnect_time = auto_disconnect_time self._client = None - self.on_create_connection_channel_response = lambda channel, error, connection_status: None + self.on_create_connection_channel_response = ( + lambda channel, error, connection_status: None + ) self.on_removed = lambda channel, removed_reason: None - self.on_connection_status_changed = lambda channel, connection_status, disconnect_reason: None - self.on_button_up_or_down = lambda channel, click_type, was_queued, time_diff: None - self.on_button_click_or_hold = lambda channel, click_type, was_queued, time_diff: None - self.on_button_single_or_double_click = lambda channel, click_type, was_queued, time_diff: None - self.on_button_single_or_double_click_or_hold = lambda channel, click_type, was_queued, time_diff: None + self.on_connection_status_changed = ( + lambda channel, connection_status, disconnect_reason: None + ) + self.on_button_up_or_down = ( + lambda channel, click_type, was_queued, time_diff: None + ) + self.on_button_click_or_hold = ( + lambda channel, click_type, was_queued, time_diff: None + ) + self.on_button_single_or_double_click = ( + lambda channel, click_type, was_queued, time_diff: None + ) + self.on_button_single_or_double_click_or_hold = ( + lambda channel, click_type, was_queued, time_diff: None + ) @property def bd_addr(self): @@ -174,11 +224,17 @@ class ButtonConnectionChannel: self._latency_mode = latency_mode return - self._latency_mode = latency_mode - if not self._client._closed: - self._client._send_command("CmdChangeModeParameters", - {"conn_id": self._conn_id, "latency_mode": self._latency_mode, - "auto_disconnect_time": self._auto_disconnect_time}) + with self._client._lock: + self._latency_mode = latency_mode + if not self._client._closed: + self._client._send_command( + "CmdChangeModeParameters", + { + "conn_id": self._conn_id, + "latency_mode": self._latency_mode, + "auto_disconnect_time": self._auto_disconnect_time, + }, + ) @property def auto_disconnect_time(self): @@ -190,14 +246,20 @@ class ButtonConnectionChannel: self._auto_disconnect_time = auto_disconnect_time return - self._auto_disconnect_time = auto_disconnect_time - if not self._client._closed: - self._client._send_command("CmdChangeModeParameters", - {"conn_id": self._conn_id, "latency_mode": self._latency_mode, - "auto_disconnect_time": self._auto_disconnect_time}) + with self._client._lock: + self._auto_disconnect_time = auto_disconnect_time + if not self._client._closed: + self._client._send_command( + "CmdChangeModeParameters", + { + "conn_id": self._conn_id, + "latency_mode": self._latency_mode, + "auto_disconnect_time": self._auto_disconnect_time, + }, + ) -class FlicClient(asyncio.Protocol): +class FlicClient: """FlicClient class. When this class is constructed, a socket connection is established. @@ -211,6 +273,7 @@ class FlicClient(asyncio.Protocol): The ButtonScanner is used to set up a handler for advertisement packets. The ButtonConnectionChannel is used to interact with connections to flic buttons and receive their events. + The BatteryStatusListener is used to get battery level. Other events are handled by the following callback functions that can be assigned to this object (and a list of the callback function parameters): on_new_verified_button: bd_addr @@ -220,117 +283,171 @@ class FlicClient(asyncio.Protocol): """ _EVENTS = [ - ("EvtAdvertisementPacket", "> 8 bytes[2] = opcode bytes += data_bytes - self.transport.write(bytes) + with self._lock: + if not self._closed: + self._sock.sendall(bytes) def _dispatch_event(self, data): if len(data) == 0: return opcode = data[0] - if opcode >= len(FlicClient._EVENTS) or FlicClient._EVENTS[opcode] is None: + if opcode >= len(FlicClient._EVENTS) or FlicClient._EVENTS[opcode] == None: return event_name = FlicClient._EVENTS[opcode][0] - data_tuple = FlicClient._EVENT_STRUCTS[opcode].unpack(data[1: 1 + FlicClient._EVENT_STRUCTS[opcode].size]) + data_tuple = FlicClient._EVENT_STRUCTS[opcode].unpack( + data[1 : 1 + FlicClient._EVENT_STRUCTS[opcode].size] + ) items = FlicClient._EVENT_NAMED_TUPLES[opcode]._make(data_tuple)._asdict() # Process some kind of items whose data type is not supported by struct if "bd_addr" in items: - items["bd_addr"] = FlicClient._bdaddr_bytes_to_string() + items["bd_addr"] = FlicClient._bdaddr_bytes_to_string(items["bd_addr"]) if "name" in items: items["name"] = items["name"].decode("utf-8") @@ -459,28 +635,44 @@ class FlicClient(asyncio.Protocol): if event_name == "EvtConnectionChannelRemoved": items["removed_reason"] = RemovedReason(items["removed_reason"]) - if event_name.startswith("EvtButton"): + if ( + event_name == "EvtButtonUpOrDown" + or event_name == "EvtButtonClickOrHold" + or event_name == "EvtButtonSingleOrDoubleClick" + or event_name == "EvtButtonSingleOrDoubleClickOrHold" + ): items["click_type"] = ClickType(items["click_type"]) if event_name == "EvtGetInfoResponse": - items["bluetooth_controller_state"] = BluetoothControllerState(items["bluetooth_controller_state"]) - items["my_bd_addr"] = FlicClient._bdaddr_bytes_to_string() + items["bluetooth_controller_state"] = BluetoothControllerState( + items["bluetooth_controller_state"] + ) + items["my_bd_addr"] = FlicClient._bdaddr_bytes_to_string( + items["my_bd_addr"] + ) items["my_bd_addr_type"] = BdAddrType(items["my_bd_addr_type"]) items["bd_addr_of_verified_buttons"] = [] pos = FlicClient._EVENT_STRUCTS[opcode].size - for i in range(items["nb_verified_buttons"]): + for _ in range(items["nb_verified_buttons"]): items["bd_addr_of_verified_buttons"].append( - FlicClient._bdaddr_bytes_to_string()) + FlicClient._bdaddr_bytes_to_string(data[1 + pos : 1 + pos + 6]) + ) pos += 6 if event_name == "EvtBluetoothControllerStateChange": items["state"] = BluetoothControllerState(items["state"]) - if event_name == "EvtGetButtonUUIDResponse": + if event_name == "EvtGetButtonInfoResponse": items["uuid"] = "".join(map(lambda x: "%02x" % x, items["uuid"])) if items["uuid"] == "00000000000000000000000000000000": items["uuid"] = None + items["color"] = items["color"].decode("utf-8") + if items["color"] == "": + items["color"] = None + items["serial_number"] = items["serial_number"].decode("utf-8") + if items["serial_number"] == "": + items["serial_number"] = None if event_name == "EvtScanWizardCompleted": items["result"] = ScanWizardResult(items["result"]) @@ -489,18 +681,30 @@ class FlicClient(asyncio.Protocol): if event_name == "EvtAdvertisementPacket": scanner = self._scanners.get(items["scan_id"]) if scanner is not None: - scanner.on_advertisement_packet(scanner, items["bd_addr"], items["name"], items["rssi"], - items["is_private"], items["already_verified"]) + scanner.on_advertisement_packet( + scanner, + items["bd_addr"], + items["name"], + items["rssi"], + items["is_private"], + items["already_verified"], + items["already_connected_to_this_device"], + items["already_connected_to_other_device"], + ) if event_name == "EvtCreateConnectionChannelResponse": channel = self._connection_channels[items["conn_id"]] if items["error"] != CreateConnectionChannelError.NoError: del self._connection_channels[items["conn_id"]] - channel.on_create_connection_channel_response(channel, items["error"], items["connection_status"]) + channel.on_create_connection_channel_response( + channel, items["error"], items["connection_status"] + ) if event_name == "EvtConnectionStatusChanged": channel = self._connection_channels[items["conn_id"]] - channel.on_connection_status_changed(channel, items["connection_status"], items["disconnect_reason"]) + channel.on_connection_status_changed( + channel, items["connection_status"], items["disconnect_reason"] + ) if event_name == "EvtConnectionChannelRemoved": channel = self._connection_channels[items["conn_id"]] @@ -509,36 +713,53 @@ class FlicClient(asyncio.Protocol): if event_name == "EvtButtonUpOrDown": channel = self._connection_channels[items["conn_id"]] - channel.on_button_up_or_down(channel, items["click_type"], items["was_queued"], items["time_diff"]) + channel.on_button_up_or_down( + channel, items["click_type"], items["was_queued"], items["time_diff"] + ) if event_name == "EvtButtonClickOrHold": channel = self._connection_channels[items["conn_id"]] - channel.on_button_click_or_hold(channel, items["click_type"], items["was_queued"], items["time_diff"]) + channel.on_button_click_or_hold( + channel, items["click_type"], items["was_queued"], items["time_diff"] + ) if event_name == "EvtButtonSingleOrDoubleClick": channel = self._connection_channels[items["conn_id"]] - channel.on_button_single_or_double_click(channel, items["click_type"], items["was_queued"], - items["time_diff"]) + channel.on_button_single_or_double_click( + channel, items["click_type"], items["was_queued"], items["time_diff"] + ) if event_name == "EvtButtonSingleOrDoubleClickOrHold": channel = self._connection_channels[items["conn_id"]] - channel.on_button_single_or_double_click_or_hold(channel, items["click_type"], items["was_queued"], - items["time_diff"]) + channel.on_button_single_or_double_click_or_hold( + channel, items["click_type"], items["was_queued"], items["time_diff"] + ) if event_name == "EvtNewVerifiedButton": self.on_new_verified_button(items["bd_addr"]) if event_name == "EvtGetInfoResponse": - self.on_get_info(items) + self._get_info_response_queue.get()(items) if event_name == "EvtNoSpaceForNewConnection": - self.on_no_space_for_new_connection(items["max_concurrently_connected_buttons"]) + self.on_no_space_for_new_connection( + items["max_concurrently_connected_buttons"] + ) if event_name == "EvtGotSpaceForNewConnection": - self.on_got_space_for_new_connection(items["max_concurrently_connected_buttons"]) + self.on_got_space_for_new_connection( + items["max_concurrently_connected_buttons"] + ) if event_name == "EvtBluetoothControllerStateChange": self.on_bluetooth_controller_state_change(items["state"]) - if event_name == "EvtGetButtonUUIDResponse": - self.on_get_button_uuid(items["bd_addr"], items["uuid"]) + if event_name == "EvtGetButtonInfoResponse": + self._get_button_info_queue.get()( + items["bd_addr"], + items["uuid"], + items["color"], + items["serial_number"], + items["flic_version"], + items["firmware_version"], + ) if event_name == "EvtScanWizardFoundPrivateButton": scan_wizard = self._scan_wizards[items["scan_wizard_id"]] @@ -548,27 +769,76 @@ class FlicClient(asyncio.Protocol): scan_wizard = self._scan_wizards[items["scan_wizard_id"]] scan_wizard._bd_addr = items["bd_addr"] scan_wizard._name = items["name"] - scan_wizard.on_found_public_button(scan_wizard, scan_wizard._bd_addr, scan_wizard._name) + scan_wizard.on_found_public_button( + scan_wizard, scan_wizard._bd_addr, scan_wizard._name + ) if event_name == "EvtScanWizardButtonConnected": scan_wizard = self._scan_wizards[items["scan_wizard_id"]] - scan_wizard.on_button_connected(scan_wizard, scan_wizard._bd_addr, scan_wizard._name) + scan_wizard.on_button_connected( + scan_wizard, scan_wizard._bd_addr, scan_wizard._name + ) if event_name == "EvtScanWizardCompleted": scan_wizard = self._scan_wizards[items["scan_wizard_id"]] del self._scan_wizards[items["scan_wizard_id"]] - scan_wizard.on_completed(scan_wizard, items["result"], scan_wizard._bd_addr, scan_wizard._name) + scan_wizard.on_completed( + scan_wizard, items["result"], scan_wizard._bd_addr, scan_wizard._name + ) - def data_received(self, data): - cdata = self.buffer + data - self.buffer = b"" - while len(cdata): - packet_len = cdata[0] | (cdata[1] << 8) - packet_len += 2 - if len(cdata) >= packet_len: - self._dispatch_event(cdata[2:packet_len]) - cdata = cdata[packet_len:] - else: - if len(cdata): - self.buffer = cdata # unlikely to happen but..... + if event_name == "EvtButtonDeleted": + self.on_button_deleted(items["bd_addr"], items["deleted_by_this_client"]) + + if event_name == "EvtBatteryStatus": + listener = self._battery_status_listeners.get(items["listener_id"]) + if listener is not None: + listener.on_battery_status( + listener, items["battery_percentage"], items["timestamp"] + ) + + def _handle_one_event(self): + if len(self._timers.queue) > 0: + current_timer = self._timers.queue[0] + timeout = max(current_timer[0] - time.monotonic(), 0) + if timeout == 0: + self._timers.get()[1]() + return True + if len(select.select([self._sock], [], [], timeout)[0]) == 0: + return True + + len_arr = bytearray(2) + view = memoryview(len_arr) + + toread = 2 + while toread > 0: + nbytes = self._sock.recv_into(view, toread) + if nbytes == 0: + return False + view = view[nbytes:] + toread -= nbytes + + packet_len = len_arr[0] | (len_arr[1] << 8) + data = bytearray(packet_len) + view = memoryview(data) + toread = packet_len + while toread > 0: + nbytes = self._sock.recv_into(view, toread) + if nbytes == 0: + return False + view = view[nbytes:] + toread -= nbytes + + self._dispatch_event(data) + return True + + def handle_events(self): + """Start the main loop for this client. + + This method will not return until the socket has been closed. + Once it has returned, any use of this FlicClient is illegal. + """ + self._handle_event_thread_ident = threading.get_ident() + while not self._closed: + if not self._handle_one_event(): break + self._sock.close() diff --git a/platypush/plugins/flic/manifest.yaml b/platypush/plugins/flic/manifest.yaml new file mode 100644 index 000000000..efb003c57 --- /dev/null +++ b/platypush/plugins/flic/manifest.yaml @@ -0,0 +1,7 @@ +manifest: + events: + - platypush.message.event.flic.FlicButtonEvent + install: + pip: [] + package: platypush.plugins.flic + type: plugin diff --git a/setup.cfg b/setup.cfg index ffd1ea24f..9816660de 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,7 +17,11 @@ description_file = README.md [flake8] max-line-length = 120 -extend-ignore = +exclude = + # Legacy library copied from the upstream repo + fliclib.py + +extend-ignore = E203 W503 SIM104