From 2b3305c45d56e1c264b7b1c247c06809c0c55667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olive=CC=81r=20Falvai?= Date: Wed, 13 Oct 2021 20:35:32 +0200 Subject: [PATCH 1/2] Dishwasher support --- custom_components/candy/client/__init__.py | 4 +- custom_components/candy/client/model.py | 65 +++++++++++++ custom_components/candy/const.py | 3 + custom_components/candy/sensor.py | 81 +++++++++++++++- tests/fixtures/dishwasher/idle.json | 21 +++++ tests/fixtures/dishwasher/wash.json | 21 +++++ tests/test_sensor_dishwasher.py | 102 +++++++++++++++++++++ 7 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/dishwasher/idle.json create mode 100644 tests/fixtures/dishwasher/wash.json create mode 100644 tests/test_sensor_dishwasher.py diff --git a/custom_components/candy/client/__init__.py b/custom_components/candy/client/__init__.py index 5617265..cd75d60 100644 --- a/custom_components/candy/client/__init__.py +++ b/custom_components/candy/client/__init__.py @@ -7,7 +7,7 @@ import backoff from aiohttp import ClientSession -from .model import WashingMachineStatus, TumbleDryerStatus, OvenStatus +from .model import WashingMachineStatus, TumbleDryerStatus, DishwasherStatus, OvenStatus _LOGGER = logging.getLogger(__name__) @@ -44,6 +44,8 @@ async def status(self) -> Union[WashingMachineStatus, TumbleDryerStatus]: status = WashingMachineStatus.from_json(resp_json["statusLavatrice"]) elif "statusForno" in resp_json: status = OvenStatus.from_json(resp_json["statusForno"]) + elif "statusDWash" in resp_json: + status = DishwasherStatus.from_json(resp_json["statusDWash"]) else: raise Exception("Unable to detect machine type from API response", resp_json) diff --git a/custom_components/candy/client/model.py b/custom_components/candy/client/model.py index 2bb077a..2ce32e8 100644 --- a/custom_components/candy/client/model.py +++ b/custom_components/candy/client/model.py @@ -144,6 +144,71 @@ def from_json(cls, json): ) +class DishwasherState(Enum): + """ + Dishwashers have a single state combining the machine state and program state + """ + + IDLE = 0 + PRE_WASH = 1 + WASH = 2 + RINSE = 3 + DRYING = 4 + FINISHED = 5 + + def __str__(self): + if self == DishwasherState.IDLE: + return "Idle" + elif self == DishwasherState.PRE_WASH: + return "Pre-wash" + elif self == DishwasherState.WASH: + return "Wash" + elif self == DishwasherState.RINSE: + return "Rinse" + elif self == DishwasherState.DRYING: + return "Drying" + elif self == DishwasherState.FINISHED: + return "Finished" + else: + return "%s" % self + + +@dataclass +class DishwasherStatus: + machine_state: DishwasherState + program: str + remaining_minutes: int + door_open: bool + eco_mode: bool + remote_control: bool + + @classmethod + def from_json(cls, json): + return cls( + machine_state=DishwasherState(int(json["StatoDWash"])), + program=DishwasherStatus.parse_program(json), + remaining_minutes=int(json["RemTime"]), + door_open=json["OpenDoor"] != "0", + eco_mode=json["Eco"] != "0", + remote_control=json["StatoWiFi"] == "1" + ) + + @staticmethod + def parse_program(json) -> str: + """ + Parse final program label, like P1, P1+, P1- + """ + program = json["Program"] + option = json["OpzProg"] + if option == "p": + return program + "+" + elif option == "m": + return program + "-" + else: + # Third OpzProg value is 0 + return program + + class OvenState(Enum): IDLE = 0 HEATING = 1 diff --git a/custom_components/candy/const.py b/custom_components/candy/const.py index fcd7d71..8cc71ca 100644 --- a/custom_components/candy/const.py +++ b/custom_components/candy/const.py @@ -17,10 +17,13 @@ UNIQUE_ID_OVEN = "{0}-oven" UNIQUE_ID_OVEN_TEMP = "{0}-oven-temp" +UNIQUE_ID_DISHWASHER = "{0}-dishwasher" +UNIQUE_ID_DISHWASHER_REMAINING_TIME = "{0}-dishwasher_remaining_time" DEVICE_NAME_WASHING_MACHINE = "Washing machine" DEVICE_NAME_TUMBLE_DRYER = "Tumble dryer" DEVICE_NAME_OVEN = "Oven" +DEVICE_NAME_DISHWASHER = "Dishwasher" SUGGESTED_AREA_BATHROOM = "Bathroom" SUGGESTED_AREA_KITCHEN = "Kitchen" diff --git a/custom_components/candy/sensor.py b/custom_components/candy/sensor.py index 862bc00..b9155a3 100644 --- a/custom_components/candy/sensor.py +++ b/custom_components/candy/sensor.py @@ -3,7 +3,7 @@ from homeassistant.helpers.typing import StateType from .client import WashingMachineStatus -from .client.model import MachineState, TumbleDryerStatus, OvenStatus +from .client.model import MachineState, TumbleDryerStatus, OvenStatus, DishwasherStatus, DishwasherState from .const import * from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -36,6 +36,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn CandyOvenSensor(coordinator, config_id), CandyOvenTempSensor(coordinator, config_id) ]) + elif type(coordinator.data) is DishwasherStatus: + async_add_entities([ + CandyDishwasherSensor(coordinator, config_id), + CandyDishwasherRemainingTimeSensor(coordinator, config_id) + ]) else: raise Exception(f"Unable to determine machine type: {coordinator.data}") @@ -338,3 +343,77 @@ def unit_of_measurement(self) -> str: @property def icon(self) -> str: return "mdi:thermometer" + + +class CandyDishwasherSensor(CandyBaseSensor): + + def device_name(self) -> str: + return DEVICE_NAME_DISHWASHER + + def suggested_area(self) -> str: + return SUGGESTED_AREA_KITCHEN + + @property + def name(self) -> str: + return self.device_name() + + @property + def unique_id(self) -> str: + return UNIQUE_ID_DISHWASHER.format(self.config_id) + + @property + def state(self) -> StateType: + status: DishwasherStatus = self.coordinator.data + return str(status.machine_state) + + @property + def icon(self) -> str: + return "mdi:glass-wine" + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + status: DishwasherStatus = self.coordinator.data + + attributes = { + "program": status.program, + "remaining_minutes": 0 if status.machine_state in + [DishwasherState.IDLE, DishwasherState.FINISHED] else status.remaining_minutes, + "remote_control": status.remote_control, + "door_open": status.door_open, + "eco_mode": status.eco_mode, + } + + return attributes + + +class CandyDishwasherRemainingTimeSensor(CandyBaseSensor): + + def device_name(self) -> str: + return DEVICE_NAME_DISHWASHER + + def suggested_area(self) -> str: + return SUGGESTED_AREA_KITCHEN + + @property + def name(self) -> str: + return "Dishwasher remaining time" + + @property + def unique_id(self) -> str: + return UNIQUE_ID_DISHWASHER_REMAINING_TIME.format(self.config_id) + + @property + def state(self) -> StateType: + status: DishwasherStatus = self.coordinator.data + if status.machine_state in [DishwasherState.IDLE, DishwasherState.FINISHED]: + return 0 + else: + return status.remaining_minutes + + @property + def unit_of_measurement(self) -> str: + return TIME_MINUTES + + @property + def icon(self) -> str: + return "mdi:progress-clock" diff --git a/tests/fixtures/dishwasher/idle.json b/tests/fixtures/dishwasher/idle.json new file mode 100644 index 0000000..89636d2 --- /dev/null +++ b/tests/fixtures/dishwasher/idle.json @@ -0,0 +1,21 @@ +{ + "statusDWash": { + "StatoWiFi": "1", + "StatoDWash": "0", + "CodiceErrore": "E0", + "StartStop": "0", + "Program": "P1", + "OpzProg": "p", + "DelayStart": "0", + "RemTime": "12", + "TreinUno": "0", + "Eco": "0", + "MetaCarico": "0", + "ExtraDry": "0", + "MissSalt": "0", + "MissRinse": "0", + "OpenDoor": "0", + "Reset": "0", + "FWver": "L1.12" + } +} \ No newline at end of file diff --git a/tests/fixtures/dishwasher/wash.json b/tests/fixtures/dishwasher/wash.json new file mode 100644 index 0000000..19b658a --- /dev/null +++ b/tests/fixtures/dishwasher/wash.json @@ -0,0 +1,21 @@ +{ + "statusDWash": { + "StatoWiFi": "0", + "StatoDWash": "2", + "CodiceErrore": "E0", + "StartStop": "0", + "Program": "P2", + "OpzProg": "m", + "DelayStart": "0", + "RemTime": "68", + "TreinUno": "0", + "Eco": "1", + "MetaCarico": "0", + "ExtraDry": "0", + "MissSalt": "0", + "MissRinse": "0", + "OpenDoor": "0", + "Reset": "0", + "FWver": "L1.12" + } +} \ No newline at end of file diff --git a/tests/test_sensor_dishwasher.py b/tests/test_sensor_dishwasher.py new file mode 100644 index 0000000..d2b80ad --- /dev/null +++ b/tests/test_sensor_dishwasher.py @@ -0,0 +1,102 @@ +"""Tests for various sensors""" +from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture +from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry, device_registry + +from .common import init_integration + + +async def test_main_sensor_idle(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + await init_integration(hass, aioclient_mock, load_fixture("dishwasher/idle.json")) + + state = hass.states.get("sensor.dishwasher") + + assert state + assert state.state == "Idle" + assert state.attributes == { + "program": "P1+", + "remaining_minutes": 0, + "eco_mode": False, + "door_open": False, + "remote_control": True, + "friendly_name": "Dishwasher", + "icon": "mdi:glass-wine" + } + + +async def test_main_sensor_wash(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + await init_integration(hass, aioclient_mock, load_fixture("dishwasher/wash.json")) + + state = hass.states.get("sensor.dishwasher") + + assert state + assert state.state == "Wash" + assert state.attributes == { + "program": "P2-", + "remaining_minutes": 68, + "eco_mode": True, + "door_open": False, + "remote_control": False, + "friendly_name": "Dishwasher", + "icon": "mdi:glass-wine" + } + + +async def test_remaining_time_sensor_idle(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + await init_integration(hass, aioclient_mock, load_fixture("dishwasher/idle.json")) + + state = hass.states.get("sensor.dishwasher_remaining_time") + + assert state + assert state.state == "0" + assert state.attributes == { + "friendly_name": "Dishwasher remaining time", + "icon": "mdi:progress-clock", + "unit_of_measurement": "min", + } + + +async def test_remaining_time_sensor_wash(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + await init_integration(hass, aioclient_mock, load_fixture("dishwasher/wash.json")) + + state = hass.states.get("sensor.dishwasher_remaining_time") + + assert state + assert state.state == "68" + assert state.attributes == { + "friendly_name": "Dishwasher remaining time", + "icon": "mdi:progress-clock", + "unit_of_measurement": "min", + } + + +async def test_main_sensor_device_info(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + await init_integration(hass, aioclient_mock, load_fixture("dishwasher/idle.json")) + + er = entity_registry.async_get(hass) + dr = device_registry.async_get(hass) + entry = er.async_get("sensor.dishwasher") + device = dr.async_get(entry.device_id) + + assert device + assert device.manufacturer == "Candy" + assert device.name == "Dishwasher" + assert device.suggested_area == "Kitchen" + + +async def test_sensors_device_info(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + await init_integration(hass, aioclient_mock, load_fixture("dishwasher/idle.json")) + + er = entity_registry.async_get(hass) + dr = device_registry.async_get(hass) + + main_sensor = er.async_get("sensor.dishwasher") + time_sensor = er.async_get("sensor.dishwasher_remaining_time") + + main_device = dr.async_get(main_sensor.device_id) + time_device = dr.async_get(time_sensor.device_id) + + assert main_device + assert time_device + assert main_device == time_device From 3ed98e73d7e1b7451255042f525b951ec86d90e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olive=CC=81r=20Falvai?= Date: Wed, 13 Oct 2021 20:37:28 +0200 Subject: [PATCH 2/2] Update README with dishwasher --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f131428..abc990b 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,13 @@ This is still work-in-progress, it may not support every appliance type or featu ## Features -- Supported appliances: washing machine, tumble dryer, oven +- Supported appliances: + - washing machine + - tumble dryer + - oven + - dishwasher - Uses the local API and its status endpoint -- Displays the machine status, wash cycle status, remaining time and some other attributes +- Creates various sensors, such as overall state and remaining time. Everything else is exposed as sensor attributes ## Installation