diff --git a/.travis.yml b/.travis.yml index 1565424..b0f50a6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,5 +9,11 @@ python: - "pypy3" install: "pip install -e .[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: slack: raspberrypifoundation:YoIHtVdg8Hd6gcA09QEmCYXN diff --git a/docs/api_exc.rst b/docs/api_exc.rst index 3167fee..3a8b932 100644 --- a/docs/api_exc.rst +++ b/docs/api_exc.rst @@ -43,6 +43,10 @@ Errors .. autoexception:: BadEventHandler +.. autoexception:: BadQueueLen + +.. autoexception:: BadWaitTime + .. autoexception:: CompositeDeviceError .. autoexception:: CompositeDeviceBadName @@ -63,10 +67,6 @@ Errors .. autoexception:: GPIOPinMissing -.. autoexception:: GPIOBadQueueLen - -.. autoexception:: GPIOBadSampleWait - .. autoexception:: InputDeviceError .. autoexception:: OutputDeviceError diff --git a/docs/api_generic.rst b/docs/api_generic.rst index 66ee153..1f6ac25 100644 --- a/docs/api_generic.rst +++ b/docs/api_generic.rst @@ -37,6 +37,9 @@ There are also several `mixin classes`_: * :class:`EventsMixin` which adds activated/deactivated events to devices 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 The current class hierarchies are displayed below. For brevity, the mixin @@ -141,3 +144,6 @@ Mixin Classes .. autoclass:: EventsMixin(...) :members: +.. autoclass:: HoldMixin(...) + :members: + diff --git a/docs/api_pins.rst b/docs/api_pins.rst index 789d138..83a9dae 100644 --- a/docs/api_pins.rst +++ b/docs/api_pins.rst @@ -72,7 +72,7 @@ to utilize pins that are part of IO extender chips. For example:: .. 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`` for another. This is unsupported, and if it results in your script crashing, your components failing, or your Raspberry Pi turning into an diff --git a/docs/images/reaction_game_bb.pdf b/docs/images/reaction_game_bb.pdf index 9a8ce94..ff5552a 100644 Binary files a/docs/images/reaction_game_bb.pdf and b/docs/images/reaction_game_bb.pdf differ diff --git a/docs/images/rgb_led_bb.pdf b/docs/images/rgb_led_bb.pdf index 410e513..ee83a65 100644 Binary files a/docs/images/rgb_led_bb.pdf and b/docs/images/rgb_led_bb.pdf differ diff --git a/docs/images/rgb_led_bb.png b/docs/images/rgb_led_bb.png index f85a305..bd8bc8d 100644 Binary files a/docs/images/rgb_led_bb.png and b/docs/images/rgb_led_bb.png differ diff --git a/docs/images/traffic_lights_bb.pdf b/docs/images/traffic_lights_bb.pdf index 0f3dc11..2f507f3 100644 Binary files a/docs/images/traffic_lights_bb.pdf and b/docs/images/traffic_lights_bb.pdf differ diff --git a/gpiozero/__init__.py b/gpiozero/__init__.py index e4c0d48..9c7f0c7 100644 --- a/gpiozero/__init__.py +++ b/gpiozero/__init__.py @@ -17,6 +17,8 @@ from .exc import ( GPIOZeroError, DeviceClosed, BadEventHandler, + BadWaitTime, + BadQueueLen, CompositeDeviceError, CompositeDeviceBadName, CompositeDeviceBadOrder, @@ -28,8 +30,6 @@ from .exc import ( GPIODeviceClosed, GPIOPinInUse, GPIOPinMissing, - GPIOBadQueueLen, - GPIOBadSampleWait, InputDeviceError, OutputDeviceError, OutputDeviceBadValue, @@ -61,6 +61,7 @@ from .mixins import ( SourceMixin, ValuesMixin, EventsMixin, + HoldMixin, ) from .input_devices import ( InputDevice, diff --git a/gpiozero/exc.py b/gpiozero/exc.py index 77e8e6d..b36034f 100644 --- a/gpiozero/exc.py +++ b/gpiozero/exc.py @@ -16,6 +16,12 @@ class DeviceClosed(GPIOZeroError): class BadEventHandler(GPIOZeroError, ValueError): "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): "Base class for errors specific to the CompositeDevice hierarchy" @@ -49,15 +55,6 @@ class GPIOPinInUse(GPIODeviceError): class GPIOPinMissing(GPIODeviceError, ValueError): "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): "Base class for errors specific to the InputDevice hierarchy" diff --git a/gpiozero/input_devices.py b/gpiozero/input_devices.py index 957af34..5a79de3 100644 --- a/gpiozero/input_devices.py +++ b/gpiozero/input_devices.py @@ -12,7 +12,7 @@ from threading import Event from .exc import InputDeviceError, DeviceClosed from .devices import GPIODevice -from .mixins import GPIOQueue, EventsMixin +from .mixins import GPIOQueue, EventsMixin, HoldMixin class InputDevice(GPIODevice): @@ -222,7 +222,7 @@ class SmoothedInputDevice(EventsMixin, InputDevice): return self.value > self.threshold -class Button(DigitalInputDevice): +class Button(HoldMixin, DigitalInputDevice): """ Extends :class:`DigitalInputDevice` and represents a simple push button or switch. @@ -254,11 +254,24 @@ class Button(DigitalInputDevice): If ``None`` (the default), no software bounce compensation will be performed. Otherwise, this is the length in time (in seconds) that the 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) + self.hold_time = hold_time + self.hold_repeat = hold_repeat Button.is_pressed = Button.is_active +Button.pressed_time = Button.active_time Button.when_pressed = Button.when_activated Button.when_released = Button.when_deactivated Button.wait_for_press = Button.wait_for_active diff --git a/gpiozero/mixins.py b/gpiozero/mixins.py index 7ca534a..29bd5ab 100644 --- a/gpiozero/mixins.py +++ b/gpiozero/mixins.py @@ -12,6 +12,7 @@ import weakref from functools import wraps from threading import Event from collections import deque +from time import time try: from statistics import median except ImportError: @@ -20,10 +21,9 @@ except ImportError: from .threads import GPIOThread from .exc import ( BadEventHandler, + BadWaitTime, + BadQueueLen, DeviceClosed, - GPIOBadSourceDelay, - GPIOBadQueueLen, - GPIOBadSampleWait, ) class ValuesMixin(object): @@ -89,7 +89,7 @@ class SourceMixin(object): @source_delay.setter def source_delay(self, value): 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) @property @@ -160,6 +160,7 @@ class EventsMixin(object): self._when_activated = None self._when_deactivated = None self._last_state = None + self._last_changed = time() def wait_for_active(self, timeout=None): """ @@ -223,6 +224,28 @@ class EventsMixin(object): def when_deactivated(self, 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): if fn is None: return None @@ -256,6 +279,16 @@ class EventsMixin(object): 'value must be a callable which accepts up to one ' '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): old_state = self._last_state new_state = self._last_state = self.is_active @@ -266,17 +299,143 @@ class EventsMixin(object): self._active_event.set() else: self._inactive_event.set() - else: - if not old_state and new_state: + elif old_state != new_state: + self._last_changed = time() + if new_state: self._inactive_event.clear() self._active_event.set() - if self.when_activated: - self.when_activated() - elif old_state and not new_state: + self._fire_activated() + else: self._active_event.clear() self._inactive_event.set() - if self.when_deactivated: - self.when_deactivated() + self._fire_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): @@ -293,9 +452,9 @@ class GPIOQueue(GPIOThread): assert callable(average) super(GPIOQueue, self).__init__(target=self.fill) 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: - 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.partial = partial self.sample_wait = sample_wait