Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dishwasher support #26

Merged
merged 2 commits into from
Oct 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion custom_components/candy/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)

Expand Down
65 changes: 65 additions & 0 deletions custom_components/candy/client/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions custom_components/candy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
81 changes: 80 additions & 1 deletion custom_components/candy/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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"
21 changes: 21 additions & 0 deletions tests/fixtures/dishwasher/idle.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions tests/fixtures/dishwasher/wash.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
102 changes: 102 additions & 0 deletions tests/test_sensor_dishwasher.py
Original file line number Diff line number Diff line change
@@ -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