Merge pull request #260 from waveform80/button-held

Fix #115
This commit is contained in:
Dave Jones
2016-04-08 10:52:19 +01:00
12 changed files with 214 additions and 32 deletions

View File

@@ -9,5 +9,11 @@ python:
- "pypy3" - "pypy3"
install: "pip install -e .[test]" install: "pip install -e .[test]"
script: make test script: make test
before_install:
# Coverage 4.0 no longer supports py3.2 and codecov depends on latest coverage
- if [[ $TRAVIS_PYTHON_VERSION == '3.2' ]]; then pip install "coverage<4.0dev"; fi
- pip install codecov
after_success:
- codecov
notifications: notifications:
slack: raspberrypifoundation:YoIHtVdg8Hd6gcA09QEmCYXN slack: raspberrypifoundation:YoIHtVdg8Hd6gcA09QEmCYXN

View File

@@ -43,6 +43,10 @@ Errors
.. autoexception:: BadEventHandler .. autoexception:: BadEventHandler
.. autoexception:: BadQueueLen
.. autoexception:: BadWaitTime
.. autoexception:: CompositeDeviceError .. autoexception:: CompositeDeviceError
.. autoexception:: CompositeDeviceBadName .. autoexception:: CompositeDeviceBadName
@@ -63,10 +67,6 @@ Errors
.. autoexception:: GPIOPinMissing .. autoexception:: GPIOPinMissing
.. autoexception:: GPIOBadQueueLen
.. autoexception:: GPIOBadSampleWait
.. autoexception:: InputDeviceError .. autoexception:: InputDeviceError
.. autoexception:: OutputDeviceError .. autoexception:: OutputDeviceError

View File

@@ -37,6 +37,9 @@ There are also several `mixin classes`_:
* :class:`EventsMixin` which adds activated/deactivated events to devices * :class:`EventsMixin` which adds activated/deactivated events to devices
along with the machinery to trigger those events along with the machinery to trigger those events
* :class:`HoldMixin` which derives from :class:`EventsMixin` and adds the
held event to devices along with the machinery to repeatedly trigger it
.. _mixin classes: https://en.wikipedia.org/wiki/Mixin .. _mixin classes: https://en.wikipedia.org/wiki/Mixin
The current class hierarchies are displayed below. For brevity, the mixin The current class hierarchies are displayed below. For brevity, the mixin
@@ -141,3 +144,6 @@ Mixin Classes
.. autoclass:: EventsMixin(...) .. autoclass:: EventsMixin(...)
:members: :members:
.. autoclass:: HoldMixin(...)
:members:

View File

@@ -72,7 +72,7 @@ to utilize pins that are part of IO extender chips. For example::
.. warning:: .. warning::
The astute and mischievious reader may note that it is possible to mix pin The astute and mischievous reader may note that it is possible to mix pin
implementations, e.g. using ``RPiGPIOPin`` for one pin, and ``NativePin`` implementations, e.g. using ``RPiGPIOPin`` for one pin, and ``NativePin``
for another. This is unsupported, and if it results in your script for another. This is unsupported, and if it results in your script
crashing, your components failing, or your Raspberry Pi turning into an crashing, your components failing, or your Raspberry Pi turning into an

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

View File

@@ -17,6 +17,8 @@ from .exc import (
GPIOZeroError, GPIOZeroError,
DeviceClosed, DeviceClosed,
BadEventHandler, BadEventHandler,
BadWaitTime,
BadQueueLen,
CompositeDeviceError, CompositeDeviceError,
CompositeDeviceBadName, CompositeDeviceBadName,
CompositeDeviceBadOrder, CompositeDeviceBadOrder,
@@ -28,8 +30,6 @@ from .exc import (
GPIODeviceClosed, GPIODeviceClosed,
GPIOPinInUse, GPIOPinInUse,
GPIOPinMissing, GPIOPinMissing,
GPIOBadQueueLen,
GPIOBadSampleWait,
InputDeviceError, InputDeviceError,
OutputDeviceError, OutputDeviceError,
OutputDeviceBadValue, OutputDeviceBadValue,
@@ -61,6 +61,7 @@ from .mixins import (
SourceMixin, SourceMixin,
ValuesMixin, ValuesMixin,
EventsMixin, EventsMixin,
HoldMixin,
) )
from .input_devices import ( from .input_devices import (
InputDevice, InputDevice,

View File

@@ -16,6 +16,12 @@ class DeviceClosed(GPIOZeroError):
class BadEventHandler(GPIOZeroError, ValueError): class BadEventHandler(GPIOZeroError, ValueError):
"Error raised when an event handler with an incompatible prototype is specified" "Error raised when an event handler with an incompatible prototype is specified"
class BadWaitTime(GPIOZeroError, ValueError):
"Error raised when an invalid wait time is specified"
class BadQueueLen(GPIOZeroError, ValueError):
"Error raised when non-positive queue length is specified"
class CompositeDeviceError(GPIOZeroError): class CompositeDeviceError(GPIOZeroError):
"Base class for errors specific to the CompositeDevice hierarchy" "Base class for errors specific to the CompositeDevice hierarchy"
@@ -49,15 +55,6 @@ class GPIOPinInUse(GPIODeviceError):
class GPIOPinMissing(GPIODeviceError, ValueError): class GPIOPinMissing(GPIODeviceError, ValueError):
"Error raised when a pin number is not specified" "Error raised when a pin number is not specified"
class GPIOBadQueueLen(GPIODeviceError, ValueError):
"Error raised when non-positive queue length is specified"
class GPIOBadSampleWait(GPIODeviceError, ValueError):
"Error raised when a negative sampling wait period is specified"
class GPIOBadSourceDelay(GPIODeviceError, ValueError):
"Error raised when a negative source delay is specified"
class InputDeviceError(GPIODeviceError): class InputDeviceError(GPIODeviceError):
"Base class for errors specific to the InputDevice hierarchy" "Base class for errors specific to the InputDevice hierarchy"

View File

@@ -12,7 +12,7 @@ from threading import Event
from .exc import InputDeviceError, DeviceClosed from .exc import InputDeviceError, DeviceClosed
from .devices import GPIODevice from .devices import GPIODevice
from .mixins import GPIOQueue, EventsMixin from .mixins import GPIOQueue, EventsMixin, HoldMixin
class InputDevice(GPIODevice): class InputDevice(GPIODevice):
@@ -222,7 +222,7 @@ class SmoothedInputDevice(EventsMixin, InputDevice):
return self.value > self.threshold return self.value > self.threshold
class Button(DigitalInputDevice): class Button(HoldMixin, DigitalInputDevice):
""" """
Extends :class:`DigitalInputDevice` and represents a simple push button Extends :class:`DigitalInputDevice` and represents a simple push button
or switch. or switch.
@@ -254,11 +254,24 @@ class Button(DigitalInputDevice):
If ``None`` (the default), no software bounce compensation will be If ``None`` (the default), no software bounce compensation will be
performed. Otherwise, this is the length in time (in seconds) that the performed. Otherwise, this is the length in time (in seconds) that the
component will ignore changes in state after an initial change. component will ignore changes in state after an initial change.
:param float hold_time:
The length of time (in seconds) to wait after the button is pushed,
until executing the :attr:`when_held` handler.
:param bool hold_repeat:
If ``True``, the :attr:`when_held` handler will be repeatedly executed
as long as the device remains active, every *hold_time* seconds.
""" """
def __init__(self, pin=None, pull_up=True, bounce_time=None): def __init__(
self, pin=None, pull_up=True, bounce_time=None,
hold_time=1, hold_repeat=False):
super(Button, self).__init__(pin, pull_up, bounce_time) super(Button, self).__init__(pin, pull_up, bounce_time)
self.hold_time = hold_time
self.hold_repeat = hold_repeat
Button.is_pressed = Button.is_active Button.is_pressed = Button.is_active
Button.pressed_time = Button.active_time
Button.when_pressed = Button.when_activated Button.when_pressed = Button.when_activated
Button.when_released = Button.when_deactivated Button.when_released = Button.when_deactivated
Button.wait_for_press = Button.wait_for_active Button.wait_for_press = Button.wait_for_active

View File

@@ -12,6 +12,7 @@ import weakref
from functools import wraps from functools import wraps
from threading import Event from threading import Event
from collections import deque from collections import deque
from time import time
try: try:
from statistics import median from statistics import median
except ImportError: except ImportError:
@@ -20,10 +21,9 @@ except ImportError:
from .threads import GPIOThread from .threads import GPIOThread
from .exc import ( from .exc import (
BadEventHandler, BadEventHandler,
BadWaitTime,
BadQueueLen,
DeviceClosed, DeviceClosed,
GPIOBadSourceDelay,
GPIOBadQueueLen,
GPIOBadSampleWait,
) )
class ValuesMixin(object): class ValuesMixin(object):
@@ -89,7 +89,7 @@ class SourceMixin(object):
@source_delay.setter @source_delay.setter
def source_delay(self, value): def source_delay(self, value):
if value < 0: if value < 0:
raise GPIOBadSourceDelay('source_delay must be 0 or greater') raise BadWaitTime('source_delay must be 0 or greater')
self._source_delay = float(value) self._source_delay = float(value)
@property @property
@@ -160,6 +160,7 @@ class EventsMixin(object):
self._when_activated = None self._when_activated = None
self._when_deactivated = None self._when_deactivated = None
self._last_state = None self._last_state = None
self._last_changed = time()
def wait_for_active(self, timeout=None): def wait_for_active(self, timeout=None):
""" """
@@ -223,6 +224,28 @@ class EventsMixin(object):
def when_deactivated(self, value): def when_deactivated(self, value):
self._when_deactivated = self._wrap_callback(value) self._when_deactivated = self._wrap_callback(value)
@property
def active_time(self):
"""
The length of time (in seconds) that the device has been active for.
When the device is inactive, this is ``None``.
"""
if self._active_event.wait(0):
return time() - self._last_changed
else:
return None
@property
def inactive_time(self):
"""
The length of time (in seconds) that the device has been inactive for.
When the device is inactive, this is ``None``.
"""
if self._inactive_event.wait(0):
return time() - self._last_changed
else:
return None
def _wrap_callback(self, fn): def _wrap_callback(self, fn):
if fn is None: if fn is None:
return None return None
@@ -256,6 +279,16 @@ class EventsMixin(object):
'value must be a callable which accepts up to one ' 'value must be a callable which accepts up to one '
'mandatory parameter') 'mandatory parameter')
def _fire_activated(self):
# These methods are largely here to be overridden by descendents
if self.when_activated:
self.when_activated()
def _fire_deactivated(self):
# These methods are largely here to be overridden by descendents
if self.when_deactivated:
self.when_deactivated()
def _fire_events(self): def _fire_events(self):
old_state = self._last_state old_state = self._last_state
new_state = self._last_state = self.is_active new_state = self._last_state = self.is_active
@@ -266,17 +299,143 @@ class EventsMixin(object):
self._active_event.set() self._active_event.set()
else: else:
self._inactive_event.set() self._inactive_event.set()
else: elif old_state != new_state:
if not old_state and new_state: self._last_changed = time()
if new_state:
self._inactive_event.clear() self._inactive_event.clear()
self._active_event.set() self._active_event.set()
if self.when_activated: self._fire_activated()
self.when_activated() else:
elif old_state and not new_state:
self._active_event.clear() self._active_event.clear()
self._inactive_event.set() self._inactive_event.set()
if self.when_deactivated: self._fire_deactivated()
self.when_deactivated()
class HoldMixin(EventsMixin):
def __init__(self, *args, **kwargs):
super(HoldMixin, self).__init__(*args, **kwargs)
self._when_held = None
self._held_from = None
self._hold_time = 1
self._hold_repeat = False
self._hold_thread = HoldThread(self)
def close(self):
if self._hold_thread:
self._hold_thread.stop()
self._hold_thread = None
try:
super(HoldMixin, self).close()
except AttributeError:
pass
def _fire_activated(self):
super(HoldMixin, self)._fire_activated()
self._hold_thread.holding.set()
def _fire_deactivated(self):
self._held_from = None
super(HoldMixin, self)._fire_deactivated()
def _fire_held(self):
if self.when_held:
self.when_held()
@property
def when_held(self):
"""
The function to run when the device has remained active for
:attr:`hold_time` seconds.
This can be set to a function which accepts no (mandatory) parameters,
or a Python function which accepts a single mandatory parameter (with
as many optional parameters as you like). If the function accepts a
single mandatory parameter, the device that activated will be passed
as that parameter.
Set this property to ``None`` (the default) to disable the event.
"""
return self._when_held
@when_held.setter
def when_held(self, value):
self._when_held = self._wrap_callback(value)
@property
def hold_time(self):
"""
The length of time (in seconds) to wait after the device is activated,
until executing the :attr:`when_held` handler. If :attr:`hold_repeat`
is True, this is also the length of time between invocations of
:attr:`when_held`.
"""
return self._hold_time
@hold_time.setter
def hold_time(self, value):
if value < 0:
raise BadWaitTime('source_delay must be 0 or greater')
self._hold_time = float(value)
@property
def hold_repeat(self):
"""
If ``True``, :attr:`when_held` will be executed repeatedly with
:attr:`hold_time` seconds between each invocation.
"""
return self._hold_repeat
@hold_repeat.setter
def hold_repeat(self, value):
self._hold_repeat = bool(value)
@property
def is_held(self):
"""
When ``True``, the device has been active for at least
:attr:`hold_time` seconds.
"""
return self._held_from is not None
@property
def held_time(self):
"""
The length of time (in seconds) that the device has been held for.
This is counted from the first execution of the :attr:`when_held` event
rather than when the device activated, in contrast to
:attr:`active_time`. If the device is not currently held, this is
``None``.
"""
if self._held_from is not None:
return time() - self._held_from
else:
return None
class HoldThread(GPIOThread):
"""
Extends :class:`GPIOThread`. Provides a background thread that repeatedly
fires the :attr:`HoldMixin.when_held` event as long as the owning
device is active.
"""
def __init__(self, parent):
super(HoldThread, self).__init__(target=self.held, args=(parent,))
self.holding = Event()
self.start()
def held(self, parent):
while not self.stopping.wait(0):
if self.holding.wait(0.1):
self.holding.clear()
while not (
self.stopping.wait(0) or
parent._inactive_event.wait(parent.hold_time)
):
if parent._held_from is None:
parent._held_from = time()
parent._fire_held()
if not parent.hold_repeat:
break
class GPIOQueue(GPIOThread): class GPIOQueue(GPIOThread):
@@ -293,9 +452,9 @@ class GPIOQueue(GPIOThread):
assert callable(average) assert callable(average)
super(GPIOQueue, self).__init__(target=self.fill) super(GPIOQueue, self).__init__(target=self.fill)
if queue_len < 1: if queue_len < 1:
raise GPIOBadQueueLen('queue_len must be at least one') raise BadQueueLen('queue_len must be at least one')
if sample_wait < 0: if sample_wait < 0:
raise GPIOBadSampleWait('sample_wait must be 0 or greater') raise BadWaitTime('sample_wait must be 0 or greater')
self.queue = deque(maxlen=queue_len) self.queue = deque(maxlen=queue_len)
self.partial = partial self.partial = partial
self.sample_wait = sample_wait self.sample_wait = sample_wait