diff --git a/docs/api_exc.rst b/docs/api_exc.rst index fe6e461..0514dce 100644 --- a/docs/api_exc.rst +++ b/docs/api_exc.rst @@ -41,6 +41,8 @@ Errors .. autoexception:: DeviceClosed +.. autoexception:: BadEventHandler + .. autoexception:: CompositeDeviceError .. autoexception:: CompositeDeviceBadName diff --git a/docs/api_generic.rst b/docs/api_generic.rst index 5c6b51f..66ee153 100644 --- a/docs/api_generic.rst +++ b/docs/api_generic.rst @@ -16,6 +16,9 @@ classes: * :class:`SPIDevice` represents devices that communicate over an SPI interface (implemented as four GPIO pins) +* :class:`InternalDevice` represents devices that are entirely internal to + the Pi (usually operating system related services) + * :class:`CompositeDevice` represents devices composed of multiple other devices like HATs @@ -31,6 +34,9 @@ There are also several `mixin classes`_: * :class:`SharedMixin` which causes classes to track their construction and return existing instances when equivalent constructor arguments are passed +* :class:`EventsMixin` which adds activated/deactivated events to devices + along with the machinery to trigger those events + .. _mixin classes: https://en.wikipedia.org/wiki/Mixin The current class hierarchies are displayed below. For brevity, the mixin @@ -47,6 +53,10 @@ Next, the classes below :class:`SPIDevice`: .. image:: images/spi_device_hierarchy.* +Next, the classes below :class:`InternalDevice`: + +.. image:: images/other_device_hierarchy.* + Next, the classes below :class:`CompositeDevice`: .. image:: images/composite_device_hierarchy.* @@ -60,15 +70,18 @@ Base Classes ============ .. autoclass:: Device - :members: close, closed + :members: close, closed, value, is_active .. autoclass:: GPIODevice(pin) :members: -.. autoclass:: CompositeDevice +.. autoclass:: SPIDevice :members: -.. autoclass:: SPIDevice +.. autoclass:: InternalDevice + :members: + +.. autoclass:: CompositeDevice :members: Input Devices @@ -77,9 +90,6 @@ Input Devices .. autoclass:: InputDevice(pin, pull_up=False) :members: -.. autoclass:: WaitableInputDevice - :members: - .. autoclass:: DigitalInputDevice(pin, pull_up=False, bounce_time=None) :members: @@ -128,3 +138,6 @@ Mixin Classes .. autoclass:: SharedMixin(...) :members: _shared_key +.. autoclass:: EventsMixin(...) + :members: + diff --git a/docs/api_other.rst b/docs/api_other.rst new file mode 100644 index 0000000..a5d9d31 --- /dev/null +++ b/docs/api_other.rst @@ -0,0 +1,14 @@ +================ +Internal Devices +================ + +.. currentmodule:: gpiozero + +GPIO Zero also provides several "internal" devices which represent facilities +provided by the operating system itself. These can be used to react to things +like the time of day, or whether a server is available on the network. + + +.. autoclass:: TimeOfDay + +.. autoclass:: PingServer diff --git a/docs/api_source_tools.rst b/docs/api_source_tools.rst new file mode 100644 index 0000000..f0cb189 --- /dev/null +++ b/docs/api_source_tools.rst @@ -0,0 +1,49 @@ +============ +Source Tools +============ + +.. currentmodule:: gpiozero + +GPIO Zero includes several utility routines which are intended to be used with +the :attr:`~SourceMixin.source` and :attr:`~ValuesMixin.values` attributes +common to most devices in the library. Given that ``source`` and ``values`` +deal with infinite iterators, another excellent source of utilities is the +:mod:`itertools` module in the standard library. + +Single source conversions +========================= + +.. autofunction:: negated + +.. autofunction:: inverted + +.. autofunction:: scaled + +.. autofunction:: clamped + +.. autofunction:: post_delayed + +.. autofunction:: pre_delayed + +.. autofunction:: quantized + +.. autofunction:: queued + +Combining sources +================= + +.. autofunction:: conjunction + +.. autofunction:: disjunction + +.. autofunction:: averaged + +Artifical sources +================= + +.. autofunction:: random_values + +.. autofunction:: sin_values + +.. autofunction:: cos_values + diff --git a/docs/images/gpio_device_hierarchy.dot b/docs/images/gpio_device_hierarchy.dot index 3eaf266..7ff0bed 100644 --- a/docs/images/gpio_device_hierarchy.dot +++ b/docs/images/gpio_device_hierarchy.dot @@ -9,7 +9,6 @@ digraph classes { node [color="#9ec6e0", fontcolor="#000000"] Device; GPIODevice; - WaitableInputDevice; SmoothedInputDevice; /* Concrete classes */ @@ -17,9 +16,8 @@ digraph classes { GPIODevice->Device; InputDevice->GPIODevice; - WaitableInputDevice->InputDevice; - DigitalInputDevice->WaitableInputDevice; - SmoothedInputDevice->WaitableInputDevice; + DigitalInputDevice->InputDevice; + SmoothedInputDevice->InputDevice; Button->DigitalInputDevice; MotionSensor->SmoothedInputDevice; LightSensor->SmoothedInputDevice; diff --git a/docs/images/gpio_device_hierarchy.pdf b/docs/images/gpio_device_hierarchy.pdf index 8c981cc..9dc2a3e 100644 Binary files a/docs/images/gpio_device_hierarchy.pdf and b/docs/images/gpio_device_hierarchy.pdf differ diff --git a/docs/images/gpio_device_hierarchy.png b/docs/images/gpio_device_hierarchy.png index 1df7301..ff0de16 100644 Binary files a/docs/images/gpio_device_hierarchy.png and b/docs/images/gpio_device_hierarchy.png differ diff --git a/docs/images/gpio_device_hierarchy.svg b/docs/images/gpio_device_hierarchy.svg index 92a33af..1b35ddb 100644 --- a/docs/images/gpio_device_hierarchy.svg +++ b/docs/images/gpio_device_hierarchy.svg @@ -4,175 +4,165 @@ - - + + classes - + Device - -Device + +Device GPIODevice - -GPIODevice + +GPIODevice GPIODevice->Device - - - - -WaitableInputDevice - -WaitableInputDevice - - -InputDevice - -InputDevice - - -WaitableInputDevice->InputDevice - - + + -SmoothedInputDevice +SmoothedInputDevice SmoothedInputDevice - -SmoothedInputDevice->WaitableInputDevice - - + +InputDevice + +InputDevice + + +SmoothedInputDevice->InputDevice + + InputDevice->GPIODevice - - + + -DigitalInputDevice - -DigitalInputDevice +DigitalInputDevice + +DigitalInputDevice - -DigitalInputDevice->WaitableInputDevice - - + +DigitalInputDevice->InputDevice + + -Button +Button Button -Button->DigitalInputDevice - - +Button->DigitalInputDevice + + -MotionSensor +MotionSensor MotionSensor -MotionSensor->SmoothedInputDevice +MotionSensor->SmoothedInputDevice -LightSensor +LightSensor LightSensor -LightSensor->SmoothedInputDevice +LightSensor->SmoothedInputDevice -LineSensor +LineSensor LineSensor -LineSensor->SmoothedInputDevice +LineSensor->SmoothedInputDevice -DistanceSensor +DistanceSensor DistanceSensor -DistanceSensor->SmoothedInputDevice +DistanceSensor->SmoothedInputDevice -OutputDevice - -OutputDevice +OutputDevice + +OutputDevice -OutputDevice->GPIODevice - - +OutputDevice->GPIODevice + + -DigitalOutputDevice - -DigitalOutputDevice +DigitalOutputDevice + +DigitalOutputDevice -DigitalOutputDevice->OutputDevice - - +DigitalOutputDevice->OutputDevice + + -LED - -LED +LED + +LED -LED->DigitalOutputDevice - - +LED->DigitalOutputDevice + + -Buzzer - -Buzzer +Buzzer + +Buzzer -Buzzer->DigitalOutputDevice - - +Buzzer->DigitalOutputDevice + + -PWMOutputDevice - -PWMOutputDevice +PWMOutputDevice + +PWMOutputDevice -PWMOutputDevice->OutputDevice - - +PWMOutputDevice->OutputDevice + + -PWMLED - -PWMLED +PWMLED + +PWMLED -PWMLED->PWMOutputDevice - - +PWMLED->PWMOutputDevice + + diff --git a/docs/images/other_device_hierarchy.dot b/docs/images/other_device_hierarchy.dot new file mode 100644 index 0000000..c8654d7 --- /dev/null +++ b/docs/images/other_device_hierarchy.dot @@ -0,0 +1,19 @@ +/* vim: set et sw=4 sts=4: */ + +digraph classes { + graph [rankdir=BT]; + node [shape=rect, style=filled, fontname=Sans, fontsize=10]; + edge []; + + /* Abstract classes */ + node [color="#9ec6e0", fontcolor="#000000"] + Device; + InternalDevice; + + /* Concrete classes */ + node [color="#2980b9", fontcolor="#ffffff"]; + + InternalDevice->Device; + TimeOfDay->InternalDevice; + PingServer->InternalDevice; +} diff --git a/docs/images/other_device_hierarchy.pdf b/docs/images/other_device_hierarchy.pdf new file mode 100644 index 0000000..dcfa7de Binary files /dev/null and b/docs/images/other_device_hierarchy.pdf differ diff --git a/docs/images/other_device_hierarchy.png b/docs/images/other_device_hierarchy.png new file mode 100644 index 0000000..52da6f1 Binary files /dev/null and b/docs/images/other_device_hierarchy.png differ diff --git a/docs/images/other_device_hierarchy.svg b/docs/images/other_device_hierarchy.svg new file mode 100644 index 0000000..275484c --- /dev/null +++ b/docs/images/other_device_hierarchy.svg @@ -0,0 +1,48 @@ + + + + + + +classes + + +Device + +Device + + +InternalDevice + +InternalDevice + + +InternalDevice->Device + + + + +TimeOfDay + +TimeOfDay + + +TimeOfDay->InternalDevice + + + + +PingServer + +PingServer + + +PingServer->InternalDevice + + + + + diff --git a/docs/index.rst b/docs/index.rst index 2f5498f..14357b4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,7 +12,9 @@ Table of Contents api_output api_spi api_boards + api_other api_generic + api_source_tools api_pins api_exc changelog diff --git a/gpiozero/__init__.py b/gpiozero/__init__.py index 6ac96ea..b5200d1 100644 --- a/gpiozero/__init__.py +++ b/gpiozero/__init__.py @@ -11,6 +11,7 @@ from .pins import ( from .exc import ( GPIOZeroError, DeviceClosed, + BadEventHandler, CompositeDeviceError, CompositeDeviceBadName, SPIError, @@ -45,13 +46,15 @@ from .devices import ( Device, GPIODevice, CompositeDevice, +) +from .mixins import ( SharedMixin, SourceMixin, ValuesMixin, + EventsMixin, ) from .input_devices import ( InputDevice, - WaitableInputDevice, DigitalInputDevice, SmoothedInputDevice, Button, @@ -103,3 +106,24 @@ from .boards import ( CamJamKitRobot, Energenie, ) +from .other_devices import ( + InternalDevice, + PingServer, + TimeOfDay, +) +from .source_tools import ( + averaged, + clamped, + conjunction, + cos_values, + disjunction, + inverted, + negated, + post_delayed, + pre_delayed, + quantized, + queued, + random_values, + scaled, + sin_values, +) diff --git a/gpiozero/boards.py b/gpiozero/boards.py index d799dbd..24ec9d5 100644 --- a/gpiozero/boards.py +++ b/gpiozero/boards.py @@ -22,7 +22,8 @@ from .exc import ( from .input_devices import Button from .output_devices import OutputDevice, LED, PWMLED, Buzzer, Motor from .threads import GPIOThread -from .devices import Device, CompositeDevice, SharedMixin, SourceMixin +from .devices import Device, CompositeDevice +from .mixins import SharedMixin, SourceMixin class CompositeOutputDevice(SourceMixin, CompositeDevice): diff --git a/gpiozero/devices.py b/gpiozero/devices.py index 527ac28..5493664 100644 --- a/gpiozero/devices.py +++ b/gpiozero/devices.py @@ -15,6 +15,10 @@ from types import FunctionType from threading import RLock from .threads import GPIOThread, _threads_shutdown +from .mixins import ( + ValuesMixin, + SharedMixin, + ) from .exc import ( DeviceClosed, GPIOPinMissing, @@ -201,127 +205,35 @@ class GPIOBase(GPIOMeta(nstr('GPIOBase'), (), {})): self.close() -class ValuesMixin(object): - """ - Adds a :attr:`values` property to the class which returns an infinite - generator of readings from the :attr:`value` property. - - .. note:: - - Use this mixin *first* in the parent class list. - """ - - @property - def values(self): - """ - An infinite iterator of values read from `value`. - """ - while True: - try: - yield self.value - except GPIODeviceClosed: - break - - -class SourceMixin(object): - """ - Adds a :attr:`source` property to the class which, given an iterable, - sets :attr:`value` to each member of that iterable until it is exhausted. - - .. note:: - - Use this mixin *first* in the parent class list. - """ - - def __init__(self, *args, **kwargs): - self._source = None - self._source_thread = None - self._source_delay = 0.01 - super(SourceMixin, self).__init__(*args, **kwargs) - - def close(self): - try: - super(SourceMixin, self).close() - except AttributeError: - pass - self.source = None - - def _copy_values(self, source): - for v in source: - self.value = v - if self._source_thread.stopping.wait(self._source_delay): - break - - @property - def source_delay(self): - """ - The delay (measured in seconds) in the loop used to read values from - :attr:`source`. Defaults to 0.01 seconds which is generally sufficient - to keep CPU usage to a minimum while providing adequate responsiveness. - """ - return self._source_delay - - @source_delay.setter - def source_delay(self, value): - if value < 0: - raise GPIOBadSourceDelay('source_delay must be 0 or greater') - self._source_delay = float(value) - - @property - def source(self): - """ - The iterable to use as a source of values for :attr:`value`. - """ - return self._source - - @source.setter - def source(self, value): - if self._source_thread is not None: - self._source_thread.stop() - self._source_thread = None - self._source = value - if value is not None: - self._source_thread = GPIOThread(target=self._copy_values, args=(value,)) - self._source_thread.start() - - -class SharedMixin(object): - """ - This mixin marks a class as "shared". In this case, the meta-class - (GPIOMeta) will use :meth:`_shared_key` to convert the constructor - arguments to an immutable key, and will check whether any existing - instances match that key. If they do, they will be returned by the - constructor instead of a new instance. An internal reference counter is - used to determine how many times an instance has been "constructed" in this - way. - - When :meth:`close` is called, an internal reference counter will be - decremented and the instance will only close when it reaches zero. - """ - _INSTANCES = {} - - def __del__(self): - self._refs = 0 - super(SharedMixin, self).__del__() - - @classmethod - def _shared_key(cls, *args, **kwargs): - """ - Given the constructor arguments, returns an immutable key representing - the instance. The default simply assumes all positional arguments are - immutable. - """ - return args - - class Device(ValuesMixin, GPIOBase): """ Represents a single device of any type; GPIO-based, SPI-based, I2C-based, - etc. This is the base class of the device hierarchy. + etc. This is the base class of the device hierarchy. It defines the + basic services applicable to all devices (specifically thhe :attr:`is_active` + property, the :attr:`value` property, and the :meth:`close` method). """ def __repr__(self): return "" % (self.__class__.__name__) + @property + def value(self): + """ + Returns a value representing the device's state. Frequently, this is a + boolean value, or a number between 0 and 1 but some devices use larger + ranges (e.g. -1 to +1) and composite devices usually use tuples to + return the states of all their subordinate components. + """ + return 0 + + @property + def is_active(self): + """ + Returns ``True`` if the device is currently active and ``False`` + otherwise. This property is usually derived from :attr:`value`. Unlike + :attr:`value`, this is *always* a boolean. + """ + return bool(self.value) + class CompositeDevice(Device): """ @@ -417,15 +329,16 @@ class CompositeDevice(Device): def value(self): return self.tuple(*(device.value for device in self)) + @property + def is_active(self): + return any(self.value) + class GPIODevice(Device): """ - Extends :class:`Device`. Represents a generic GPIO device. - - This is the class at the root of the gpiozero class hierarchy. It handles - ensuring that two GPIO devices do not share the same pin, and provides - basic services applicable to all devices (specifically the :attr:`pin` - property, :attr:`is_active` property, and the :attr:`close` method). + Extends :class:`Device`. Represents a generic GPIO device and provides + the services common to all single-pin GPIO devices (like ensuring two + GPIO devices do no share a :attr:`pin`). :param int pin: The GPIO pin (in BCM numbering) that the device is connected to. If @@ -494,14 +407,8 @@ class GPIODevice(Device): @property def value(self): - """ - Returns ``True`` if the device is currently active and ``False`` - otherwise. - """ return self._read() - is_active = value - def __repr__(self): try: return "" % ( diff --git a/gpiozero/exc.py b/gpiozero/exc.py index de2a530..e3b262e 100644 --- a/gpiozero/exc.py +++ b/gpiozero/exc.py @@ -13,6 +13,9 @@ class GPIOZeroError(Exception): class DeviceClosed(GPIOZeroError): "Error raised when an operation is attempted on a closed device" +class BadEventHandler(GPIOZeroError, ValueError): + "Error raised when an event handler with an incompatible prototype is specified" + class CompositeDeviceError(GPIOZeroError): "Base class for errors specific to the CompositeDevice hierarchy" diff --git a/gpiozero/input_devices.py b/gpiozero/input_devices.py index bafc67a..b19f802 100644 --- a/gpiozero/input_devices.py +++ b/gpiozero/input_devices.py @@ -7,15 +7,12 @@ from __future__ import ( division, ) -import inspect import warnings -from functools import wraps from time import sleep, time -from threading import Event from .exc import InputDeviceError, GPIODeviceError, GPIODeviceClosed from .devices import GPIODevice, CompositeDevice -from .threads import GPIOQueue +from .mixins import GPIOQueue, EventsMixin class InputDevice(GPIODevice): @@ -65,148 +62,7 @@ class InputDevice(GPIODevice): return super(InputDevice, self).__repr__() -class WaitableInputDevice(InputDevice): - """ - Represents a generic input device with distinct waitable states. - - This class extends :class:`InputDevice` with methods for waiting on the - device's status (:meth:`wait_for_active` and :meth:`wait_for_inactive`), - and properties that hold functions to be called when the device changes - state (:meth:`when_activated` and :meth:`when_deactivated`). These are - aliased appropriately in various subclasses. - - .. note:: - - Note that this class provides no means of actually firing its events; - it's effectively an abstract base class. - """ - def __init__(self, pin=None, pull_up=False): - super(WaitableInputDevice, self).__init__(pin, pull_up) - self._active_event = Event() - self._inactive_event = Event() - self._when_activated = None - self._when_deactivated = None - self._last_state = None - - def wait_for_active(self, timeout=None): - """ - Pause the script until the device is activated, or the timeout is - reached. - - :param float timeout: - Number of seconds to wait before proceeding. If this is ``None`` - (the default), then wait indefinitely until the device is active. - """ - return self._active_event.wait(timeout) - - def wait_for_inactive(self, timeout=None): - """ - Pause the script until the device is deactivated, or the timeout is - reached. - - :param float timeout: - Number of seconds to wait before proceeding. If this is ``None`` - (the default), then wait indefinitely until the device is inactive. - """ - return self._inactive_event.wait(timeout) - - @property - def when_activated(self): - """ - The function to run when the device changes state from inactive to - active. - - 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_activated - - @when_activated.setter - def when_activated(self, value): - self._when_activated = self._wrap_callback(value) - - @property - def when_deactivated(self): - """ - The function to run when the device changes state from active to - inactive. - - 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 deactivated will be - passed as that parameter. - - Set this property to ``None`` (the default) to disable the event. - """ - return self._when_deactivated - - @when_deactivated.setter - def when_deactivated(self, value): - self._when_deactivated = self._wrap_callback(value) - - def _wrap_callback(self, fn): - if fn is None: - return None - elif not callable(fn): - raise InputDeviceError('value must be None or a callable') - elif inspect.isbuiltin(fn): - # We can't introspect the prototype of builtins. In this case we - # assume that the builtin has no (mandatory) parameters; this is - # the most reasonable assumption on the basis that pre-existing - # builtins have no knowledge of gpiozero, and the sole parameter - # we would pass is a gpiozero object - return fn - else: - # Try binding ourselves to the argspec of the provided callable. - # If this works, assume the function is capable of accepting no - # parameters - try: - inspect.getcallargs(fn) - return fn - except TypeError: - try: - # If the above fails, try binding with a single parameter - # (ourselves). If this works, wrap the specified callback - inspect.getcallargs(fn, self) - @wraps(fn) - def wrapper(): - return fn(self) - return wrapper - except TypeError: - raise InputDeviceError( - 'value must be a callable which accepts up to one ' - 'mandatory parameter') - - def _fire_events(self): - old_state = self._last_state - new_state = self._last_state = self.is_active - if old_state is None: - # Initial "indeterminate" state; set events but don't fire - # callbacks as there's not necessarily an edge - if new_state: - self._active_event.set() - else: - self._inactive_event.set() - else: - if not old_state and 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._active_event.clear() - self._inactive_event.set() - if self.when_deactivated: - self.when_deactivated() - - -class DigitalInputDevice(WaitableInputDevice): +class DigitalInputDevice(EventsMixin, InputDevice): """ Represents a generic input device with typical on/off behaviour. @@ -233,7 +89,7 @@ class DigitalInputDevice(WaitableInputDevice): raise -class SmoothedInputDevice(WaitableInputDevice): +class SmoothedInputDevice(EventsMixin, InputDevice): """ Represents a generic input device which takes its value from the mean of a queue of historical values. diff --git a/gpiozero/mixins.py b/gpiozero/mixins.py new file mode 100644 index 0000000..979b917 --- /dev/null +++ b/gpiozero/mixins.py @@ -0,0 +1,326 @@ +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, + ) +nstr = str +str = type('') + +import inspect +import weakref +from functools import wraps +from threading import Event +from collections import deque +try: + from statistics import median, mean +except ImportError: + from .compat import median, mean + +from .threads import GPIOThread +from .exc import BadEventHandler, DeviceClosed + + +class ValuesMixin(object): + """ + Adds a :attr:`values` property to the class which returns an infinite + generator of readings from the :attr:`value` property. + + .. note:: + + Use this mixin *first* in the parent class list. + """ + + @property + def values(self): + """ + An infinite iterator of values read from `value`. + """ + while True: + try: + yield self.value + except DeviceClosed: + break + + +class SourceMixin(object): + """ + Adds a :attr:`source` property to the class which, given an iterable, + sets :attr:`value` to each member of that iterable until it is exhausted. + + .. note:: + + Use this mixin *first* in the parent class list. + """ + + def __init__(self, *args, **kwargs): + self._source = None + self._source_thread = None + self._source_delay = 0.01 + super(SourceMixin, self).__init__(*args, **kwargs) + + def close(self): + try: + super(SourceMixin, self).close() + except AttributeError: + pass + self.source = None + + def _copy_values(self, source): + for v in source: + self.value = v + if self._source_thread.stopping.wait(self._source_delay): + break + + @property + def source_delay(self): + """ + The delay (measured in seconds) in the loop used to read values from + :attr:`source`. Defaults to 0.01 seconds which is generally sufficient + to keep CPU usage to a minimum while providing adequate responsiveness. + """ + return self._source_delay + + @source_delay.setter + def source_delay(self, value): + if value < 0: + raise GPIOBadSourceDelay('source_delay must be 0 or greater') + self._source_delay = float(value) + + @property + def source(self): + """ + The iterable to use as a source of values for :attr:`value`. + """ + return self._source + + @source.setter + def source(self, value): + if self._source_thread is not None: + self._source_thread.stop() + self._source_thread = None + self._source = value + if value is not None: + self._source_thread = GPIOThread(target=self._copy_values, args=(value,)) + self._source_thread.start() + + +class SharedMixin(object): + """ + This mixin marks a class as "shared". In this case, the meta-class + (GPIOMeta) will use :meth:`_shared_key` to convert the constructor + arguments to an immutable key, and will check whether any existing + instances match that key. If they do, they will be returned by the + constructor instead of a new instance. An internal reference counter is + used to determine how many times an instance has been "constructed" in this + way. + + When :meth:`close` is called, an internal reference counter will be + decremented and the instance will only close when it reaches zero. + """ + _INSTANCES = {} + + def __del__(self): + self._refs = 0 + super(SharedMixin, self).__del__() + + @classmethod + def _shared_key(cls, *args, **kwargs): + """ + Given the constructor arguments, returns an immutable key representing + the instance. The default simply assumes all positional arguments are + immutable. + """ + return args + + +class EventsMixin(object): + """ + Adds edge-detected :meth:`when_activated` and :meth:`when_deactivated` + events to a device based on changes to the :attr:`~Device.is_active` + property common to all devices. Also adds :meth:`wait_for_active` and + :meth:`wait_for_inactive` methods for level-waiting. + + .. note:: + + Note that this mixin provides no means of actually firing its events; + call :meth:`_fire_events` in sub-classes when device state changes to + trigger the events. This should also be called once at the end of + initialization to set initial states. + """ + def __init__(self, *args, **kwargs): + super(EventsMixin, self).__init__(*args, **kwargs) + self._active_event = Event() + self._inactive_event = Event() + self._when_activated = None + self._when_deactivated = None + self._last_state = None + + def wait_for_active(self, timeout=None): + """ + Pause the script until the device is activated, or the timeout is + reached. + + :param float timeout: + Number of seconds to wait before proceeding. If this is ``None`` + (the default), then wait indefinitely until the device is active. + """ + return self._active_event.wait(timeout) + + def wait_for_inactive(self, timeout=None): + """ + Pause the script until the device is deactivated, or the timeout is + reached. + + :param float timeout: + Number of seconds to wait before proceeding. If this is ``None`` + (the default), then wait indefinitely until the device is inactive. + """ + return self._inactive_event.wait(timeout) + + @property + def when_activated(self): + """ + The function to run when the device changes state from inactive to + active. + + 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_activated + + @when_activated.setter + def when_activated(self, value): + self._when_activated = self._wrap_callback(value) + + @property + def when_deactivated(self): + """ + The function to run when the device changes state from active to + inactive. + + 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 deactivated will be + passed as that parameter. + + Set this property to ``None`` (the default) to disable the event. + """ + return self._when_deactivated + + @when_deactivated.setter + def when_deactivated(self, value): + self._when_deactivated = self._wrap_callback(value) + + def _wrap_callback(self, fn): + if fn is None: + return None + elif not callable(fn): + raise BadEventHandler('value must be None or a callable') + elif inspect.isbuiltin(fn): + # We can't introspect the prototype of builtins. In this case we + # assume that the builtin has no (mandatory) parameters; this is + # the most reasonable assumption on the basis that pre-existing + # builtins have no knowledge of gpiozero, and the sole parameter + # we would pass is a gpiozero object + return fn + else: + # Try binding ourselves to the argspec of the provided callable. + # If this works, assume the function is capable of accepting no + # parameters + try: + inspect.getcallargs(fn) + return fn + except TypeError: + try: + # If the above fails, try binding with a single parameter + # (ourselves). If this works, wrap the specified callback + inspect.getcallargs(fn, self) + @wraps(fn) + def wrapper(): + return fn(self) + return wrapper + except TypeError: + raise BadEventHandler( + 'value must be a callable which accepts up to one ' + 'mandatory parameter') + + def _fire_events(self): + old_state = self._last_state + new_state = self._last_state = self.is_active + if old_state is None: + # Initial "indeterminate" state; set events but don't fire + # callbacks as there's not necessarily an edge + if new_state: + self._active_event.set() + else: + self._inactive_event.set() + else: + if not old_state and 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._active_event.clear() + self._inactive_event.set() + if self.when_deactivated: + self.when_deactivated() + + +class GPIOQueue(GPIOThread): + """ + Extends :class:`GPIOThread`. Provides a background thread that monitors a + device's values and provides a running *average* (defaults to median) of + those values. If the *parent* device includes the :class:`EventsMixin` in + its ancestry, the thread automatically calls + :meth:`~EventsMixin._fire_events`. + """ + def __init__( + self, parent, queue_len=5, sample_wait=0.0, partial=False, + average=median): + assert callable(average) + super(GPIOQueue, self).__init__(target=self.fill) + if queue_len < 1: + raise GPIOBadQueueLen('queue_len must be at least one') + if sample_wait < 0: + raise GPIOBadSampleWait('sample_wait must be 0 or greater') + self.queue = deque(maxlen=queue_len) + self.partial = partial + self.sample_wait = sample_wait + self.full = Event() + self.parent = weakref.proxy(parent) + self.average = average + + @property + def value(self): + if not self.partial: + self.full.wait() + try: + return self.average(self.queue) + except ZeroDivisionError: + # No data == inactive value + return 0.0 + + def fill(self): + try: + while (not self.stopping.wait(self.sample_wait) and + len(self.queue) < self.queue.maxlen): + self.queue.append(self.parent._read()) + if self.partial and isinstance(self.parent, EventsMixin): + self.parent._fire_events() + self.full.set() + while not self.stopping.wait(self.sample_wait): + self.queue.append(self.parent._read()) + if isinstance(self.parent, EventsMixin): + self.parent._fire_events() + except ReferenceError: + # Parent is dead; time to die! + pass + diff --git a/gpiozero/other_devices.py b/gpiozero/other_devices.py new file mode 100644 index 0000000..1d0f3da --- /dev/null +++ b/gpiozero/other_devices.py @@ -0,0 +1,168 @@ +# vim: set fileencoding=utf-8: + +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) +str = type('') + + +import os +import io +import subprocess +from datetime import datetime, time + +from .devices import Device +from .mixins import EventsMixin + + +class InternalDevice(EventsMixin, Device): + """ + Extends :class:`Device` to provide a basis for devices which have no + specific hardware representation. This are effectively pseudo-devices and + usually represent operating system services like the internal clock, file + systems or network facilities. + """ + + +class PingServer(InternalDevice): + """ + Extends :class:`InternalDevice` to provide a device which is active when a + *host* on the network can be pinged. + + The following example lights an LED while a server is reachable (note the + use of :attr:`~SourceMixin.source_delay` to ensure the server is not + flooded with pings):: + + from gpiozero import PingServer, LED + from signal import pause + + server = PingServer('my-server') + led = LED(4) + led.source_delay = 1 + led.source = server.values + pause() + + :param str host: + The hostname or IP address to attempt to ping. + """ + def __init__(self, host): + self.host = host + super(PingServer, self).__init__() + self._fire_events() + + def __repr__(self): + return '' % self.host + + @property + def value(self): + # XXX This is doing a DNS lookup every time it's queried; should we + # call gethostbyname in the constructor and ping that instead (good + # for consistency, but what if the user *expects* the host to change + # address?) + with io.open(os.devnull, 'wb') as devnull: + try: + subprocess.check_call( + ['ping', '-c1', self.host], + stdout=devnull, stderr=devnull) + except subprocess.CalledProcessError: + return False + else: + return True + + +class TimeOfDay(InternalDevice): + """ + Extends :class:`InternalDevice` to provide a device which is active when + the computer's clock indicates that the current time is between + *start_time* and *end_time* (inclusive) which are :class:`~datetime.time` + instances. + + The following example turns on a lamp attached to an :class:`Energenie` + plug between 7 and 8 AM:: + + from datetime import time + from gpiozero import TimeOfDay, Energenie + from signal import pause + + lamp = Energenie(0) + morning = TimeOfDay(time(7), time(8)) + morning.when_activated = lamp.on + morning.when_deactivated = lamp.off + pause() + + :param ~datetime.time start_time: + The time from which the device will be considered active. + + :param ~datetime.time end_time: + The time after which the device will be considered inactive. + + :param bool utc: + If ``True`` (the default), a naive UTC time will be used for the + comparison rather than a local time-zone reading. + """ + def __init__(self, start_time, end_time, utc=True): + self._start_time = None + self._end_time = None + self._utc = True + super(TimeOfDay, self).__init__() + self.start_time = start_time + self.end_time = end_time + self.utc = utc + self._fire_events() + + def __repr__(self): + return '' % ( + self.start_time, self.end_time, ('local', 'UTC')[self.utc]) + + @property + def start_time(self): + """ + The time of day after which the device will be considered active. + """ + return self._start_time + + @start_time.setter + def start_time(self, value): + if isinstance(value, datetime): + value = value.time() + if not isinstance(value, time): + raise ValueError('start_time must be a datetime, or time instance') + self._start_time = value + + @property + def end_time(self): + """ + The time of day after which the device will be considered inactive. + """ + return self._end_time + + @end_time.setter + def end_time(self, value): + if isinstance(value, datetime): + value = value.time() + if not isinstance(value, time): + raise ValueError('end_time must be a datetime, or time instance') + self._end_time = value + + @property + def utc(self): + """ + If ``True``, use a naive UTC time reading for comparison instead of a + local timezone reading. + """ + return self._utc + + @utc.setter + def utc(self, value): + self._utc = bool(value) + + @property + def value(self): + if self.utc: + return self.start_time <= datetime.utcnow().time() <= self.end_time + else: + return self.start_time <= datetime.now().time() <= self.end_time + diff --git a/gpiozero/output_devices.py b/gpiozero/output_devices.py index c65dc73..e5f806d 100644 --- a/gpiozero/output_devices.py +++ b/gpiozero/output_devices.py @@ -11,7 +11,8 @@ from threading import Lock from itertools import repeat, cycle, chain from .exc import OutputDeviceBadValue, GPIOPinMissing -from .devices import GPIODevice, Device, CompositeDevice, SourceMixin +from .devices import GPIODevice, Device, CompositeDevice +from .mixins import SourceMixin from .threads import GPIOThread diff --git a/gpiozero/source_tools.py b/gpiozero/source_tools.py new file mode 100644 index 0000000..4aa1b51 --- /dev/null +++ b/gpiozero/source_tools.py @@ -0,0 +1,296 @@ +# vim: set fileencoding=utf-8: + +from __future__ import ( + unicode_literals, + print_function, + absolute_import, + division, +) +str = type('') + + +from random import random +from time import sleep +try: + from itertools import izip as zip +except ImportError: + pass +from itertools import count, cycle +from math import sin, cos, floor +try: + from statistics import mean +except ImportError: + from .compat import mean + + +def negated(values): + """ + Returns the negation of the supplied values (``True`` becomes ``False``, + and ``False`` becomes ``True``). For example:: + + from gpiozero import Button, LED, negated + from signal import pause + + led = LED(4) + btn = Button(17) + led.source = negated(btn.values) + pause() + """ + for v in values: + yield not v + + +def inverted(values): + """ + Returns the inversion of the supplied values (1 becomes 0, 0 becomes 1, + 0.1 becomes 0.9, etc.). For example:: + + from gpiozero import MCP3008, PWMLED, inverted + from signal import pause + + led = PWMLED(4) + pot = MCP3008(channel=0) + led.source = inverted(pot.values) + pause() + """ + for v in values: + yield 1 - v + + +def scaled(values, range_min, range_max, domain_min=0, domain_max=1): + """ + Returns *values* scaled from *range_min* to *range_max*, assuming that all + items in *values* lie between *domain_min* and *domain_max* (which default + to 0 and 1 respectively). For example, to control the direction of a motor + (which is represented as a value between -1 and 1) using a potentiometer + (which typically provides values between 0 and 1):: + + from gpiozero import Motor, MCP3008, scaled + from signal import pause + + motor = Motor(20, 21) + pot = MCP3008(channel=0) + motor.source = scaled(pot.values, -1, 1) + pause() + """ + domain_size = domain_max - domain_min + range_size = range_max - range_min + for v in values: + yield (((v - domain_min) / domain_size) * range_size) + range_min + + +def clamped(values, range_min=0, range_max=1): + """ + Returns *values* clamped from *range_min* to *range_max*, i.e. any items + less than *range_min* will be returned as *range_min* and any items + larger than *range_max* will be returned as *range_max* (these default to + 0 and 1 respectively). For example:: + + from gpiozero import PWMLED, MCP3008, clamped + from signal import pause + + led = PWMLED(4) + pot = MCP3008(channel=0) + led.source = clamped(pot.values, 0.5, 1.0) + pause() + """ + for v in values: + yield min(max(v, range_min), range_max) + + +def quantized(values, steps, range_min=0, range_max=1): + """ + Returns *values* quantized to *steps* increments. All items in *values* are + assumed to be between *range_min* and *range_max* (use :func:`scaled` to + ensure this if necessary). + + For example, to quantize values between 0 and 1 to 5 "steps" (0.0, 0.25, + 0.5, 0.75, 1.0):: + + from gpiozero import PWMLED, MCP3008, quantized + from signal import pause + + led = PWMLED(4) + pot = MCP3008(channel=0) + led.source = quantized(pot.values, 4) + pause() + """ + range_size = range_max - range_min + for v in scaled(values, 0, 1, range_min, range_max): + yield ((int(v * steps) / steps) * range_size) + range_min + + +def conjunction(*values): + """ + Returns the `logical conjunction`_ of all supplied values (the result is + only ``True`` if and only if all input values are simultaneously ``True``). + One or more *values* can be specified. For example, to light an + :class:`LED` only when *both* buttons are pressed:: + + from gpiozero import LED, Button, conjunction + from signal import pause + + led = LED(4) + btn1 = Button(20) + btn2 = Button(21) + led.source = conjunction(btn1.values, btn2.values) + pause() + + .. _logical conjunction: https://en.wikipedia.org/wiki/Logical_conjunction + """ + for v in zip(*values): + yield all(v) + + +def disjunction(*values): + """ + Returns the `logical disjunction`_ of all supplied values (the result is + ``True`` if any of the input values are currently ``True``). One or more + *values* can be specified. For example, the light an :class:`LED` when + *any* button is pressed:: + + from gpiozero import LED, Button, conjunction + from signal import pause + + led = LED(4) + btn1 = Button(20) + btn2 = Button(21) + led.source = disjunction(btn1.values, btn2.values) + pause() + + .. _logical disjunction: https://en.wikipedia.org/wiki/Logical_disjunction + """ + for v in zip(*values): + yield any(v) + + +def averaged(*values): + """ + Returns the mean of all supplied values. One or more *values* can be + specified. For example, to light a :class:`PWMLED` as the average of + several potentiometers connected to an :class:`MCP3008` ADC:: + + from gpiozero import MCP3008, PWMLED, averaged + from signal import pause + + pot1 = MCP3008(channel=0) + pot2 = MCP3008(channel=1) + pot3 = MCP3008(channel=2) + led = PWMLED(4) + led.source = averaged(pot1.values, pot2.values, pot3.values) + pause() + """ + for v in zip(*values): + yield mean(v) + + +def queued(values, qsize): + """ + Queues up readings from *values* (the number of readings queued is + determined by *qsize*) and begins yielding values only when the queue is + full. For example, to "cascade" values along a sequence of LEDs:: + + from gpiozero import LEDBoard, Button, queued + from signal import pause + + leds = LEDBoard(5, 6, 13, 19, 26) + btn = Button(17) + for i in range(4): + leds[i].source = queued(leds[i + 1].values, 5) + leds[i].source_delay = 0.01 + leds[4].source = btn.values + pause() + """ + q = [] + it = iter(values) + for i in range(qsize): + q.append(next(it)) + for i in cycle(range(qsize)): + yield q[i] + try: + q[i] = next(it) + except StopIteration: + break + + +def pre_delayed(values, delay): + """ + Waits for *delay* seconds before returning each item from *values*. + """ + for v in values: + sleep(delay) + yield v + + +def post_delayed(values, delay): + """ + Waits for *delay* seconds after returning each item from *values*. + """ + for v in values: + yield v + sleep(delay) + + +def random_values(): + """ + Provides an infinite source of random values between 0 and 1. For example, + to produce a "flickering candle" effect with an LED:: + + from gpiozero import PWMLED, random_values + from signal import pause + + led = PWMLED(4) + led.source = random_values() + pause() + + If you require a wider range than 0 to 1, see :func:`scaled`. + """ + while True: + yield random() + + +def sin_values(): + """ + Provides an infinite source of values representing a sine wave (from -1 to + +1), calculated as the result of applying sign to a simple degrees counter + that increments by one for each requested value. For example, to produce a + "siren" effect with a couple of LEDs:: + + from gpiozero import PWMLED, sin_values, scaled, inverted + from signal import pause + + red = PWMLED(2) + blue = PWMLED(3) + red.source_delay = 0.1 + blue.source_delay = 0.1 + red.source = scaled(sin_values(), 0, 1, -1, 1) + blue.source = inverted(red.values) + pause() + + If you require a wider range than 0 to 1, see :func:`scaled`. + """ + for d in cycle(range(360)): + yield sin(d) + + +def cos_values(): + """ + Provides an infinite source of values representing a cosine wave (from -1 + to +1), calculated as the result of applying sign to a simple degrees + counter that increments by one for each requested value. For example, to + produce a "siren" effect with a couple of LEDs:: + + from gpiozero import PWMLED, cos_values, scaled, inverted + from signal import pause + + red = PWMLED(2) + blue = PWMLED(3) + red.source = scaled(cos_values(), 0, 1, -1, 1) + blue.source = inverted(red.values) + pause() + + If you require a wider range than 0 to 1, see :func:`scaled`. + """ + for d in cycle(range(360)): + yield cos(d) + diff --git a/gpiozero/threads.py b/gpiozero/threads.py index 55c4beb..805212d 100644 --- a/gpiozero/threads.py +++ b/gpiozero/threads.py @@ -6,13 +6,7 @@ from __future__ import ( ) str = type('') -import weakref -from collections import deque -from threading import Thread, Event, RLock -try: - from statistics import median, mean -except ImportError: - from .compat import median, mean +from threading import Thread, Event from .exc import ( GPIOBadQueueLen, @@ -46,46 +40,3 @@ class GPIOThread(Thread): super(GPIOThread, self).join() _THREADS.discard(self) - -class GPIOQueue(GPIOThread): - def __init__( - self, parent, queue_len=5, sample_wait=0.0, partial=False, - average=median): - assert callable(average) - super(GPIOQueue, self).__init__(target=self.fill) - if queue_len < 1: - raise GPIOBadQueueLen('queue_len must be at least one') - if sample_wait < 0: - raise GPIOBadSampleWait('sample_wait must be 0 or greater') - self.queue = deque(maxlen=queue_len) - self.partial = partial - self.sample_wait = sample_wait - self.full = Event() - self.parent = weakref.proxy(parent) - self.average = average - - @property - def value(self): - if not self.partial: - self.full.wait() - try: - return self.average(self.queue) - except ZeroDivisionError: - # No data == inactive value - return 0.0 - - def fill(self): - try: - while (not self.stopping.wait(self.sample_wait) and - len(self.queue) < self.queue.maxlen): - self.queue.append(self.parent._read()) - if self.partial: - self.parent._fire_events() - self.full.set() - while not self.stopping.wait(self.sample_wait): - self.queue.append(self.parent._read()) - self.parent._fire_events() - except ReferenceError: - # Parent is dead; time to die! - pass -