Skip to content
Open
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
21 changes: 8 additions & 13 deletions .claude/skills/run-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,15 @@ for. Do not widen the shared config.

## 5. What the suite does not cover

The suite covers the protocol layer, `custom_components/gree_custom/aiogree/`.
It does not cover the Home Assistant layer: the entities, the coordinator and
the config flow.

Those need
The suite covers the protocol layer, `custom_components/gree_custom/aiogree/`,
and the config flow, in `tests/config_flow/`. The config flow tests run a real
Home Assistant through
[pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component),
which extracts Home Assistant's own test plugins for custom integrations. It
needs the `enable_custom_integrations` fixture and `asyncio_mode = auto`, which
`pytest.ini` already sets. It is not a dependency of this repository yet,
because it pulls in all of Home Assistant and CI does not need it for the
protocol tests.

So a green run does not mean a change is safe. For anything that touches an
entity, a service or the config flow, also run Home Assistant itself against a
against a fake Gree Cloud, a fake MQTT broker and fake units on loopback.

It does not cover the entities, the coordinator beyond setup and unload, or the
real Gree servers. So a green run does not mean a change is safe. For anything
that touches an entity or a service, also run Home Assistant itself against a
device or a fake one, and say in the pull request which one you did.

## 6. Report
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ Code:

- Match the style of the code around you. Do not reformat lines you did not need to touch.
- The version in `manifest.json` belongs to the maintainers. Do not change it in a normal PR unless asked.
- `tests/` holds a pytest suite for the protocol layer. Run it with `pytest`. A change to `aiogree/` is expected to come with a test. See [docs/development.md](docs/development.md#testing).
- The suite does not cover the Home Assistant entities, the config flow, the cloud or MQTT. For those, test against a real device or a fake one, and say in the PR which one you did.
- `tests/` holds a pytest suite for the protocol layer, and `tests/config_flow/` tests the config flow inside a real Home Assistant, with a fake cloud and a fake MQTT broker. Run both with `pytest`. A change to `aiogree/` or to the config flow is expected to come with a test. See [docs/development.md](docs/development.md#testing).
- The suite does not cover what the entities show, the services or the 4.x migration. For those, test against a real device or a fake one, and say in the PR which one you did.

Writing, for everything you produce in this repo:

Expand Down
134 changes: 108 additions & 26 deletions custom_components/gree_custom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Gree climate integration init."""

# Standard library imports
from collections.abc import Mapping
import json
import logging
from typing import Any
Expand All @@ -13,10 +14,8 @@
CONF_HOST,
CONF_NAME,
CONF_PORT,
CONF_REGION,
CONF_SCAN_INTERVAL,
CONF_TIMEOUT,
CONF_TOKEN,
Platform,
)
from homeassistant.core import HomeAssistant
Expand All @@ -29,7 +28,6 @@
from homeassistant.helpers.typing import ConfigType

from .aiogree.cipher import EncryptionVersion
from .aiogree.cloud_api import GreeRegion
from .aiogree.device import GreeDevice
from .aiogree.errors import GreeConnectionError
from .aiogree.transport_mqtt import GreeMqttTransport
Expand Down Expand Up @@ -67,7 +65,15 @@
ENCRYPTION_VERSION_AUTO,
)
from .coordinator import GreeConfigEntry, GreeCoordinator
from .helpers import get_vrf_controller_mac, reconcile_vrf_controllers, try_find_new_ip
from .helpers import (
async_disconnect_transports,
async_refresh_cloud_token,
create_mqtt_transport,
get_vrf_controller_mac,
is_mqtt_auth_refusal,
reconcile_vrf_controllers,
try_find_new_ip,
)
from .migration import (
async_migrate_legacy_registry,
async_prepare_legacy_migration,
Expand Down Expand Up @@ -131,6 +137,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool
return True

cloud_conf = conf.get(CONF_CLOUD) or {}
# The stored token is refreshed at most once per setup
token_refreshed = False
device_configs: dict[str, Any] = conf[CONF_DEVICES]
coordinators: dict[str, GreeCoordinator] = {}
mqtt_transport: GreeMqttTransport | None = None
Expand Down Expand Up @@ -194,16 +202,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool
if mac_controller_cloud and cloud_conf and not mqtt_transport:
_LOGGER.debug("Creating MQTT transport for %s", cloud_conf[CONF_EMAIL])

userid: int = cloud_conf.get(CONF_UID, 0)
token: str = cloud_conf.get(CONF_TOKEN, "")
region: str = cloud_conf.get(CONF_REGION, "")

if not userid or not token or not region:
raise ConfigEntryAuthFailed("no_account_info")

mqtt_transport = GreeMqttTransport(
user_id=str(userid), token=token, region=GreeRegion(region)
)
mqtt_transport = create_mqtt_transport(cloud_conf)
if mqtt_transport is None:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="no_account_info"
)

device = GreeDevice(
name=name,
Expand All @@ -212,18 +215,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool
user_id=uid,
)

local_transport = (
None if prefer_cloud else local_transports.get(mac_controller_local)
)

try:
await device.bind_with_transport(
preferred_local_version=preferred_local_version,
local_controller_mac=mac_controller_local,
local_transport=None
if prefer_cloud
else local_transports.get(mac_controller_local),
mqtt_controller_mac=mac_controller_cloud,
mqtt_transport=mqtt_transport,
)
try:
await device.bind_with_transport(
preferred_local_version=preferred_local_version,
local_controller_mac=mac_controller_local,
local_transport=local_transport,
mqtt_controller_mac=mac_controller_cloud,
mqtt_transport=mqtt_transport,
)
except MqttConnectError as err:
if token_refreshed or not is_mqtt_auth_refusal(err):
raise

# The stored token is no longer valid, log in again once
token_refreshed = True
cloud_conf = await _async_refresh_entry_token(hass, entry, cloud_conf)
await async_disconnect_transports([mqtt_transport])
mqtt_transport = create_mqtt_transport(cloud_conf)

# The local transport failed already, so only MQTT is retried
await device.bind_with_transport(
preferred_local_version=preferred_local_version,
local_controller_mac=mac_controller_local,
local_transport=None,
mqtt_controller_mac=mac_controller_cloud,
mqtt_transport=mqtt_transport,
)
except ConfigEntryAuthFailed:
raise
except MqttConnectError as err:
raise ConfigEntryAuthFailed("bad_credentials") from err
# The broker's reason, so a user log shows why the login failed
_LOGGER.warning(
"Gree Cloud refused the MQTT connection for entry '%s': %s",
entry.title,
err,
)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="bad_credentials"
) from err
except GreeConnectionError:
if not await try_find_new_ip(hass, device, entry):
_LOGGER.exception("Error setting up device %s", mac)
Expand Down Expand Up @@ -273,9 +307,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool
return True


async def _async_refresh_entry_token(
hass: HomeAssistant, entry: GreeConfigEntry, cloud_conf: Mapping[str, Any]
) -> dict[str, Any]:
"""Log in with the stored account and store the new token in the entry.

The token is stored right after the login, because the login made the
old token invalid. Raises ConfigEntryAuthFailed when the login fails.
"""
try:
new_cloud_conf = await async_refresh_cloud_token(cloud_conf, entry.title)
except Exception as err:
_LOGGER.warning(
"Gree Cloud login with the stored account failed for entry '%s': %s",
entry.title,
err,
)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="bad_credentials"
) from err

# This integration has no update listener, so this does not reload
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_CLOUD: new_cloud_conf}
)
return new_cloud_conf


async def async_unload_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
"""Unload a config entry and close its connections.

Every reload sets up new transports, so the old ones must close here.
The coordinators unbind their devices first. Home Assistant calls their
shutdown again after this, which then does nothing.
"""
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
return False

# An entry without devices sets no runtime data
coordinators: dict[str, GreeCoordinator] = getattr(entry, "runtime_data", {})
transports = [c.device.transport for c in coordinators.values()]

for coordinator in coordinators.values():
try:
await coordinator.async_shutdown()
except Exception:
_LOGGER.exception(
"Error unbinding device %s", coordinator.device.mac_address
)

await async_disconnect_transports(transports)
return True


async def async_remove_config_entry_device(
Expand Down
Loading
Loading