diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index 77cccf945..20e059566 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -43,6 +43,7 @@ RoomsEvent, SafeProtectEvent, StateEvent, + StationErrorEvent, StationEvent, StatsEvent, SweepModeEvent, @@ -242,6 +243,7 @@ class CapabilityStation: auto_empty.Frequency, ] state: CapabilityEvent[StationEvent] + error: CapabilityEvent[StationErrorEvent] | None = None @dataclass(frozen=True, kw_only=True) diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index 5a11cc646..b7068172c 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -28,7 +28,7 @@ PositionsEvent, ) from .network import NetworkInfoEvent -from .station import StationEvent +from .station import StationErrorEvent, StationEvent from .work_mode import WorkMode, WorkModeEvent if TYPE_CHECKING: @@ -58,6 +58,7 @@ "NetworkInfoEvent", "Position", "PositionsEvent", + "StationErrorEvent", "StationEvent", "SweepModeEvent", "WorkMode", diff --git a/deebot_client/events/station.py b/deebot_client/events/station.py index d3135e694..ab40da1f5 100644 --- a/deebot_client/events/station.py +++ b/deebot_client/events/station.py @@ -7,7 +7,7 @@ from .base import Event as _Event -__all__ = ["State", "StationEvent"] +__all__ = ["State", "StationErrorEvent", "StationEvent"] @unique @@ -25,3 +25,16 @@ class StationEvent(_Event): """Base Station Event representation.""" state: State + + +@dataclass(frozen=True) +class StationErrorEvent(_Event): + """Errors reported by the base station, e.g. a water-tank condition. + + ``errors`` holds the raw Ecovacs codes exactly as reported, including the + 0/100 "no error" sentinels; map them via + :data:`deebot_client.const.ERROR_CODES`. An empty tuple means the station + reported an empty error list. + """ + + errors: tuple[int, ...] diff --git a/deebot_client/messages/json/station_state.py b/deebot_client/messages/json/station_state.py index 014dace62..8056e85fe 100644 --- a/deebot_client/messages/json/station_state.py +++ b/deebot_client/messages/json/station_state.py @@ -4,15 +4,43 @@ from typing import TYPE_CHECKING, Any -from deebot_client.events.station import State, StationEvent +from deebot_client.events.station import State, StationErrorEvent, StationEvent from deebot_client.message import HandlingResult, MessageBodyDataDict if TYPE_CHECKING: from deebot_client.event_bus import EventBus +def _parse_error_codes(value: Any) -> tuple[int, ...] | None: + """Return the station error codes, or None when no codes are usable. + + An empty list means "no current errors" and clears a previously reported + fault. None means the error channel is absent, is not a list, or held no + usable code -- the last known fault must be preserved rather than cleared. + """ + if not isinstance(value, list): + return None + + codes: list[int] = [] + for entry in value: + if isinstance(entry, bool): + continue + if isinstance(entry, int): + codes.append(entry) + elif isinstance(entry, str): + try: + codes.append(int(entry)) + except ValueError: + continue + + if value and not codes: + # A non-empty list with nothing usable is "unknown", not "all clear". + return None + return tuple(codes) + + class OnStationState(MessageBodyDataDict): - """On battery message.""" + """On station state message.""" NAME = "onStationState" @@ -27,19 +55,26 @@ def _handle_body_data_dict( # "body":{"data":{"content":{"error":[],"type":0},"state":0},"code":0,"msg":"ok"} - Idle # "body":{"data":{"content":{"error":[],"type":1,"motionState":1},"state":1},"code":0,"msg":"ok"} - Emptying # "body":{"data":{"content":{"error":[],"type":2,"motionState":1},"state":1},"code":0,"msg":"ok"} - Drying mop + content = data.get("content") + if isinstance(content, dict): + # Report station errors first, so an unrecognised activity state + # cannot swallow a water-tank or other station fault. + errors = _parse_error_codes(content.get("error")) + if errors is not None: + event_bus.notify(StationErrorEvent(errors)) if (state := data.get("state")) == 0: reported_state = State.IDLE elif ( state == 1 - and (content := data.get("content")) + and content and content.get("type") == 1 and content.get("motionState") == 1 ): reported_state = State.EMPTYING_DUSTBIN elif ( state == 1 - and (content := data.get("content")) + and content and content.get("type") == 2 and content.get("motionState") == 1 ): diff --git a/tests/commands/json/test_station_state.py b/tests/commands/json/test_station_state.py index 37d13c4c5..09e3b7ed1 100644 --- a/tests/commands/json/test_station_state.py +++ b/tests/commands/json/test_station_state.py @@ -5,7 +5,7 @@ import pytest from deebot_client.commands.json.station_state import GetStationState -from deebot_client.events.station import State, StationEvent +from deebot_client.events.station import State, StationErrorEvent, StationEvent from deebot_client.message import HandlingResult, HandlingState from tests.helpers import get_request_json, get_success_body @@ -34,14 +34,40 @@ async def test_GetStationState( ) ) await assert_command( - GetStationState(), json, (firmware_event, StationEvent(expected)) + GetStationState(), + json, + (firmware_event, StationErrorEvent(()), StationEvent(expected)), + ) + + +async def test_GetStationState_reports_errors() -> None: + """Poll responses surface the station error tuple too.""" + json, firmware_event = get_request_json( + get_success_body({"content": {"error": [301, 314], "type": 0}, "state": 0}) + ) + await assert_command( + GetStationState(), + json, + (firmware_event, StationErrorEvent((301, 314)), StationEvent(State.IDLE)), + ) + + +async def test_GetStationState_without_error_field() -> None: + """A poll without an error key means 'unknown', not 'no errors'.""" + json, firmware_event = get_request_json( + get_success_body({"content": {"type": 0}, "state": 0}) + ) + await assert_command( + GetStationState(), + json, + (firmware_event, StationEvent(State.IDLE)), ) @pytest.mark.parametrize( ("state", "additional_content"), [ - # content missing + # no type or motionState (1, {}), # type present but motionState missing (1, {"type": 2}), @@ -67,6 +93,6 @@ async def test_GetStationState_analyse( await assert_command( GetStationState(), json, - firmware_event, + (firmware_event, StationErrorEvent(())), handling_result=HandlingResult(HandlingState.ANALYSE_LOGGED), ) diff --git a/tests/messages/json/test_station_state.py b/tests/messages/json/test_station_state.py index 9b180e767..82f8eae7d 100644 --- a/tests/messages/json/test_station_state.py +++ b/tests/messages/json/test_station_state.py @@ -5,11 +5,25 @@ import pytest from deebot_client.events import FirmwareEvent -from deebot_client.events.station import State, StationEvent +from deebot_client.events.station import State, StationErrorEvent, StationEvent from deebot_client.message import HandlingState from deebot_client.messages.json.station_state import OnStationState from tests.messages.json import assert_message +_HEADER = { + "pri": 1, + "tzm": 60, + "ts": "1734719921057", + "ver": "0.0.1", + "fwVer": "1.30.0", + "hwVer": "0.1.1", + "wkVer": "0.1.54", +} + + +def _payload(data: dict[str, Any]) -> dict[str, Any]: + return {"header": dict(_HEADER), "body": {"data": data, "code": 0, "msg": "ok"}} + @pytest.mark.parametrize( ("state", "additional_content", "expected"), @@ -25,32 +39,19 @@ def test_onStationState( additional_content: dict[str, Any], expected: State, ) -> None: - data: dict[str, Any] = { - "header": { - "pri": 1, - "tzm": 60, - "ts": "1734719921057", - "ver": "0.0.1", - "fwVer": "1.30.0", - "hwVer": "0.1.1", - "wkVer": "0.1.54", - }, - "body": { - "data": {"content": {"error": [], **additional_content}, "state": state}, - "code": 0, - "msg": "ok", - }, - } + data = _payload({"content": {"error": [], **additional_content}, "state": state}) assert_message( - OnStationState, data, (FirmwareEvent("1.30.0"), StationEvent(expected)) + OnStationState, + data, + (FirmwareEvent("1.30.0"), StationErrorEvent(()), StationEvent(expected)), ) @pytest.mark.parametrize( ("state", "additional_content"), [ - # content missing + # no type or motionState (1, {}), # type present but motionState missing (1, {"type": 2}), @@ -62,27 +63,123 @@ def test_onStationState( ) @pytest.mark.benchmark def test_onStationState_analyse(state: int, additional_content: dict[str, Any]) -> None: - """Cases that should fall through to analyse() (not handled).""" - data: dict[str, Any] = { - "header": { - "pri": 1, - "tzm": 60, - "ts": "1734719921057", - "ver": "0.0.1", - "fwVer": "1.30.0", - "hwVer": "0.1.1", - "wkVer": "0.1.54", - }, - "body": { - "data": {"content": {"error": [], **additional_content}, "state": state}, - "code": 0, - "msg": "ok", - }, - } + """Cases that fall through to analyse() (not handled). + + The error channel is still reported, so an unrecognised state does not + swallow a station fault. + """ + data = _payload({"content": {"error": [], **additional_content}, "state": state}) assert_message( OnStationState, data, - (FirmwareEvent("1.30.0"),), + (FirmwareEvent("1.30.0"), StationErrorEvent(())), expected_state=HandlingState.ANALYSE_LOGGED, ) + + +@pytest.mark.benchmark +def test_onStationState_error_not_a_list() -> None: + """A non-list error field means 'unknown', not 'no errors'.""" + data = _payload({"content": {"error": "301", "type": 0}, "state": 0}) + assert_message( + OnStationState, + data, + (FirmwareEvent("1.30.0"), StationEvent(State.IDLE)), + ) + + +@pytest.mark.benchmark +def test_onStationState_without_content() -> None: + """A frame without a content/error channel must not assert 'no errors'.""" + data = _payload({"state": 0}) + assert_message( + OnStationState, + data, + (FirmwareEvent("1.30.0"), StationEvent(State.IDLE)), + ) + + +@pytest.mark.benchmark +def test_onStationState_without_error_field() -> None: + """Content without an error key means 'unknown', not 'no errors'.""" + data = _payload({"content": {"type": 0}, "state": 0}) + assert_message( + OnStationState, + data, + (FirmwareEvent("1.30.0"), StationEvent(State.IDLE)), + ) + + +@pytest.mark.benchmark +def test_onStationState_string_error_codes() -> None: + """Error codes sent as strings are coerced to ints.""" + data = _payload({"content": {"error": ["301", "302"], "type": 0}, "state": 0}) + assert_message( + OnStationState, + data, + ( + FirmwareEvent("1.30.0"), + StationErrorEvent((301, 302)), + StationEvent(State.IDLE), + ), + ) + + +@pytest.mark.benchmark +def test_onStationState_unrecognised_state_keeps_errors() -> None: + """A valid error tuple survives an unrecognised activity state.""" + data = _payload( + {"content": {"error": [301], "type": 99, "motionState": 1}, "state": 1} + ) + assert_message( + OnStationState, + data, + (FirmwareEvent("1.30.0"), StationErrorEvent((301,))), + expected_state=HandlingState.ANALYSE_LOGGED, + ) + + +@pytest.mark.parametrize("errors", [[301], [301, 314], [305, 318, 323]]) +@pytest.mark.benchmark +def test_onStationState_errors(errors: list[int]) -> None: + """Station error codes are surfaced when the state is known.""" + data = _payload({"content": {"error": errors, "type": 0}, "state": 0}) + assert_message( + OnStationState, + data, + ( + FirmwareEvent("1.30.0"), + StationErrorEvent(tuple(errors)), + StationEvent(State.IDLE), + ), + ) + + +@pytest.mark.parametrize("error", [[True], [3.0], ["x"], [{"code": 301}]]) +@pytest.mark.benchmark +def test_onStationState_unparseable_errors_are_ignored(error: list[Any]) -> None: + """A non-empty but unusable error list is unknown, not 'no errors'.""" + data = _payload({"content": {"error": error, "type": 0}, "state": 0}) + assert_message( + OnStationState, + data, + (FirmwareEvent("1.30.0"), StationEvent(State.IDLE)), + ) + + +@pytest.mark.benchmark +def test_onStationState_mixed_error_entries() -> None: + """Only usable entries are kept; unusable ones are skipped.""" + data = _payload( + {"content": {"error": [True, 301, "302", 3.0], "type": 0}, "state": 0} + ) + assert_message( + OnStationState, + data, + ( + FirmwareEvent("1.30.0"), + StationErrorEvent((301, 302)), + StationEvent(State.IDLE), + ), + ) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 000000000..293f8dc4f --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from deebot_client.capabilities import ( + CapabilityEvent, + CapabilityExecuteTypes, + CapabilitySetTypes, + CapabilityStation, + _get_events, +) +from deebot_client.commands import StationAction +from deebot_client.commands.json.auto_empty import GetAutoEmpty, SetAutoEmpty +from deebot_client.commands.json.station_action import ( + StationAction as StationActionCommand, +) +from deebot_client.commands.json.station_state import GetStationState +from deebot_client.events import ( + AutoEmptyEvent, + StationErrorEvent, + StationEvent, + auto_empty, +) + + +def test_station_error_event_is_refreshable() -> None: + """Registering StationErrorEvent under CapabilityStation wires a refresh.""" + station = CapabilityStation( + action=CapabilityExecuteTypes( + StationActionCommand, + types=(StationAction.EMPTY_DUSTBIN,), + ), + auto_empty=CapabilitySetTypes( + event=AutoEmptyEvent, + get=[GetAutoEmpty()], + set=SetAutoEmpty, + types=(auto_empty.Frequency.AUTO,), + ), + state=CapabilityEvent(StationEvent, [GetStationState()]), + error=CapabilityEvent(StationErrorEvent, [GetStationState()]), + ) + + events = _get_events(station) + + assert events[StationEvent] == [GetStationState()] + assert events[StationErrorEvent] == [GetStationState()]