diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0e34f5..5e6d071 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,8 @@ that source won't show up until tests are actually running, so you'll need to configure the control surface manually while you're running tests for the first time. -You can add the test control surface in addition to the main SSCOM -control surface, if you don't want to deal with switching the +You can safely add this test control surface alongside your primary +modeStep control surface, if you don't want to deal with switching the input/output when you want to run tests. To run tests, use: @@ -25,13 +25,19 @@ For debug output: DEBUG=1 make test ``` +To run only specs tagged with `@now`: + +```shell +.venv/bin/pytest -m now +``` + ## Linting and type checks Before submitting a PR, make sure the following are passing: ```shell -make lint # Validates code style -make check # Validates types +make lint # Validates code style. +make check # Validates types. ``` Some lint errors can be fixed automatically with: diff --git a/Makefile b/Makefile index bd17beb..caec6ce 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ SYSTEM_MIDI_REMOTE_SCRIPTS_DIR := /Applications/Ableton\ Live\ 12\ Suite.app/Contents/App-Resources/MIDI\ Remote\ Scripts -TEST_PROJECT_DIR = tests/modeStep_tests_project + +TEST_PROJECT_SET_NAMES := backlight default overrides standalone wide_clip_launch +TEST_PROJECT_DIR := tests/modeStep_tests_project +TEST_PROJECT_SETS := $(addprefix $(TEST_PROJECT_DIR)/, $(addsuffix .als, $(TEST_PROJECT_SET_NAMES))) .PHONY: deps deps: __ext__/System_MIDIRemoteScripts/.make.decompile .make.pip-install @@ -19,7 +22,7 @@ check: .make.pip-install __ext__/System_MIDIRemoteScripts/.make.decompile .venv/bin/pyright . .PHONY: test -test: .make.pip-install $(TEST_PROJECT_DIR)/default.als $(TEST_PROJECT_DIR)/overrides.als $(TEST_PROJECT_DIR)/standalone.als $(TEST_PROJECT_DIR)/wide_clip_launch.als +test: .make.pip-install $(TEST_PROJECT_SETS) .venv/bin/pytest .PHONY: img diff --git a/control_surface/__init__.py b/control_surface/__init__.py index add23e1..6cd5cd6 100644 --- a/control_surface/__init__.py +++ b/control_surface/__init__.py @@ -2,6 +2,7 @@ import logging import typing +from contextlib import contextmanager from functools import partial from ableton.v3.base import const, depends, inject, listens, task @@ -44,7 +45,13 @@ from .session import SessionComponent from .session_navigation import SessionNavigationComponent from .session_ring import SessionRingComponent -from .sysex import DEVICE_FAMILY_BYTES, MANUFACTURER_ID_BYTES +from .sysex import ( + DEVICE_FAMILY_BYTES, + MANUFACTURER_ID_BYTES, + SYSEX_BACKLIGHT_OFF_REQUEST, + SYSEX_BACKLIGHT_ON_REQUEST, + SYSEX_STANDALONE_MODE_ON_REQUESTS, +) from .track_controls import TrackControlsComponent, TrackControlsState from .transport import TransportComponent from .types import Action, TrackControl @@ -176,6 +183,14 @@ class Specification(ControlSurfaceSpecification): *DEVICE_FAMILY_BYTES, ) + # Force the controller into standalone mode when exiting (this will be redundant if + # a standalone mode is already active.) The disconnect program change message will + # be appended below, if configured. + goodbye_messages: typing.Collection[ + typing.Tuple[int, ...] + ] = SYSEX_STANDALONE_MODE_ON_REQUESTS + send_goodbye_messages_last = True + component_map = { "Clip_Actions": ClipActionsComponent, "Device": create_device_component, @@ -217,12 +232,29 @@ def __init__(self, specification=Specification, *a, c_instance=None, **k): specification.link_session_ring_to_track_selection = ( self._configuration.link_session_ring_to_track_selection ) - - super().__init__(*a, specification=specification, c_instance=c_instance, **k) + if self._configuration.disconnect_program is not None: + specification.goodbye_messages = [ + *specification.goodbye_messages, + (0xC0, self._configuration.disconnect_program), + ] + if self._configuration.disconnect_backlight is not None: + specification.goodbye_messages = [ + *specification.goodbye_messages, + ( + SYSEX_BACKLIGHT_ON_REQUEST + if self._configuration.disconnect_backlight + else SYSEX_BACKLIGHT_OFF_REQUEST + ), + ] # Internal tracker during connect/reconnect events. self._mode_after_identified = self._configuration.initial_mode + # For hacking around the weird LED behavior when updating the backlight. + self.__is_suppressing_hardware: bool = False + + super().__init__(*a, specification=specification, c_instance=c_instance, **k) + # Dependencies to be injected throughout the application. # # We need the `Any` return type because otherwise the type checker @@ -250,38 +282,29 @@ def _create_elements(self, specification: ControlSurfaceSpecification): # type: return super(modeStep, modeStep)._create_elements(specification) def setup(self): - # Activate the background mode before doing anything. No-op if no background - # program has been set. - self.component_map[ - "Hardware" - ].standalone_program = self._configuration.background_program - self._flush_midi_messages() - - # Put the controller explicitly into hosted mode. This avoids - # the need to modify this attribute in every non-standalone - # control surface mode, which would send unnecessary sysex - # messages on every mode change. - self.component_map["Hardware"].standalone = False - super().setup() - logger.info(f"{self.__class__.__name__} setup complete") + hardware = self.component_map["Hardware"] + # Activate the background program before doing anything. The program change will + # get sent when the controller is placed into `_stanadlone_init_mode`. No-op if + # no background program has been set. + hardware.standalone_program = self._configuration.background_program - def disconnect(self): - # The individual control element `disconnect()` methods will - # be called, which should clear all lights and put the - # controller into standalone mode. The order of MIDI events is - # not guaranteed, but it shouldn't really matter, since LED - # updates will be applied to the current standalone preset - # regardless of whether the controller is in standalone or - # hosted mode. - super().disconnect() - - # Send the final program change message, if any. - if self._configuration.disconnect_program: - self._send_midi( - (0xC0, self._configuration.disconnect_program), optimized=False - ) + # Turn on hosted mode by default, so it doesn't need to be specified explicitly + # in normal (non-standalone) mode layers. + hardware.standalone = False + + # Activate `_disabled` mode, which will enable the hardware controller in its + # `on_leave` callback. + self.main_modes.selected_mode = DISABLED_MODE_NAME + + # Listen for backlight color values, to hack around the weird LED behavior when + # the backlight sysexes get sent. + assert self.__on_backlight_send_value is not None + assert self.elements + self.__on_backlight_send_value.subject = self.elements.backlight_sysex + + logger.info(f"{self.__class__.__name__} setup complete") @property def main_modes(self): @@ -301,15 +324,17 @@ def on_identified(self, response_bytes): if not self._identity_response_timeout_task.is_killed: self._identity_response_timeout_task.kill() - # Don't do anything unless we're currently in disabled - # mode. There's no need to force a controller update. + # We'll reach this point on startup, as well as when MIDI ports change (due to + # hardware disconnects/reconnects or changes in the Live settings). Don't do + # anything unless we're currently in disabled mode, i.e. unless we're + # transitioning from a disconnected controller - there's no need to switch in + # and out of standalone mode otherwise. if ( self.main_modes.selected_mode is None or self.main_modes.selected_mode == DISABLED_MODE_NAME ): - # Force the controller into standalone mode (so we're starting - # from a consistent state), send the standalone background - # program if any, and clear the LEDs and display. + # Force the controller into standalone mode, and send the standalone + # background program, if any. self.main_modes.selected_mode = STANDALONE_INIT_MODE_NAME # After a short delay, load the main desired mode. This @@ -335,13 +360,13 @@ def _on_identity_response_timeout(self): ): self._mode_after_identified = self.main_modes.selected_mode - # Enter disabled mode, which relinquishes control of - # everything. This ensures that nothing will be bound when the - # controller is identified, so we won't send a bunch of LED - # messages before placing it into hosted mode. (This could - # still happen if the controller were connected and - # disconnected quickly, but in any case it would just - # potentially mess with standalone-mode LEDs.) + # Enter disabled mode, which relinquishes control of everything. This ensures + # that sysex state values will be invalidated (by disconnecting their control + # elements), and nothing will be bound when the controller is next identified, + # so we won't send a bunch of LED messages before placing it into hosted + # mode. (This could still happen if the controller were connected and + # disconnected quickly, but in any case it would just potentially mess with + # standalone-mode LEDs.) self.main_modes.selected_mode = DISABLED_MODE_NAME @listens("is_identified") @@ -384,3 +409,62 @@ def _after_identified(self): else self._configuration.initial_mode ) self.main_modes.selected_mode = mode + + # Whenever a backlight sysex is fired, after several seconds, the LEDs revert to the + # initial colors of the most recent standalone preset (even when in hosted + # mode). This appears to be a firmware bug, as the behavior is also reproducible + # when setting the backlight via the SoftStep editor. Work around this by refreshing + # device state a few times after the appropriate wait. + @lazy_attribute + def _backlight_workaround_task(self): + def refresh_state_except_backlight(): + with self.__suppressing_backlight(): + # Clears all send caches and updates all components. + self.update() + + backlight_workaround_task = self._tasks.add( + task.sequence( + task.wait(3.5), + # Keep trying for a bit, sometimes the LEDs blank out later than + # expected. + *[ + task.sequence( + task.run(refresh_state_except_backlight), task.wait(0.2) + ) + for _ in range(10) + ], + ) + ) + backlight_workaround_task.kill() + return backlight_workaround_task + + @listens("send_value") + def __on_backlight_send_value(self, _): + # If actual sends are being suppressed, we don't care about the event. + if not self.__is_suppressing_hardware: + if not self._backlight_workaround_task.is_killed: + self._backlight_workaround_task.kill() + self._backlight_workaround_task.restart() + + # Use this while force-refreshing the LED state after a backlight update. Suppresses + # all messages for the backlight (because that's why we're here in the first place) + # and the standalone/hosted state (because it causes LED flicker and shouldn't be + # necessary to re-send). + @contextmanager + def __suppressing_backlight(self, is_suppressing_backlight=True): + old_suppressing_hardware = self.__is_suppressing_hardware + self.__is_suppressing_hardware = is_suppressing_backlight + + try: + assert self.elements + backlight_sysex = self.elements.backlight_sysex + standalone_sysex = self.elements.standalone_sysex + + with backlight_sysex.deferring_send(), standalone_sysex.deferring_send(): + yield + + # Hack to prevent any updates from actually getting sent. + backlight_sysex._deferred_message = None + standalone_sysex._deferred_message = None + finally: + self.__is_suppressing_hardware = old_suppressing_hardware diff --git a/control_surface/colors.py b/control_surface/colors.py index 3b65b10..91608bd 100644 --- a/control_surface/colors.py +++ b/control_surface/colors.py @@ -225,6 +225,9 @@ class Off: class On: On = TOGGLE_ON + class Unset: + On = TOGGLE_OFF + class Mixer: ArmOn = GREEN_ON ArmOff = RED_ON diff --git a/control_surface/configuration.py b/control_surface/configuration.py index 02d47da..5f21502 100644 --- a/control_surface/configuration.py +++ b/control_surface/configuration.py @@ -61,8 +61,13 @@ class Configuration(NamedTuple): # may override this behavior. auto_arm: bool = False - # Whether to turn on the backlight. Can be toggled from utility mode. - backlight: bool = False + # Backlight on/off state (or `None` to leave it unmanaged) to be set at + # startup. + backlight: Optional[bool] = None + + # Backlight on/off state (or `None` to leave it unmanaged) to be set at + # exit. + disconnect_backlight: Optional[bool] = None # Add a behavior when long pressing a clip (currently just "stop_track_clips" is available). clip_long_press_action: Optional[ClipSlotAction] = None @@ -237,7 +242,8 @@ def get_configuration(song) -> Configuration: ElementOverride = Tuple[str, str, str] ## -# Helpers for overriding elements. Keys are the physical key numbers on the SoftStep. +# Helpers for overriding elements, see above for usage examples. Keys are the +# physical key numbers on the SoftStep. # Override a key with an action. diff --git a/control_surface/elements/sysex.py b/control_surface/elements/sysex.py index 7910b4d..64de22a 100644 --- a/control_surface/elements/sysex.py +++ b/control_surface/elements/sysex.py @@ -1,10 +1,13 @@ -from typing import Collection, Optional, Tuple +import logging +from typing import Collection, Tuple from ableton.v2.control_surface.elements import ButtonElementMixin from ableton.v3.control_surface.elements import ( SysexElement, ) +logger = logging.getLogger(__name__) + class SysexButtonElement(SysexElement, ButtonElementMixin): def is_momentary(self): @@ -15,6 +18,9 @@ def is_momentary(self): # messages for a light, mode, etc. in the hardware. The element accepts True and False # as color values to trigger the on and off messages, respectively. class SysexToggleElement(SysexButtonElement): + # Fires after all the messages for the given on/off value have been sent. + __events__ = ("send_value",) + def __init__( self, on_messages: Collection[Tuple[int, ...]], @@ -26,12 +32,9 @@ def __init__( self._off_messages = off_messages assert len(on_messages) > 0 and len(off_messages) > 0 - # Store the last value to `set_light` to avoid sending unnecessary messages. - self._last_value: Optional[bool] = None - super().__init__( *a, - # Messages just get passed directly to send_value. + # Messages just get passed directly to the parent's send_value. send_message_generator=lambda msg: msg, optimized_send_midi=False, # Prevent mysterious crashes if there's mo @@ -40,19 +43,18 @@ def __init__( **k, ) - def _on_resource_received(self, client, *a, **k): - # Make sure we send our initial message. Since sending sysexes can cause - # momentary performance issues and other weirdness on the device, try to avoid - # disconnecting/reconnecting resources too often. - self._last_value = None - return super()._on_resource_received(client, *a, **k) - - def set_light(self, value): + # This can get called via `set_light`, or from elsewhere within the framework. + def send_value(self, *a, **k): + assert len(a) == 1 + value = a[0] assert isinstance(value, bool) - # Avoid re-sending these on every update. - if value != self._last_value: - messages = self._on_messages if value else self._off_messages - for message in messages: - self.send_value(message) - self._last_value = value + # Send multiple messages by calling the parent repeatedly. + messages = self._on_messages if value else self._off_messages + for message in messages: + super().send_value(message, *a[1:], **k) + + self.notify_send_value(value) + + def set_light(self, value): + self.send_value(value) diff --git a/control_surface/hardware.py b/control_surface/hardware.py index 01008d3..91435d5 100644 --- a/control_surface/hardware.py +++ b/control_surface/hardware.py @@ -9,11 +9,27 @@ logger = logging.getLogger(__name__) +# A sysex which accepts a null color to represent an unmanaged property. +class NullableColorSysexControl(ColorSysexControl): + class State(ColorSysexControl.State): + def __init__(self, color=None, *a, **k): + super().__init__(color, *a, **k) + + # The parent sets this to "DefaultButton.Disabled" by default, override with + # actual `None`. + if color is None: + self.color = color + + def _send_current_color(self): + if self.color is not None: + return super()._send_current_color() + + class HardwareComponent(Component): # These are expected to be mapped to `ToggleSysexElement`s or, # more specifically, to sysex elements which accept boolean values # as colors. - backlight_sysex: ColorSysexControl.State = ColorSysexControl(color=True) # type: ignore + backlight_sysex: ColorSysexControl.State = NullableColorSysexControl(color=None) # type: ignore standalone_sysex: ColorSysexControl.State = ColorSysexControl(color=False) # type: ignore ping_button: Any = ButtonControl() @@ -30,15 +46,16 @@ def __init__( assert send_midi is not None self._send_midi = send_midi + # The program change to send when switching into standalone mode. self._standalone_program: Optional[int] = None - self._backlight: bool = False - # Assume the controller is in standalone mode when it's first - # connected. - self._standalone: bool = True + # Initial values for device properties - these will get set externally when + # actually setting up this component. + self._backlight: Optional[bool] = None # None for unmanaged. + self._standalone: bool = False @property - def backlight(self) -> bool: + def backlight(self) -> Optional[bool]: return self._backlight @backlight.setter @@ -86,7 +103,6 @@ def _update_backlight(self): def _update_standalone(self): if self.is_enabled(): self.standalone_sysex.color = self._standalone - self._update_standalone_program() def _update_standalone_program(self): diff --git a/control_surface/mappings.py b/control_surface/mappings.py index 5d8a3f5..005ad70 100644 --- a/control_surface/mappings.py +++ b/control_surface/mappings.py @@ -41,7 +41,6 @@ MainModesComponent, ModeSelectBehaviour, ReleaseBehaviour, - SetMutableAttributeMode, ToggleModesComponent, get_index_str, get_main_mode_category, @@ -272,9 +271,9 @@ def create(self) -> Mappings: mappings: Mappings = {} mappings["Hardware"] = dict( - # Turn on by default, and shouldn't be toggled except when entering/exiting - # `_disabled` mode, to avoid sending unnecessary sysexes. - enable=True, + # Turned off by default, since we'll be in `_disabled` mode at startup. This + # shouldn't be toggled except when entering/exiting `_disabled` mode. + enable=False, # Permanent hardware mappings. backlight_sysex="backlight_sysex", standalone_sysex="standalone_sysex", @@ -320,7 +319,7 @@ def set_backlight(backlight: bool): # controller to be placed into hosted mode. STANDALONE_INIT_MODE_NAME: { "modes": [ - self._enter_standalone_mode, + self._enter_standalone_mode(None), # TODO: Make sure LEDs are cleared. ] }, @@ -345,9 +344,9 @@ def _get_component(self, name: str) -> Component: assert component return component - # A mode which just enters standalone mode. - @lazy_attribute - def _enter_standalone_mode(self) -> Mode: + # Return a mode which enters standalone mode and activates the given program (if + # any), and returns to the background program (if any) on exit. + def _enter_standalone_mode(self, standalone_program: Optional[int]) -> Mode: hardware = self._get_component("Hardware") def clear_light_caches(): @@ -356,15 +355,33 @@ def clear_light_caches(): for light in elements.lights_raw: light.clear_send_cache() + def set_standalone_program(standalone_program: Optional[int]): + if ( + standalone_program is not None + # Program changes cause blinks and other weirdness on the SoftStep. In + # case e.g. `standalone_program` is the same as the background program, + # we don't need to send both PC messages + and standalone_program != hardware.standalone_program + ): + hardware.standalone_program = standalone_program + + # Make sure the program change gets sent immediately (if the controller + # is currently in standalone mode). Otherwise, the program change can + # get batched with the hosted-mode sysexes and end up firing after them. + self._control_surface._flush_midi_messages() + return CompoundMode( # We don't have control of the LEDs, so make sure everything gets rendered # as we re-enter hosted mode. This also ensures that LED states will all be # rendered on disconnect/reconnect events. CallFunctionMode(on_exit_fn=clear_light_caches), # Set the program attribute before actually switching into standalone mode, - # so that when we leave the compound mode, the attribute doesn't get - # switched to something else before the host-mode messages are sent. - # + # so that we don't send an extra message for whatever program is currently + # active. + CallFunctionMode( + on_enter_fn=partial(set_standalone_program, standalone_program) + ), + SetAttributeMode(hardware, "standalone", True), # The SoftStep seems to keep track of the current LED states for each # standalone preset in the setlist. Whenever a preset is loaded, the Init # source will fire (potentially setting some LED states explicitly), and any @@ -377,12 +394,12 @@ def clear_light_caches(): # # We avoid this by switching to a swap mode (whose LED state we don't care # about) before returning to host mode. - SetAttributeMode( - hardware, - "standalone_program", - self._configuration.background_program, + CallFunctionMode( + on_exit_fn=partial( + set_standalone_program, + self._configuration.background_program, + ) ), - SetAttributeMode(hardware, "standalone", True), ) # Mappings of (mode name, alt mode name) -> element for the mode select screen. @@ -752,18 +769,8 @@ def flush(): CallFunctionMode(on_enter_fn=flush), # Make sure the background has no bindings, so no more LED updates get sent. LayerMode(self._get_component("Background"), Layer()), - # Enter the standalone mode. We should be in the background program at this - # point (or the most recent standalone program if no background program is - # set). - self._enter_standalone_mode, - # On exit, flush the original standalone program before re-enabling any LED stuff. - CallFunctionMode(on_exit_fn=flush), - # Send the actual standalone program for this mode. The modes component may - # send new standalone programs within this mode (when quick switching - # between standalone presets), so make sure to use the mutable mode. - SetMutableAttributeMode( - self._get_component("Hardware"), "standalone_program", index - 1 - ), + # Enter standalone mode and select the program. + self._enter_standalone_mode(index - 1), ] diff --git a/control_surface/mode.py b/control_surface/mode.py index 992e057..4897ca4 100644 --- a/control_surface/mode.py +++ b/control_surface/mode.py @@ -7,7 +7,6 @@ from functools import partial from time import time -from ableton.v2.control_surface.mode import SetAttributeMode from ableton.v3.base import depends, listenable_property, memoize from ableton.v3.control_surface.controls import ButtonControl from ableton.v3.control_surface.mode import ( @@ -77,11 +76,15 @@ def get_index_str(name: str): return name.split("_")[-1] -class SetMutableAttributeMode(SetAttributeMode): - # Set the attribute regardless of whether it's changed. +class InvertedMode(Mode): + def __init__(self, mode: Mode): + self._mode = mode + + def enter_mode(self): + self._mode.leave_mode() + def leave_mode(self): - assert self._attribute - setattr(self._get_object(), self._attribute, self._old_value) + self._mode.enter_mode() # The mode select button: @@ -197,17 +200,6 @@ def update_button(self, component: MainModesComponent, mode, selected_mode): # button.mode_unselected_color = f"{mode_color_base_name}.Off" -class InvertedMode(Mode): - def __init__(self, mode: Mode): - self._mode = mode - - def enter_mode(self): - self._mode.leave_mode() - - def leave_mode(self): - self._mode.enter_mode() - - class MainModesComponent(ModesComponentBase): # We need slightly different behaviour for mode select from the # standalone mode, since we don't want to trigger anything while @@ -344,15 +336,22 @@ def _get_mode_color_base_name(self, mode_name): class ToggleModesComponent(ModesComponentBase): _on_mode = "on" _off_mode = "off" + _unset_mode = "unset" def __init__( self, # Turn the target on or off. This will be called when the component switches - # modes, as well as once immediately during the initial mode select. + # modes, as well as (if an initial state is given) once immediately during + # the initial mode select. set_state: typing.Callable[[bool], typing.Any], *a, # Initial state to set. - initial_state: bool = False, + # + # If `None`, the setter won't be called during + # initializtion, but the cycling position will be set such that the ON state is + # next. For example, to leave the backlight unmanaged at first but allow + # toggling it in real time. + initial_state: typing.Optional[bool] = None, # Don't want long-press behavior for the cycle button. support_momentary_mode_cycling=False, **k, @@ -363,21 +362,39 @@ def __init__( *a, support_momentary_mode_cycling=support_momentary_mode_cycling, **k ) - self._state: bool = initial_state + self._state: typing.Optional[bool] = initial_state def set_and_record_state(state: bool): set_state(state) self._state = state + # ON mode should come first so that it's next in the cycle if no initial state + # was passed. for name, state in ((self._on_mode, True), (self._off_mode, False)): self.add_mode( name, CallFunctionMode(partial(set_and_record_state, state)), ) + # No-op mode that will get skipped in the cycle, to represent an initial state + # of `None`. Note we need to set our `selected_mode` explicitly in the + # constructor (it can't just be `None`), since otherwise the framework's setup + # logic will just select one for us. + self.add_mode(self._unset_mode, Mode()) + self._suppressing_notifications = False with self.suppressing_notifications(): - self.selected_mode = self._on_mode if initial_state else self._off_mode + if initial_state is None: + self.selected_mode = self._unset_mode + else: + self.selected_mode = self._on_mode if initial_state else self._off_mode + + def cycle_mode(self, delta=1): + super().cycle_mode(delta) + + # Skip the null mode in the cycle. + if self.selected_mode == self._unset_mode: + super().cycle_mode(1) # Maybe there's a more built-in way to do this? @contextmanager diff --git a/tests/backlight.feature b/tests/backlight.feature new file mode 100644 index 0000000..23838f9 --- /dev/null +++ b/tests/backlight.feature @@ -0,0 +1,27 @@ +Feature: Backlight management + Scenario Outline: Toggling the backlight when it is unconfigured + Given the set is open + And the SS2 is initialized + + Then the backlight should be + And the display should be "Trns" + + When I press key 0 + And I long-press key 9 + Then the display should be "Util" + And light 6 should be solid + + When I press key 6 + Then light 6 should be solid + And the display should be "" + And the backlight should be + + When I press key 6 + Then light 6 should be solid + And the display should be "" + And the backlight should be + + Examples: + | set_name | initial_state | toggle_state | toggle2_state | toggle_disp | toggle2_disp | initial_color | toggle_color | + | default | unmanaged | on | off | +BaK | -BaK | red | green | + | backlight | on | off | on | -BaK | +BaK | green | red | diff --git a/tests/basics.feature b/tests/basics.feature index 23ef49a..e91bd82 100644 --- a/tests/basics.feature +++ b/tests/basics.feature @@ -57,22 +57,3 @@ Feature: Basic usage When I long-press key 0 Then the display should be "Expr" - - Scenario: Toggling the backlight - Then the backlight should be off - And the display should be "Trns" - - When I press key 0 - And I long-press key 9 - Then the display should be "Util" - And light 6 should be solid red - - When I press key 6 - Then light 6 should be solid green - And the display should be "+BaK" - And the backlight should be on - - When I press key 6 - Then light 6 should be solid red - And the display should be "-BaK" - And the backlight should be off diff --git a/tests/conftest.py b/tests/conftest.py index 9826672..efad187 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -404,10 +404,11 @@ def ioport(): yield ioport -# Relay port to the physical device for visual feedback during tests, if available. +# Cheap thrills - relay the test port to the physical device for visual feedback during +# tests, if available. @fixture def relay_port() -> Generator[Optional[mido.ports.BaseOutput], Never, None]: - port_name = "SSCOM Port 1" + port_name = "SoftStep Control Surface" try: with mido.open_output(port_name) as relay_port: # type: ignore yield relay_port @@ -773,6 +774,11 @@ def should_be_backlight_off(device_state: DeviceState): assert device_state.backlight is False +@then("the backlight should be unmanaged") +def should_be_backlight_unmanaged(device_state: DeviceState): + assert device_state.backlight is None + + @then("the SS2 should be in standalone mode") def should_be_standalone_mode(device_state: DeviceState): assert all([t is True for t in device_state.standalone_toggles]) diff --git a/tests/modeStep_tests_project/create_set.py b/tests/modeStep_tests_project/create_set.py index 7c99e07..2920c42 100644 --- a/tests/modeStep_tests_project/create_set.py +++ b/tests/modeStep_tests_project/create_set.py @@ -25,6 +25,7 @@ def _asdict(self): configurations: Dict[str, Configuration] = { "default": Configuration(), + "backlight": Configuration(backlight=True), "overrides": Configuration( override_elements={ "transport": [ diff --git a/tests/test_backlight.py b/tests/test_backlight.py new file mode 100644 index 0000000..2097f6c --- /dev/null +++ b/tests/test_backlight.py @@ -0,0 +1,3 @@ +from pytest_bdd import scenarios + +scenarios("backlight.feature") diff --git a/tests/test_standalone_modes.py b/tests/test_standalone_modes.py index 4aeda71..d1026e6 100644 --- a/tests/test_standalone_modes.py +++ b/tests/test_standalone_modes.py @@ -70,9 +70,9 @@ def should_enter_standalone_program( # Make sure the device is fully out of standalone mode. assert all([t is False for t in device_state.standalone_toggles]) - # Events that need to happen in order. + # Events that need to happen in order. Note the background program shouldn't get + # sent when transitioning in this direction. remaining_standalone_requests = sysex.SYSEX_STANDALONE_MODE_ON_REQUESTS - received_background_program = False received_main_program = False with _message_queue(device_state) as queue: @@ -82,26 +82,18 @@ def should_enter_standalone_program( message_attrs = message.dict() if message_attrs["type"] == "sysex": - # The background program comes after all sysexes. - assert not received_background_program - remaining_standalone_requests = _dequeue_sysex( message, remaining_standalone_requests ) elif message_attrs["type"] == "program_change": message_program: int = message_attrs["program"] - if message_program == BACKGROUND_PROGRAM: + if message_program == program: # Make sure the controller has already been put into standalone # mode. assert len(remaining_standalone_requests) == 0 - # Assert no duplicates. - assert not received_background_program - received_background_program = True - elif message_program == program: # Make sure we got the background program (and therefore also the # switch to standalone mode) prior to the main one. - assert received_background_program received_main_program = True else: raise RuntimeError(f"received unexpected program change: {message}") @@ -117,7 +109,6 @@ def should_enter_standalone_program( queue.empty() and all(device_state.standalone_toggles) and len(remaining_standalone_requests) == 0 - and received_background_program and received_main_program )