diff --git a/docs/api_output.rst b/docs/api_output.rst index 1824f11..b6710c2 100644 --- a/docs/api_output.rst +++ b/docs/api_output.rst @@ -28,7 +28,7 @@ PWMLED RGBLED ====== -.. autoclass:: RGBLED(red, green, blue, active_high=True, initial_value=(0, 0, 0)) +.. autoclass:: RGBLED(red, green, blue, active_high=True, initial_value=(0, 0, 0), pwm=True) :members: on, off, toggle, blink, pulse, red, green, blue, is_lit, color Buzzer @@ -40,7 +40,7 @@ Buzzer Motor ===== -.. autoclass:: Motor(forward, backward) +.. autoclass:: Motor(forward, backward, pwm=True) :members: forward, backward, stop Base Classes diff --git a/docs/images/composed_devices.dot b/docs/images/composed_devices.dot index 2a13174..b1611f2 100644 --- a/docs/images/composed_devices.dot +++ b/docs/images/composed_devices.dot @@ -5,6 +5,7 @@ digraph classes { node [shape=rect, style=filled, color="#298029", fontname=Sans, fontcolor="#ffffff", fontsize=10]; edge [arrowhead=onormal, style=dashed]; + RGBLED->LED; RGBLED->PWMLED; LEDBoard->LED; LEDBoard->PWMLED; @@ -16,5 +17,6 @@ digraph classes { TrafficLightsBuzzer->Button; Robot->Motor; + Motor->DigitalOutputDevice; Motor->PWMOutputDevice; } diff --git a/docs/images/composed_devices.pdf b/docs/images/composed_devices.pdf index 8c18056..f0dfaf8 100644 Binary files a/docs/images/composed_devices.pdf and b/docs/images/composed_devices.pdf differ diff --git a/docs/images/composed_devices.png b/docs/images/composed_devices.png index 12cfc53..4e02377 100644 Binary files a/docs/images/composed_devices.png and b/docs/images/composed_devices.png differ diff --git a/docs/images/composed_devices.svg b/docs/images/composed_devices.svg index 6e83761..7313446 100644 --- a/docs/images/composed_devices.svg +++ b/docs/images/composed_devices.svg @@ -4,120 +4,135 @@ - + classes - + RGBLED RGBLED + +LED + +LED + + +RGBLED->LED + + + -PWMLED - -PWMLED +PWMLED + +PWMLED -RGBLED->PWMLED - - +RGBLED->PWMLED + + -LEDBoard - -LEDBoard - - -LEDBoard->PWMLED - - - - -LED - -LED +LEDBoard + +LEDBoard -LEDBoard->LED - - +LEDBoard->LED + + + + +LEDBoard->PWMLED + + LEDBarGraph - -LEDBarGraph - - -LEDBarGraph->PWMLED - - + +LEDBarGraph -LEDBarGraph->LED - - +LEDBarGraph->LED + + + + +LEDBarGraph->PWMLED + + TrafficLightsBuzzer - -TrafficLightsBuzzer + +TrafficLightsBuzzer TrafficLights - -TrafficLights + +TrafficLights -TrafficLightsBuzzer->TrafficLights - - +TrafficLightsBuzzer->TrafficLights + + Buzzer - -Buzzer + +Buzzer -TrafficLightsBuzzer->Buzzer - - +TrafficLightsBuzzer->Buzzer + + Button - -Button + +Button -TrafficLightsBuzzer->Button - - +TrafficLightsBuzzer->Button + + Robot - -Robot + +Robot Motor - -Motor + +Motor -Robot->Motor - - +Robot->Motor + + + + +DigitalOutputDevice + +DigitalOutputDevice + + +Motor->DigitalOutputDevice + + -PWMOutputDevice - -PWMOutputDevice +PWMOutputDevice + +PWMOutputDevice -Motor->PWMOutputDevice - - +Motor->PWMOutputDevice + + diff --git a/gpiozero/output_devices.py b/gpiozero/output_devices.py index 5c13956..ecb2a5b 100644 --- a/gpiozero/output_devices.py +++ b/gpiozero/output_devices.py @@ -555,16 +555,22 @@ class RGBLED(SourceMixin, Device): :param bool initial_value: The initial color for the LED. Defaults to black ``(0, 0, 0)``. + + :param bool pwm: + If ``True`` (the default), construct :class:`PWMLED` instances for + each component of the RGBLED. If ``False``, construct regular + :class:`LED` instances, which prevents smooth color graduations. """ def __init__( self, red=None, green=None, blue=None, active_high=True, - initial_value=(0, 0, 0)): + initial_value=(0, 0, 0), pwm=True): self._leds = () self._blink_thread = None if not all([red, green, blue]): raise GPIOPinMissing('red, green, and blue pins must be provided') + LEDClass = PWMLED if pwm else LED super(RGBLED, self).__init__() - self._leds = tuple(PWMLED(pin, active_high) for pin in (red, green, blue)) + self._leds = tuple(LEDClass(pin, active_high) for pin in (red, green, blue)) self.value = initial_value red = _led_property(0) @@ -587,7 +593,8 @@ class RGBLED(SourceMixin, Device): def value(self): """ Represents the color of the LED as an RGB 3-tuple of ``(red, green, - blue)`` where each value is between 0 and 1. + blue)`` where each value is between 0 and 1 if ``pwm`` was ``True`` + when the class was constructed (and only 0 or 1 if not). For example, purple would be ``(1, 0, 1)`` and yellow would be ``(1, 1, 0)``, while orange would be ``(1, 0.5, 0)``. @@ -596,6 +603,12 @@ class RGBLED(SourceMixin, Device): @value.setter def value(self, value): + for component in value: + if not 0 <= component <= 1: + raise OutputDeviceBadValue('each RGB color component must be between 0 and 1') + if isinstance(self._leds[0], LED): + if component not in (0, 1): + raise OutputDeviceBadValue('each RGB color component must be 0 or 1 with non-PWM RGBLEDs') self._stop_blink() self.red, self.green, self.blue = value @@ -647,10 +660,14 @@ class RGBLED(SourceMixin, Device): Number of seconds off. Defaults to 1 second. :param float fade_in_time: - Number of seconds to spend fading in. Defaults to 0. + Number of seconds to spend fading in. Defaults to 0. Must be 0 if + ``pwm`` was ``False`` when the class was constructed + (:exc:`ValueError` will be raised if not). :param float fade_out_time: - Number of seconds to spend fading out. Defaults to 0. + Number of seconds to spend fading out. Defaults to 0. Must be 0 if + ``pwm`` was ``False`` when the class was constructed + (:exc:`ValueError` will be raised if not). :param tuple on_color: The color to use when the LED is "on". Defaults to white. @@ -667,6 +684,11 @@ class RGBLED(SourceMixin, Device): blink is finished (warning: the default value of *n* will result in this method never returning). """ + if isinstance(self._leds[0], LED): + if fade_in_time: + raise ValueError('fade_in_time must be 0 with non-PWM RGBLEDs') + if fade_out_time: + raise ValueError('fade_out_time must be 0 with non-PWM RGBLEDs') self._stop_blink() self._blink_thread = GPIOThread( target=self._blink_device, @@ -782,22 +804,31 @@ class Motor(SourceMixin, CompositeDevice): :param int backward: The GPIO pin that the backward input of the motor driver chip is connected to. + + :param bool pwm: + If ``True`` (the default), construct :class:`PWMOutputDevice` + instances for the motor controller pins, allowing both direction and + variable speed control. If ``False``, construct + :class:`DigitalOutputDevice` instances, allowing only direction + control. """ - def __init__(self, forward=None, backward=None): + def __init__(self, forward=None, backward=None, pwm=True): if not all([forward, backward]): raise GPIOPinMissing( 'forward and backward pins must be provided' ) + PinClass = PWMOutputDevice if pwm else DigitalOutputDevice super(Motor, self).__init__( - forward_device=PWMOutputDevice(forward), - backward_device=PWMOutputDevice(backward), + forward_device=PinClass(forward), + backward_device=PinClass(backward), _order=('forward_device', 'backward_device')) @property def value(self): """ Represents the speed of the motor as a floating point value between -1 - (full speed backward) and 1 (full speed forward). + (full speed backward) and 1 (full speed forward), with 0 representing + stopped. """ return self.forward_device.value - self.backward_device.value @@ -806,9 +837,15 @@ class Motor(SourceMixin, CompositeDevice): if not -1 <= value <= 1: raise OutputDeviceBadValue("Motor value must be between -1 and 1") if value > 0: - self.forward(value) + try: + self.forward(value) + except ValueError as e: + raise OutputDeviceBadValue(e) elif value < 0: - self.backward(-value) + try: + self.backward(-value) + except ValueError as e: + raise OutputDeviceBadValue(e) else: self.stop() @@ -826,8 +863,14 @@ class Motor(SourceMixin, CompositeDevice): :param float speed: The speed at which the motor should turn. Can be any value between - 0 (stopped) and the default 1 (maximum speed). + 0 (stopped) and the default 1 (maximum speed) if ``pwm`` was + ``True`` when the class was constructed (and only 0 or 1 if not). """ + if not 0 <= speed <= 1: + raise ValueError('forward speed must be between 0 and 1') + if isinstance(self.forward_device, DigitalOutputDevice): + if speed not in (0, 1): + raise ValueError('forward speed must be 0 or 1 with non-PWM Motors') self.backward_device.off() self.forward_device.value = speed @@ -837,8 +880,14 @@ class Motor(SourceMixin, CompositeDevice): :param float speed: The speed at which the motor should turn. Can be any value between - 0 (stopped) and the default 1 (maximum speed). + 0 (stopped) and the default 1 (maximum speed) if ``pwm`` was + ``True`` when the class was constructed (and only 0 or 1 if not). """ + if not 0 <= speed <= 1: + raise ValueError('backward speed must be between 0 and 1') + if isinstance(self.backward_device, DigitalOutputDevice): + if speed not in (0, 1): + raise ValueError('backward speed must be 0 or 1 with non-PWM Motors') self.forward_device.off() self.backward_device.value = speed diff --git a/tests/test_outputs.py b/tests/test_outputs.py index 435cf17..444b1d9 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -378,67 +378,6 @@ def test_output_pwm_pulse_foreground(): (0.04, 0), ]) -@pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), - reason='timing is too random on pypy') -def test_output_pwm_pulse_background(): - pin = MockPWMPin(2) - with PWMOutputDevice(pin) as device: - device.pulse(0.2, 0.2, n=2) - device._blink_thread.join() - pin.assert_states_and_times([ - (0.0, 0), - (0.04, 0.2), - (0.04, 0.4), - (0.04, 0.6), - (0.04, 0.8), - (0.04, 1), - (0.04, 0.8), - (0.04, 0.6), - (0.04, 0.4), - (0.04, 0.2), - (0.04, 0), - (0.04, 0.2), - (0.04, 0.4), - (0.04, 0.6), - (0.04, 0.8), - (0.04, 1), - (0.04, 0.8), - (0.04, 0.6), - (0.04, 0.4), - (0.04, 0.2), - (0.04, 0), - ]) - -@pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), - reason='timing is too random on pypy') -def test_output_pwm_pulse_foreground(): - pin = MockPWMPin(2) - with PWMOutputDevice(pin) as device: - device.pulse(0.2, 0.2, n=2, background=False) - pin.assert_states_and_times([ - (0.0, 0), - (0.04, 0.2), - (0.04, 0.4), - (0.04, 0.6), - (0.04, 0.8), - (0.04, 1), - (0.04, 0.8), - (0.04, 0.6), - (0.04, 0.4), - (0.04, 0.2), - (0.04, 0), - (0.04, 0.2), - (0.04, 0.4), - (0.04, 0.6), - (0.04, 0.8), - (0.04, 1), - (0.04, 0.8), - (0.04, 0.6), - (0.04, 0.4), - (0.04, 0.2), - (0.04, 0), - ]) - def test_output_pwm_blink_interrupt(): pin = MockPWMPin(2) with PWMOutputDevice(pin) as device: @@ -461,9 +400,47 @@ def test_rgbled_initial_value(): assert isclose(g.state, 0.2) assert isclose(b.state, 0.0) +def test_rgbled_initial_value_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False, initial_value=(0, 1, 1)) as device: + assert r.state == 0 + assert g.state == 1 + assert b.state == 1 + +def test_rgbled_initial_bad_value(): + r, g, b = (MockPWMPin(i) for i in (1, 2, 3)) + with pytest.raises(ValueError): + RGBLED(r, g, b, initial_value=(0.1, 0.2, 1.2)) + +def test_rgbled_initial_bad_value_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with pytest.raises(ValueError): + RGBLED(r, g, b, pwm=False, initial_value=(0.1, 0.2, 0)) + def test_rgbled_value(): r, g, b = (MockPWMPin(i) for i in (1, 2, 3)) with RGBLED(r, g, b) as device: + assert isinstance(device._leds[0], PWMLED) + assert isinstance(device._leds[1], PWMLED) + assert isinstance(device._leds[2], PWMLED) + assert not device.is_active + assert device.value == (0, 0, 0) + device.on() + assert device.is_active + assert device.value == (1, 1, 1) + device.off() + assert not device.is_active + assert device.value == (0, 0, 0) + device.value = (0.5, 0.5, 0.5) + assert device.is_active + assert device.value == (0.5, 0.5, 0.5) + +def test_rgbled_value_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + assert isinstance(device._leds[0], LED) + assert isinstance(device._leds[1], LED) + assert isinstance(device._leds[2], LED) assert not device.is_active assert device.value == (0, 0, 0) device.on() @@ -473,6 +450,33 @@ def test_rgbled_value(): assert not device.is_active assert device.value == (0, 0, 0) +def test_rgbled_bad_value(): + r, g, b = (MockPWMPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b) as device: + with pytest.raises(ValueError): + device.value = (2, 0, 0) + with RGBLED(r, g, b) as device: + with pytest.raises(ValueError): + device.value = (0, -1, 0) + +def test_rgbled_bad_value_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.value = (2, 0, 0) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.value = (0, -1, 0) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.value = (0.5, 0, 0) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.value = (0, 0.5, 0) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.value = (0, 0, 0.5) + def test_rgbled_toggle(): r, g, b = (MockPWMPin(i) for i in (1, 2, 3)) with RGBLED(r, g, b) as device: @@ -485,6 +489,18 @@ def test_rgbled_toggle(): assert not device.is_active assert device.value == (0, 0, 0) +def test_rgbled_toggle_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + assert not device.is_active + assert device.value == (0, 0, 0) + device.toggle() + assert device.is_active + assert device.value == (1, 1, 1) + device.toggle() + assert not device.is_active + assert device.value == (0, 0, 0) + @pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), reason='timing is too random on pypy') def test_rgbled_blink_background(): @@ -506,6 +522,27 @@ def test_rgbled_blink_background(): g.assert_states_and_times(expected) b.assert_states_and_times(expected) +@pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), + reason='timing is too random on pypy') +def test_rgbled_blink_background_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + start = time() + device.blink(0.1, 0.1, n=2) + assert isclose(time() - start, 0, abs_tol=0.05) + device._blink_thread.join() + assert isclose(time() - start, 0.4, abs_tol=0.05) + expected = [ + (0.0, 0), + (0.0, 1), + (0.1, 0), + (0.1, 1), + (0.1, 0) + ] + r.assert_states_and_times(expected) + g.assert_states_and_times(expected) + b.assert_states_and_times(expected) + @pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), reason='timing is too random on pypy') def test_rgbled_blink_foreground(): @@ -525,6 +562,25 @@ def test_rgbled_blink_foreground(): g.assert_states_and_times(expected) b.assert_states_and_times(expected) +@pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), + reason='timing is too random on pypy') +def test_rgbled_blink_foreground_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + start = time() + device.blink(0.1, 0.1, n=2, background=False) + assert isclose(time() - start, 0.4, abs_tol=0.05) + expected = [ + (0.0, 0), + (0.0, 1), + (0.1, 0), + (0.1, 1), + (0.1, 0) + ] + r.assert_states_and_times(expected) + g.assert_states_and_times(expected) + b.assert_states_and_times(expected) + @pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), reason='timing is too random on pypy') def test_rgbled_fade_background(): @@ -562,6 +618,12 @@ def test_rgbled_fade_background(): g.assert_states_and_times(expected) b.assert_states_and_times(expected) +def test_rgbled_fade_background_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.blink(0, 0, 0.2, 0.2, n=2) + @pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), reason='timing is too random on pypy') def test_rgbled_fade_foreground(): @@ -597,6 +659,12 @@ def test_rgbled_fade_foreground(): g.assert_states_and_times(expected) b.assert_states_and_times(expected) +def test_rgbled_fade_foreground_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.blink(0, 0, 0.2, 0.2, n=2, background=False) + @pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), reason='timing is too random on pypy') def test_rgbled_pulse_background(): @@ -634,6 +702,12 @@ def test_rgbled_pulse_background(): g.assert_states_and_times(expected) b.assert_states_and_times(expected) +def test_rgbled_pulse_background_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.pulse(0.2, 0.2, n=2) + @pytest.mark.skipif(hasattr(sys, 'pypy_version_info'), reason='timing is too random on pypy') def test_rgbled_pulse_foreground(): @@ -669,6 +743,12 @@ def test_rgbled_pulse_foreground(): g.assert_states_and_times(expected) b.assert_states_and_times(expected) +def test_rgbled_pulse_foreground_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + with pytest.raises(ValueError): + device.pulse(0.2, 0.2, n=2, background=False) + def test_rgbled_blink_interrupt(): r, g, b = (MockPWMPin(i) for i in (1, 2, 3)) with RGBLED(r, g, b) as device: @@ -679,6 +759,16 @@ def test_rgbled_blink_interrupt(): g.assert_states([0, 1, 0]) b.assert_states([0, 1, 0]) +def test_rgbled_blink_interrupt_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + device.blink(1, 0.1) + sleep(0.2) + device.off() # should interrupt while on + r.assert_states([0, 1, 0]) + g.assert_states([0, 1, 0]) + b.assert_states([0, 1, 0]) + def test_rgbled_close(): r, g, b = (MockPWMPin(i) for i in (1, 2, 3)) with RGBLED(r, g, b) as device: @@ -688,6 +778,15 @@ def test_rgbled_close(): device.close() assert device.closed +def test_rgbled_close_nonpwm(): + r, g, b = (MockPin(i) for i in (1, 2, 3)) + with RGBLED(r, g, b, pwm=False) as device: + assert not device.closed + device.close() + assert device.closed + device.close() + assert device.closed + def test_motor_missing_pins(): with pytest.raises(ValueError): Motor() @@ -697,7 +796,18 @@ def test_motor_pins(): b = MockPWMPin(2) with Motor(f, b) as device: assert device.forward_device.pin is f + assert isinstance(device.forward_device, PWMOutputDevice) assert device.backward_device.pin is b + assert isinstance(device.backward_device, PWMOutputDevice) + +def test_motor_pins_nonpwm(): + f = MockPin(1) + b = MockPin(2) + with Motor(f, b, pwm=False) as device: + assert device.forward_device.pin is f + assert isinstance(device.forward_device, DigitalOutputDevice) + assert device.backward_device.pin is b + assert isinstance(device.backward_device, DigitalOutputDevice) def test_motor_close(): f = MockPWMPin(1) @@ -710,6 +820,15 @@ def test_motor_close(): device.close() assert device.closed +def test_motor_close_nonpwm(): + f = MockPin(1) + b = MockPin(2) + with Motor(f, b, pwm=False) as device: + device.close() + assert device.closed + assert device.forward_device.pin is None + assert device.backward_device.pin is None + def test_motor_value(): f = MockPWMPin(1) b = MockPWMPin(2) @@ -726,6 +845,27 @@ def test_motor_value(): assert device.is_active assert device.value == 0.5 assert b.state == 0 and f.state == 0.5 + device.value = -0.5 + assert device.is_active + assert device.value == -0.5 + assert b.state == 0.5 and f.state == 0 + device.value = 0 + assert not device.is_active + assert not device.value + assert b.state == 0 and f.state == 0 + +def test_motor_value_nonpwm(): + f = MockPin(1) + b = MockPin(2) + with Motor(f, b, pwm=False) as device: + device.value = -1 + assert device.is_active + assert device.value == -1 + assert b.state == 1 and f.state == 0 + device.value = 1 + assert device.is_active + assert device.value == 1 + assert b.state == 0 and f.state == 1 device.value = 0 assert not device.is_active assert not device.value @@ -735,9 +875,24 @@ def test_motor_bad_value(): f = MockPWMPin(1) b = MockPWMPin(2) with Motor(f, b) as device: + with pytest.raises(ValueError): + device.value = -2 with pytest.raises(ValueError): device.value = 2 +def test_motor_bad_value_nonpwm(): + f = MockPin(1) + b = MockPin(2) + with Motor(f, b, pwm=False) as device: + with pytest.raises(ValueError): + device.value = -2 + with pytest.raises(ValueError): + device.value = 2 + with pytest.raises(ValueError): + device.value = 0.5 + with pytest.raises(ValueError): + device.value = -0.5 + def test_motor_reverse(): f = MockPWMPin(1) b = MockPWMPin(2) @@ -748,4 +903,20 @@ def test_motor_reverse(): device.reverse() assert device.value == -1 assert b.state == 1 and f.state == 0 + device.backward(0.5) + assert device.value == -0.5 + assert b.state == 0.5 and f.state == 0 + device.reverse() + assert device.value == 0.5 + assert b.state == 0 and f.state == 0.5 +def test_motor_reverse_nonpwm(): + f = MockPin(1) + b = MockPin(2) + with Motor(f, b, pwm=False) as device: + device.forward() + assert device.value == 1 + assert b.state == 0 and f.state == 1 + device.reverse() + assert device.value == -1 + assert b.state == 1 and f.state == 0