Repository: missionpinball/mpf

Issue:
Implement digital_outputs
Most platforms drive lights and coils separately because they need slightly different commands. However, lights may be on coil outputs (mostly for incandescent bulbs) and we support that case by mapping lights to a coil output (via the virtual coils platform). However, we got some more special cases:

- Outputs to drive motors can be either on coil outputs (most custom games) or on lights outputs (Ghostbusters on Spike)
- Flipper enable outputs (System 1/80; Lisy platform)
- GI enable outputs (System 1/80; Lisy)
- AC relay (System 11; Snux platform)

Implement a digital_output which can either drive a light or a driver/coil channel on a platform. Maybe some platforms even got additional logic outputs (such as Aux on the P-Roc).

Discussion:

Patch:
diff --git a/mpf/core/config_spec.py b/mpf/core/config_spec.py
index ef463d1ef..e47ec2dd7 100644
--- a/mpf/core/config_spec.py
+++ b/mpf/core/config_spec.py
@@ -247,6 +247,14 @@ coils:
     platform_settings: single|dict|None
     psu: single|machine(psus)|default
     platform: single|str|None
+digital_outputs:
+    __valid_in__: machine
+    number: single|str|
+    disable_events: dict|str:ms|None
+    enable_events: dict|str:ms|None
+    platform: single|str|None
+    type: single|enum(light,driver)|
+    light_subtype: single|str|None
 dual_wound_coils:
     __valid_in__: machine
     main_coil: single|machine(coils)|
diff --git a/mpf/devices/digital_output.py b/mpf/devices/digital_output.py
new file mode 100644
index 000000000..8a985d1f0
--- /dev/null
+++ b/mpf/devices/digital_output.py
@@ -0,0 +1,100 @@
+"""A digital output on either a light or driver platform."""
+from functools import partial
+from typing import Union, Tuple
+
+from mpf.core.machine import MachineController
+from mpf.core.platform import DriverConfig
+from mpf.core.system_wide_device import SystemWideDevice
+from mpf.platforms.interfaces.driver_platform_interface import PulseSettings, HoldSettings
+
+MYPY = False
+if MYPY:    # noqa
+    from mpf.core.platform import DriverPlatform, LightsPlatform
+    from mpf.platforms.interfaces.driver_platform_interface import DriverPlatformInterface
+    from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface
+
+
+class DigitalOutput(SystemWideDevice):
+
+    """A digital output on either a light or driver platform."""
+
+    config_section = 'digital_outputs'
+    collection = 'digital_outputs'
+    class_label = 'digital_output'
+
+    def __init__(self, machine: MachineController, name: str) -> None:
+        """Initialise digital output."""
+        self.hw_driver = None           # type: Union[DriverPlatformInterface, LightPlatformInterface]
+        self.platform = None            # type: Union[DriverPlatform, LightsPlatform]
+        self.type = None                # type: str
+        super().__init__(machine, name)
+
+    def _initialize(self):
+        """Initialise the hardware driver for this digital output."""
+        if self.config['type'] == "driver":
+            self._initialize_driver()
+        elif self.config['type'] == "light":
+            self._initialize_light()
+        else:
+            raise AssertionError("Invalid type {}".format(self.config['type']))
+
+    def _initialize_light(self):
+        """Configure a light as digital output."""
+        self.platform = self.machine.get_platform_sections('lights', self.config['platform'])
+        self.type = "light"
+
+        try:
+            self.hw_driver = self.platform.configure_light(self.config['number'], self.config['light_subtype'], {})
+        except AssertionError as e:
+            raise AssertionError("Failed to configure light {} in platform. See error above".format(self.name)) from e
+
+    def _initialize_driver(self):
+        """Configure a driver as digital output."""
+        self.platform = self.machine.get_platform_sections('coils', self.config['platform'])
+        self.type = "driver"
+
+        config = DriverConfig(
+            default_pulse_ms=255,
+            default_pulse_power=1.0,
+            default_hold_power=1.0,
+            default_recycle=False,
+            max_pulse_ms=255,
+            max_pulse_power=1.0,
+            max_hold_power=1.0)
+
+        try:
+            self.hw_driver = self.platform.configure_driver(config, self.config['number'], {})
+        except AssertionError as e:
+            raise AssertionError("Failed to configure driver {} in platform. See error above".format(self.name)) from e
+
+    @staticmethod
+    def _get_state(max_fade_ms: int, state: bool) -> Tuple[float, int]:
+        """Return the current state without any fade."""
+        del max_fade_ms
+        if state:
+            return 1.0, -1
+        else:
+            return 0.0, -1
+
+    def enable(self, **kwargs):
+        """Enable digital output."""
+        del kwargs
+        if self.type == "driver":
+            self.hw_driver.enable(PulseSettings(power=1.0, duration=0),
+                                  HoldSettings(power=1.0))
+        elif self.type == "light":
+            self.hw_driver.set_fade(partial(self._get_state, state=True))
+            self.platform.light_sync()
+        else:
+            raise AssertionError("Invalid type {}".format(self.type))
+
+    def disable(self, **kwargs):
+        """Disable digital output."""
+        del kwargs
+        if self.type == "driver":
+            self.hw_driver.disable()
+        elif self.type == "light":
+            self.hw_driver.set_fade(partial(self._get_state, state=False))
+            self.platform.light_sync()
+        else:
+            raise AssertionError("Invalid type {}".format(self.type))
diff --git a/mpf/mpfconfig.yaml b/mpf/mpfconfig.yaml
index 9459d6ffe..570718476 100644
--- a/mpf/mpfconfig.yaml
+++ b/mpf/mpfconfig.yaml
@@ -38,6 +38,7 @@ mpf:
 
     device_modules:
         - mpf.devices.driver.Driver
+        - mpf.devices.digital_output.DigitalOutput
         - mpf.devices.dual_wound_coil.DualWoundCoil
         - mpf.devices.switch.Switch
         - mpf.devices.light.Light


Test patch:
diff --git a/mpf/tests/machine_files/digital_output/config/config.yaml b/mpf/tests/machine_files/digital_output/config/config.yaml
new file mode 100644
index 000000000..b18f3a515
--- /dev/null
+++ b/mpf/tests/machine_files/digital_output/config/config.yaml
@@ -0,0 +1,10 @@
+#config_version=5
+
+digital_outputs:
+    light_output:
+        number: 1
+        type: light
+        light_subtype: test_subtype
+    driver_output:
+        number: 1
+        type: driver
diff --git a/mpf/tests/machine_files/lisy/config/config.yaml b/mpf/tests/machine_files/lisy/config/config.yaml
index 583f4913e..958e06999 100644
--- a/mpf/tests/machine_files/lisy/config/config.yaml
+++ b/mpf/tests/machine_files/lisy/config/config.yaml
@@ -29,20 +29,18 @@ coils:
     c_trough_eject:
         number: 103
         default_pulse_ms: 3s
+
+digital_outputs:
     game_over_relay:
-        number: 101
-        default_hold_power: 1.0
+        number: 1
+        type: light
+        enable_events: ball_started
+        disable_events: ball_will_end
 
 lights:
   test_light:
     number: 3
 
-coil_player:
-   ball_started:
-     game_over_relay: enable
-   ball_will_end:
-     game_over_relay: disable
-
 segment_displays:
   info_display:
     number: 0
diff --git a/mpf/tests/test_DigitalOutput.py b/mpf/tests/test_DigitalOutput.py
new file mode 100644
index 000000000..c2cb8a86b
--- /dev/null
+++ b/mpf/tests/test_DigitalOutput.py
@@ -0,0 +1,31 @@
+from mpf.platforms.virtual import VirtualDriver, VirtualLight
+
+from mpf.tests.MpfTestCase import MpfTestCase
+
+
+class TestDigitalOutputs(MpfTestCase):
+
+    def getConfigFile(self):
+        return 'config.yaml'
+
+    def getMachinePath(self):
+        return 'tests/machine_files/digital_output/'
+
+    def test_enable_disable(self):
+        light = self.machine.digital_outputs["light_output"].hw_driver
+        driver = self.machine.digital_outputs["driver_output"].hw_driver
+        self.assertIsInstance(driver, VirtualDriver)
+        self.assertIsInstance(light, VirtualLight)
+        self.assertEqual("1", driver.number)
+        self.assertEqual("test_subtype-1", light.number)
+        self.assertEqual("disabled", driver.state)
+        self.machine.digital_outputs["driver_output"].enable()
+        self.assertEqual("enabled", driver.state)
+        self.machine.digital_outputs["driver_output"].disable()
+        self.assertEqual("disabled", driver.state)
+
+        self.assertEqual(0.0, light.current_brightness)
+        self.machine.digital_outputs["light_output"].enable()
+        self.assertEqual(1.0, light.current_brightness)
+        self.machine.digital_outputs["light_output"].disable()
+        self.assertEqual(0.0, light.current_brightness)
diff --git a/mpf/tests/test_Lisy.py b/mpf/tests/test_Lisy.py
index c756861d4..f5cd6560e 100644
--- a/mpf/tests/test_Lisy.py
+++ b/mpf/tests/test_Lisy.py
@@ -190,7 +190,7 @@ class TestLisy(MpfTestCase):
         self._wait_for_processing()
         self.assertFalse(self.serialMock.expected_commands)
 
-        # disable flipper (using light 1)
+        # disable light (using light 3)
         self.serialMock.expected_commands = {
             b'\x0c\x03': None
         }
@@ -200,16 +200,17 @@ class TestLisy(MpfTestCase):
 
         # start ball. enable flipper (using light 1)
         self.serialMock.expected_commands = {
-            b'\x18\x65\x0a': None,  # set pulse_ms to 10ms
-            b'\x15\x65': None
+            b'\x0b\x01': None
         }
         self.post_event("ball_started")
         self._wait_for_processing()
         self.assertFalse(self.serialMock.expected_commands)
 
+        self.advance_time_and_run()
+
         # end ball. disable flipper (using light 1)
         self.serialMock.expected_commands = {
-            b'\x16\x65': None
+            b'\x0c\x01': None
         }
         self.post_event("ball_will_end")
         self._wait_for_processing()