Search code examples
pythonpyqtpysidepyside6qstatemachine

Emitting a `Signal` to directly transition a `QStateMachine` to another `QState`


I have the following state machine:

enter image description here

I have implemented this in Python via the standard state pattern. This state machine is running in a separate thread from a PySide6 application thread, which is running on the program's main thread. Let's call this state machine class Controller.

On the GUI side, I am using the Qt for Python State Machine Framework. (Note: the linked documentation is for PySide2/PySide5, but I am using Pyside6. For some reason, the same documentation doesn't exist for PySide6.) This makes it very nice to define what happens in the GUI when a state is entered.

What I am looking for is not for the GUI to initiate transitions but for my core state machine Controller running in another thread to control the transitions. Typically, one uses <QState>.addTransition(...) to add a transition at the GUI level, but I want the GUI to simple send commands down to Controller, and then when Controller transitions its state, I want it to emit a Signal that triggers the PySide6 QStateMachine to enter a given state and thus set all the properties appropriate for that state. So in other words, I want the GUI to dispatch commands to the Controller state machine and for the GUI to "listen" for the Controller's state transitions.


So the question is, given a QState, how do I send a signal to it that forces the QStateMachine it is a part of to transition to that state? Or do I need to send a signal to the QStateMachine and provide it a QState to transition to?

Example program:

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import override
from PySide6.QtStateMachine import QState


class Controller:
    def __init__(self, initial_state: IState, states: list[QState]) -> None:
        """Initialize the controller to the given initial state and call the `
        on_entry` method for the state.
        """
        self.__state = initial_state
        self.__state.controller = self
        self.__state.on_entry()
        self.__state.states = states

    def _transition_to(self, new_state: IState) -> None:
        """Transition from the current state to the given new state. This calls
        the `on_exit` method on the current state and the `on_entry` of the
        new state. This method should not be called by any object other than
        concrete implementations of `IState`.
        """
        self.__state.on_exit()
        self.__state = new_state
        self.__state.controller = self
        self.__state.on_entry()

    @property
    def state(self):
        """Get the current state"""
        return self.__state

    def start_camera_exposure(self) -> None:
        self.__state.start_camera_exposure()

    def stop_camera_exposure(self) -> None:
        self.__state.stop_camera_exposure()

    def abort_camera_exposure(self) -> None:
        self.__state.abort_camera_exposure()


class IState(ABC):
    """Serve as an interface between the controller and the explicit, individual states."""

    @property
    def controller(self) -> Controller:
        return self.__controller

    @controller.setter
    def controller(self, controller: Controller) -> None:
        self.__controller = controller

    @property
    def states(self) -> list[QState]:
        return self.__states

    @states.setter
    def states(self, states: list[QState]):
        self.__states = states

    def on_entry(self) -> None:
        """Can be overridden by a state to perform an action when the state is
        being entered, i.e., transitions into. It is not required to be overridden.
        """
        pass

    def on_exit(self) -> None:
        """Can be overridden by a state to perform an action when the state is
        being exited, i.e., transitioned from. It is not required to be overridden.
        """
        pass

    # If a concrete implementation does not handle the called method, i.e., it is an invalid action
    # in the specific state, it is enough to simply call `pass`.

    @abstractmethod
    def start_camera_exposure(self) -> None: ...

    @abstractmethod
    def stop_camera_exposure(self) -> None: ...

    @abstractmethod
    def abort_camera_exposure(self) -> None: ...


class Idle(IState):
    @override
    def on_entry(self):
        # I want to emit a signal here to force a `QStateMachine` to go to state: `self.__states[0]`
        print("Idling ...")

    def start_camera_exposure(self) -> None:
        self.controller._transition_to(CameraExposing())

    def stop_camera_exposure(self) -> None:
        pass

    def abort_camera_exposure(self) -> None:
        pass


class CameraExposing(IState):
    @override
    def on_entry(self) -> None:
        # I want to emit a signal here to force a `QStateMachine` to go to state: `self.__states[1]`
        print("Starting camera exposure ...")

    @override
    def on_exit(self) -> None:
        print("Stopping camera exposure ...")

    def start_camera_exposure(self) -> None:
        pass

    def stop_camera_exposure(self) -> None:
        self.controller._transition_to(SavingCameraImages())

    def abort_camera_exposure(self) -> None:
        self.controller._transition_to(AbortingCameraExposure())


class SavingCameraImages(IState):
    @override
    def on_entry(self) -> None:
        # I want to emit a signal here to force a `QStateMachine` to go to state: `self.__states[2]`
        print("Saving camera images ...")
        self.controller._transition_to(Idle())

    def start_camera_exposure(self) -> None:
        pass

    def stop_camera_exposure(self) -> None:
        pass

    def abort_camera_exposure(self) -> None:
        pass


class AbortingCameraExposure(IState):
    @override
    def on_entry(self) -> None:
        # I want to emit a signal here to force a `QStateMachine` to go to state: `self.__states[3]`
        print("Aborting camera exposure ...")
        self.controller._transition_to(Idle())

    def start_camera_exposure(self) -> None:
        pass

    def stop_camera_exposure(self) -> None:
        pass

    def abort_camera_exposure(self) -> None:
        pass

On the GUI side, I have something like:

from PySide6.QtStateMachine import QState, QStateMachine


class MainWindow(QWidget):
    def __init__(self) -> None:
        super().__init__()

        self.machine = QStateMachine(parent=self)
        self.state_idle = QState(self.machine)
        self.state_camera_exposing = QState(self.machine)
        self.state_saving_camera_images = QState(self.machine)
        self.state_aborting_camera_exposure = QState(self.machine)
        self.machine.setInitialState(self.state_idle)
        self.states = [
            self.state_idle,
            self.state_camera_exposing,
            self.state_saving_camera_images,
            self.state_aborting_camera_exposure,
        ]

        self.initialize()

Solution

  • The only way that I have so far figured out how to do this is by simply creating adding all possible transitions for every transition signal for every state.

    Inside class MainWindow(QWidget), define class variables to represent the various transition signals:

    transition_to_idle = Signal()
    transition_to_camera_exposing = Signal()
    transition_to_saving_camera_images = Signal()
    transition_to_aborting_camera_exposure = Signal()
    

    Then add transitions for each of these signals for each state, transitioning to the state determined by the signal:

    machine = self.machine
    state_idle = self.state_idle
    state_camera_exposing = self.state_camera_exposing
    state_saving_camera_images = self.state_saving_camera_images
    state_aborting_camera_exposure = self.state_aborting_camera_exposure
    
    state_idle.addTransition(self.transition_to_idle, state_idle)
    state_idle.addTransition(self.transition_to_camera_exposing, state_camera_exposing)
    state_idle.addTransition(self.transition_to_saving_camera_images, state_saving_camera_images)
    state_idle.addTransition(self.transition_to_aborting_camera_exposure, state_aborting_camera_exposure)
    
    state_camera_exposing.addTransition(self.transition_to_idle, state_idle)
    state_camera_exposing.addTransition(self.transition_to_camera_exposing, state_camera_exposing)
    state_camera_exposing.addTransition(self.transition_to_saving_camera_images, state_saving_camera_images)
    state_camera_exposing.addTransition(self.transition_to_aborting_camera_exposure, state_aborting_camera_exposure)
    
    state_saving_camera_images.addTransition(self.transition_to_idle, state_idle)
    state_saving_camera_images.addTransition(self.transition_to_camera_exposing, state_camera_exposing)
    state_saving_camera_images.addTransition(self.transition_to_saving_camera_images, state_saving_camera_images)
    state_saving_camera_images.addTransition(self.transition_to_aborting_camera_exposure, state_aborting_camera_exposure)
    
    state_aborting_camera_exposure.addTransition(self.transition_to_idle, state_idle)
    state_aborting_camera_exposure.addTransition(self.transition_to_camera_exposing, state_camera_exposing)
    state_aborting_camera_exposure.addTransition(self.transition_to_saving_camera_images, state_saving_camera_images)
    state_aborting_camera_exposure.addTransition(self.transition_to_aborting_camera_exposure, state_aborting_camera_exposure)
    

    Then in the other thread, signals are emitted with emit:

    transition_to_idle.emit()
    transition_to_camera_exposing.emit()
    transition_to_saving_camera_images.emit()
    transition_to_aborting_camera_exposure.emit()
    

    Edit: To set the transitions more succinctly, a for loop can be used. For example:

    for state in states:
        state.addTransition(self.transition_to_idle, state_idle)
        state.addTransition(self.transition_to_camera_exposing, state_camera_exposing)
        state.addTransition(self.transition_to_saving_camera_images, state_saving_camera_images)
        state.addTransition(self.transition_to_aborting_camera_exposure, state_aborting_camera_exposure)
    

    where states is a list of all the possible states.