Skip to content

Commit

Permalink
[update]: add tokit cooker integration
Browse files Browse the repository at this point in the history
  • Loading branch information
oooohhoo committed Sep 2, 2024
1 parent b6831fe commit f0422d6
Show file tree
Hide file tree
Showing 24 changed files with 1,589 additions and 0 deletions.
18 changes: 18 additions & 0 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Validate

on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:

jobs:
validate-hacs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- name: HACS validation
uses: "hacs/action@main"
with:
category: "integration"
37 changes: 37 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.2
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
args:
- --safe
- --quiet
files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$
- repo: https://github.com/codespell-project/codespell
rev: v2.2.6
hooks:
- id: codespell
args:
- --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing
- --skip="./.*,*.csv,*.json"
- --quiet-level=2
exclude_types: [csv, json]
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.4.8
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
args:
- --pretty
- --show-error-codes
- --show-error-context
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# TOKIT Smart Rice Cooker for Home Assistant
This is a custom component for home assistant to integrate the TOKIT Smart Rice Cooker.

Currently supported device: `tokit.cooker.tk4001`

Please follow the instructions on [Retrieving the Access Token](https://www.home-assistant.io/integrations/xiaomi_miio/#retrieving-the-access-token) to get the API token to use.

Credits: Thanks to [Rytilahti](https://github.com/rytilahti/python-miio) for all the work.

## Features
* Cooker Status
* Start cooking
* Schedule cooking
* Stop cooking
* Set menu
* Delete menu


## Installation
You can install this custom component via [HACS](https://hacs.xyz/). Search for for 'TOKIT Smart Rice Cooker Integration' at the integration page of HACS. Alternatively, you can install it manually by copying the custom_component folder to your Home Assistant configuration folder.

## Setup
200 changes: 200 additions & 0 deletions custom_components/tokit_miio_cooker/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
from datetime import timedelta
from typing import Any, Optional
from homeassistant import core
from homeassistant.const import Platform
from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_MODEL, CONF_NAME
from .const import COOKER_DEL_MENU, COOKER_SET_MENU, COOKER_START, COOKER_STOP, DOMAIN, SUPPORTED_MODELS
from custom_components.tokit_miio_cooker.const import DOMAIN
from homeassistant.helpers import device_registry, storage
from homeassistant import config_entries
from miio import TokitCooker, DeviceException
from homeassistant.exceptions import PlatformNotReady, ServiceValidationError
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.helpers.debounce import Debouncer
import async_timeout


import logging
_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.SENSOR, Platform.SELECT, Platform.TIME, Platform.BINARY_SENSOR, Platform.SWITCH, Platform.BUTTON]

def bind_services_to_device(hass: core.HomeAssistant):
def get_cooker(call) -> TokitCooker:
device_ids = call.data.get('device_id')
if not device_ids or len(device_ids) > 1:
raise ServiceValidationError("More than 1 device selected.")
device_id = device_ids[0]
device = hass.data[DOMAIN].get("devices",{}).get(device_id)
if not device:
_LOGGER.warning('Call service failed: Device not found for %s', device_id)
return
config_entry_id = list(device.config_entries)[0]
return hass.data[DOMAIN][config_entry_id]["cooker"]

async def cooker_stop(call):
"""Service to stop cooking."""
_LOGGER.debug(f"service data: {call.data}")
device_ids = call.data.get('device_id')
if not device_ids:
raise ServiceValidationError("No tokit devices selected.")

cookers = []
dentry: device_registry.DeviceEntry
for did, dentry in hass.data[DOMAIN].get("devices",{}).items():
if did in device_ids:
config_entry_id = list(dentry.config_entries)[0]
cookers.append(hass.data[DOMAIN][config_entry_id]["cooker"])

if not cookers:
_LOGGER.warning('Call service failed: Device(s) not found for %s', device_ids)
return
for cooker in cookers:
cooker.stop()

async def cooker_start(call):
"""Service to start cooking."""
_LOGGER.warning(call.data)
cooker = get_cooker(call)
cooker.start(
call.data.get("name"),
call.data.get("duration"),
call.data.get("schedule"),
call.data.get("auto_keep_warm"),
)

async def cooker_del_menu(call):
"""Service to delete menu."""
cooker = get_cooker(call)
cooker.delete_menu(call.data["index"])

async def cooker_set_menu(call):
"""Service to set menu."""
cooker = get_cooker(call)
cooker.set_menu(call.data["name"],
call.data["index"],
call.data.get("duration"),
call.data.get("schedule"),
call.data.get("auto_keep_warm"))

hass.services.async_register(DOMAIN, COOKER_STOP, cooker_stop)
hass.services.async_register(DOMAIN, COOKER_START, cooker_start)
hass.services.async_register(DOMAIN, COOKER_DEL_MENU, cooker_del_menu)
hass.services.async_register(DOMAIN, COOKER_SET_MENU, cooker_set_menu)


async def async_setup(hass: core.HomeAssistant, config: dict) -> bool:
hass.data.setdefault(DOMAIN, {})
return True


async def async_setup_entry(hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry):

if "config_entries" in hass.data[DOMAIN]:
hass.data[DOMAIN]["config_entries"][config_entry.entry_id] = config_entry
else:
hass.data[DOMAIN]["config_entries"] = {config_entry.entry_id: config_entry}

host = config_entry.data[CONF_HOST]
token = config_entry.data[CONF_TOKEN]
model = config_entry.data.get(CONF_MODEL)
name = config_entry.options[CONF_NAME]
scan_interval = config_entry.options[CONF_SCAN_INTERVAL]

try:
cooker = TokitCooker(host, token)
device_info = cooker.info()
if model is None:
model = device_info.model
_LOGGER.info(
"%s %s %s detected",
model,
device_info.firmware_version,
device_info.hardware_version,
)
except DeviceException:
raise PlatformNotReady

if model not in SUPPORTED_MODELS:
_LOGGER.error(
f"Unsupported device found: {model}"
)
return False

if not config_entry.entry_id in hass.data[DOMAIN]:
hass.data[DOMAIN][config_entry.entry_id] = {
"device_info": device_info,
"cooker": cooker,
"entities": {}
}
if isinstance(scan_interval, int):
scan_interval = timedelta(seconds=scan_interval)

if "coordinator" in hass.data[DOMAIN][config_entry.entry_id]:
tokit_cooker_coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
tokit_cooker_coordinator.update_interval=scan_interval
else:
tokit_cooker_coordinator = TokitCookerCoordinator(hass, cooker, scan_interval)
hass.data[DOMAIN][config_entry.entry_id]["coordinator"] = tokit_cooker_coordinator

bind_services_to_device(hass)

await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
await tokit_cooker_coordinator.async_config_entry_first_refresh()

config_entry.async_on_unload(config_entry.add_update_listener(options_update_listener))

return True

async def options_update_listener(hass: core.HomeAssistant, entry: config_entries.ConfigEntry):
"""Handle options update."""
# _LOGGER.warning("options_update_listener")
await hass.config_entries.async_reload(entry.entry_id)


async def async_unload_entry(hass: core.HomeAssistant, entry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


class TokitCookerCoordinator(DataUpdateCoordinator):
"""Tokit Cooker coordinator."""

def __init__(self, hass, cooker, scan_interval):
"""Initialize my coordinator."""
super().__init__(
hass,
_LOGGER,
# Name of the data. For logging purposes.
name="Tokit Cooker sensor",
# Polling interval. Will only be polled if there are subscribers.
update_interval=scan_interval,
# Set always_update to `False` if the data returned from the
# api can be compared via `__eq__` to avoid duplicate updates
# being dispatched to listeners
always_update=True,
request_refresh_debouncer=Debouncer(
hass,
_LOGGER,
cooldown=1, # update immediately when turn off or turn on
immediate=True
)
)
self.cooker: TokitCooker = cooker

async def _async_update_data(self):
"""Fetch data.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
async with async_timeout.timeout(10):
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
try:
return self.cooker.status()
except Exception as err:
raise UpdateFailed(f"Error communicating with the cooker: {err}")
55 changes: 55 additions & 0 deletions custom_components/tokit_miio_cooker/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_NAME

from custom_components.tokit_miio_cooker.utils import get_device_info, get_entity_id, get_unique_id
from .const import AUTO_KEEP_WARM, DOMAIN
from homeassistant.core import callback
from miio.deviceinfo import DeviceInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.update_coordinator import CoordinatorEntity

import logging
_LOGGER = logging.getLogger(__name__)

async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities):
"""Set up entry."""

device_name = config_entry.options[CONF_NAME]
device_info = hass.data[DOMAIN][config_entry.entry_id]["device_info"]
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]

auto_keep_warm_sensor = AutoKeepWarmSensor(coordinator, device_info, device_name)

async_add_entities([auto_keep_warm_sensor])

class AutoKeepWarmSensor(CoordinatorEntity, BinarySensorEntity):

def __init__(self, coordinator, device_info, device_name):
"""Initialize binary sensor entity."""
super().__init__(coordinator)
self._attr_has_entity_name = True
self._device_info: DeviceInfo = device_info
self._device_name = device_name
self._attr_icon = "mdi:fire"
self._attr = AUTO_KEEP_WARM
self._attr_is_on = None

self.entity_id = get_entity_id(self._device_info, self._attr, ENTITY_ID_FORMAT)
self._attr_unique_id = get_unique_id(self._device_info, self._attr, ENTITY_ID_FORMAT)

@property
def device_info(self):
return get_device_info(self._device_name, self._device_info)

@property
def translation_key(self):
"""Return the translation key."""
return self._attr

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_is_on = getattr(self.coordinator.data, self._attr)
self.async_write_ha_state()
Loading

0 comments on commit f0422d6

Please sign in to comment.