Add Zimi Cloud Connect Integration (#129876)

* Give entry unique id with MAC, strings.json tweaks

* Update codeowners

* Add config_flow tests

* Update requirements

* Update homeassistant/components/zimi/__init__.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Store controller reference in entry.runtime_data instead of hass.data

* Add typing

* Removed hass data pop on unload.  (No longer needed when hass data moved for runtime_data)

* Refactor config_flow based on feedback from @zweckj with inline validation, simpler defaults, better description data

* Add Michael to codeowners

* Remove manual debug override in entity

* Populate via_device

* remove empty keys from manifest.json

* Refactor with DataUpdateCoordinator
Device Entities use existing push update method

* set via_device to match zcc identifier

* Changed logger to use debug level

* Define the zimi constants

* Move extraaneous code out from try

* Move __del__ to async_wil_remove_from_hass

* Use zcc device for name

* Print debug if mac mismatch
Add final exception if api is not ready after connect

* Re-work configuration flow:
1. Remove unused CONF_TIMEOUT, CONF_VERBOSITY and CONF_WATCHDOG
2. Move connect() logic out of ZimiCoordinator
3. Add fast connect check during ConfigFlow to check mac matches
4. Use zcc version 3.2.3 with default watchdog time value (and remove this from HA)

* Add error detail to mac mismatch

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/const.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/coordinator.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/coordinator.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/light.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Remove coordinator and move setup to __init__

* Set name in _attr_name

* Use _light directly for status etc; Remove _state and _brightness; SImplify update()

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* No need to delete device, fix return

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Remove non-failing items from try
Abort duplicate configurations

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Move attr change to notify

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/__init__.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Remove superflous defalt

* Update homeassistant/components/zimi/light.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/light.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Move aysnc_connect_to_controller to helpers.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Invert if api

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Added ZimiConfigEntry to type runtime_data correctly.
Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Use _abort_if_unique_id_configured
Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Invert error logic for cleaner flow
Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Add ZimiDimmer class

* Set colour_mode only in ZimiDimmer

* Use device name instead of entity name
Update deviceinfo for zcc
Update deviceinfo for lights
More ZimiDimmer and ZimiLight cleanup

* Update homeassistant/components/zimi/__init__.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/__init__.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Add missing import for CONNECTION_NETWORK_MAC

* @mhannon11 Fixed some minor style changes BUT these tests need re-working now that the config_flow has a second call to the zcc helper to check the API.   The tests as written now fail with connect_fail

* Remove some code from try

* Moved static items from initialiser

* Remove superflous assert when unloading entry

* refactor - move title out of data

* One call to async_add_entities
Update ZimiDimmer to initialise color_modes after calling super()

* Create ZimiEntity base class
(as ToggleEntity)

* Updated test of config_flow

* Move api_mock parameters to test cases

* Much improved tests

* Test for input value mismatch and then recovery of flow

* Import FlowResultType

* Implement Entities event setup correctly

* Initial quality_scale.yml

* Update homeassistant/components/zimi/quality_scale.yml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/manifest.json

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Add link to zcc repo

* Update homeassistant/components/zimi/entity.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/entity.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Removed unecessary f-strings

* Filled in all of the quality scale

* Updated in line with latest documentation improvements

* FIx missing import for Entity

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Simplify logger and throw

* Update homeassistant/components/zimi/helpers.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Re-factor config_flow with multi-stage steps

* Add comments to notify

* Don't set hw_version

* Update homeassistant/components/zimi/light.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/light.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/quality_scale.yml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* mark docs-troubleshooting done

* Update with zcc-helper version supporting PEP 625 sdist rules on PyPi

* Comment re characteristic ID

* Pulls in latest zcc that closes UDP listening port correctly after discovery timeout

* Re-factored config_flow

1. Try discovery and auto-populate
2. Try manual configuration (with optional values for port and mac)

In most cases, auto-discovery does it all.

Discovery will only fail if UDP broadcast is not possible to/from zcc.

* Do not show error message if discovery fails

* Refactor with self.data and async_show_step_finish()

* Update homeassistant/components/zimi/light.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/quality_scale.yml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/quality_scale.yml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/entity.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/light.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* refactor import to use ConfigFlow

* Change status for discovery

* Add dynamic title to config flow

* string

* Revert title from form but add IP:port to static title

* Automatically finish configuration if possible, if not show form

* Use StrEnum instead of Exception class

* Remove MAC from user forms

* Disconnect api before form completion

* Assign to self.mac instead of returning as detail

* Updated test suite

* Update test status

* mark action exemptions todo

* Remove mac related error cases from flow completely

* Remove unused MAC error strings

* Moved error details to logs
Removed _error_tuple
Removed error details

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* rename check_errors

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update zcc-helper and support HA devices via zcc manufacter_info fields

* Partial implementation - Use updated zcc-helper to discover multiple controllers

* Config_flow with support for auto-discovery of one or more zcc or fallback to manual configuration.

* Don't re-connect to api if validate_connection already did

* Make fast=False is used for creation

* Pull in improved zcc_helper version to address data completeness after machine_info implementation

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Import and use ConfigFlowResult

* Latest zcc to fix discovers() return value bug

* Update config_flow.py

* Update homeassistant/components/zimi/manifest.json

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Use latest release version of 3.3 (no changes to rc4)

* Improved sentence casing

* Update strings.json

* Update homeassistant/components/zimi/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Remove superflous logging
Use Zimi network_name as ZCC name
Cleanup device info inputs

* Remove __del__

* Rename arguments

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Move PLATFORMS to init

* Update homeassistant/components/zimi/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove debug at init

* Update homeassistant/components/zimi/helpers.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove _attr_has_entity = False

* More naming changes

* Revised config_flow to use zcc-helper for validation using new zcc-helper version

* Update homeassistant/components/zimi/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Removed commented enum

* s/_entity/_device/g

* Update homeassistant/components/zimi/entity.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/helpers.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Don't log error when raising exception

* Updated tests for new config_flow

* Refactor with new zcc that uses Exception classes to pass errors

* Updated tests for config_flow to use Exceptions

* Device name is based on model

* Device name is None

Maps better to ZCC concept where devices do not have a name but the individual entities have names.

* Fix quality filename

* Bump zcc-helper to 3.4 release version

* Remove name override

* Bump zcc-helper to 3.4.1 with new device_name attribute used to populate devinfo

* Update homeassistant/components/zimi/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add missing transalation picked up by CI

* Update homeassistant/components/zimi/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Bump zcc-helper to only classify light and dimmer controlPointType as lights

* Bump to non dev version of zcc-helper

* Ruff fixes

* Add missing data description for pytest

* Remove confusing comment

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/const.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/const.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/const.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zimi/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* f-strings

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Assert result type, step and errors between each step

* test for duplicate entry

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/zimi/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove duplicate test for discovery failure

* Calculate brightness

* Don't re-raise Exception in helper

* Fix ruff and mypi errors

* Add tests for missing connection exceptions

* Added standard invalid_host and timeout strings

* Explain limitations in discovery.

* Update quality_scale.yaml

* Update quality_scale.yaml

* Removed duplicate strings with reference

---------

Co-authored-by: markhannon <mark.hannon@gmail.com>
Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Michael Hannon
2025-05-04 23:58:32 +10:00
committed by GitHub
parent 9e388f5b13
commit 095318114b
16 changed files with 991 additions and 0 deletions

2
CODEOWNERS generated
View File

@ -1796,6 +1796,8 @@ build.json @home-assistant/supervisor
/tests/components/zeversolar/ @kvanzuijlen
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon
/homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core

View File

@ -0,0 +1,67 @@
"""The zcc integration."""
from __future__ import annotations
import logging
from zcc import ControlPoint, ControlPointError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from .const import DOMAIN
from .helpers import async_connect_to_controller
PLATFORMS = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
type ZimiConfigEntry = ConfigEntry[ControlPoint]
async def async_setup_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool:
"""Connect to Zimi Controller and register device."""
try:
api = await async_connect_to_controller(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
)
except ControlPointError as error:
raise ConfigEntryNotReady(f"Zimi setup failed: {error}") from error
_LOGGER.debug("\n%s", api.describe())
entry.runtime_data = api
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, api.mac)},
manufacturer=api.brand,
name=f"{api.network_name}",
model="Zimi Cloud Connect",
sw_version=api.firmware_version,
connections={(CONNECTION_NETWORK_MAC, api.mac)},
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug("Zimi setup complete")
return True
async def async_unload_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool:
"""Unload a config entry."""
api = entry.runtime_data
api.disconnect()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,172 @@
"""Config flow for zcc integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from zcc import (
ControlPoint,
ControlPointCannotConnectError,
ControlPointConnectionRefusedError,
ControlPointDescription,
ControlPointDiscoveryService,
ControlPointError,
ControlPointInvalidHostError,
ControlPointTimeoutError,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_PORT = 5003
STEP_MANUAL_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
)
SELECTED_HOST_AND_PORT = "selected_host_and_port"
class ZimiConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for zcc."""
api: ControlPoint = None
api_descriptions: list[ControlPointDescription]
data: dict[str, Any]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial auto-discovery step."""
self.data = {}
try:
self.api_descriptions = await ControlPointDiscoveryService().discovers()
except ControlPointError:
# ControlPointError is expected if no zcc are found on LAN
return await self.async_step_manual()
if len(self.api_descriptions) == 1:
self.data[CONF_HOST] = self.api_descriptions[0].host
self.data[CONF_PORT] = self.api_descriptions[0].port
await self.check_connection(self.data[CONF_HOST], self.data[CONF_PORT])
return await self.create_entry()
return await self.async_step_selection()
async def async_step_selection(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle selection of zcc to configure if multiple are discovered."""
errors: dict[str, str] | None = {}
if user_input is not None:
self.data[CONF_HOST] = user_input[SELECTED_HOST_AND_PORT].split(":")[0]
self.data[CONF_PORT] = int(user_input[SELECTED_HOST_AND_PORT].split(":")[1])
errors = await self.check_connection(
self.data[CONF_HOST], self.data[CONF_PORT]
)
if not errors:
return await self.create_entry()
available_options = [
SelectOptionDict(
label=f"{description.host}:{description.port}",
value=f"{description.host}:{description.port}",
)
for description in self.api_descriptions
]
available_schema = vol.Schema(
{
vol.Required(
SELECTED_HOST_AND_PORT, default=available_options[0]["value"]
): SelectSelector(
SelectSelectorConfig(
options=available_options,
mode=SelectSelectorMode.DROPDOWN,
)
)
}
)
return self.async_show_form(
step_id="selection", data_schema=available_schema, errors=errors
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle manual configuration step if needed."""
errors: dict[str, str] | None = {}
if user_input is not None:
self.data = {**self.data, **user_input}
errors = await self.check_connection(
self.data[CONF_HOST], self.data[CONF_PORT]
)
if not errors:
return await self.create_entry()
return self.async_show_form(
step_id="manual",
data_schema=self.add_suggested_values_to_schema(
STEP_MANUAL_DATA_SCHEMA, self.data
),
errors=errors,
)
async def check_connection(self, host: str, port: int) -> dict[str, str] | None:
"""Check connection to zcc.
Stores mac and returns None if successful, otherwise returns error message.
"""
try:
result = await ControlPointDiscoveryService().validate_connection(
self.data[CONF_HOST], self.data[CONF_PORT]
)
except ControlPointInvalidHostError:
return {"base": "invalid_host"}
except ControlPointConnectionRefusedError:
return {"base": "connection_refused"}
except ControlPointCannotConnectError:
return {"base": "cannot_connect"}
except ControlPointTimeoutError:
return {"base": "timeout"}
except Exception:
_LOGGER.exception("Unexpected error")
return {"base": "unknown"}
self.data[CONF_MAC] = format_mac(result.mac)
return None
async def create_entry(self) -> ConfigFlowResult:
"""Create entry for zcc."""
await self.async_set_unique_id(self.data[CONF_MAC])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"ZIMI Controller ({self.data[CONF_HOST]}:{self.data[CONF_PORT]})",
data=self.data,
)

View File

@ -0,0 +1,3 @@
"""Constants for the zcc integration."""
DOMAIN = "zimi"

View File

@ -0,0 +1,66 @@
"""Base entity for zimi integrations."""
from __future__ import annotations
import logging
from zcc import ControlPoint
from zcc.device import ControlPointDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class ZimiEntity(Entity):
"""Representation of a Zimi API entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None:
"""Initialize an HA Entity which is a ZimiDevice."""
self._device = device
self._attr_unique_id = self._device.identifier
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device.manufacture_info.identifier)},
manufacturer=self._device.manufacture_info.manufacturer,
model=self._device.manufacture_info.model,
name=self._device.manufacture_info.name,
hw_version=device.manufacture_info.hwVersion,
sw_version=device.manufacture_info.firmwareVersion,
suggested_area=device.room,
via_device=(DOMAIN, api.mac),
)
self._attr_name = self._device.name.strip()
self._attr_suggested_area = self._device.room
@property
def available(self) -> bool:
"""Return True if Home Assistant is able to read the state and control the underlying device."""
return self._device.is_connected
async def async_added_to_hass(self) -> None:
"""Subscribe to the events."""
await super().async_added_to_hass()
self._device.subscribe(self)
async def async_will_remove_from_hass(self) -> None:
"""Cleanup ZimiLight with removal of notification prior to removal."""
self._device.unsubscribe(self)
await super().async_will_remove_from_hass()
def notify(self, _observable: object) -> None:
"""Receive notification from device that state has changed.
No data is fetched for the notification but schedule_update_ha_state is called.
"""
_LOGGER.debug(
"Received notification() for %s in %s", self._device.name, self._device.room
)
self.schedule_update_ha_state(force_refresh=True)

View File

@ -0,0 +1,38 @@
"""The zcc integration helpers."""
from __future__ import annotations
import logging
from zcc import ControlPoint, ControlPointDescription
from homeassistant.exceptions import ConfigEntryNotReady
_LOGGER = logging.getLogger(__name__)
async def async_connect_to_controller(
host: str, port: int, fast: bool = False
) -> ControlPoint:
"""Connect to Zimi Cloud Controller with defined parameters."""
_LOGGER.debug("Connecting to %s:%d", host, port)
api = ControlPoint(
description=ControlPointDescription(
host=host,
port=port,
)
)
await api.connect(fast=fast)
if api.ready:
_LOGGER.debug("Connected")
if not fast:
api.start_watchdog()
_LOGGER.debug("Started watchdog")
return api
raise ConfigEntryNotReady("Connection failed: not ready")

View File

@ -0,0 +1,103 @@
"""Light platform for zcc integration."""
from __future__ import annotations
import logging
from typing import Any
from zcc import ControlPoint
from zcc.device import ControlPointDevice
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ZimiConfigEntry
from .entity import ZimiEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ZimiConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zimi Light platform."""
api = config_entry.runtime_data
lights: list[ZimiLight | ZimiDimmer] = [
ZimiLight(device, api) for device in api.lights if device.type != "dimmer"
]
lights.extend(
[ZimiDimmer(device, api) for device in api.lights if device.type == "dimmer"]
)
async_add_entities(lights)
class ZimiLight(ZimiEntity, LightEntity):
"""Representation of a Zimi Light."""
def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None:
"""Initialize a ZimiLight."""
super().__init__(device, api)
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {ColorMode.ONOFF}
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._device.is_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on (with optional brightness)."""
_LOGGER.debug(
"Sending turn_on() for %s in %s", self._device.name, self._device.room
)
await self._device.turn_on()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
_LOGGER.debug(
"Sending turn_off() for %s in %s", self._device.name, self._device.room
)
await self._device.turn_off()
class ZimiDimmer(ZimiLight):
"""Zimi Light supporting dimming."""
def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None:
"""Initialize a ZimiDimmer."""
super().__init__(device, api)
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
if self._device.type != "dimmer":
raise ValueError("ZimiDimmer needs a dimmable light")
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on (with optional brightness)."""
brightness = kwargs.get(ATTR_BRIGHTNESS, 255) * 100 / 255
_LOGGER.debug(
"Sending turn_on(brightness=%d) for %s in %s",
brightness,
self._device.name,
self._device.room,
)
await self._device.set_brightness(brightness)
@property
def brightness(self) -> int | None:
"""Return the brightness of the light."""
return round(self._device.brightness * 255 / 100)

View File

@ -0,0 +1,10 @@
{
"domain": "zimi",
"name": "zimi",
"codeowners": ["@markhannon"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zimi",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["zcc-helper==3.5"]
}

View File

@ -0,0 +1,99 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
There are no service actions.
appropriate-polling:
status: done
comment: |
There is no polling of the entities.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency:
status: done
comment: |
https://mark_hannon@bitbucket.org/mark_hannon/zcc.git
docs-actions:
status: exempt
comment: |
There are no service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
config-entry-unloading: done
log-when-unavailable: todo
entity-unavailable: done
action-exceptions: todo
reauthentication-flow:
status: exempt
comment: |
There is no user authentication needed.
parallel-updates:
status: todo
comment: |
Test of parallel updates will be done before setting.
test-coverage: todo
integration-owner: done
docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: |
This integration has no options flow
# Gold
entity-translations: todo
entity-device-class:
status: todo
comment: |
Will set device classes for subsequent entities - not relevant for light.
devices: done
entity-category: todo
entity-disabled-by-default: todo
discovery:
status: todo
comment: >
Discovery is supported for the case where the Zimi Cloud Controller(s) are
connected to a local LAN network. Discover is not supported if the Zimi
Cloud Controller(s) are not connected to the local LAN network.
stale-devices: todo
diagnostics: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices:
status: todo
comment: |
New devices will be automatically added - but only when the zcc connection is re-established.
discovery-update-info:
status: todo
comment: >
Discovery is not supported.
repair-issues: todo
docs-use-cases: todo
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: done
docs-data-update: done
docs-known-limitations: done
docs-examples: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
This integration does not use web sessions.
strict-typing:
status: todo

View File

@ -0,0 +1,46 @@
{
"config": {
"step": {
"user": {
"title": "Zimi - Discover device(s)",
"description": "Discover and auto-configure Zimi Cloud Connect device."
},
"selection": {
"title": "Zimi - Select device",
"description": "Select Zimi Cloud Connect device to configure.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"selected_host_and_port": "Selected ZCC"
},
"data_description": {
"host": "Mandatory - ZCC IP address.",
"port": "Mandatory - ZCC port number (default=5003).",
"selected_host_and_port": "Selected ZCC IP address and port number"
}
},
"manual": {
"title": "Zimi - Configure device",
"description": "Enter details of your Zimi Cloud Connect device.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "[%key:component::zimi::config::step::selection::data_description::host%]",
"port": "[%key:component::zimi::config::step::selection::data_description::port%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"timeout": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"connection_refused": "Connection refused"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -736,6 +736,7 @@ FLOWS = {
"zerproc",
"zeversolar",
"zha",
"zimi",
"zodiac",
"zwave_js",
"zwave_me",

View File

@ -7630,6 +7630,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
"zimi": {
"name": "zimi",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
"zodiac": {
"integration_type": "hub",
"config_flow": true,

3
requirements_all.txt generated
View File

@ -3155,6 +3155,9 @@ zabbix-utils==2.0.2
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zimi
zcc-helper==3.5
# homeassistant.components.zeroconf
zeroconf==0.147.0

View File

@ -2554,6 +2554,9 @@ yt-dlp[default]==2025.03.31
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zimi
zcc-helper==3.5
# homeassistant.components.zeroconf
zeroconf==0.147.0

View File

@ -0,0 +1 @@
"""Tests for the zimi component."""

View File

@ -0,0 +1,371 @@
"""Tests for the zimi config flow."""
from unittest.mock import MagicMock, patch
import pytest
from zcc import (
ControlPointCannotConnectError,
ControlPointConnectionRefusedError,
ControlPointDescription,
ControlPointError,
ControlPointInvalidHostError,
ControlPointTimeoutError,
)
from homeassistant import config_entries
from homeassistant.components.zimi.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.device_registry import format_mac
from tests.common import MockConfigEntry
INPUT_MAC = "aa:bb:cc:dd:ee:ff"
INPUT_MAC_EXTRA = "aa:bb:cc:dd:ee:ee"
INPUT_HOST = "192.168.1.100"
INPUT_HOST_EXTRA = "192.168.1.101"
INPUT_PORT = 5003
INPUT_PORT_EXTRA = 5004
INVALID_INPUT_MAC = "xyz"
MISMATCHED_INPUT_MAC = "aa:bb:cc:dd:ee:ee"
SELECTED_HOST_AND_PORT = "selected_host_and_port"
@pytest.fixture
def discovery_mock():
"""Mock the ControlPointDiscoveryService."""
with patch(
"homeassistant.components.zimi.config_flow.ControlPointDiscoveryService",
autospec=True,
) as mock:
mock.return_value = mock
yield mock
async def test_user_discovery_success(
hass: HomeAssistant,
discovery_mock: MagicMock,
) -> None:
"""Test user form transitions to creation if zcc discovery succeeds."""
discovery_mock.discovers.return_value = [
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT)
]
discovery_mock.return_value.validate_connection.return_value = (
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC)
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"host": INPUT_HOST,
"port": INPUT_PORT,
"mac": format_mac(INPUT_MAC),
}
async def test_user_discovery_success_selection(
hass: HomeAssistant,
discovery_mock: MagicMock,
) -> None:
"""Test user form transitions via selection to creation if zcc discovery succeeds has multiple hosts."""
discovery_mock.discovers.return_value = [
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT),
ControlPointDescription(host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA),
]
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "selection"
assert result["errors"] == {}
discovery_mock.return_value.validate_connection.return_value = (
ControlPointDescription(
host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA, mac=INPUT_MAC_EXTRA
)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
SELECTED_HOST_AND_PORT: f"{INPUT_HOST_EXTRA}:{INPUT_PORT_EXTRA!s}",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"host": INPUT_HOST_EXTRA,
"port": INPUT_PORT_EXTRA,
"mac": format_mac(INPUT_MAC_EXTRA),
}
async def test_user_discovery_duplicates(
hass: HomeAssistant,
discovery_mock: MagicMock,
) -> None:
"""Test that flow is aborted if duplicates are added."""
MockConfigEntry(
domain=DOMAIN,
unique_id=INPUT_MAC,
data={
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
"mac": format_mac(INPUT_MAC),
},
).add_to_hass(hass)
discovery_mock.discovers.return_value = [
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT)
]
discovery_mock.return_value.validate_connection.return_value = (
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC)
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_finish_manual_success(
hass: HomeAssistant,
discovery_mock: MagicMock,
) -> None:
"""Test manual form transitions to creation with valid data."""
discovery_mock.discovers.side_effect = ControlPointError("Discovery failed")
discovery_mock.return_value.validate_connection.return_value = (
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC)
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})"
assert result["data"] == {
"host": INPUT_HOST,
"port": INPUT_PORT,
"mac": format_mac(INPUT_MAC),
}
async def test_manual_cannot_connect(
hass: HomeAssistant,
discovery_mock: MagicMock,
) -> None:
"""Test manual form transitions via cannot_connect to creation."""
discovery_mock.discovers.side_effect = ControlPointError("Discovery failed")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {}
# First attempt fails with CANNOT_CONNECT when attempting to connect
discovery_mock.return_value.validate_connection.side_effect = (
ControlPointCannotConnectError
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {"base": "cannot_connect"}
# Second attempt succeeds
discovery_mock.return_value.validate_connection.side_effect = None
discovery_mock.return_value.validate_connection.return_value = (
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})"
assert result["data"] == {
"host": INPUT_HOST,
"port": INPUT_PORT,
"mac": format_mac(INPUT_MAC),
}
async def test_manual_gethostbyname_error(
hass: HomeAssistant,
discovery_mock: MagicMock,
) -> None:
"""Test manual form transitions via gethostbyname failure to creation."""
discovery_mock.discovers.side_effect = ControlPointError("Discovery failed")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {}
# First attempt fails with name lookup failure when attempting to connect
discovery_mock.return_value.validate_connection.side_effect = (
ControlPointInvalidHostError
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"]
assert result["errors"] == {"base": "invalid_host"}
# Second attempt succeeds
discovery_mock.return_value.validate_connection.side_effect = None
discovery_mock.return_value.validate_connection.return_value = (
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})"
assert result["data"] == {
"host": INPUT_HOST,
"port": INPUT_PORT,
"mac": format_mac(INPUT_MAC),
}
@pytest.mark.parametrize(
("side_effect", "error_expected"),
[
(
ControlPointInvalidHostError,
{"base": "invalid_host"},
),
(
ControlPointConnectionRefusedError,
{"base": "connection_refused"},
),
(
ControlPointCannotConnectError,
{"base": "cannot_connect"},
),
(
ControlPointTimeoutError,
{"base": "timeout"},
),
(
Exception,
{"base": "unknown"},
),
],
)
async def test_manual_connection_errors(
hass: HomeAssistant,
discovery_mock: MagicMock,
side_effect: Exception,
error_expected: dict,
) -> None:
"""Test manual form connection errors."""
discovery_mock.discovers.side_effect = ControlPointError("Discovery failed")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {}
# First attempt fails with connection errors
discovery_mock.return_value.validate_connection.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == error_expected
# Second attempt succeeds
discovery_mock.return_value.validate_connection.side_effect = None
discovery_mock.return_value.validate_connection.return_value = (
ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: INPUT_HOST,
CONF_PORT: INPUT_PORT,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})"
assert result["data"] == {
"host": INPUT_HOST,
"port": INPUT_PORT,
"mac": format_mac(INPUT_MAC),
}