fgmacedo / python-statemachine

Python Finite State Machines made easy.
MIT License
854 stars 84 forks source link

FRQ: special state for error handling #455

Open AlexMKX opened 2 months ago

AlexMKX commented 2 months ago

Description

Sometime action can't be completed and we need sort of cleanup code, which can be run after any action. In my case (for irrigation automation) : confirm the valve is closed and closed it if its not.

Idea to solve: Ability to issue special "from any state" transition to the cleanup handling final state. Specify the error handler in FSM configuration. Specify which states can be handled by the handler.

The current workaround

# the decorator to enter functions which could be handled
def catch_errors(handler=None):
    def actual_decorator(func):
        @wraps(func)
        async def _wrapper(*args, **kwargs):
            try:
                return await func(*args, **kwargs)
            except Exception as e:
                if handler:
                    return await handler(*args, **kwargs)
                return None

        return _wrapper

    return actual_decorator

class WorkItem(StateMachine):
    created = State('created', initial=True)
    open = State('open')
    opened = State('opened')
    close = State('close')
    closed = State('closed', final=True)
    error = State('error', final=True)
    do_work = (created.to(open) |
               open.to(opened, cond="is_open") |
               opened.to(close, cond="can_close") |
               close.to(closed, cond="is_closed"))

# the error transition map
    do_error = (created.to(error) | open.to(error) | opened.to(error) | close.to(error))

# this will be sent if error (e.g. exception in enter handler occur)
    async def on_error(self, *args, **kwargs):
        await self.async_send("do_error")

# the decorated handler
    @catch_errors(handler=on_error)
    async def on_enter_created(self):
        self._app.log(f"Entering 'created' state.")
        if not self.is_closed():
            async with asyncio.timeout(30):
                self._app.error(f"Valve {self.zone.valve} is already open, closing it")
                # this will throw exception
                await self._app.call_service('homeassistant/turn_off1', entity_id=self.zone.valve)

# cleanup handler
    async def on_enter_error(self):
        self._app.error(f"Error in {self.zone.valve} {self.zone.moisture}")
        try:
            async with asyncio.timeout(60):
                self._app.log(f"Closing valve {self.zone.valve} because of error")
                await self._app.call_service('homeassistant/turn_off', entity_id=self.zone.valve)
                while not self.is_closed():
                    await asyncio.sleep(1)
        finally:
            self._app.error(
                f"Error in {self.zone.valve} {self.zone.moisture} the valve status is {self.zone.get_valve_state()}")
fgmacedo commented 6 days ago

Hi @AlexMKX , how are you? Thanks for your suggestion. I think that in the end this relates to https://github.com/fgmacedo/python-statemachine/issues/386, as an alternative implementation solving the same issue.

Do you believe that some kind of a "finalize event handler" may solve your issue as well?