Support for Flic button events
This commit is contained in:
parent
18830232a4
commit
6928e440bc
14 changed files with 1378 additions and 24 deletions
|
@ -106,7 +106,7 @@ class Daemon(object):
|
|||
self.bus = Bus(on_message=self.on_message())
|
||||
|
||||
# Initialize the backends and link them to the bus
|
||||
self.backends = register_backends(bus=self.bus)
|
||||
self.backends = register_backends(bus=self.bus, global_scope=True)
|
||||
|
||||
# Start the backend threads
|
||||
for backend in self.backends.values():
|
||||
|
|
|
@ -33,6 +33,7 @@ class AssistantGoogleBackend(Backend):
|
|||
with open(self.credentials_file, 'r') as f:
|
||||
self.credentials = google.oauth2.credentials.Credentials(token=None,
|
||||
**json.load(f))
|
||||
logging.info('Initialized Google Assistant backend')
|
||||
|
||||
def _process_event(self, event):
|
||||
logging.info('Received assistant event: {}'.format(event))
|
||||
|
|
0
platypush/backend/button/__init__.py
Normal file
0
platypush/backend/button/__init__.py
Normal file
102
platypush/backend/button/flic/__init__.py
Normal file
102
platypush/backend/button/flic/__init__.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
import importlib
|
||||
import logging
|
||||
|
||||
from enum import Enum
|
||||
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):
|
||||
_long_press_timeout = 0.3
|
||||
_btn_timeout = 0.5
|
||||
ShortPressEvent = "ShortPressEvent"
|
||||
LongPressEvent = "LongPressEvent"
|
||||
|
||||
def __init__(self, server, long_press_timeout=_long_press_timeout,
|
||||
btn_timeout=_btn_timeout, **kwargs):
|
||||
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 = []
|
||||
|
||||
logging.info('Initialized Flic buttons backend on {}'.format(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():
|
||||
logging.info('Flic event triggered from {}: {}'.format(
|
||||
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):
|
||||
def _f(bd_addr, channel, click_type, was_queued, time_diff):
|
||||
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 send_message(self, msg):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
|
||||
self.client.handle_events()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
554
platypush/backend/button/flic/fliclib/aioflic.py
Normal file
554
platypush/backend/button/flic/fliclib/aioflic.py
Normal file
|
@ -0,0 +1,554 @@
|
|||
"""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".
|
||||
"""
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from collections import namedtuple
|
||||
import time
|
||||
import struct
|
||||
import itertools
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
"""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", "<I6s17pb??", "scan_id bd_addr name rssi is_private already_verified"),
|
||||
("EvtCreateConnectionChannelResponse", "<IBB", "conn_id error connection_status"),
|
||||
("EvtConnectionStatusChanged", "<IBB", "conn_id connection_status disconnect_reason"),
|
||||
("EvtConnectionChannelRemoved", "<IB", "conn_id removed_reason"),
|
||||
("EvtButtonUpOrDown", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtButtonClickOrHold", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtButtonSingleOrDoubleClick", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtButtonSingleOrDoubleClickOrHold", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtNewVerifiedButton", "<6s", "bd_addr"),
|
||||
("EvtGetInfoResponse", "<B6sBBhBBH", "bluetooth_controller_state my_bd_addr my_bd_addr_type max_pending_connections max_concurrently_connected_buttons current_pending_connections currently_no_space_for_new_connection nb_verified_buttons"),
|
||||
("EvtNoSpaceForNewConnection", "<B", "max_concurrently_connected_buttons"),
|
||||
("EvtGotSpaceForNewConnection", "<B", "max_concurrently_connected_buttons"),
|
||||
("EvtBluetoothControllerStateChange", "<B", "state"),
|
||||
("EvtPingResponse", "<I", "ping_id"),
|
||||
("EvtGetButtonUUIDResponse", "<6s16s", "bd_addr uuid"),
|
||||
("EvtScanWizardFoundPrivateButton", "<I", "scan_wizard_id"),
|
||||
("EvtScanWizardFoundPublicButton", "<I6s17p", "scan_wizard_id bd_addr name"),
|
||||
("EvtScanWizardButtonConnected", "<I", "scan_wizard_id"),
|
||||
("EvtScanWizardCompleted", "<IB", "scan_wizard_id result")
|
||||
]
|
||||
_EVENT_STRUCTS = list(map(lambda x: None if x == None else struct.Struct(x[1]), _EVENTS))
|
||||
_EVENT_NAMED_TUPLES = list(map(lambda x: None if x == None else namedtuple(x[0], x[2]), _EVENTS))
|
||||
|
||||
_COMMANDS = [
|
||||
("CmdGetInfo", "", ""),
|
||||
("CmdCreateScanner", "<I", "scan_id"),
|
||||
("CmdRemoveScanner", "<I", "scan_id"),
|
||||
("CmdCreateConnectionChannel", "<I6sBh", "conn_id bd_addr latency_mode auto_disconnect_time"),
|
||||
("CmdRemoveConnectionChannel", "<I", "conn_id"),
|
||||
("CmdForceDisconnect", "<6s", "bd_addr"),
|
||||
("CmdChangeModeParameters", "<IBh", "conn_id latency_mode auto_disconnect_time"),
|
||||
("CmdPing", "<I", "ping_id"),
|
||||
("CmdGetButtonUUID", "<6s", "bd_addr"),
|
||||
("CmdCreateScanWizard", "<I", "scan_wizard_id"),
|
||||
("CmdCancelScanWizard", "<I", "scan_wizard_id")
|
||||
]
|
||||
|
||||
_COMMAND_STRUCTS = list(map(lambda x: struct.Struct(x[1]), _COMMANDS))
|
||||
_COMMAND_NAMED_TUPLES = list(map(lambda x: namedtuple(x[0], x[2]), _COMMANDS))
|
||||
_COMMAND_NAME_TO_OPCODE = dict((x[0], i) for i, x in enumerate(_COMMANDS))
|
||||
|
||||
|
||||
def _bdaddr_bytes_to_string(bdaddr_bytes):
|
||||
return ":".join(map(lambda x: "%02x" % x, reversed(bdaddr_bytes)))
|
||||
|
||||
def _bdaddr_string_to_bytes(bdaddr_string):
|
||||
return bytearray.fromhex("".join(reversed(bdaddr_string.split(":"))))
|
||||
|
||||
def __init__(self, loop,parent=None):
|
||||
self.loop = loop
|
||||
self.buffer=b""
|
||||
self.transport=None
|
||||
self.parent=parent
|
||||
self._scanners = {}
|
||||
self._scan_wizards = {}
|
||||
self._connection_channels = {}
|
||||
self._closed = False
|
||||
|
||||
self.on_new_verified_button = lambda bd_addr: None
|
||||
self.on_no_space_for_new_connection = lambda max_concurrently_connected_buttons: None
|
||||
self.on_got_space_for_new_connection = lambda max_concurrently_connected_buttons: None
|
||||
self.on_bluetooth_controller_state_change = lambda state: None
|
||||
self.on_get_info = lambda items: None
|
||||
self.on_get_button_uuid = lambda addr, uuid: None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport=transport
|
||||
if self.parent:
|
||||
self.parent.register_protocol(self)
|
||||
|
||||
|
||||
def close(self):
|
||||
"""Closes the client. The handle_events() method will return."""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
|
||||
def add_scanner(self, scanner):
|
||||
"""Add a ButtonScanner object.
|
||||
|
||||
The scan will start directly once the scanner is added.
|
||||
"""
|
||||
if scanner._scan_id in self._scanners:
|
||||
return
|
||||
|
||||
self._scanners[scanner._scan_id] = scanner
|
||||
self._send_command("CmdCreateScanner", {"scan_id": scanner._scan_id})
|
||||
|
||||
def remove_scanner(self, scanner):
|
||||
"""Remove a ButtonScanner object.
|
||||
|
||||
You will no longer receive advertisement packets.
|
||||
"""
|
||||
if scanner._scan_id not in self._scanners:
|
||||
return
|
||||
|
||||
del self._scanners[scanner._scan_id]
|
||||
self._send_command("CmdRemoveScanner", {"scan_id": scanner._scan_id})
|
||||
|
||||
def add_scan_wizard(self, scan_wizard):
|
||||
"""Add a ScanWizard object.
|
||||
|
||||
The scan wizard will start directly once the scan wizard is added.
|
||||
"""
|
||||
if scan_wizard._scan_wizard_id in self._scan_wizards:
|
||||
return
|
||||
|
||||
self._scan_wizards[scan_wizard._scan_wizard_id] = scan_wizard
|
||||
self._send_command("CmdCreateScanWizard", {"scan_wizard_id": scan_wizard._scan_wizard_id})
|
||||
|
||||
def cancel_scan_wizard(self, scan_wizard):
|
||||
"""Cancel a ScanWizard.
|
||||
|
||||
Note: The effect of this command will take place at the time the on_completed event arrives on the scan wizard object.
|
||||
If cancelled due to this command, "result" in the on_completed event will be "WizardCancelledByUser".
|
||||
"""
|
||||
if scan_wizard._scan_wizard_id not in self._scan_wizards:
|
||||
return
|
||||
|
||||
self._send_command("CmdCancelScanWizard", {"scan_wizard_id": scan_wizard._scan_wizard_id})
|
||||
|
||||
def add_connection_channel(self, channel):
|
||||
"""Adds a connection channel to a specific Flic button.
|
||||
|
||||
This will start listening for a specific Flic button's connection and button events.
|
||||
Make sure the Flic is either in public mode (by holding it down for 7 seconds) or already verified before calling this method.
|
||||
|
||||
The on_create_connection_channel_response callback property will be called on the
|
||||
connection channel after this command has been received by the server.
|
||||
|
||||
You may have as many connection channels as you wish for a specific Flic Button.
|
||||
"""
|
||||
if channel._conn_id in self._connection_channels:
|
||||
return
|
||||
|
||||
channel._client = self
|
||||
|
||||
self._connection_channels[channel._conn_id] = channel
|
||||
self._send_command("CmdCreateConnectionChannel", {"conn_id": channel._conn_id, "bd_addr": channel.bd_addr, "latency_mode": channel._latency_mode, "auto_disconnect_time": channel._auto_disconnect_time})
|
||||
|
||||
def remove_connection_channel(self, channel):
|
||||
"""Remove a connection channel.
|
||||
|
||||
This will stop listening for new events for a specific connection channel that has previously been added.
|
||||
Note: The effect of this command will take place at the time the on_removed event arrives on the connection channel object.
|
||||
"""
|
||||
if channel._conn_id not in self._connection_channels:
|
||||
return
|
||||
|
||||
self._send_command("CmdRemoveConnectionChannel", {"conn_id": channel._conn_id})
|
||||
|
||||
def force_disconnect(self, bd_addr):
|
||||
"""Force disconnection or cancel pending connection of a specific Flic button.
|
||||
|
||||
This removes all connection channels for all clients connected to the server for this specific Flic button.
|
||||
"""
|
||||
self._send_command("CmdForceDisconnect", {"bd_addr": bd_addr})
|
||||
|
||||
def get_info(self):
|
||||
"""Get info about the current state of the server.
|
||||
|
||||
The server will send back its information directly and the callback will be called once the response arrives.
|
||||
The callback takes only one parameter: info. This info parameter is a dictionary with the following objects:
|
||||
bluetooth_controller_state, my_bd_addr, my_bd_addr_type, max_pending_connections, max_concurrently_connected_buttons,
|
||||
current_pending_connections, currently_no_space_for_new_connection, bd_addr_of_verified_buttons (a list of bd addresses).
|
||||
"""
|
||||
self._send_command("CmdGetInfo", {})
|
||||
|
||||
def get_button_uuid(self, bd_addr):
|
||||
"""Get button uuid for a verified button.
|
||||
|
||||
The server will send back its information directly and the callback will be called once the response arrives.
|
||||
Responses will arrive in the same order as requested.
|
||||
|
||||
The callback takes two parameters: bd_addr, uuid (hex string of 32 characters).
|
||||
|
||||
Note: if the button isn't verified, the uuid sent to the callback will rather be None.
|
||||
"""
|
||||
self._send_command("CmdGetButtonUUID", {"bd_addr": bd_addr})
|
||||
|
||||
|
||||
def run_on_handle_events_thread(self, callback):
|
||||
"""Run a function on the thread that handles the events."""
|
||||
if threading.get_ident() == self._handle_event_thread_ident:
|
||||
callback()
|
||||
else:
|
||||
self.set_timer(0, callback)
|
||||
|
||||
def _send_command(self, name, items):
|
||||
|
||||
for key, value in items.items():
|
||||
if isinstance(value, Enum):
|
||||
items[key] = value.value
|
||||
|
||||
if "bd_addr" in items:
|
||||
items["bd_addr"] = FlicClient._bdaddr_string_to_bytes(items["bd_addr"])
|
||||
|
||||
opcode = FlicClient._COMMAND_NAME_TO_OPCODE[name]
|
||||
data_bytes = FlicClient._COMMAND_STRUCTS[opcode].pack(*FlicClient._COMMAND_NAMED_TUPLES[opcode](**items))
|
||||
bytes = bytearray(3)
|
||||
bytes[0] = (len(data_bytes) + 1) & 0xff
|
||||
bytes[1] = (len(data_bytes) + 1) >> 8
|
||||
bytes[2] = opcode
|
||||
bytes += data_bytes
|
||||
self.transport.write(bytes)
|
||||
|
||||
def _dispatch_event(self, data):
|
||||
if len(data) == 0:
|
||||
return
|
||||
opcode = data[0]
|
||||
|
||||
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])
|
||||
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.on_get_info(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.on_get_button_uuid(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 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.....
|
||||
break
|
||||
|
||||
|
607
platypush/backend/button/flic/fliclib/fliclib.py
Normal file
607
platypush/backend/button/flic/fliclib/fliclib.py
Normal file
|
@ -0,0 +1,607 @@
|
|||
"""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", "<I6s17pb??", "scan_id bd_addr name rssi is_private already_verified"),
|
||||
("EvtCreateConnectionChannelResponse", "<IBB", "conn_id error connection_status"),
|
||||
("EvtConnectionStatusChanged", "<IBB", "conn_id connection_status disconnect_reason"),
|
||||
("EvtConnectionChannelRemoved", "<IB", "conn_id removed_reason"),
|
||||
("EvtButtonUpOrDown", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtButtonClickOrHold", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtButtonSingleOrDoubleClick", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtButtonSingleOrDoubleClickOrHold", "<IBBI", "conn_id click_type was_queued time_diff"),
|
||||
("EvtNewVerifiedButton", "<6s", "bd_addr"),
|
||||
("EvtGetInfoResponse", "<B6sBBhBBH", "bluetooth_controller_state my_bd_addr my_bd_addr_type max_pending_connections max_concurrently_connected_buttons current_pending_connections currently_no_space_for_new_connection nb_verified_buttons"),
|
||||
("EvtNoSpaceForNewConnection", "<B", "max_concurrently_connected_buttons"),
|
||||
("EvtGotSpaceForNewConnection", "<B", "max_concurrently_connected_buttons"),
|
||||
("EvtBluetoothControllerStateChange", "<B", "state"),
|
||||
("EvtPingResponse", "<I", "ping_id"),
|
||||
("EvtGetButtonUUIDResponse", "<6s16s", "bd_addr uuid"),
|
||||
("EvtScanWizardFoundPrivateButton", "<I", "scan_wizard_id"),
|
||||
("EvtScanWizardFoundPublicButton", "<I6s17p", "scan_wizard_id bd_addr name"),
|
||||
("EvtScanWizardButtonConnected", "<I", "scan_wizard_id"),
|
||||
("EvtScanWizardCompleted", "<IB", "scan_wizard_id result")
|
||||
]
|
||||
_EVENT_STRUCTS = list(map(lambda x: None if x == None else struct.Struct(x[1]), _EVENTS))
|
||||
_EVENT_NAMED_TUPLES = list(map(lambda x: None if x == None else namedtuple(x[0], x[2]), _EVENTS))
|
||||
|
||||
_COMMANDS = [
|
||||
("CmdGetInfo", "", ""),
|
||||
("CmdCreateScanner", "<I", "scan_id"),
|
||||
("CmdRemoveScanner", "<I", "scan_id"),
|
||||
("CmdCreateConnectionChannel", "<I6sBh", "conn_id bd_addr latency_mode auto_disconnect_time"),
|
||||
("CmdRemoveConnectionChannel", "<I", "conn_id"),
|
||||
("CmdForceDisconnect", "<6s", "bd_addr"),
|
||||
("CmdChangeModeParameters", "<IBh", "conn_id latency_mode auto_disconnect_time"),
|
||||
("CmdPing", "<I", "ping_id"),
|
||||
("CmdGetButtonUUID", "<6s", "bd_addr"),
|
||||
("CmdCreateScanWizard", "<I", "scan_wizard_id"),
|
||||
("CmdCancelScanWizard", "<I", "scan_wizard_id")
|
||||
]
|
||||
|
||||
_COMMAND_STRUCTS = list(map(lambda x: struct.Struct(x[1]), _COMMANDS))
|
||||
_COMMAND_NAMED_TUPLES = list(map(lambda x: namedtuple(x[0], x[2]), _COMMANDS))
|
||||
_COMMAND_NAME_TO_OPCODE = dict((x[0], i) for i, x in enumerate(_COMMANDS))
|
||||
|
||||
def _bdaddr_bytes_to_string(bdaddr_bytes):
|
||||
return ":".join(map(lambda x: "%02x" % x, reversed(bdaddr_bytes)))
|
||||
|
||||
def _bdaddr_string_to_bytes(bdaddr_string):
|
||||
return bytearray.fromhex("".join(reversed(bdaddr_string.split(":"))))
|
||||
|
||||
def __init__(self, host, port = 5551):
|
||||
self._sock = socket.create_connection((host, port), None)
|
||||
self._lock = threading.RLock()
|
||||
self._scanners = {}
|
||||
self._scan_wizards = {}
|
||||
self._connection_channels = {}
|
||||
self._get_info_response_queue = queue.Queue()
|
||||
self._get_button_uuid_queue = queue.Queue()
|
||||
self._timers = queue.PriorityQueue()
|
||||
self._handle_event_thread_ident = None
|
||||
self._closed = False
|
||||
|
||||
self.on_new_verified_button = lambda bd_addr: None
|
||||
self.on_no_space_for_new_connection = lambda max_concurrently_connected_buttons: None
|
||||
self.on_got_space_for_new_connection = lambda max_concurrently_connected_buttons: None
|
||||
self.on_bluetooth_controller_state_change = lambda state: None
|
||||
|
||||
def close(self):
|
||||
"""Closes the client. The handle_events() method will return."""
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
if threading.get_ident() != self._handle_event_thread_ident:
|
||||
self._send_command("CmdPing", {"ping_id": 0}) # To unblock socket select
|
||||
|
||||
self._closed = True
|
||||
|
||||
def add_scanner(self, scanner):
|
||||
"""Add a ButtonScanner object.
|
||||
|
||||
The scan will start directly once the scanner is added.
|
||||
"""
|
||||
with self._lock:
|
||||
if scanner._scan_id in self._scanners:
|
||||
return
|
||||
|
||||
self._scanners[scanner._scan_id] = scanner
|
||||
self._send_command("CmdCreateScanner", {"scan_id": scanner._scan_id})
|
||||
|
||||
def remove_scanner(self, scanner):
|
||||
"""Remove a ButtonScanner object.
|
||||
|
||||
You will no longer receive advertisement packets.
|
||||
"""
|
||||
with self._lock:
|
||||
if scanner._scan_id not in self._scanners:
|
||||
return
|
||||
|
||||
del self._scanners[scanner._scan_id]
|
||||
self._send_command("CmdRemoveScanner", {"scan_id": scanner._scan_id})
|
||||
|
||||
def add_scan_wizard(self, scan_wizard):
|
||||
"""Add a ScanWizard object.
|
||||
|
||||
The scan wizard will start directly once the scan wizard is added.
|
||||
"""
|
||||
with self._lock:
|
||||
if scan_wizard._scan_wizard_id in self._scan_wizards:
|
||||
return
|
||||
|
||||
self._scan_wizards[scan_wizard._scan_wizard_id] = scan_wizard
|
||||
self._send_command("CmdCreateScanWizard", {"scan_wizard_id": scan_wizard._scan_wizard_id})
|
||||
|
||||
def cancel_scan_wizard(self, scan_wizard):
|
||||
"""Cancel a ScanWizard.
|
||||
|
||||
Note: The effect of this command will take place at the time the on_completed event arrives on the scan wizard object.
|
||||
If cancelled due to this command, "result" in the on_completed event will be "WizardCancelledByUser".
|
||||
"""
|
||||
with self._lock:
|
||||
if scan_wizard._scan_wizard_id not in self._scan_wizards:
|
||||
return
|
||||
|
||||
self._send_command("CmdCancelScanWizard", {"scan_wizard_id": scan_wizard._scan_wizard_id})
|
||||
|
||||
def add_connection_channel(self, channel):
|
||||
"""Adds a connection channel to a specific Flic button.
|
||||
|
||||
This will start listening for a specific Flic button's connection and button events.
|
||||
Make sure the Flic is either in public mode (by holding it down for 7 seconds) or already verified before calling this method.
|
||||
|
||||
The on_create_connection_channel_response callback property will be called on the
|
||||
connection channel after this command has been received by the server.
|
||||
|
||||
You may have as many connection channels as you wish for a specific Flic Button.
|
||||
"""
|
||||
with self._lock:
|
||||
if channel._conn_id in self._connection_channels:
|
||||
return
|
||||
|
||||
channel._client = self
|
||||
|
||||
self._connection_channels[channel._conn_id] = channel
|
||||
self._send_command("CmdCreateConnectionChannel", {"conn_id": channel._conn_id, "bd_addr": channel.bd_addr, "latency_mode": channel._latency_mode, "auto_disconnect_time": channel._auto_disconnect_time})
|
||||
|
||||
def remove_connection_channel(self, channel):
|
||||
"""Remove a connection channel.
|
||||
|
||||
This will stop listening for new events for a specific connection channel that has previously been added.
|
||||
Note: The effect of this command will take place at the time the on_removed event arrives on the connection channel object.
|
||||
"""
|
||||
with self._lock:
|
||||
if channel._conn_id not in self._connection_channels:
|
||||
return
|
||||
|
||||
self._send_command("CmdRemoveConnectionChannel", {"conn_id": channel._conn_id})
|
||||
|
||||
def force_disconnect(self, bd_addr):
|
||||
"""Force disconnection or cancel pending connection of a specific Flic button.
|
||||
|
||||
This removes all connection channels for all clients connected to the server for this specific Flic button.
|
||||
"""
|
||||
self._send_command("CmdForceDisconnect", {"bd_addr": bd_addr})
|
||||
|
||||
def get_info(self, callback):
|
||||
"""Get info about the current state of the server.
|
||||
|
||||
The server will send back its information directly and the callback will be called once the response arrives.
|
||||
The callback takes only one parameter: info. This info parameter is a dictionary with the following objects:
|
||||
bluetooth_controller_state, my_bd_addr, my_bd_addr_type, max_pending_connections, max_concurrently_connected_buttons,
|
||||
current_pending_connections, currently_no_space_for_new_connection, bd_addr_of_verified_buttons (a list of bd addresses).
|
||||
"""
|
||||
self._get_info_response_queue.put(callback)
|
||||
self._send_command("CmdGetInfo", {})
|
||||
|
||||
def get_button_uuid(self, bd_addr, callback):
|
||||
"""Get button uuid for a verified button.
|
||||
|
||||
The server will send back its information directly and the callback will be called once the response arrives.
|
||||
Responses will arrive in the same order as requested.
|
||||
|
||||
The callback takes two parameters: bd_addr, uuid (hex string of 32 characters).
|
||||
|
||||
Note: if the button isn't verified, the uuid sent to the callback will rather be None.
|
||||
"""
|
||||
with self._lock:
|
||||
self._get_button_uuid_queue.put(callback)
|
||||
self._send_command("CmdGetButtonUUID", {"bd_addr": bd_addr})
|
||||
|
||||
def set_timer(self, timeout_millis, callback):
|
||||
"""Set a timer
|
||||
|
||||
This timer callback will run after the specified timeout_millis on the thread that handles the events.
|
||||
"""
|
||||
point_in_time = time.monotonic() + timeout_millis / 1000.0
|
||||
self._timers.put((point_in_time, callback))
|
||||
|
||||
if threading.get_ident() != self._handle_event_thread_ident:
|
||||
self._send_command("CmdPing", {"ping_id": 0}) # To unblock socket select
|
||||
|
||||
def run_on_handle_events_thread(self, callback):
|
||||
"""Run a function on the thread that handles the events."""
|
||||
if threading.get_ident() == self._handle_event_thread_ident:
|
||||
callback()
|
||||
else:
|
||||
self.set_timer(0, callback)
|
||||
|
||||
def _send_command(self, name, items):
|
||||
for key, value in items.items():
|
||||
if isinstance(value, Enum):
|
||||
items[key] = value.value
|
||||
|
||||
if "bd_addr" in items:
|
||||
items["bd_addr"] = FlicClient._bdaddr_string_to_bytes(items["bd_addr"])
|
||||
|
||||
opcode = FlicClient._COMMAND_NAME_TO_OPCODE[name]
|
||||
data_bytes = FlicClient._COMMAND_STRUCTS[opcode].pack(*FlicClient._COMMAND_NAMED_TUPLES[opcode](**items))
|
||||
bytes = bytearray(3)
|
||||
bytes[0] = (len(data_bytes) + 1) & 0xff
|
||||
bytes[1] = (len(data_bytes) + 1) >> 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] == 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()
|
|
@ -1,5 +1,6 @@
|
|||
import importlib
|
||||
import functools
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
from ..config import Config
|
||||
|
||||
|
@ -9,7 +10,7 @@ backends = {}
|
|||
# Map: plugin_name -> plugin_instance
|
||||
plugins = {}
|
||||
|
||||
def register_backends(bus=None, **kwargs):
|
||||
def register_backends(bus=None, global_scope=False, **kwargs):
|
||||
""" Initialize the backend objects based on the configuration and returns
|
||||
a name -> backend_instance map.
|
||||
Params:
|
||||
|
@ -19,7 +20,10 @@ def register_backends(bus=None, **kwargs):
|
|||
kwargs -- Any additional key-value parameters required to initialize the backends
|
||||
"""
|
||||
|
||||
if global_scope:
|
||||
global backends
|
||||
else:
|
||||
backends = {}
|
||||
|
||||
for (name, cfg) in Config.get_backends().items():
|
||||
module = importlib.import_module('platypush.backend.' + name)
|
||||
|
|
|
@ -120,16 +120,24 @@ class EventHook(object):
|
|||
return cls(name=name, condition=condition, actions=actions)
|
||||
|
||||
|
||||
def matches_event(self, event):
|
||||
""" Returns an EventMatchResult object containing the information
|
||||
about the match between the event and this hook """
|
||||
|
||||
return event.matches_condition(self.condition)
|
||||
|
||||
|
||||
def run(self, event):
|
||||
""" Checks the condition of the hook against a particular event and
|
||||
runs the hook actions if the condition is met """
|
||||
|
||||
result = event.matches_condition(self.condition)
|
||||
if result[0]: # is match
|
||||
result = self.matches_event(event)
|
||||
|
||||
if result.is_match:
|
||||
logging.info('Running hook {} triggered by an event'.format(self.name))
|
||||
|
||||
for action in self.actions:
|
||||
action.execute(**result[1])
|
||||
action.execute(**result.parsed_args)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -24,7 +24,18 @@ class EventProcessor(object):
|
|||
def process_event(self, event):
|
||||
""" Processes an event and runs any matched hooks """
|
||||
|
||||
matched_hooks = []
|
||||
max_score = 0
|
||||
|
||||
for hook in self.hooks:
|
||||
match = hook.matches_event(event)
|
||||
if match.is_match:
|
||||
if match.score > max_score:
|
||||
matched_hooks = [hook]
|
||||
elif match.score == max_score:
|
||||
matched_hooks.append(hook)
|
||||
|
||||
for hook in matched_hooks:
|
||||
hook.run(event)
|
||||
|
||||
|
||||
|
|
|
@ -45,25 +45,24 @@ class Event(Message):
|
|||
|
||||
def matches_condition(self, condition):
|
||||
"""
|
||||
If the event matches an event condition, it will return True and a
|
||||
dictionary containing any parsed arguments, otherwise False and {}
|
||||
If the event matches an event condition, it will return an EventMatchResult
|
||||
Params:
|
||||
-- condition -- The platypush.event.hook.EventCondition object
|
||||
"""
|
||||
|
||||
parsed_args = {}
|
||||
if not isinstance(self, condition.type): return [False, parsed_args]
|
||||
result = EventMatchResult(is_match=False)
|
||||
if not isinstance(self, condition.type): return result
|
||||
|
||||
for (attr, value) in condition.args.items():
|
||||
# TODO Be more sophisticated, not only simple match options!
|
||||
if not hasattr(self.args, attr):
|
||||
return [False, parsed_args]
|
||||
return result
|
||||
if isinstance(self.args[attr], str) and not value in self.args[attr]:
|
||||
return [False, parsed_args]
|
||||
return result
|
||||
elif self.args[attr] != value:
|
||||
return [False, parsed_args]
|
||||
return result
|
||||
|
||||
return [True, parsed_args]
|
||||
result.is_match = True
|
||||
return result
|
||||
|
||||
|
||||
@staticmethod
|
||||
|
@ -92,6 +91,19 @@ class Event(Message):
|
|||
})
|
||||
|
||||
|
||||
class EventMatchResult(object):
|
||||
""" When comparing an event against an event condition, you want to
|
||||
return this object. It contains the match status (True or False),
|
||||
any parsed arguments, and a match_score that identifies how "strong"
|
||||
the match is - in case of multiple event matches, the ones with the
|
||||
highest score will win """
|
||||
|
||||
def __init__(self, is_match, score=1, parsed_args = {}):
|
||||
self.is_match = is_match
|
||||
self.score = score
|
||||
self.parsed_args = parsed_args
|
||||
|
||||
|
||||
# XXX Should be a stop Request, not an Event
|
||||
class StopEvent(Event):
|
||||
""" StopEvent message. When received on a Bus, it will terminate the
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import re
|
||||
|
||||
from platypush.context import get_backend
|
||||
from platypush.message.event import Event
|
||||
from platypush.message.event import Event, EventMatchResult
|
||||
|
||||
class AssistantEvent(Event):
|
||||
""" Base class for assistant events """
|
||||
|
@ -27,11 +27,12 @@ class SpeechRecognizedEvent(AssistantEvent):
|
|||
self.recognized_phrase = phrase.strip().lower()
|
||||
|
||||
def matches_condition(self, condition):
|
||||
if not isinstance(self, condition.type): return [False, {}]
|
||||
result = EventMatchResult(is_match=False, score=0)
|
||||
|
||||
if not isinstance(self, condition.type): return result
|
||||
|
||||
recognized_tokens = re.split('\s+', self.recognized_phrase.strip().lower())
|
||||
condition_tokens = re.split('\s+', condition.args['phrase'].strip().lower())
|
||||
parsed_args = {}
|
||||
|
||||
while recognized_tokens and condition_tokens:
|
||||
rec_token = recognized_tokens[0]
|
||||
|
@ -40,21 +41,23 @@ class SpeechRecognizedEvent(AssistantEvent):
|
|||
if rec_token == cond_token:
|
||||
recognized_tokens.pop(0)
|
||||
condition_tokens.pop(0)
|
||||
result.score += 1
|
||||
elif re.search(cond_token, rec_token):
|
||||
condition_tokens.pop(0)
|
||||
else:
|
||||
m = re.match('^\$([\w\d_])', cond_token)
|
||||
if m:
|
||||
parsed_args[cond_token[1:]] = rec_token
|
||||
result.parsed_args[cond_token[1:]] = rec_token
|
||||
recognized_tokens.pop(0)
|
||||
condition_tokens.pop(0)
|
||||
result.score += 1
|
||||
else:
|
||||
recognized_tokens.pop(0)
|
||||
|
||||
is_match = len(condition_tokens) == 0
|
||||
if is_match and self._assistant: self._assistant.stop_conversation()
|
||||
result.is_match = len(condition_tokens) == 0
|
||||
if result.is_match and self._assistant: self._assistant.stop_conversation()
|
||||
|
||||
return [is_match, parsed_args]
|
||||
return result
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
0
platypush/message/event/button/__init__.py
Normal file
0
platypush/message/event/button/__init__.py
Normal file
36
platypush/message/event/button/flic/__init__.py
Normal file
36
platypush/message/event/button/flic/__init__.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from platypush.message.event import Event, EventMatchResult
|
||||
|
||||
|
||||
class FlicButtonEvent(Event):
|
||||
""" Flic button event """
|
||||
|
||||
def __init__(self, btn_addr, sequence, *args, **kwargs):
|
||||
super().__init__(btn_addr=btn_addr, sequence=sequence, *args, **kwargs)
|
||||
|
||||
|
||||
def matches_condition(self, condition):
|
||||
result = EventMatchResult(is_match=False)
|
||||
|
||||
if not isinstance(self, condition.type) \
|
||||
or self.args['btn_addr'] != condition.args['btn_addr']:
|
||||
return result
|
||||
|
||||
cond_sequence = list(condition.args['sequence'])
|
||||
event_sequence = list(self.args['sequence'])
|
||||
|
||||
while cond_sequence and event_sequence:
|
||||
cond_press = cond_sequence[0]
|
||||
event_press = event_sequence[0]
|
||||
|
||||
if cond_press == event_press:
|
||||
cond_sequence.pop(0)
|
||||
result.score += 1
|
||||
|
||||
event_sequence.pop(0)
|
||||
|
||||
result.is_match = len(cond_sequence) == 0
|
||||
return result
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
@ -26,7 +26,9 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
return self._exec('play')
|
||||
|
||||
def pause(self):
|
||||
return self._exec('pause')
|
||||
status = self.status().output['state']
|
||||
if status == 'play': return self._exec('pause')
|
||||
else: return self._exec('play')
|
||||
|
||||
def stop(self):
|
||||
return self._exec('stop')
|
||||
|
@ -40,6 +42,20 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
def setvol(self, vol):
|
||||
return self._exec('setvol', vol)
|
||||
|
||||
def volup(self, delta=10):
|
||||
volume = int(self.status().output['volume'])
|
||||
new_volume = volume+delta
|
||||
if new_volume <= 100:
|
||||
self.setvol(str(new_volume))
|
||||
return self.status()
|
||||
|
||||
def voldown(self, delta=10):
|
||||
volume = int(self.status().output['volume'])
|
||||
new_volume = volume-delta
|
||||
if new_volume >= 0:
|
||||
self.setvol(str(new_volume))
|
||||
return self.status()
|
||||
|
||||
def add(self, content):
|
||||
return self._exec('add', content)
|
||||
|
||||
|
|
Loading…
Reference in a new issue