Compare commits

...

26 Commits

Author SHA1 Message Date
Paul Bottein 75ec9a9058 Publish numeric sensor device classes as generated sensor.json 2026-06-15 18:14:30 +02:00
Erik Montnemery 2434341e04 Queue nested firing of events (#173519) 2026-06-15 17:27:16 +02:00
Franck Nijhof 047edc035d Skip literal_eval for template results that cannot be a Python literal (#173664)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-15 17:24:29 +02:00
epenet 8b5f27e016 Optimize module parsing in pylint imports checker (#173077) 2026-06-15 17:12:02 +02:00
Markus Adrario 5200a8131f Homee: QS examples done (#173543) 2026-06-15 17:11:02 +02:00
Manu 2dc1870ecd Add notify entities to SMTP integration (#173557) 2026-06-15 16:32:24 +02:00
Josef Zweck d8f125dfe9 Add connectivity binary sensor to opendisplay (#172539) 2026-06-15 16:29:41 +02:00
Paulus Schoutsen 311cd56c93 Expose on-disk file path when resolving TTS media source (#172884)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 09:25:56 -05:00
Erwin Douna 4b17e3abcb MELCloud Home add diagnostics platform (#173583) 2026-06-15 16:24:03 +02:00
Martin Hjelmare f2839bbf7a Add util.dt.naive_now (#173443) 2026-06-15 16:22:42 +02:00
Mick Vleeshouwer 0229545184 Fix Atlantic DHW Production V2 CE FLAT C2 water heater controls in Overkiz (#172823) 2026-06-15 16:21:28 +02:00
bkobus-bbx e8ce995560 Add DHCP discovery support to BleBox integration (#173498) 2026-06-15 16:20:55 +02:00
johanzander 46ffb3bd95 Fix Growatt total_output_power 1000x too low with V1 API (#172474)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 16:19:44 +02:00
Åke Strandberg 27677a07a6 Aqvify reaches silver tier on quality scale (#173618) 2026-06-15 16:12:44 +02:00
Tim Laing f619ccca4b Feature/icloud media browser (#162001)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
2026-06-15 16:11:58 +02:00
Christian Lackas 09a72ac505 homematicip_cloud: harden post-reconnect state recovery using 2.9.0 diagnostics (#169526) 2026-06-15 16:08:40 +02:00
BrettLynch123 27573c5231 Fix daikin setup_error on transient DaikinException during startup (#173660) 2026-06-15 16:05:06 +02:00
Franck Nijhof d5f23fffa8 Bump pyvizio to 0.1.64 (#173859)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-15 16:04:18 +02:00
Paul van Schayck 3b70ac987d Migrate unifi_direct from DeviceScanner to ScannerEntity and add ConfigFlow (#171991) 2026-06-15 16:02:12 +02:00
epenet e00b8f154e Add pylint checker for direct calls to component.async_unload_entry (#173870) 2026-06-15 15:49:30 +02:00
Joost Lekkerkerker abc751fd1c Update agents to avoid useless comments (#173523)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-06-15 14:48:02 +01:00
Franck Nijhof 6b5c7ec864 Bump pyHik to 0.4.3 (#173889) 2026-06-15 15:47:48 +02:00
Jakub Brzezowski d63bb48040 Add integration for Greencell HabuDen EVSE (#145302) 2026-06-15 15:43:53 +02:00
Åke Strandberg b71b155ffb Automatic delete of stale devices for aqvify (#173496) 2026-06-15 15:39:33 +02:00
Franck Nijhof 0f59a6070f Include spoken language in Google Generative AI STT prompt (#173631) 2026-06-15 15:39:15 +02:00
Robert Resch bb34887983 Fix aw check requirements (#173893) 2026-06-15 15:35:49 +02:00
131 changed files with 7745 additions and 596 deletions
+3 -1
View File
@@ -6,6 +6,7 @@
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
# GitHub Copilot & Claude Code Instructions
@@ -50,4 +51,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
+1 -1
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e2211304306ff1e9b9a5b6fd1dd16eb989688e2456f8320340108d7aa5520a5b","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# ___ _ _
# / _ \ | | (_)
+1 -1
View File
@@ -102,7 +102,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
+2 -1
View File
@@ -40,4 +40,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
Generated
+3
View File
@@ -695,6 +695,8 @@ CLAUDE.md @home-assistant/core
/tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greencell/ @BrzezowskiGC
/tests/components/greencell/ @BrzezowskiGC
/homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
@@ -1890,6 +1892,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/tests/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifi_discovery/ @RaHehl
/tests/components/unifi_discovery/ @RaHehl
/homeassistant/components/unifiled/ @florisvdk
+19 -2
View File
@@ -12,6 +12,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -49,6 +50,7 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
self.previous_devices: set[str] = set()
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -102,10 +104,25 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
},
) from err
current_devices = set(devices.devices.keys())
if stale_devices := self.previous_devices - current_devices:
account_id = self.config_entry.unique_id
device_registry = dr.async_get(self.hass)
for device_id in stale_devices:
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{account_id}_{device_id}")}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_devices = current_devices
device_data = {}
for device in devices.devices.values():
for aqvify_device in devices.devices.values():
try:
device_key = str(device.device_key)
device_key = str(aqvify_device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyaqvify==0.0.9"]
}
@@ -29,16 +29,28 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
docs-configuration-parameters:
status: exempt
comment: |
There are no configuration options.
docs-installation-parameters: done
entity-unavailable:
status: done
comment: |
Handled by coordinator.
integration-owner: done
log-when-unavailable:
status: done
comment: |
Handled by coordinator.
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
reauthentication-flow: done
test-coverage: done
# Gold
devices: todo
+43 -23
View File
@@ -17,6 +17,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import get_maybe_authenticated_session
@@ -75,6 +76,21 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_box_from_host_or_abort(
self, api_host: ApiHost
) -> Box | ConfigFlowResult:
"""Try to connect to the device; return product or an abort result."""
try:
return await Box.async_from_host(api_host)
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except Error:
return self.async_abort(reason="cannot_connect")
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
@@ -101,45 +117,50 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
hass = self.hass
ipaddress = (discovery_info.host, discovery_info.port)
self.device_config["host"] = discovery_info.host
self.device_config["port"] = discovery_info.port
websession = async_get_clientsession(hass)
async def _async_handle_discovery(self, host: str, port: int) -> ConfigFlowResult:
"""Handle discovery by IP and port; probe device then confirm with the user."""
self.device_config["host"] = host
self.device_config["port"] = port
websession = async_get_clientsession(self.hass)
api_host = ApiHost(
*ipaddress, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
try:
product = await Box.async_from_host(api_host)
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
result = await self._async_box_from_host_or_abort(api_host)
if not isinstance(result, Box):
return result
product = result
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self.context.update(
{
"title_placeholders": {
"name": self.device_config["name"],
"host": self.device_config["host"],
"host": host,
},
"configuration_url": f"http://{discovery_info.host}",
"configuration_url": f"http://{host}",
}
)
return await self.async_step_confirm_discovery()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
return await self._async_handle_discovery(discovery_info.ip, DEFAULT_PORT)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
return await self._async_handle_discovery(
discovery_info.host, discovery_info.port or DEFAULT_PORT
)
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -158,7 +179,6 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={
"name": self.device_config["name"],
"host": self.device_config["host"],
"port": self.device_config["port"],
},
)
@@ -3,6 +3,45 @@
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"config_flow": true,
"dhcp": [
{ "hostname": "rollergate*" },
{ "hostname": "gatebox*" },
{ "hostname": "doorbox*" },
{ "hostname": "shutterbox*" },
{ "hostname": "switchbox*" },
{ "hostname": "dimmerbox*" },
{ "hostname": "dacbox*" },
{ "hostname": "wlightbox*" },
{ "hostname": "pixelbox*" },
{ "hostname": "saunabox*" },
{ "hostname": "thermobox*" },
{ "hostname": "tempsensor*" },
{ "hostname": "energymeter*" },
{ "hostname": "airsensor*" },
{ "hostname": "humiditysensor*" },
{ "hostname": "rainsensor*" },
{ "hostname": "floodsensor*" },
{ "hostname": "luxsensor*" },
{ "hostname": "inputsensor*" },
{ "hostname": "opensensor*" },
{ "hostname": "windsensor*" },
{ "hostname": "co2sensor*" },
{ "hostname": "simongo*" },
{ "hostname": "sabaj-k-smrt*" },
{ "hostname": "rico*" },
{ "hostname": "smartrollergate*" },
{ "hostname": "darco_ero_32ws_0*" },
{ "hostname": "pergoladc*" },
{ "hostname": "seltsmartscreen*" },
{ "hostname": "seltvenetianblind*" },
{ "hostname": "doorunitbox*" },
{ "hostname": "drutexsmart*" },
{ "hostname": "swingatecontroller*" },
{ "hostname": "windowopener*" },
{ "hostname": "smartawning*" },
{ "hostname": "smartshade*" },
{ "hostname": "smartshutter*" }
],
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",
"iot_class": "local_polling",
@@ -4,6 +4,7 @@
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"authorization_required": "The BleBox device requires authentication.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device.",
@@ -18,6 +19,10 @@
},
"flow_title": "{name} ({host})",
"step": {
"confirm_discovery": {
"description": "Do you want to add the BleBox device **{name}** at `{host}` to Home Assistant?",
"title": "BleBox device discovered"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -5,6 +5,7 @@ import logging
from aiohttp import ClientConnectionError
from pydaikin.daikin_base import Appliance
from pydaikin.exceptions import DaikinException
from pydaikin.factory import DaikinFactory
from homeassistant.const import (
@@ -56,6 +57,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
except ClientConnectionError as err:
_LOGGER.debug("ClientConnectionError to %s", host)
raise ConfigEntryNotReady from err
except DaikinException as err:
# pydaikin has no subclass hierarchy for transient vs permanent errors.
# DaikinException during factory/init almost always means the device is not
# yet ready (e.g. "Empty values." when the unit hasn't finished booting),
# so treat all factory-time DaikinExceptions as transient.
_LOGGER.debug("DaikinException from %s: %s", host, err)
raise ConfigEntryNotReady from err
coordinator = DaikinCoordinator(hass, entry, device)
@@ -230,11 +230,19 @@ class GoogleGenerativeAISttEntity(
f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}",
)
prompt = self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
if metadata.language:
prompt = (
f"{prompt}\n"
f"The spoken language is {metadata.language}. "
f"Transcribe in that language."
)
try:
response = await self._genai_client.aio.models.generate_content(
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
contents=[
self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
prompt,
Part.from_bytes(
data=audio_data,
mime_type=f"audio/{metadata.format.value}",
@@ -0,0 +1,91 @@
"""Home Assistant integration for Greencell EVSE devices."""
import asyncio
from collections.abc import Callable
import json
import logging
from greencell_client.access import GreencellAccess, GreencellHaAccessLevel
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from .const import CONF_SERIAL_NUMBER, DISCOVERY_TIMEOUT, GREENCELL_DISC_TOPIC
from .models import GreencellConfigEntry, GreencellRuntimeData
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
def make_ready_handler(
serial: str, event: asyncio.Event
) -> Callable[[ReceiveMessage], None]:
"""Create an MQTT message handler that sets event when device matches serial."""
@callback
def _on_message(message: ReceiveMessage) -> None:
if event.is_set():
return
try:
data = json.loads(message.payload)
except ValueError, TypeError:
return
if message.topic == GREENCELL_DISC_TOPIC:
if data.get("id") != serial:
return
elif data.get("id") and data["id"] != serial:
return
event.set()
return _on_message
async def async_setup_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
"""Set up Greencell from a config entry."""
if not await mqtt.async_wait_for_mqtt_client(hass):
raise ConfigEntryNotReady("MQTT integration is not available")
serial: str = entry.data[CONF_SERIAL_NUMBER]
device_ready_event = asyncio.Event()
on_message = make_ready_handler(serial, device_ready_event)
try:
unsub_disc = await mqtt.async_subscribe(hass, GREENCELL_DISC_TOPIC, on_message)
unsub_volt = await mqtt.async_subscribe(
hass, f"/greencell/evse/{serial}/voltage", on_message
)
try:
async with asyncio.timeout(DISCOVERY_TIMEOUT):
await device_ready_event.wait()
finally:
unsub_disc()
unsub_volt()
except TimeoutError as err:
raise ConfigEntryNotReady(f"No initial data from device {serial}") from err
except HomeAssistantError as err:
raise ConfigEntryNotReady(f"MQTT error: {err}") from err
entry.runtime_data = GreencellRuntimeData(
access=GreencellAccess(GreencellHaAccessLevel.EXECUTE),
current_data=ElecData3Phase(),
voltage_data=ElecData3Phase(),
power_data=ElecDataSinglePhase(),
state_data=ElecDataSinglePhase(),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,192 @@
"""Config flow for Greencell EVSE integration in Home Assistant."""
import asyncio
from collections.abc import Callable
import json
import logging
from typing import Any
from greencell_client.utils import GreencellUtils
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from . import const
from .const import (
CONF_SERIAL_NUMBER,
DOMAIN,
GREENCELL_BROADCAST_TOPIC,
GREENCELL_DISC_TOPIC,
GREENCELL_HABU_DEN,
GREENCELL_OTHER_DEVICE,
)
_LOGGER = logging.getLogger(__name__)
class EVSEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Greencell EVSE devices."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, dict[str, Any]] = {}
self._discovered_serial: str | None = None
self._discovery_event: asyncio.Event | None = None
self._remove_listener: Callable | None = None
def _get_device_name(self, serial: str) -> str:
"""Determine the device name based on the serial number."""
return (
GREENCELL_HABU_DEN
if GreencellUtils.device_is_habu_den(serial)
else GREENCELL_OTHER_DEVICE
)
@callback
def _async_mqtt_message_received(self, msg: ReceiveMessage) -> None:
"""Handle incoming MQTT messages on the discovery topic."""
try:
payload = json.loads(msg.payload)
except json.JSONDecodeError, AttributeError:
return
serial = payload.get("id")
if isinstance(serial, str) and serial.strip():
self._discovered[serial] = payload
if self._discovery_event:
self._discovery_event.set()
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> config_entries.ConfigFlowResult:
"""Handle a flow initialized by MQTT discovery."""
try:
payload = json.loads(discovery_info.payload)
serial = payload.get("id")
except json.JSONDecodeError, AttributeError:
return self.async_abort(reason="invalid_discovery_data")
if not isinstance(serial, str) or not serial.strip():
return self.async_abort(reason="invalid_discovery_data")
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
self._discovered_serial = serial
device_name = self._get_device_name(serial)
self.context.update({"title_placeholders": {"name": f"{device_name} {serial}"}})
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Confirm addition of a discovered device."""
assert self._discovered_serial is not None
serial = self._discovered_serial
if user_input is not None:
return self.async_create_entry(
title=f"{self._get_device_name(serial)} {serial}",
data={CONF_SERIAL_NUMBER: serial},
)
return self.async_show_form(
step_id="confirm",
description_placeholders={"serial": serial},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Manual step: start active discovery process."""
try:
if not mqtt.is_connected(self.hass):
return self.async_abort(reason="mqtt_not_connected")
except KeyError:
return self.async_abort(reason="mqtt_not_configured")
return await self.async_step_discover()
async def async_step_discover(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Discovery step: subscribe, broadcast, and wait for responses."""
self._discovery_event = asyncio.Event()
try:
self._remove_listener = await mqtt.async_subscribe(
self.hass,
GREENCELL_DISC_TOPIC,
self._async_mqtt_message_received,
)
except HomeAssistantError, ValueError:
return self.async_abort(reason="mqtt_subscription_failed")
try:
payload = json.dumps({"name": "BROADCAST"})
await mqtt.async_publish(
self.hass, GREENCELL_BROADCAST_TOPIC, payload, qos=0, retain=False
)
try:
await asyncio.wait_for(
self._discovery_event.wait(), timeout=const.DISCOVERY_TIMEOUT
)
# Grace period for additional devices
await asyncio.sleep(0.5)
except TimeoutError:
_LOGGER.debug("Discovery timed out waiting for device responses")
finally:
self._remove_listener()
if not self._discovered:
return self.async_abort(reason="no_discovery_data")
if len(self._discovered) == 1:
serial = next(iter(self._discovered))
return await self._async_create_entry(serial)
return await self.async_step_select()
async def async_step_select(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Let the user select one of the discovered devices."""
if user_input is not None:
serial = user_input[CONF_SERIAL_NUMBER]
return await self._async_create_entry(serial)
return self.async_show_form(
step_id="select",
data_schema=vol.Schema(
{
vol.Required(CONF_SERIAL_NUMBER): vol.In(
list(self._discovered.keys())
)
}
),
description_placeholders={"count": str(len(self._discovered))},
)
async def _async_create_entry(self, serial: str) -> config_entries.ConfigFlowResult:
"""Finalize entry creation for selected device."""
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
device_name = self._get_device_name(serial)
title = f"{device_name} {serial}"
_LOGGER.info("Discovered and added device: %s", title)
return self.async_create_entry(
title=title,
data={CONF_SERIAL_NUMBER: serial},
)
@@ -0,0 +1,31 @@
"""Core constants for the Greencell EVSE Home Assistant integration."""
from typing import Final
# Greencell constants
DOMAIN = "greencell"
MANUFACTURER: Final = "Greencell"
# Maximal current configuration
DEFAULT_MIN_CURRENT = 6
DEFAULT_MAX_CURRENT_OTHER = 16
DEFAULT_MAX_CURRENT_HABU_DEN = 32
# Topics
GREENCELL_BROADCAST_TOPIC = "/greencell/broadcast"
GREENCELL_DISC_TOPIC = "/greencell/broadcast/device"
# Device names
GREENCELL_HABU_DEN = "Habu Den"
GREENCELL_OTHER_DEVICE = "Greencell Device"
# Other constants
DISCOVERY_MIN_TIMEOUT = 5.0
DISCOVERY_TIMEOUT = 30.0
SET_CURRENT_RETRY_TIME = 15
CONF_SERIAL_NUMBER = "serial_number"
@@ -0,0 +1,30 @@
{
"entity": {
"sensor": {
"current_l1": {
"default": "mdi:flash"
},
"current_l2": {
"default": "mdi:flash"
},
"current_l3": {
"default": "mdi:flash"
},
"power": {
"default": "mdi:battery-charging-high"
},
"status": {
"default": "mdi:ev-plug-type2"
},
"voltage_l1": {
"default": "mdi:meter-electric"
},
"voltage_l2": {
"default": "mdi:meter-electric"
},
"voltage_l3": {
"default": "mdi:meter-electric"
}
}
}
}
@@ -0,0 +1,13 @@
{
"domain": "greencell",
"name": "Greencell",
"codeowners": ["@BrzezowskiGC"],
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/greencell",
"integration_type": "device",
"iot_class": "local_push",
"mqtt": ["/greencell/broadcast/device"],
"quality_scale": "bronze",
"requirements": ["greencell_client==1.0.3"]
}
@@ -0,0 +1,22 @@
"""Type definitions for Greencell integration."""
from dataclasses import dataclass
from greencell_client.access import GreencellAccess
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from homeassistant.config_entries import ConfigEntry
@dataclass
class GreencellRuntimeData:
"""Runtime data for Greencell integration."""
access: GreencellAccess
current_data: ElecData3Phase
voltage_data: ElecData3Phase
power_data: ElecDataSinglePhase
state_data: ElecDataSinglePhase
type GreencellConfigEntry = ConfigEntry[GreencellRuntimeData]
@@ -0,0 +1,63 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions or services.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions: done
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
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,341 @@
"""Home Assistant integration module for Greencell EVSE sensor entities over MQTT."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from greencell_client.access import GreencellAccess
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from greencell_client.mqtt_parser import MqttParser
from greencell_client.utils import GreencellUtils
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfPower,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
CONF_SERIAL_NUMBER,
DOMAIN,
GREENCELL_HABU_DEN,
GREENCELL_OTHER_DEVICE,
MANUFACTURER,
)
from .models import GreencellConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class GreencellSensorDescription(SensorEntityDescription):
"""Describe a Greencell sensor."""
value_fn: Callable[[Any], StateType]
SENSOR_DESCRIPTIONS = (
GreencellSensorDescription(
key="current_l1",
translation_key="current_l1",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="current_l2",
translation_key="current_l2",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="current_l3",
translation_key="current_l3",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="voltage_l1",
translation_key="voltage_l1",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="voltage_l2",
translation_key="voltage_l2",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="voltage_l3",
translation_key="voltage_l3",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="power",
translation_key="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=1,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="status",
translation_key="status",
device_class=SensorDeviceClass.ENUM,
options=[
"idle",
"connected",
"waiting_for_car",
"charging",
"finished",
"error_car",
"error_evse",
],
value_fn=lambda data: str(data).lower() if isinstance(data, str) else None,
),
)
# --- Config Flow Setup ---
async def async_setup_entry(
hass: HomeAssistant,
entry: GreencellConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Greencell EVSE sensors from a config entry."""
serial_number: str = entry.data[CONF_SERIAL_NUMBER]
mqtt_topic_current = f"/greencell/evse/{serial_number}/current"
mqtt_topic_voltage = f"/greencell/evse/{serial_number}/voltage"
mqtt_topic_power = f"/greencell/evse/{serial_number}/power"
mqtt_topic_status = f"/greencell/evse/{serial_number}/status"
mqtt_topic_device_state = f"/greencell/evse/{serial_number}/device_state"
desc_map = {desc.key: desc for desc in SENSOR_DESCRIPTIONS}
runtime = entry.runtime_data
access = runtime.access
current_data_obj = runtime.current_data
voltage_data_obj = runtime.voltage_data
power_data_obj = runtime.power_data
state_data_obj = runtime.state_data
data_mapping = {
"current": current_data_obj,
"voltage": voltage_data_obj,
"power": power_data_obj,
"status": state_data_obj,
}
sensors: list[HabuSensor] = [
Habu3PhaseSensor(
sensor_data=data_mapping[description.key.split("_")[0]],
phase=description.key.split("_")[-1],
sensor_type=description.key,
serial_number=serial_number,
access=access,
description=description,
)
for description in SENSOR_DESCRIPTIONS
if description.key.startswith(("current_l", "voltage_l"))
]
sensors.extend(
HabuSingleSensor(
sensor_data=data_mapping[key],
serial_number=serial_number,
sensor_type=key,
access=access,
description=desc_map[key],
)
for key in ("power", "status")
)
@callback
def current_message_received(msg: ReceiveMessage) -> None:
"""Handle the current message."""
MqttParser.parse_3phase_msg(msg.payload, current_data_obj)
@callback
def voltage_message_received(msg: ReceiveMessage) -> None:
"""Handle the voltage message."""
MqttParser.parse_3phase_msg(msg.payload, voltage_data_obj)
@callback
def power_message_received(msg: ReceiveMessage) -> None:
"""Handle the power message."""
MqttParser.parse_single_phase_msg(msg.payload, "momentary", power_data_obj)
@callback
def status_message_received(msg: ReceiveMessage) -> None:
"""Handle the status message. If the device is unavailable, disable the entity."""
str_payload = (
msg.payload.decode("utf-8", errors="ignore")
if isinstance(msg.payload, (bytes, bytearray))
else str(msg.payload)
)
if "UNAVAILABLE" in str_payload or "OFFLINE" in str_payload:
access.update("UNAVAILABLE")
else:
MqttParser.parse_single_phase_msg(msg.payload, "state", state_data_obj)
@callback
def device_state_message_received(msg: ReceiveMessage) -> None:
"""Handle the device state message. If device was unavailable, enable the entity."""
access.on_msg(msg.payload)
try:
for topic, handler in (
(mqtt_topic_current, current_message_received),
(mqtt_topic_voltage, voltage_message_received),
(mqtt_topic_power, power_message_received),
(mqtt_topic_status, status_message_received),
(mqtt_topic_device_state, device_state_message_received),
):
unsub = await mqtt.async_subscribe(hass, topic, handler)
if unsub is not None:
entry.async_on_unload(unsub)
except HomeAssistantError as err:
raise ConfigEntryNotReady(f"MQTT is unavailable: {err}") from err
async_add_entities(sensors)
class HabuSensor(SensorEntity):
"""Abstract base class for Habu sensors integration."""
entity_description: GreencellSensorDescription
_attr_has_entity_name = True
_remove_listener: Callable[[], None] | None = None
def __init__(
self,
sensor_type: str,
serial_number: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the sensor entity."""
self._sensor_type = sensor_type
self._serial_number = serial_number
self._access = access
self.entity_description = description
self._attr_unique_id = f"{serial_number}_{description.key}"
if GreencellUtils.device_is_habu_den(self._serial_number):
device_name = GREENCELL_HABU_DEN
else:
device_name = GREENCELL_OTHER_DEVICE
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=f"{device_name} {serial_number}",
manufacturer=MANUFACTURER,
model=device_name,
serial_number=serial_number,
)
@property
def available(self) -> bool:
"""Return True if the entity is available."""
return not self._access.is_disabled()
async def async_added_to_hass(self) -> None:
"""Register the entity with Home Assistant."""
unsub = self._access.register_listener(self._schedule_update)
if unsub is not None:
self.async_on_remove(unsub)
def _schedule_update(self) -> None:
"""Schedule an update for the entity."""
self.async_schedule_update_ha_state()
class Habu3PhaseSensor(HabuSensor):
"""Abstract class for 3-phase sensors (e.g. current, voltage)."""
def __init__(
self,
sensor_data: ElecData3Phase,
phase: str,
sensor_type: str,
serial_number: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the 3-phase sensor."""
super().__init__(sensor_type, serial_number, access, description)
self._sensor_data = sensor_data
self._phase = phase
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
raw_value = self._sensor_data.get_value(self._phase)
if raw_value is None:
return None
return self.entity_description.value_fn(raw_value)
class HabuSingleSensor(HabuSensor):
"""Example class for sensors that return a single value."""
def __init__(
self,
sensor_data: ElecDataSinglePhase,
serial_number: str,
sensor_type: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the single-value sensor."""
super().__init__(sensor_type, serial_number, access, description)
self._value = sensor_data
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
raw_value = self._value.data
if raw_value is None:
return None
return self.entity_description.value_fn(raw_value)
@@ -0,0 +1,70 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"invalid_discovery_data": "The received discovery data is invalid.",
"mqtt_not_configured": "MQTT is not configured. Please configure MQTT first.",
"mqtt_not_connected": "MQTT is not connected. Ensure the MQTT broker is running and configured.",
"mqtt_subscription_failed": "Failed to subscribe to the MQTT topic for discovery.",
"no_discovery_data": "No discovery data received. Ensure the device is online and broadcasting."
},
"step": {
"confirm": {
"description": "A Greencell device with serial number {serial} was discovered. Do you want to add it?",
"title": "Greencell device discovered"
},
"select": {
"data": {
"serial_number": "Device serial number"
},
"data_description": {
"serial_number": "Select the device you want to add to Home Assistant"
},
"description": "Multiple Greencell devices were found (total: {count}). Please choose which one you want to configure.",
"title": "Select your device"
},
"user": {
"description": "The integration will try to discover your EVSE devices over MQTT.",
"title": "Set up your Greencell HabuDen EVSE"
}
}
},
"entity": {
"sensor": {
"current_l1": {
"name": "Current phase L1"
},
"current_l2": {
"name": "Current phase L2"
},
"current_l3": {
"name": "Current phase L3"
},
"power": {
"name": "Power"
},
"status": {
"name": "Status",
"state": {
"charging": "[%key:common::state::charging%]",
"connected": "[%key:common::state::connected%]",
"error_car": "Car error",
"error_evse": "EVSE error",
"finished": "Finished",
"idle": "[%key:common::state::idle%]",
"waiting_for_car": "Waiting for car"
}
},
"voltage_l1": {
"name": "Voltage phase L1"
},
"voltage_l2": {
"name": "Voltage phase L2"
},
"voltage_l3": {
"name": "Voltage phase L3"
}
}
}
}
@@ -201,7 +201,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
# V1 API returns current_power in kW, convert to W
total_info["invTodayPpv"] = total_info["current_power"] * 1000
else:
# Classic API: use plant_info as before.
# Copy the response to avoid mutating the dict returned by the library
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"requirements": ["pyHik==0.4.2"]
"requirements": ["pyHik==0.4.3"]
}
@@ -48,7 +48,7 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
@@ -22,4 +22,7 @@ async def async_get_config_entry_diagnostics(
anonymized = handle_config(json_state, anonymize=True)
config = json.loads(anonymized)
return async_redact_data(config, TO_REDACT_CONFIG)
return {
"websocket": hap.websocket_diagnostics(),
"config": async_redact_data(config, TO_REDACT_CONFIG),
}
@@ -164,9 +164,11 @@ class HomematicipHAP:
self.set_all_to_unavailable()
elif self._ws_connection_closed.is_set():
_LOGGER.info("HMIP access point has reconnected to the cloud")
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._start_get_state_task()
@callback
def async_create_entity(self, *args, **kwargs) -> None:
@@ -180,44 +182,103 @@ class HomematicipHAP:
await asyncio.sleep(30)
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
def websocket_diagnostics(self) -> dict[str, Any]:
"""Return websocket diagnostics dict (None values omitted)."""
diagnostics = {
"last_disconnect_reason": self.home.websocket_last_disconnect_reason(),
"reconnect_attempts": self.home.websocket_reconnect_attempt_count(),
"seconds_since_last_message": (
self.home.websocket_seconds_since_last_message()
),
"message_count": self.home.websocket_message_count(),
}
return {k: v for k, v in diagnostics.items() if v is not None}
def _websocket_diagnostic_context(self) -> str:
"""Return a single-line summary of websocket diagnostics for logs."""
diagnostics = self.websocket_diagnostics()
if not diagnostics:
return "no diagnostics available"
return ", ".join(f"{k}={v!r}" for k, v in diagnostics.items())
@callback
def _start_get_state_task(self) -> None:
"""Cancel any in-flight reconnect refresh and start a new one."""
if self._get_state_task is not None and not self._get_state_task.done():
_LOGGER.debug(
"Cancelling previous HomematicIP reconnect state refresh task"
)
self._get_state_task.cancel()
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
async def _try_get_state(self) -> None:
"""Call get_state in a loop until no error occurs.
"""Refresh state after a websocket reconnect.
Uses exponential backoff on error.
Delegates the bounded websocket wait + retry-with-exponential-backoff
to the homematicip library (``refresh_state_after_reconnect_async``),
and only handles HA-specific concerns here:
- on authentication failure, trigger reauth
- clear the per-device ``unreach`` flag and signal entity updates
(the workaround for core#160048)
"""
try:
await self.home.refresh_state_after_reconnect_async()
except HmipAuthenticationError:
_LOGGER.error(
"Authentication error from HomematicIP Cloud, triggering reauth"
)
self.config_entry.async_start_reauth(self.hass)
return
self._post_state_refresh()
# Wait until WebSocket connection is established.
while not self.home.websocket_is_connected():
await asyncio.sleep(2)
async def _on_websocket_stale(self, severity: str, seconds_since: float) -> None:
"""Log a websocket-stale event surfaced by the library.
delay = 8
max_delay = 1500
while True:
try:
await self.get_state()
break
except HmipAuthenticationError:
_LOGGER.error(
"Authentication error from HomematicIP Cloud, triggering reauth"
)
self.config_entry.async_start_reauth(self.hass)
break
except HmipConnectionError as err:
_LOGGER.warning(
"Get_state failed, retrying in %s seconds: %s", delay, err
)
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
The library polls staleness internally and fires this callback once
per severity per stuck period; it re-arms when fresh messages arrive.
We just translate severity to a log level.
"""
log = _LOGGER.error if severity == "error" else _LOGGER.warning
log(
"HomematicIP websocket has not received a message for "
"%.0f seconds while reporting connected",
seconds_since,
)
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
async def get_state(self) -> None:
"""Update HMIP state and tell Home Assistant."""
await self.home.get_current_state_async()
self._post_state_refresh()
def _post_state_refresh(self) -> None:
"""Apply HA-specific post-processing after a state refresh.
``set_all_to_unavailable`` marked every device unreach=True on
disconnect; ``get_current_state_async`` only clears that flag for
devices whose state actually changed during the outage, so the rest
stay stuck unavailable after reconnect. Force-clear for all devices.
Trade-off: a device that is *genuinely* unreachable on the cloud
side will briefly appear available until its next state push
corrects it. That self-corrects, while the previous behaviour left
entities stuck unavailable indefinitely (core #160048).
"""
for device in self.home.devices:
device.unreach = False
self.update_all()
def get_state_finished(self, future) -> None:
"""Execute when try_get_state coroutine has finished."""
try:
future.result()
except asyncio.CancelledError:
_LOGGER.debug("HomematicIP reconnect state refresh task was cancelled")
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Error updating state after HMIP access point reconnect: %s", err
@@ -246,6 +307,7 @@ class HomematicipHAP:
home.set_on_connected_handler(self.ws_connected_handler)
home.set_on_disconnected_handler(self.ws_disconnected_handler)
home.set_on_reconnect_handler(self.ws_reconnected_handler)
home.set_on_websocket_stale_handler(self._on_websocket_stale)
async def async_reset(self) -> bool:
"""Close the websocket connection."""
@@ -275,23 +337,28 @@ class HomematicipHAP:
"""Handle websocket connected."""
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
if self._ws_connection_closed.is_set():
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
self._start_get_state_task()
async def ws_disconnected_handler(self) -> None:
"""Handle websocket disconnection."""
_LOGGER.warning("Websocket connection to HomematicIP Cloud closed")
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._ws_connection_closed.set()
async def ws_reconnected_handler(self, reason: str) -> None:
"""Handle websocket reconnection."""
_LOGGER.info(
"Websocket connection to HomematicIP Cloud trying"
" to reconnect due to reason: %s",
"Websocket connection to HomematicIP Cloud trying to reconnect due to "
"reason: %s",
reason,
)
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._ws_connection_closed.set()
+3 -1
View File
@@ -18,6 +18,7 @@ from .const import (
STORAGE_KEY,
STORAGE_VERSION,
)
from .media_source import async_setup_mediasource, async_setup_photo_cache
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -27,7 +28,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up iCloud integration."""
async_setup_services(hass)
async_setup_mediasource(hass)
return True
@@ -61,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
entry.runtime_data = account
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_photo_cache(hass, account)
return True
+6 -1
View File
@@ -3,7 +3,7 @@
from datetime import timedelta
import logging
import operator
from typing import Any
from typing import TYPE_CHECKING, Any
from pyicloud import PyiCloudService
from pyicloud.exceptions import (
@@ -55,6 +55,9 @@ from .const import (
DOMAIN,
)
if TYPE_CHECKING:
from .media_source import PhotoCache
_LOGGER = logging.getLogger(__name__)
type IcloudConfigEntry = ConfigEntry[IcloudAccount]
@@ -95,6 +98,8 @@ class IcloudAccount:
self._unsub_fetch: CALLBACK_TYPE | None = None
self.listeners: list[CALLBACK_TYPE] = []
self.photo_cache: PhotoCache | None = None
def setup(self) -> None:
"""Set up an iCloud account."""
try:
@@ -3,6 +3,7 @@
"name": "Apple iCloud",
"codeowners": ["@Quentame", "@nzapponi"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/icloud",
"integration_type": "hub",
"iot_class": "cloud_polling",
@@ -0,0 +1,671 @@
"""Expose iCloud photo albums as a media source."""
from base64 import b64decode, b64encode
import binascii
from collections import OrderedDict
from dataclasses import dataclass
import logging
import threading
import urllib.parse
from aiohttp import ClientTimeout, hdrs, web
from pyicloud.services.photos import (
AlbumContainer,
BasePhotoAlbum,
PhotoAlbumFolder,
PhotoAsset,
)
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.static import CACHE_HEADERS
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
from homeassistant.components.media_source import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .account import IcloudAccount
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
MAX_PHOTO_CACHE_SIZE = 1000
def async_setup_mediasource(hass: HomeAssistant) -> None:
"""Set up the iCloud media source."""
hass.http.register_view(IcloudMediaSourceView(hass))
async def async_get_media_source(hass: HomeAssistant) -> IcloudMediaSource:
"""Set up iCloud media source."""
return IcloudMediaSource(hass)
def _get_icloud_account_and_title(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> tuple[IcloudAccount, str]:
"""Get iCloud account from identifier. Also return the account title for display purposes."""
entry = hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, identifier.config_entry_id
)
if entry is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={"entry": identifier.config_entry_id},
)
if getattr(entry, "runtime_data", None) is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
return entry.runtime_data, entry.title
async def async_setup_photo_cache(hass, account):
"""Set up the photo cache for the iCloud account."""
if account.photo_cache is None:
account.photo_cache = PhotoCache()
async def _get_photo_library(
hass: HomeAssistant,
icloud_account: IcloudAccount,
identifier: IcloudMediaSourceIdentifier,
) -> AlbumContainer:
"""Get photo library."""
def get_photo_library_sync() -> AlbumContainer:
"""Get photo library synchronously."""
if icloud_account.api is None or icloud_account.api.photos is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
return (
icloud_account.api.photos.shared_streams
if identifier.shared_album is True
else icloud_account.api.photos.albums
)
return await hass.async_add_executor_job(get_photo_library_sync)
async def _get_photo_album(
hass: HomeAssistant,
icloud_account: IcloudAccount,
identifier: IcloudMediaSourceIdentifier,
) -> BasePhotoAlbum:
"""Get photo album from identifier."""
def _find_album_sync() -> BasePhotoAlbum | None:
"""Find album synchronously."""
album: BasePhotoAlbum | None = (
albums.get(identifier.album_id) if albums and identifier.album_id else None
)
if not album:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="album_not_found",
)
return album
albums: AlbumContainer | None = None
if icloud_account.api is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
albums = await _get_photo_library(hass, icloud_account, identifier)
return await hass.async_add_executor_job(_find_album_sync)
async def _get_photo_asset(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> PhotoAsset:
"""Get photo asset asynchronously."""
def _get_photo_asset_sync(album: BasePhotoAlbum) -> PhotoAsset | None:
"""Get photo asset synchronously."""
for item in album.photos:
if item.id == identifier.photo_id and identifier.photo_id is not None:
PhotoCache.instance(icloud_account).set(identifier.photo_id, item)
return item
return None
icloud_account, _ = _get_icloud_account_and_title(hass, identifier)
if identifier.album_id is None or identifier.photo_id is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="incomplete_media_source_identifier",
)
photo: PhotoAsset | None = await hass.async_add_executor_job(
PhotoCache.instance(icloud_account).get, identifier.photo_id
)
if photo is None:
album: BasePhotoAlbum = await _get_photo_album(hass, icloud_account, identifier)
photo = await hass.async_add_executor_job(_get_photo_asset_sync, album)
if photo is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="photo_not_found",
)
return photo
async def _get_media_mime_type(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> str:
"""Get media MIME type asynchronously."""
photo: PhotoAsset = await _get_photo_asset(hass, identifier)
match photo.item_type:
case "image":
if photo.filename.lower().endswith(".png"):
return "image/png"
if photo.filename.lower().endswith(".heic"):
return "image/heic"
return "image/jpeg"
case "movie":
return "video/mp4"
case _:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="unsupported_media_type",
)
class PhotoCache:
"""Simple in-memory cache for PhotoAsset objects."""
@classmethod
def instance(cls, icloud_account: IcloudAccount) -> PhotoCache:
"""Get the account instance of the photo cache."""
if icloud_account.photo_cache is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
return icloud_account.photo_cache
def __init__(self, max_size: int = MAX_PHOTO_CACHE_SIZE) -> None:
"""Initialize the photo cache."""
self._cache: OrderedDict[str, PhotoAsset] = OrderedDict()
self._max_size = max_size
self._lock = threading.RLock()
def get(self, photo_id: str) -> PhotoAsset | None:
"""Get a photo from the cache."""
with self._lock:
photo = self._cache.get(photo_id)
if photo is not None:
# Move the accessed item to the end to show that it was recently used
self._cache.move_to_end(photo_id)
return photo
def set(self, photo_id: str, photo: PhotoAsset) -> None:
"""Set a photo in the cache."""
with self._lock:
self._cache[photo_id] = photo
if len(self._cache) > self._max_size:
self._cache.popitem(last=False)
@dataclass(kw_only=True)
class IcloudMediaSourceIdentifier:
"""Parse and represent an iCloud media source identifier.
Example identifier format: config_entry_id/album/album_id
Example identifier format: config_entry_id/shared/shared_album_id
Example identifier format: config_entry_id/album/album_id/photo_id
Example identifier format: config_entry_id/shared/shared_album_id/photo_id
"""
config_entry_id: str
shared_album: bool | None = None
album_id: str | None = None
photo_id: str | None = None
@staticmethod
def from_identifier(identifier: str) -> IcloudMediaSourceIdentifier:
"""Initialize iCloud media source identifier."""
config_entry_id: str = ""
shared_album: bool | None = None
album_id: str | None = None
photo_id: str | None = None
parts: list[str] = identifier.split("/") if identifier else []
for idx, part in enumerate(parts):
if idx == 0:
config_entry_id = part
elif idx == 1:
if part.lower() not in ("shared", "album"):
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="invalid_view_type",
)
shared_album = part.lower() == "shared"
elif idx == 2:
album_id = part
elif idx == 3:
photo_id = part
if not config_entry_id:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="incomplete_media_source_identifier",
)
return IcloudMediaSourceIdentifier(
config_entry_id=config_entry_id,
shared_album=shared_album,
album_id=album_id,
photo_id=photo_id,
)
def __str__(self) -> str:
"""Return string representation of the identifier."""
parts = [self.config_entry_id]
if self.shared_album is not None:
parts.append("shared" if self.shared_album else "album")
if self.album_id is not None:
parts.append(self.album_id)
if self.photo_id is not None:
parts.append(self.photo_id)
return "/".join(parts)
class IcloudMediaSource(MediaSource):
"""Provide iCloud media source."""
name = "iCloud"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize iCloud media source."""
super().__init__(DOMAIN)
self._hass = hass
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve a media item to a playable object."""
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
raise BrowseError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
mime_type = await _get_media_mime_type(self._hass, identifier)
return PlayMedia(
f"/api/icloud/media_source/serve/original/{b64encode(str(item.identifier).encode()).decode()}",
mime_type,
)
def _get_config_entries(self) -> list[ConfigEntry]:
"""Get iCloud config entries."""
return self._hass.config_entries.async_entries(
DOMAIN, include_disabled=False, include_ignore=False
)
async def _build_title_for_identifier(
self,
identifier: IcloudMediaSourceIdentifier | None,
) -> str:
"""Build title for media source identifier."""
title_parts = ["iCloud Media"]
icloud_account = None
if identifier and identifier.config_entry_id is not None:
icloud_account, title = _get_icloud_account_and_title(
self._hass, identifier
)
title_parts.append(title)
if identifier and identifier.shared_album is True:
title_parts.append("Shared Streams")
elif identifier and identifier.shared_album is False:
title_parts.append("Albums")
if icloud_account and identifier and identifier.album_id is not None:
album = await _get_photo_album(self._hass, icloud_account, identifier)
title_parts.append(album.title)
return " / ".join(title_parts)
async def async_browse_media(
self,
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
raise BrowseError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
if not item.identifier:
return await self._async_build_icloud_accounts()
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
if identifier.shared_album is None:
return await self._async_build_album_types(identifier)
icloud_account, _ = _get_icloud_account_and_title(self._hass, identifier)
if identifier.album_id is None:
return await self._async_build_albums(identifier, icloud_account)
if identifier.photo_id is None:
return await self._async_build_photos(identifier, icloud_account)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_media_item",
)
async def _async_build_icloud_accounts(
self,
) -> BrowseMediaSource:
"""Handle browsing of different iCloud accounts."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(None),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(config_entry_id=entry.unique_id)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=entry.title,
can_play=False,
can_expand=True,
)
for entry in self._get_config_entries()
if entry.unique_id is not None
],
)
async def _async_build_album_types(
self,
identifier: IcloudMediaSourceIdentifier,
) -> BrowseMediaSource:
"""Handle browsing of album types (albums vs shared albums)."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=False,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title="Albums",
),
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=True,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title="Shared Streams",
),
],
)
async def _async_build_albums(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> BrowseMediaSource:
"""Handle browsing of albums."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=await self._browse_albums(identifier, icloud_account),
)
async def _async_build_photos(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> BrowseMediaSource:
"""Handle browsing of photos in an album."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=await self._get_photo_list(identifier, icloud_account),
)
async def _browse_albums(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> list[BrowseMediaSource]:
"""Browse albums asynchronously."""
albums: AlbumContainer | None = None
if icloud_account.api is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
albums = await _get_photo_library(self._hass, icloud_account, identifier)
children: list[BrowseMediaSource] = []
if albums is not None:
for album in albums:
if isinstance(album, PhotoAlbumFolder):
continue
children.append(
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=identifier.shared_album,
album_id=album.id,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title=album.title,
)
)
return children
async def _get_photo_list(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> list[BrowseMediaSource]:
"""Get list of photos asynchronously."""
def _get_photo_list_sync(album: BasePhotoAlbum) -> list[BrowseMediaSource]:
"""Get list of photos synchronously."""
items: list[BrowseMediaSource] = []
for photo in album.photos:
PhotoCache.instance(icloud_account).set(photo.id, photo)
photo_id = IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=identifier.shared_album,
album_id=identifier.album_id,
photo_id=photo.id,
)
item = BrowseMediaSource(
domain=DOMAIN,
identifier=str(photo_id),
media_class=(
MediaClass.IMAGE
if photo.item_type == "image"
else MediaClass.VIDEO
),
media_content_type=(
MediaType.IMAGE
if photo.item_type == "image"
else MediaType.VIDEO
),
can_play=True,
can_expand=False,
title=photo.filename,
thumbnail=f"/api/icloud/media_source/serve/thumb{'' if photo.item_type == 'image' else '_image'}/{b64encode(str(photo_id).encode()).decode()}",
)
items.append(item)
return items
album: BasePhotoAlbum = await _get_photo_album(
self._hass, icloud_account, identifier
)
return await self._hass.async_add_executor_job(_get_photo_list_sync, album)
class IcloudMediaSourceView(HomeAssistantView):
"""Handle media serving via HTTP view."""
url = "/api/icloud/media_source/serve/{version}/{image_id}"
name = "api:icloud:media_source:serve"
requires_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize iCloud media source view."""
super().__init__()
self._hass = hass
self.session = async_get_clientsession(hass)
async def get(
self,
request: web.Request,
version: str,
image_id: str,
) -> web.StreamResponse:
"""Get the image from iCloud."""
try:
identifier = IcloudMediaSourceIdentifier.from_identifier(
b64decode(image_id, validate=True).decode()
)
except (Unresolvable, binascii.Error, UnicodeDecodeError) as err:
_LOGGER.error("Error decoding iCloud media source identifier: %s", err)
raise web.HTTPBadRequest from err
try:
photo = await _get_photo_asset(self._hass, identifier)
except Unresolvable as err:
_LOGGER.error("Error resolving iCloud media source: %s", err)
raise web.HTTPNotFound from err
url = photo.versions.get(version, {}).get("url")
if url is None and version.startswith("thumb"):
# try the medium version for thumbnails if the requested version is not available, as some videos only have a medium version and no separate thumbnail version
url = photo.versions.get(version.replace("thumb", "medium"), {}).get("url")
if url is None:
raise web.HTTPNotFound
request_headers = {}
if hdrs.RANGE in request.headers:
request_headers[hdrs.RANGE] = request.headers[hdrs.RANGE]
icloud_response = await self.session.get(
url,
timeout=ClientTimeout(
connect=15, sock_connect=15, sock_read=30, total=None
),
headers=request_headers,
)
response_headers: dict[str, str] = {}
response_headers.update(CACHE_HEADERS)
response_headers[hdrs.CONTENT_DISPOSITION] = (
f'attachment;filename="{urllib.parse.quote(photo.filename, safe="")}"'
)
for header in (
hdrs.CONTENT_TYPE,
hdrs.LAST_MODIFIED,
hdrs.ACCEPT_RANGES,
hdrs.CONTENT_RANGE,
):
if header in icloud_response.headers:
response_headers[header] = icloud_response.headers[header]
response = web.StreamResponse(
status=icloud_response.status,
reason=icloud_response.reason,
headers=response_headers,
)
await response.prepare(request)
try:
async for chunk in icloud_response.content.iter_chunked(65536):
await response.write(chunk)
except TimeoutError:
_LOGGER.warning(
"Timeout while reading iCloud, writing EOF",
)
finally:
icloud_response.release()
await response.write_eof()
return response
@@ -44,6 +44,41 @@
}
}
},
"exceptions": {
"account_not_initialized": {
"message": "Account not initialized: {entry}"
},
"album_not_found": {
"message": "Album not found"
},
"album_type_not_specified": {
"message": "Album type not specified"
},
"config_entry_not_found": {
"message": "Config entry not found for account: {entry}"
},
"config_entry_not_loaded": {
"message": "Config entry not loaded"
},
"incomplete_media_source_identifier": {
"message": "Incomplete media source identifier"
},
"invalid_media_source": {
"message": "Invalid media source"
},
"invalid_view_type": {
"message": "Invalid album view type"
},
"photo_not_found": {
"message": "Photo not found"
},
"unknown_media_item": {
"message": "Unknown media item"
},
"unsupported_media_type": {
"message": "Unsupported media type"
}
},
"services": {
"display_message": {
"description": "Displays a message on an Apple device.",
@@ -0,0 +1,32 @@
"""Diagnostics for MELCloud Home integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import MelCloudHomeConfigEntry
TO_REDACT = [
CONF_EMAIL,
CONF_PASSWORD,
"first_name",
"last_name",
"title",
"unique_id",
"id",
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: MelCloudHomeConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a MELCloud Home config entry."""
return {
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"coordinator": async_redact_data(
config_entry.runtime_data.data.model_dump(mode="json"), TO_REDACT
),
}
@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: todo
@@ -37,8 +37,8 @@ from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_BASE_PLATFORMS: list[Platform] = []
_FLEX_PLATFORMS = [Platform.EVENT, Platform.SENSOR]
BASE_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
FLEX_PLATFORMS = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR]
@dataclass
@@ -162,7 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry)
)
await hass.config_entries.async_forward_entry_setups(
entry, _FLEX_PLATFORMS if is_flex else _BASE_PLATFORMS
entry, FLEX_PLATFORMS if is_flex else BASE_PLATFORMS
)
entry.async_on_unload(coordinator.async_start())
@@ -179,5 +179,5 @@ async def async_unload_entry(
await task
return await hass.config_entries.async_unload_platforms(
entry, _FLEX_PLATFORMS if entry.runtime_data.is_flex else _BASE_PLATFORMS
entry, FLEX_PLATFORMS if entry.runtime_data.is_flex else BASE_PLATFORMS
)
@@ -0,0 +1,50 @@
"""Binary sensor platform for OpenDisplay devices."""
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OpenDisplayConfigEntry
from .entity import OpenDisplayEntity
PARALLEL_UPDATES = 0
_CONNECTIVITY_DESCRIPTION = BinarySensorEntityDescription(
key="connectivity",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenDisplayConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenDisplay binary sensor entities."""
async_add_entities(
[
OpenDisplayConnectivityBinarySensor(
entry.runtime_data.coordinator, _CONNECTIVITY_DESCRIPTION
)
]
)
class OpenDisplayConnectivityBinarySensor(OpenDisplayEntity, BinarySensorEntity):
"""Reports whether the OpenDisplay device is currently advertising over BLE."""
@property
def available(self) -> bool:
"""Connectivity is reported regardless of the device's availability."""
return True
@property
def is_on(self) -> bool:
"""Return True if the device is currently reachable via BLE."""
return self.coordinator.available
@@ -16,6 +16,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
import homeassistant.util.dt as dt_util
from .const import (
ATTR_CH_OVRD,
@@ -73,7 +74,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
{
vol.Required(ATTR_GW_ID): vol.All(cv.string),
vol.Optional(ATTR_DATE, default=date.today): cv.date,
vol.Optional(ATTR_TIME, default=lambda: datetime.now().time()): cv.time,
vol.Optional(
ATTR_TIME, default=lambda: dt_util.naive_now().time()
): cv.time,
}
)
service_set_control_setpoint_schema = vol.Schema(
@@ -122,6 +122,37 @@ class OverkizExecutor:
if refresh_afterwards:
await self.coordinator.async_refresh()
async def async_execute_commands(
self, commands: list[Command], refresh_afterwards: bool = True
) -> None:
"""Execute multiple device commands as a single batch execution.
The Overkiz API processes all commands in order within a single action group,
which is required when commands depend on each other.
:param refresh_afterwards: Whether to refresh the device state
after the batch is executed. Disable it to refresh only once
when this batch is part of a larger sequence of commands.
"""
if not commands:
return
try:
exec_id = await self.coordinator.client.execute_action_group(
label="Home Assistant",
actions=[Action(device_url=self.device.device_url, commands=commands)],
)
# Catch Overkiz exceptions to support `continue_on_error` functionality
except BaseOverkizError as exception:
raise HomeAssistantError(exception) from exception
self.coordinator.executions[exec_id] = {
"device_url": self.device.device_url,
"command_name": commands[-1].name,
}
if refresh_afterwards:
await self.coordinator.async_refresh()
async def async_cancel_command(
self, commands_to_cancel: list[OverkizCommand]
) -> bool:
@@ -41,6 +41,24 @@
"heating": "mdi:heat-wave"
}
}
},
"water_heater": {
"overkiz": {
"state": {
"auto": "mdi:thermostat-auto",
"manual": "mdi:hand-back-right-outline",
"performance": "mdi:rocket-launch"
},
"state_attributes": {
"operation_mode": {
"state": {
"auto": "mdi:thermostat-auto",
"manual": "mdi:hand-back-right-outline",
"performance": "mdi:rocket-launch"
}
}
}
}
}
},
"services": {
@@ -178,6 +178,15 @@
"tilt": "Tilt"
}
}
},
"water_heater": {
"overkiz": {
"state": {
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]",
"performance": "Boost"
}
}
}
},
"exceptions": {
@@ -11,6 +11,9 @@ from ..entity import OverkizEntity
from .atlantic_domestic_hot_water_production_mlb_component import (
AtlanticDomesticHotWaterProductionMBLComponent,
)
from .atlantic_domestic_hot_water_production_v2_ce_flat_c2_io_component import (
AtlanticDomesticHotWaterProductionV2CEFLATC2IOComponent,
)
from .atlantic_domestic_hot_water_production_v2_io_component import (
AtlanticDomesticHotWaterProductionV2IOComponent,
)
@@ -55,10 +58,16 @@ CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = {
"modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": (
AtlanticDomesticHotWaterProductionMBLComponent
),
"io:AtlanticDomesticHotWaterProductionV2_CE_FLAT_C2_IOComponent": (
AtlanticDomesticHotWaterProductionV2CEFLATC2IOComponent
),
"io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": (
AtlanticDomesticHotWaterProductionV2IOComponent
),
"io:AtlanticDomesticHotWaterProductionV2_CETHI_V4_IOComponent": (
AtlanticDomesticHotWaterProductionV2IOComponent
),
"io:AtlanticDomesticHotWaterProductionV2_MURAL_IOComponent": (
AtlanticDomesticHotWaterProductionV2IOComponent
),
}
@@ -0,0 +1,262 @@
"""Support for Atlantic Domestic Hot Water Production V2 CE FLAT C2 IO Component.
A heat-pump water heater (e.g. Thermor Malicio 2, Atlantic Explorer/Lineo,
Sauter Guelma), typically connected via a Cozytouch bridge.
It supports:
- Auto and manual operation modes (see OVERKIZ_TO_OPERATION_MODE).
- Boost ("performance"): activates the electrical coil to reach max
temperature quickly, on top of the heat pump used in manual mode.
- Away mode: pauses heating while absent.
"""
from datetime import datetime, timedelta
from typing import Any, cast
from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
from pyoverkiz.models import Command
from pyoverkiz.types import CommandParameterValue
from homeassistant.components.water_heater import (
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.util import dt as dt_util
from ..const import DOMAIN
from ..entity import OverkizEntity
DEFAULT_MIN_TEMP: float = 50.0
DEFAULT_MAX_TEMP: float = 70.0
# Away mode end date; 365 days (the app's maximum) keeps absence
# active until the user turns it off.
_AWAY_MODE_DURATION = timedelta(days=365)
STATE_AUTO = "auto"
STATE_MANUAL = "manual"
# Maps Overkiz DHW mode values to HA operation states:
# - autoMode: device manages heating from consumption history; no target temp.
# - manualEco(In)active: user sets the target temperature. Both map to "manual";
# the eco flag is an internal device state, not user-selectable.
OVERKIZ_TO_OPERATION_MODE: dict[str, str] = {
OverkizCommandParam.AUTO_MODE: STATE_AUTO,
OverkizCommandParam.MANUAL_ECO_INACTIVE: STATE_MANUAL,
OverkizCommandParam.MANUAL_ECO_ACTIVE: STATE_MANUAL,
}
# When setting manual mode, use manualEcoInactive (standard manual).
OPERATION_MODE_TO_OVERKIZ: dict[str, str] = {
STATE_AUTO: OverkizCommandParam.AUTO_MODE,
STATE_MANUAL: OverkizCommandParam.MANUAL_ECO_INACTIVE,
}
def _absence_date_parameter(value: datetime) -> list[CommandParameterValue]:
"""Build the date parameter for the setAbsence(Start|End)Date commands."""
return [
{
"year": value.year,
"month": value.month,
"day": value.day,
"hour": value.hour,
"minute": value.minute,
"second": value.second,
"weekday": value.weekday(),
}
]
class AtlanticDomesticHotWaterProductionV2CEFLATC2IOComponent(
OverkizEntity, WaterHeaterEntity
):
"""Representation of io:AtlanticDomesticHotWaterProductionV2_CE_FLAT_C2_IOComponent."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
_attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.AWAY_MODE
)
_attr_operation_list = [*OPERATION_MODE_TO_OVERKIZ, STATE_PERFORMANCE]
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
if min_temp := self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
):
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
if max_temp := self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
):
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if current_temp := self.device.states.get(
OverkizState.IO_MIDDLE_WATER_TEMPERATURE
):
return current_temp.value_as_float
return None
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
if target_temp := self.device.states.get(
OverkizState.CORE_WATER_TARGET_TEMPERATURE
):
return target_temp.value_as_float
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new temperature."""
temperature = kwargs[ATTR_TEMPERATURE]
await self.executor.async_execute_command(
OverkizCommand.SET_TARGET_TEMPERATURE,
temperature,
refresh_afterwards=False,
)
await self.executor.async_execute_command(
OverkizCommand.REFRESH_WATER_TARGET_TEMPERATURE,
refresh_afterwards=False,
)
await self.coordinator.async_refresh()
@property
def is_boost_mode_on(self) -> bool:
"""Return true if boost mode is on."""
return (
self.device.states.get_value(OverkizState.IO_DHW_BOOST_MODE)
== OverkizCommandParam.ON
)
@property
def is_away_mode_on(self) -> bool:
"""Return true if away mode is on.
io:DHWAbsenceModeState is 'off', 'on', or 'prog'. A missing state is
treated as away mode off.
"""
absence_mode = self.device.states.get_value(OverkizState.IO_DHW_ABSENCE_MODE)
if absence_mode is None:
return False
return absence_mode != OverkizCommandParam.OFF
@property
def current_operation(self) -> str | None:
"""Return current operation."""
if self.is_boost_mode_on:
return STATE_PERFORMANCE
if dhw_mode := self.device.states.get_value(OverkizState.IO_DHW_MODE):
return OVERKIZ_TO_OPERATION_MODE.get(cast(str, dhw_mode))
return None
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
if operation_mode == STATE_PERFORMANCE:
if self.is_away_mode_on:
await self.async_turn_away_mode_off(refresh_afterwards=False)
await self._async_turn_boost_mode_on()
await self.coordinator.async_refresh()
return
previous_operation = self.current_operation
# Disable boost and away before changing DHW mode
if self.is_boost_mode_on:
await self._async_turn_boost_mode_off()
if self.is_away_mode_on:
await self.async_turn_away_mode_off(refresh_afterwards=False)
await self.executor.async_execute_command(
OverkizCommand.SET_DHW_MODE,
OPERATION_MODE_TO_OVERKIZ[operation_mode],
refresh_afterwards=False,
)
# Switching from auto changes the target temperature, so refresh it.
if previous_operation == STATE_AUTO:
await self.executor.async_execute_command(
OverkizCommand.REFRESH_WATER_TARGET_TEMPERATURE,
refresh_afterwards=False,
)
await self.coordinator.async_refresh()
async def async_turn_away_mode_on(self, refresh_afterwards: bool = True) -> None:
"""Turn away mode on.
Sets absence start/end dates and 'prog' mode in a single batch.
'on' (permanent absence) is not recognized by the official app
which causes a state mismatch, so 'prog' with a far-future end is used.
"""
now = dt_util.now()
end = now + _AWAY_MODE_DURATION
await self.executor.async_execute_commands(
[
Command(
name=OverkizCommand.SET_ABSENCE_START_DATE,
parameters=_absence_date_parameter(now),
),
Command(
name=OverkizCommand.SET_ABSENCE_END_DATE,
parameters=_absence_date_parameter(end),
),
Command(
name=OverkizCommand.SET_ABSENCE_MODE,
parameters=[OverkizCommandParam.PROG],
),
],
refresh_afterwards=refresh_afterwards,
)
async def async_turn_away_mode_off(self, refresh_afterwards: bool = True) -> None:
"""Turn away mode off."""
await self.executor.async_execute_command(
OverkizCommand.SET_ABSENCE_MODE,
OverkizCommandParam.OFF,
refresh_afterwards=refresh_afterwards,
)
async def _async_turn_boost_mode_on(self) -> None:
"""Turn boost mode on.
Refreshes the boost start/end dates, then activates boost
with setBoostMode('on').
"""
await self.executor.async_execute_commands(
[
Command(name=OverkizCommand.REFRESH_BOOST_START_DATE),
Command(name=OverkizCommand.REFRESH_BOOST_END_DATE),
Command(
name=OverkizCommand.SET_BOOST_MODE,
parameters=[OverkizCommandParam.ON],
),
],
refresh_afterwards=False,
)
async def _async_turn_boost_mode_off(self) -> None:
"""Turn boost mode off."""
await self.executor.async_execute_command(
OverkizCommand.SET_BOOST_MODE,
OverkizCommandParam.OFF,
refresh_afterwards=False,
)
@@ -35,7 +35,6 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.AWAY_MODE
| WaterHeaterEntityFeature.ON_OFF
)
_attr_operation_list = [
STATE_ECO,
@@ -129,9 +128,13 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def is_away_mode_on(self) -> bool:
"""Return true if away mode is on."""
away_mode_duration = cast(
str, self.executor.select_state(OverkizState.IO_AWAY_MODE_DURATION)
away_mode_duration = self.executor.select_state(
OverkizState.IO_AWAY_MODE_DURATION
)
if away_mode_duration is None:
return False
away_mode_duration = cast(str, away_mode_duration)
# away_mode_duration can be either a Literal["always"]
if away_mode_duration == OverkizCommandParam.ALWAYS:
return True
@@ -165,13 +168,13 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def is_boost_mode_on(self) -> bool:
"""Return true if boost mode is on."""
return (
cast(
int,
self.executor.select_state(OverkizState.CORE_BOOST_MODE_DURATION),
)
> 0
boost_mode_duration = self.executor.select_state(
OverkizState.CORE_BOOST_MODE_DURATION
)
if boost_mode_duration is None:
return False
return cast(int, boost_mode_duration) > 0
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
+67 -6
View File
@@ -1,14 +1,43 @@
"""The smtp integration."""
import logging
from smtplib import SMTPAuthenticationError
from socket import gaierror
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_RECIPIENT, Platform
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_RECIPIENT,
CONF_SENDER,
CONF_TIMEOUT,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import discovery
from homeassistant.util.ssl import create_client_context
from .const import DOMAIN
from .const import (
CONF_ENCRYPTION,
CONF_SENDER_NAME,
CONF_SERVER,
DEFAULT_TIMEOUT,
DOMAIN,
)
from .helpers import SmtpClient
_LOGGER = logging.getLogger(__name__)
type SmtpConfigEntry = ConfigEntry[SmtpClient]
PLATFORMS: list[Platform] = [Platform.NOTIFY]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool:
"""Set up SMTP from a config entry."""
hass.async_create_task(
@@ -27,17 +56,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
{},
)
)
client = SmtpClient(
server=entry.data[CONF_SERVER],
port=entry.data[CONF_PORT],
timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
sender=entry.data[CONF_SENDER],
encryption=entry.data[CONF_ENCRYPTION],
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
sender_name=entry.data.get(CONF_SENDER_NAME),
verify_ssl=entry.data[CONF_VERIFY_SSL],
ssl_context=(
await hass.async_add_executor_job(create_client_context)
if entry.data[CONF_VERIFY_SSL]
else None
),
)
try:
await hass.async_add_executor_job(lambda: client.connect().quit())
except SMTPAuthenticationError as e:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
except (gaierror, ConnectionRefusedError) as e:
_LOGGER.debug("Full exception:", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
) from e
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def _async_update_listener(hass: HomeAssistant, entry: SmtpConfigEntry) -> None:
"""Handle update."""
hass.config_entries.async_schedule_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool:
"""Unload a config entry."""
return True
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+3 -3
View File
@@ -10,7 +10,6 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryData,
@@ -46,6 +45,7 @@ from homeassistant.helpers.selector import (
)
from homeassistant.util.ssl import create_client_context
from . import SmtpConfigEntry
from .const import (
CONF_ENCRYPTION,
CONF_SENDER_NAME,
@@ -120,14 +120,14 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
cls, config_entry: SmtpConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler}
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
def async_get_options_flow(config_entry: SmtpConfigEntry) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
+194
View File
@@ -0,0 +1,194 @@
"""Helpers for SMTP integration."""
from email.mime.application import MIMEApplication
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import logging
import os
from pathlib import Path
import smtplib
import socket
import ssl
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from .const import ATTR_HTML, DOMAIN
_LOGGER = logging.getLogger(__name__)
class SmtpClient:
"""Mailer class."""
def __init__(
self,
server: str,
port: int,
timeout: int,
sender: str,
encryption: str,
username: str | None,
password: str | None,
sender_name: str | None,
verify_ssl: bool,
ssl_context: ssl.SSLContext | None,
) -> None:
"""Initialize the SMTP service."""
self._server = server
self._port = port
self._timeout = timeout
self._sender = sender
self.encryption = encryption
self.username = username
self.password = password
self._sender_name = sender_name
self._verify_ssl = verify_ssl
self.tries = 2
self._ssl_context = ssl_context
def connect(self) -> smtplib.SMTP_SSL | smtplib.SMTP:
"""Connect/authenticate to SMTP Server."""
mail: smtplib.SMTP_SSL | smtplib.SMTP
if self.encryption == "tls":
mail = smtplib.SMTP_SSL(
self._server,
self._port,
timeout=self._timeout,
context=self._ssl_context,
)
else:
mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout)
mail.ehlo_or_helo_if_needed()
if self.encryption == "starttls":
mail.starttls(context=self._ssl_context)
mail.ehlo()
if self.username and self.password:
mail.login(self.username, self.password)
return mail
def connection_is_valid(self) -> bool:
"""Check for valid config, verify connectivity."""
server = None
try:
server = self.connect()
except socket.gaierror, ConnectionRefusedError:
_LOGGER.exception(
(
"SMTP server not found or refused connection (%s:%s). Please check"
" the IP address, hostname, and availability of your SMTP server"
),
self._server,
self._port,
)
except smtplib.SMTPAuthenticationError:
_LOGGER.exception(
"Login not possible. Please check your setting and/or your credentials"
)
return False
finally:
if server:
server.quit()
return True
def _build_text_msg(message: str) -> MIMEText:
"""Build plaintext email."""
_LOGGER.debug("Building plain text email")
return MIMEText(message)
def _attach_file(
hass: HomeAssistant, atch_name: str, content_id: str | None = None
) -> MIMEImage | MIMEApplication | None:
"""Create a message attachment.
If MIMEImage is successful and content_id is passed (HTML), add images in-line.
Otherwise add them as attachments.
"""
try:
file_path = Path(atch_name).parent
if os.path.exists(file_path) and not hass.config.is_allowed_path(
str(file_path)
):
allow_list = "allowlist_external_dirs"
file_name = os.path.basename(atch_name)
url = "https://www.home-assistant.io/docs/configuration/basic/"
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_path_not_allowed",
translation_placeholders={
"allow_list": allow_list,
"file_path": str(file_path),
"file_name": file_name,
"url": url,
},
)
with open(atch_name, "rb") as attachment_file:
file_bytes = attachment_file.read()
except FileNotFoundError:
_LOGGER.warning("Attachment %s not found. Skipping", atch_name)
return None
attachment: MIMEImage | MIMEApplication
try:
attachment = MIMEImage(file_bytes)
except TypeError:
_LOGGER.warning(
"Attachment %s has an unknown MIME type. Falling back to file",
atch_name,
)
attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name))
attachment["Content-Disposition"] = (
f'attachment; filename="{os.path.basename(atch_name)}"'
)
else:
if content_id:
attachment.add_header("Content-ID", f"<{content_id}>")
else:
attachment.add_header(
"Content-Disposition",
f"attachment; filename={os.path.basename(atch_name)}",
)
return attachment
def _build_multipart_msg(
hass: HomeAssistant, message: str, images: list[str]
) -> MIMEMultipart:
"""Build Multipart message with images as attachments."""
_LOGGER.debug("Building multipart email with image attachment(s)")
msg = MIMEMultipart()
body_txt = MIMEText(message)
msg.attach(body_txt)
for atch_name in images:
attachment = _attach_file(hass, atch_name)
if attachment:
msg.attach(attachment)
return msg
def _build_html_msg(
hass: HomeAssistant, text: str, html: str, images: list[str]
) -> MIMEMultipart:
"""Build Multipart message with in-line images and rich HTML (UTF-8)."""
_LOGGER.debug("Building HTML rich email")
msg = MIMEMultipart("related")
alternative = MIMEMultipart("alternative")
alternative.attach(MIMEText(text, _charset="utf-8"))
alternative.attach(MIMEText(html, ATTR_HTML, _charset="utf-8"))
msg.attach(alternative)
for atch_name in images:
name = os.path.basename(atch_name)
attachment = _attach_file(hass, atch_name, name)
if attachment:
msg.attach(attachment)
return msg
+9
View File
@@ -0,0 +1,9 @@
{
"entity": {
"notify": {
"mailto": {
"default": "mdi:email-outline"
}
}
}
}
+152 -199
View File
@@ -1,17 +1,19 @@
"""Mail (SMTP) notification service."""
from email.mime.application import MIMEApplication
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import email.utils
import logging
import os
from pathlib import Path
import smtplib
import socket
import ssl
from typing import Any
from smtplib import (
SMTP,
SMTP_SSL,
SMTPAuthenticationError,
SMTPException,
SMTPServerDisconnected,
)
from socket import gaierror
from ssl import SSLContext
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -22,8 +24,10 @@ from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT,
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
BaseNotificationService,
NotifyEntity,
NotifyEntityFeature,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.config_entries import SOURCE_IMPORT, ConfigSubentry
from homeassistant.const import (
CONF_DEBUG,
CONF_PASSWORD,
@@ -37,12 +41,15 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from homeassistant.util.ssl import create_client_context
from . import SmtpConfigEntry
from .const import (
ATTR_HTML,
ATTR_IMAGES,
@@ -57,12 +64,15 @@ from .const import (
DOMAIN,
ENCRYPTION_OPTIONS,
)
from .helpers import SmtpClient, _build_html_msg, _build_multipart_msg, _build_text_msg
from .issue import async_deprecate_yaml_issue
PLATFORMS = [Platform.NOTIFY]
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]),
@@ -109,20 +119,7 @@ async def async_get_service(
if discovery_info[CONF_VERIFY_SSL]
else None
)
mail_service = MailNotificationService(
discovery_info[CONF_SERVER],
discovery_info[CONF_PORT],
discovery_info.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
discovery_info[CONF_SENDER],
discovery_info[CONF_ENCRYPTION],
discovery_info.get(CONF_USERNAME),
discovery_info.get(CONF_PASSWORD),
discovery_info[CONF_RECIPIENT],
discovery_info.get(CONF_SENDER_NAME),
DEFAULT_DEBUG,
discovery_info[CONF_VERIFY_SSL],
ssl_context,
)
mail_service = MailNotificationService(discovery_info, ssl_context)
if await hass.async_add_executor_job(mail_service.connection_is_valid):
return mail_service
@@ -130,86 +127,140 @@ async def async_get_service(
return None
class MailNotificationService(BaseNotificationService):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SmtpConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the notification entity platform."""
client = config_entry.runtime_data
async_add_entities(
[
MailNotifyEntity(config_entry, subentry, client)
for subentry in config_entry.subentries.values()
],
)
entity_registry = er.async_get(hass)
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)
current_recipients = {
subentry.unique_id for subentry in config_entry.subentries.values()
}
for entity in entity_entries:
if (
entity.unique_id.removeprefix(f"{config_entry.entry_id}_")
not in current_recipients
):
entity_registry.async_remove(entity.entity_id)
class MailNotifyEntity(NotifyEntity):
"""Representation of an SMTP notify entity."""
_attr_has_entity_name = True
_attr_translation_key = "mailto"
_attr_supported_features = NotifyEntityFeature.TITLE
def __init__(
self,
entry: SmtpConfigEntry,
subentry: ConfigSubentry,
client: SmtpClient,
) -> None:
"""Initialize the notify entity."""
self._entry = entry
self._subentry = subentry
self._client = client
self._attr_unique_id = f"{entry.entry_id}_{subentry.unique_id}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
)
self._attr_name = subentry.title
def send_message(self, message: str, title: str | None = None) -> None:
"""Send an email message via notify.send_message action."""
msg = MIMEText(message)
msg["Subject"] = title or ATTR_TITLE_DEFAULT
self._send_email(msg=msg)
def _send_email(self, msg: MIMEMultipart | MIMEText) -> None:
"""Send the message."""
if TYPE_CHECKING:
assert self._subentry.unique_id
msg["From"] = email.utils.formataddr(
(self._entry.data.get(CONF_SENDER_NAME), self._entry.data[CONF_SENDER])
)
msg["X-Mailer"] = "Home Assistant"
msg["Date"] = email.utils.format_datetime(dt_util.now())
msg["Message-Id"] = email.utils.make_msgid()
client: SMTP_SSL | SMTP | None = None
for attempt in range(self._client.tries):
try:
client = self._client.connect()
except SMTPAuthenticationError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
except (gaierror, ConnectionRefusedError, SMTPException) as e:
_LOGGER.debug("Full exception:", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_mail_connection_error",
) from e
try:
client.sendmail(
self._entry.data[CONF_SENDER],
self._subentry.unique_id,
msg.as_string(),
)
break
except SMTPException as e:
_LOGGER.debug(
"Error sending mail at attempt %s:", attempt + 1, exc_info=True
)
if attempt == self._client.tries - 1:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_mail_connection_error",
) from e
finally:
client.quit()
class MailNotificationService(SmtpClient, BaseNotificationService):
"""Implement the notification service for E-mail messages."""
def __init__(
self,
server: str,
port: int,
timeout: int,
sender: str,
encryption: str,
username: str | None,
password: str | None,
recipients: list[str],
sender_name: str | None,
debug: bool,
verify_ssl: bool,
ssl_context: ssl.SSLContext | None,
config: DiscoveryInfoType,
ssl_context: SSLContext | None,
) -> None:
"""Initialize the SMTP service."""
self._server = server
self._port = port
self._timeout = timeout
self._sender = sender
self.encryption = encryption
self.username = username
self.password = password
self.recipients = recipients
self._sender_name = sender_name
self.debug = debug
self._verify_ssl = verify_ssl
self.tries = 2
self._ssl_context = ssl_context
def connect(self) -> smtplib.SMTP_SSL | smtplib.SMTP:
"""Connect/authenticate to SMTP Server."""
mail: smtplib.SMTP_SSL | smtplib.SMTP
if self.encryption == "tls":
mail = smtplib.SMTP_SSL(
self._server,
self._port,
timeout=self._timeout,
context=self._ssl_context,
)
else:
mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout)
mail.set_debuglevel(self.debug)
mail.ehlo_or_helo_if_needed()
if self.encryption == "starttls":
mail.starttls(context=self._ssl_context)
mail.ehlo()
if self.username and self.password:
mail.login(self.username, self.password)
return mail
def connection_is_valid(self) -> bool:
"""Check for valid config, verify connectivity."""
server = None
try:
server = self.connect()
except socket.gaierror, ConnectionRefusedError:
_LOGGER.exception(
(
"SMTP server not found or refused connection (%s:%s). Please check"
" the IP address, hostname, and availability of your SMTP server"
),
self._server,
self._port,
)
except smtplib.SMTPAuthenticationError:
_LOGGER.exception(
"Login not possible. Please check your setting and/or your credentials"
)
return False
finally:
if server:
server.quit()
return True
self.recipients = config[CONF_RECIPIENT]
super().__init__(
server=config[CONF_SERVER],
port=config[CONF_PORT],
timeout=config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
sender=config[CONF_SENDER],
encryption=config[CONF_ENCRYPTION],
username=config.get(CONF_USERNAME),
password=config.get(CONF_PASSWORD),
sender_name=config.get(CONF_SENDER_NAME),
verify_ssl=config[CONF_VERIFY_SSL],
ssl_context=ssl_context,
)
def send_message(self, message: str, **kwargs: Any) -> None:
"""Build and send a message to a user.
@@ -261,112 +312,14 @@ class MailNotificationService(BaseNotificationService):
try:
mail.sendmail(self._sender, recipients, msg.as_string())
break
except smtplib.SMTPServerDisconnected:
except SMTPServerDisconnected:
_LOGGER.warning(
"SMTPServerDisconnected sending mail: retrying connection"
)
mail.quit()
mail = self.connect()
except smtplib.SMTPException:
except SMTPException:
_LOGGER.warning("SMTPException sending mail: retrying connection")
mail.quit()
mail = self.connect()
mail.quit()
def _build_text_msg(message: str) -> MIMEText:
"""Build plaintext email."""
_LOGGER.debug("Building plain text email")
return MIMEText(message)
def _attach_file(
hass: HomeAssistant, atch_name: str, content_id: str | None = None
) -> MIMEImage | MIMEApplication | None:
"""Create a message attachment.
If MIMEImage is successful and content_id is passed (HTML), add images in-line.
Otherwise add them as attachments.
"""
try:
file_path = Path(atch_name).parent
if os.path.exists(file_path) and not hass.config.is_allowed_path(
str(file_path)
):
allow_list = "allowlist_external_dirs"
file_name = os.path.basename(atch_name)
url = "https://www.home-assistant.io/docs/configuration/basic/"
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_path_not_allowed",
translation_placeholders={
"allow_list": allow_list,
"file_path": str(file_path),
"file_name": file_name,
"url": url,
},
)
with open(atch_name, "rb") as attachment_file:
file_bytes = attachment_file.read()
except FileNotFoundError:
_LOGGER.warning("Attachment %s not found. Skipping", atch_name)
return None
attachment: MIMEImage | MIMEApplication
try:
attachment = MIMEImage(file_bytes)
except TypeError:
_LOGGER.warning(
"Attachment %s has an unknown MIME type. Falling back to file",
atch_name,
)
attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name))
attachment["Content-Disposition"] = (
f'attachment; filename="{os.path.basename(atch_name)}"'
)
else:
if content_id:
attachment.add_header("Content-ID", f"<{content_id}>")
else:
attachment.add_header(
"Content-Disposition",
f"attachment; filename={os.path.basename(atch_name)}",
)
return attachment
def _build_multipart_msg(
hass: HomeAssistant, message: str, images: list[str]
) -> MIMEMultipart:
"""Build Multipart message with images as attachments."""
_LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)")
msg = MIMEMultipart()
body_txt = MIMEText(message)
msg.attach(body_txt)
for atch_name in images:
attachment = _attach_file(hass, atch_name)
if attachment:
msg.attach(attachment)
return msg
def _build_html_msg(
hass: HomeAssistant, text: str, html: str, images: list[str]
) -> MIMEMultipart:
"""Build Multipart message with in-line images and rich HTML (UTF-8)."""
_LOGGER.debug("Building HTML rich email")
msg = MIMEMultipart("related")
alternative = MIMEMultipart("alternative")
alternative.attach(MIMEText(text, _charset="utf-8"))
alternative.attach(MIMEText(html, ATTR_HTML, _charset="utf-8"))
msg.attach(alternative)
for atch_name in images:
name = os.path.basename(atch_name)
attachment = _attach_file(hass, atch_name, name)
if attachment:
msg.attach(attachment)
return msg
@@ -84,8 +84,17 @@
}
},
"exceptions": {
"authentication_error": {
"message": "Failed to authenticate with SMTP server. Please verify your credentials."
},
"connection_error": {
"message": "Failed to connect to SMTP server."
},
"remote_path_not_allowed": {
"message": "Cannot send email with attachment \"{file_name}\" from directory \"{file_path}\" which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information."
},
"send_mail_connection_error": {
"message": "Failed to send email message due to a connection error."
}
},
"issues": {
+38 -11
View File
@@ -574,23 +574,43 @@ class ResultStream:
"""Override the TTS stream with a different media path."""
self._override_media_path = Path(media_path)
@property
def _needs_conversion(self) -> bool:
"""Return if the result requires conversion to a preferred format."""
return any(
self.options.get(option) is not None
for option in (
ATTR_PREFERRED_FORMAT,
ATTR_PREFERRED_SAMPLE_RATE,
ATTR_PREFERRED_SAMPLE_CHANNELS,
ATTR_PREFERRED_SAMPLE_BYTES,
)
)
@callback
def async_get_media_path(self) -> Path | None:
"""Return the path to the result on disk, if available."""
if self._override_media_path is not None:
# An override that needs conversion no longer matches the file on
# disk, so the result is only available through the stream.
if self._needs_conversion:
return None
return self._override_media_path
if not self.use_file_cache or not self._result_cache.done():
return None
return self._manager.async_get_cache_file_path(
self._result_cache.result().cache_key
)
async def _async_stream_override_result(self) -> AsyncGenerator[bytes]:
"""Get the stream of the overridden result."""
assert self._override_media_path is not None
preferred_format = self.options.get(ATTR_PREFERRED_FORMAT)
to_sample_rate = self.options.get(ATTR_PREFERRED_SAMPLE_RATE)
to_sample_channels = self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS)
to_sample_bytes = self.options.get(ATTR_PREFERRED_SAMPLE_BYTES)
needs_conversion = (
(preferred_format is not None)
or (to_sample_rate is not None)
or (to_sample_channels is not None)
or (to_sample_bytes is not None)
)
if not needs_conversion:
if not self._needs_conversion:
# Read file directly (no conversion)
yield await self.hass.async_add_executor_job(
self._override_media_path.read_bytes
@@ -749,6 +769,13 @@ class SpeechManager:
self.file_cache.clear()
await task
@callback
def async_get_cache_file_path(self, cache_key: str) -> Path | None:
"""Return the path to a cached TTS file, if it is in the file cache."""
if not (filename := self.file_cache.get(cache_key)):
return None
return Path(self.cache_dir) / filename
@callback
def async_register_legacy_engine(
self, engine: str, provider: Provider, config: ConfigType
+3 -1
View File
@@ -150,7 +150,9 @@ class TTSMediaSource(MediaSource):
if stream is None:
raise Unresolvable("Stream not found")
return PlayMedia(stream.url, stream.content_type)
return PlayMedia(
stream.url, stream.content_type, path=stream.async_get_media_path()
)
async def async_browse_media(
self,
@@ -1 +1,24 @@
"""The unifi_direct component."""
"""The UniFi AP Direct integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import UniFiDirectConfigEntry, UniFiDirectDataUpdateCoordinator
PLATFORMS = [Platform.DEVICE_TRACKER]
async def async_setup_entry(hass: HomeAssistant, entry: UniFiDirectConfigEntry) -> bool:
"""Set up UniFi Direct from a config entry."""
coordinator = UniFiDirectDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: UniFiDirectConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,86 @@
"""Config flow for UniFi AP Direct integration."""
from typing import Any
from unifi_ap import UniFiAP, UniFiAPConnectionException, UniFiAPDataException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_NAME, DEFAULT_SSH_PORT, DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port,
}
)
def validate_connection_data(data: dict[str, Any]) -> None:
"""Validate that we can connect to the UniFi AP with the provided configuration."""
try:
ap = UniFiAP(
target=data[CONF_HOST],
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
port=data[CONF_PORT],
)
ap.get_clients()
except (UniFiAPConnectionException, UniFiAPDataException) as err:
raise CannotConnect("Failed to connect to UniFi AP") from err
class UniFiDirectConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for UniFi Direct."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try:
await self.hass.async_add_executor_job(
validate_connection_data, user_input
)
except CannotConnect:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import existing config from configuration.yaml."""
self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
try:
await self.hass.async_add_executor_job(
validate_connection_data, import_data
)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({import_data[CONF_HOST]})",
data=import_data,
)
class CannotConnect(Exception):
"""Custom exception for failing to connect to the UniFiAP."""
@@ -0,0 +1,5 @@
"""Constants for the UniFi AP Direct integration."""
DOMAIN = "unifi_direct"
DEFAULT_NAME = "UniFi AP"
DEFAULT_SSH_PORT = 22
@@ -0,0 +1,54 @@
"""DataUpdateCoordinator for UniFi AP Direct."""
from datetime import timedelta
import logging
from unifi_ap import UniFiAP, UniFiAPConnectionException, UniFiAPDataException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SSH_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=30)
type UniFiDirectConfigEntry = ConfigEntry[UniFiDirectDataUpdateCoordinator]
class UniFiDirectDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Class to manage fetching data from the UniFi AP."""
config_entry: UniFiDirectConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: UniFiDirectConfigEntry
) -> None:
"""Initialize the coordinator using config entry."""
self.host = config_entry.data[CONF_HOST]
self.ap = UniFiAP(
target=self.host,
username=config_entry.data[CONF_USERNAME],
password=config_entry.data[CONF_PASSWORD],
port=config_entry.data.get(CONF_PORT, DEFAULT_SSH_PORT),
)
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN} - {self.host}",
update_interval=UPDATE_INTERVAL,
)
async def _async_update_data(self) -> dict[str, dict]:
"""Fetch data from the UniFi AP."""
try:
return await self.hass.async_add_executor_job(self.ap.get_clients)
except (UniFiAPConnectionException, UniFiAPDataException) as err:
raise UpdateFailed(
f"Failed to fetch data from UniFi AP {self.host}"
) from err
@@ -1,75 +1,139 @@
"""Support for Unifi AP direct access."""
"""Support for UniFi AP Direct access as device tracker using Coordinator."""
import logging
from typing import Any
from unifi_ap import UniFiAP, UniFiAPConnectionException, UniFiAPDataException
import voluptuous as vol
from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
DeviceScanner,
AsyncSeeCallback,
ScannerEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
_LOGGER = logging.getLogger(__name__)
DEFAULT_SSH_PORT = 22
from .const import DEFAULT_SSH_PORT, DOMAIN
from .coordinator import UniFiDirectConfigEntry, UniFiDirectDataUpdateCoordinator
PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port,
}
)
def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | None:
"""Validate the configuration and return a Unifi direct scanner."""
scanner = UnifiDeviceScanner(config[DEVICE_TRACKER_DOMAIN])
return scanner if scanner.update_clients() else None
async def async_setup_scanner(
hass: HomeAssistant,
config: ConfigType,
_async_see: AsyncSeeCallback,
_discovery_info: DiscoveryInfoType | None = None,
) -> bool:
"""Set up the legacy UniFi AP Direct device tracker."""
import_data = {
CONF_HOST: config[CONF_HOST],
CONF_USERNAME: config[CONF_USERNAME],
CONF_PASSWORD: config[CONF_PASSWORD],
CONF_PORT: config.get(CONF_PORT, DEFAULT_SSH_PORT),
}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=import_data,
)
class UnifiDeviceScanner(DeviceScanner):
"""Class which queries Unifi wireless access point."""
def __init__(self, config: ConfigType) -> None:
"""Initialize the scanner."""
self.clients: dict[str, dict[str, Any]] = {}
self.ap = UniFiAP(
target=config[CONF_HOST],
username=config[CONF_USERNAME],
password=config[CONF_PASSWORD],
port=config[CONF_PORT],
if result["type"] is FlowResultType.ABORT and result["reason"] == "cannot_connect":
ir.async_create_issue(
hass,
DOMAIN,
"yaml_import_cannot_connect",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
translation_key="yaml_import_cannot_connect",
translation_placeholders={"host": config[CONF_HOST]},
)
return False
def scan_devices(self) -> list[str]:
"""Scan for new devices and return a list with found device IDs."""
self.update_clients()
return list(self.clients)
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "UniFi AP",
},
)
def get_device_name(self, device: str) -> str | None:
"""Return the name of the given device or None if we don't know."""
client_info = self.clients.get(device)
if client_info:
return client_info.get("hostname")
return True
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UniFiDirectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for UniFi AP Direct."""
coordinator = config_entry.runtime_data
tracked: set[str] = set()
@callback
def _async_update_devices() -> None:
"""Add new devices from the coordinator."""
new_entities: list[UniFiScannerEntity] = []
for mac in coordinator.data:
if mac not in tracked:
tracked.add(mac)
new_entities.append(UniFiScannerEntity(coordinator, mac))
if new_entities:
async_add_entities(new_entities)
config_entry.async_on_unload(coordinator.async_add_listener(_async_update_devices))
_async_update_devices()
class UniFiScannerEntity(
CoordinatorEntity[UniFiDirectDataUpdateCoordinator], ScannerEntity
):
"""Representation of a device connected to a UniFi AP Direct."""
def __init__(self, coordinator: UniFiDirectDataUpdateCoordinator, mac: str) -> None:
"""Initialize the tracked device."""
super().__init__(coordinator)
self._mac = mac
device = coordinator.data.get(mac, {})
self._attr_name = device.get("hostname") or mac
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the AP."""
return self._mac in self.coordinator.data
@property
def mac_address(self) -> str:
"""Return the MAC address of the device."""
return self._mac
@property
def ip_address(self) -> str | None:
"""Return the IP address of the device."""
if device := self.coordinator.data.get(self._mac):
return device.get("ip")
return None
def update_clients(self) -> bool:
"""Update the client info from AP."""
try:
self.clients = self.ap.get_clients()
except UniFiAPConnectionException as e:
_LOGGER.error("Failed to connect to accesspoint: %s", str(e))
return False
except UniFiAPDataException as e:
_LOGGER.error("Failed to get proper response from accesspoint: %s", str(e))
return False
return True
@property
def hostname(self) -> str | None:
"""Return the hostname of the device."""
if device := self.coordinator.data.get(self._mac):
return device.get("hostname")
return None
@@ -2,7 +2,9 @@
"domain": "unifi_direct",
"name": "UniFi AP",
"codeowners": ["@tofuSCHNITZEL"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/unifi_direct",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["unifi_ap"],
"quality_scale": "legacy",
@@ -0,0 +1,26 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
}
}
}
},
"issues": {
"yaml_import_cannot_connect": {
"description": "The YAML configuration for UniFi AP at `{host}` could not be imported because the connection failed.\n\nPlease check that `{host}` is reachable, update your `configuration.yaml` if needed, and restart Home Assistant.",
"title": "YAML configuration import failed: cannot connect"
}
}
}
+1 -1
View File
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyvizio"],
"requirements": ["pyvizio==0.1.61"],
"requirements": ["pyvizio==0.1.64"],
"zeroconf": ["_viziocast._tcp.local."]
}
+62 -2
View File
@@ -5,7 +5,7 @@ of entities and react to changes.
"""
import asyncio
from collections import UserDict, defaultdict
from collections import UserDict, defaultdict, deque
from collections.abc import (
Callable,
Collection,
@@ -1428,10 +1428,24 @@ def _verify_event_type_length_or_raise(event_type: EventType[_DataT] | str) -> N
raise MaxLengthExceeded(event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE)
# Maximum number of events event listeners may queue while a single top-level
# event is being dispatched, to guard against event listeners firing events in
# an endless loop.
_MAX_QUEUED_EVENT_DISPATCHES: Final = 10_000
class EventBus:
"""Allow the firing of and listening for events."""
__slots__ = ("_debug", "_hass", "_listeners", "_match_all_listeners")
__slots__ = (
"_debug",
"_dispatching",
"_event_queue",
"_hass",
"_listeners",
"_match_all_listeners",
"_queued_event_count",
)
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a new event bus."""
@@ -1441,6 +1455,11 @@ class EventBus:
self._match_all_listeners: list[_FilterableJobType[Any]] = []
self._listeners[MATCH_ALL] = self._match_all_listeners
self._hass = hass
self._event_queue: deque[
tuple[EventType[Any] | str, Any, EventOrigin, Context | None, float]
] = deque()
self._dispatching = False
self._queued_event_count = 0
self._async_logging_changed()
self.async_listen(EVENT_LOGGING_CHANGED, self._async_logging_changed)
@@ -1520,6 +1539,47 @@ class EventBus:
"Bus:Handling %s", _event_repr(event_type, origin, event_data)
)
if self._dispatching:
# A nested fire is queued and dispatched after the current
# dispatch. The fire time is captured now since dispatch is
# deferred.
if self._queued_event_count >= _MAX_QUEUED_EVENT_DISPATCHES:
# Guard against event listeners firing events in an endless
# loop: stop queuing further events and raise so the firing
# listener's error handling kicks in. Events already queued
# are still dispatched.
raise HomeAssistantError(
f"Event {event_type} not fired: more than"
f" {_MAX_QUEUED_EVENT_DISPATCHES} events were queued by event"
" listeners while dispatching a single event; event listeners"
" are likely firing events in an endless loop"
)
self._queued_event_count += 1
self._event_queue.append(
(event_type, event_data, origin, context, time_fired or time.time())
)
return
self._dispatching = True
self._queued_event_count = 0
try:
self._async_dispatch(event_type, event_data, origin, context, time_fired)
event_queue = self._event_queue
while event_queue:
self._async_dispatch(*event_queue.popleft())
finally:
self._dispatching = False
@callback
def _async_dispatch(
self,
event_type: EventType[_DataT] | str,
event_data: _DataT | None,
origin: EventOrigin,
context: Context | None,
time_fired: float | None,
) -> None:
"""Dispatch an event to its listeners."""
listeners = self._listeners.get(event_type, EMPTY_LIST)
if event_type not in EVENTS_EXCLUDED_FROM_MATCH_ALL:
match_all_listeners = self._match_all_listeners
+2
View File
@@ -295,6 +295,7 @@ FLOWS = {
"gpslogger",
"gree",
"green_planet_energy",
"greencell",
"growatt_server",
"guardian",
"guntamatic",
@@ -799,6 +800,7 @@ FLOWS = {
"ukraine_alarm",
"unifi",
"unifi_access",
"unifi_direct",
"unifi_discovery",
"unifiprotect",
"upb",
+148
View File
@@ -98,6 +98,154 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "balboa",
"macaddress": "001527*",
},
{
"domain": "blebox",
"hostname": "rollergate*",
},
{
"domain": "blebox",
"hostname": "gatebox*",
},
{
"domain": "blebox",
"hostname": "doorbox*",
},
{
"domain": "blebox",
"hostname": "shutterbox*",
},
{
"domain": "blebox",
"hostname": "switchbox*",
},
{
"domain": "blebox",
"hostname": "dimmerbox*",
},
{
"domain": "blebox",
"hostname": "dacbox*",
},
{
"domain": "blebox",
"hostname": "wlightbox*",
},
{
"domain": "blebox",
"hostname": "pixelbox*",
},
{
"domain": "blebox",
"hostname": "saunabox*",
},
{
"domain": "blebox",
"hostname": "thermobox*",
},
{
"domain": "blebox",
"hostname": "tempsensor*",
},
{
"domain": "blebox",
"hostname": "energymeter*",
},
{
"domain": "blebox",
"hostname": "airsensor*",
},
{
"domain": "blebox",
"hostname": "humiditysensor*",
},
{
"domain": "blebox",
"hostname": "rainsensor*",
},
{
"domain": "blebox",
"hostname": "floodsensor*",
},
{
"domain": "blebox",
"hostname": "luxsensor*",
},
{
"domain": "blebox",
"hostname": "inputsensor*",
},
{
"domain": "blebox",
"hostname": "opensensor*",
},
{
"domain": "blebox",
"hostname": "windsensor*",
},
{
"domain": "blebox",
"hostname": "co2sensor*",
},
{
"domain": "blebox",
"hostname": "simongo*",
},
{
"domain": "blebox",
"hostname": "sabaj-k-smrt*",
},
{
"domain": "blebox",
"hostname": "rico*",
},
{
"domain": "blebox",
"hostname": "smartrollergate*",
},
{
"domain": "blebox",
"hostname": "darco_ero_32ws_0*",
},
{
"domain": "blebox",
"hostname": "pergoladc*",
},
{
"domain": "blebox",
"hostname": "seltsmartscreen*",
},
{
"domain": "blebox",
"hostname": "seltvenetianblind*",
},
{
"domain": "blebox",
"hostname": "doorunitbox*",
},
{
"domain": "blebox",
"hostname": "drutexsmart*",
},
{
"domain": "blebox",
"hostname": "swingatecontroller*",
},
{
"domain": "blebox",
"hostname": "windowopener*",
},
{
"domain": "blebox",
"hostname": "smartawning*",
},
{
"domain": "blebox",
"hostname": "smartshade*",
},
{
"domain": "blebox",
"hostname": "smartshutter*",
},
{
"domain": "blink",
"hostname": "blink*",
+8 -2
View File
@@ -2731,6 +2731,12 @@
"iot_class": "cloud_polling",
"single_config_entry": true
},
"greencell": {
"name": "Greencell",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
"greeneye_monitor": {
"name": "GreenEye Monitor (GEM)",
"integration_type": "hub",
@@ -7572,8 +7578,8 @@
"name": "UniFi Access"
},
"unifi_direct": {
"integration_type": "hub",
"config_flow": false,
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "UniFi AP"
},
+3
View File
@@ -16,6 +16,9 @@ MQTT = {
"fully_kiosk": [
"fully/deviceInfo/+",
],
"greencell": [
"/greencell/broadcast/device",
],
"inels": [
"inels/status/#",
],
+61
View File
@@ -0,0 +1,61 @@
{
"numeric_device_classes": [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed"
]
}
+14 -15
View File
@@ -230,9 +230,17 @@ RESULT_WRAPPERS[tuple] = TupleWrapper
@lru_cache(maxsize=EVAL_CACHE_SIZE)
def _cached_parse_result(render_result: str) -> Any:
def _parse_result(render_result: str) -> Any:
"""Parse a result and cache the result."""
result = literal_eval(render_result)
# lru_cache does not memoize raised exceptions. The most common template
# results, plain string states such as "on", "off" or "unavailable", are
# not Python literals, so literal_eval compiles and raises for them on
# every render. Catching here caches that outcome (return the original
# render) so the recompile only happens once per distinct result.
try:
result = literal_eval(render_result)
except ValueError, TypeError, SyntaxError, MemoryError:
return render_result
if type(result) in RESULT_WRAPPERS:
result = RESULT_WRAPPERS[type(result)](result, render_result=render_result)
@@ -343,7 +351,7 @@ class Template:
if self.is_static:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return self.template
return self._parse_result(self.template)
return _parse_result(self.template)
assert self.hass is not None, "hass variable not set on template"
return run_callback_threadsafe(
self.hass.loop,
@@ -372,7 +380,7 @@ class Template:
if self.is_static:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return self.template
return self._parse_result(self.template)
return _parse_result(self.template)
compiled = self._compiled or self._ensure_compiled(limited, strict, log_fn)
@@ -395,16 +403,7 @@ class Template:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return render_result
return self._parse_result(render_result)
def _parse_result(self, render_result: str) -> Any:
"""Parse the result."""
try:
return _cached_parse_result(render_result)
except ValueError, TypeError, SyntaxError, MemoryError:
pass
return render_result
return _parse_result(render_result)
async def async_render_will_timeout(
self,
@@ -571,7 +570,7 @@ class Template:
if not parse_result or (self.hass and self.hass.config.legacy_templates):
return render_result
return self._parse_result(render_result)
return _parse_result(render_result)
def _ensure_compiled(
self,
+20
View File
@@ -127,6 +127,26 @@ def now(time_zone: dt.tzinfo | None = None) -> dt.datetime:
return dt.datetime.now(time_zone or DEFAULT_TIME_ZONE)
def naive_now() -> dt.datetime:
"""Get now as a naive datetime in system local time.
The returned datetime has no tzinfo.
Prefer the time zone aware `now` helper unless
a naive datetime is explicitly required.
A valid use case for a naive datetime is for example when
a 3rd party library requires a naive datetime in local time.
In that case, this helper can be used to get the current time
in the expected format.
Use time.time() when calculating a relative time.
For example, to calculate the time until a future event,
do `event_time - time.time()`
instead of `event_time - datetime.datetime.now().timestamp()`.
"""
return dt.datetime.now()
def as_utc(dattim: dt.datetime) -> dt.datetime:
"""Return a datetime as UTC time.
+14
View File
@@ -104,6 +104,7 @@ Every check has a code following the
| `W7420` | [`home-assistant-tests-direct-platform-async-setup-entry`](#w7420-home-assistant-tests-direct-platform-async-setup-entry) | Tests should not call a platform's `async_setup_entry` directly |
| `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly |
| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly |
| `W7426` | [`home-assistant-tests-direct-async-unload-entry`](#w7426-home-assistant-tests-direct-async-unload-entry) | Tests should not call an integration's `async_unload_entry` directly |
| `C7414` | [`home-assistant-enforce-utcnow`](#c7414-home-assistant-enforce-utcnow) | Use `homeassistant.util.dt.utcnow` instead of `datetime.now(UTC)` |
| `C7425` | [`home-assistant-enforce-now`](#c7425-home-assistant-enforce-now) | Use `homeassistant.util.dt.now` instead of `datetime.now(<tz>)` |
| `W7423` | [`home-assistant-missing-entity-unique-id`](#w7423-home-assistant-missing-entity-unique-id) | Entity class does not statically guarantee a non-None unique id |
@@ -447,6 +448,19 @@ the setup through the normal pipeline:
See [epic #79](https://github.com/home-assistant/epics/issues/79).
## `home_assistant_tests_direct_async_unload_entry` checker
Detects tests that call an integration's `async_unload_entry` directly.
### `W7426`: `home-assistant-tests-direct-async-unload-entry`
Tests should not invoke an integration's `async_unload_entry` from
`__init__.py` directly. Instead, tests should let Home Assistant trigger
the unload via `await hass.config_entries.async_unload(entry.entry_id)` so
that the real unload flow (platform unloading, listener teardown,
`runtime_data` cleanup, etc.) is exercised.
## `home_assistant_enforce_utcnow` checker
Ensures the Home Assistant helper is used to get the current UTC time.
@@ -214,23 +214,21 @@ class HassImportsFormatChecker(BaseChecker):
}
options = ()
def __init__(self, linter: PyLinter) -> None:
"""Initialize the HassImportsFormatChecker."""
super().__init__(linter)
self.current_package: str | None = None
current_package: str
current_component: str | None
def visit_module(self, node: nodes.Module) -> None:
"""Determine current package."""
if node.package:
self.current_package = node.name
else:
self.current_package = node.name
if not node.package:
# Strip name of the current module
self.current_package = node.name[: node.name.rfind(".")]
parsed_module = parse_module(node.name, include_test=True)
self.current_component = parsed_module.domain if parsed_module else None
def visit_import(self, node: nodes.Import) -> None:
"""Check for improper `import _` invocations."""
if self.current_package is None:
return
for other_module, _alias in node.names:
if other_module.startswith(f"{self.current_package}."):
self.add_message("home-assistant-relative-import", node=node)
@@ -251,13 +249,10 @@ class HassImportsFormatChecker(BaseChecker):
self, current_package: str, node: nodes.ImportFrom
) -> None:
"""Check for improper 'from ._ import _' invocations."""
if not current_package.startswith(
("homeassistant.components.", "tests.components.")
):
if not (current_component := self.current_component):
return
split_package = current_package.split(".")
current_component = split_package[2]
self._check_for_constant_alias(node, current_component, current_component)
@@ -367,18 +362,11 @@ class HassImportsFormatChecker(BaseChecker):
def visit_importfrom(self, node: nodes.ImportFrom) -> None:
"""Check for improper 'from _ import _' invocations."""
if not self.current_package:
return
if node.level is not None:
self._visit_importfrom_relative(self.current_package, node)
return
# Cache current component
current_component: str | None = None
for root in ("homeassistant", "tests"):
if self.current_package.startswith(f"{root}.components."):
current_component = self.current_package.split(".")[2]
current_component = self.current_component
# Checks for hass-relative-import
if not self._check_for_relative_import(
self.current_package, node, current_component
@@ -0,0 +1,99 @@
"""Checker for direct calls to ``async_unload_entry`` from tests.
Tests should not invoke an integration's ``async_unload_entry``
directly. Instead, tests should let Home Assistant trigger the
unload as part of the normal setup pipeline via
``await hass.config_entries.async_unload(entry.entry_id)`` so that the
real unload flow is exercised.
This checker flags any ``await <domain>.async_unload_entry(...)``
or ``await async_unload_entry(...)`` call in a test module whose
target resolves to a function defined in an integration's ``__init__``
module under ``homeassistant.components.*``.
"""
import astroid
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
def _is_integration_async_unload_entry(call: nodes.Call) -> bool:
"""Return True if *call* targets an integration's ``async_unload_entry``."""
func = call.func
match func:
case nodes.Attribute(attrname="async_unload_entry"):
pass
case nodes.Name(name="async_unload_entry"):
pass
case _:
return False
try:
inferred_values = list(func.infer())
except astroid.InferenceError, astroid.AstroidError:
return False
seen_qnames: set[str] = set()
for inferred in inferred_values:
if inferred is astroid.Uninferable:
continue
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
continue
qname = inferred.qname()
if not qname or qname in seen_qnames:
continue
seen_qnames.add(qname)
# qname is the function's fully-qualified name, e.g.
# ``homeassistant.components.sun.async_unload_entry``. Strip the
# function name to get the module and parse it.
module_qname = qname.rsplit(".", 1)[0]
parsed = parse_module(module_qname)
if parsed is None:
continue
# ``async_unload_entry`` lives in the integration's ``__init__``.
if parsed.module is None:
return True
return False
class DirectAsyncUnloadEntry(BaseChecker):
"""Checker for direct calls to async_unload_entry in tests."""
name = "home_assistant_tests_direct_async_unload_entry"
priority = -1
msgs = {
"W7426": (
"Do not call `async_unload_entry` directly from tests; use "
"`await hass.config_entries.async_unload(entry.entry_id)` instead",
"home-assistant-tests-direct-async-unload-entry",
"Used when a test module calls an integration's "
"`async_unload_entry` directly. Tests should let Home Assistant "
"drive the unload so the unload is exercised through the "
"normal pipeline.",
),
}
options = ()
_in_test_module: bool = False
def visit_module(self, node: nodes.Module) -> None:
"""Record whether the current module is a test module."""
self._in_test_module = is_test_module(node.name)
def visit_call(self, node: nodes.Call) -> None:
"""Flag direct calls to an integration's async_unload_entry."""
if not self._in_test_module:
return
if _is_integration_async_unload_entry(node):
self.add_message(
"home-assistant-tests-direct-async-unload-entry",
node=node,
)
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(DirectAsyncUnloadEntry(linter))
@@ -5,6 +5,8 @@ import re
_INTEGRATION_ROOT = "homeassistant.components"
_INTEGRATION_ROOT_DOT = f"{_INTEGRATION_ROOT}."
_INTEGRATION_TEST_ROOT = "tests.components"
_INTEGRATION_TEST_ROOT_DOT = f"{_INTEGRATION_TEST_ROOT}."
_ROOT_SEGMENT_COUNT = _INTEGRATION_ROOT.count(".") + 1
_MODULE_REGEX: re.Pattern[str] = re.compile(
rf"^{re.escape(_INTEGRATION_ROOT)}\.\w+(\.\w+)?$"
@@ -26,14 +28,20 @@ class IntegrationModule:
"""
def parse_module(module_name: str) -> IntegrationModule | None:
def parse_module(
module_name: str, *, include_test: bool = False
) -> IntegrationModule | None:
"""Parse a dotted module name into integration parts.
Returns ``None`` if *module_name* is not under the integration root.
For deep sub-modules (e.g. ``homeassistant.components.hue.light.v2``),
``module`` is set to the first segment after the domain (``light``).
"""
if not module_name.startswith(_INTEGRATION_ROOT_DOT):
if module_name.startswith(_INTEGRATION_ROOT_DOT):
root = _INTEGRATION_ROOT
elif include_test and module_name.startswith(_INTEGRATION_TEST_ROOT_DOT):
root = _INTEGRATION_TEST_ROOT
else:
return None
parts = module_name.split(".")
@@ -42,13 +50,13 @@ def parse_module(module_name: str) -> IntegrationModule | None:
return None
if n == _ROOT_SEGMENT_COUNT + 1:
return IntegrationModule(
root=_INTEGRATION_ROOT,
root=root,
domain=parts[_ROOT_SEGMENT_COUNT],
module=None,
)
# n >= _ROOT_SEGMENT_COUNT + 2: domain.module[.submodule...]
return IntegrationModule(
root=_INTEGRATION_ROOT,
root=root,
domain=parts[_ROOT_SEGMENT_COUNT],
module=parts[_ROOT_SEGMENT_COUNT + 1],
)
+5 -2
View File
@@ -1173,6 +1173,9 @@ gps3==0.33.3
# homeassistant.components.gree
greeclimate==2.1.1
# homeassistant.components.greencell
greencell_client==1.0.3
# homeassistant.components.greeneye_monitor
greeneye_monitor==3.0.3
@@ -1972,7 +1975,7 @@ pyElectra==1.2.4
pyEmby==1.10
# homeassistant.components.hikvision
pyHik==0.4.2
pyHik==0.4.3
# homeassistant.components.homee
pyHomee==1.4.0
@@ -2809,7 +2812,7 @@ pyversasense==0.0.6
pyvesync==3.4.2
# homeassistant.components.vizio
pyvizio==0.1.61
pyvizio==0.1.64
# homeassistant.components.velux
pyvlx==0.2.35
+1
View File
@@ -23,6 +23,7 @@ COPILOT_SPECIFIC_INSTRUCTIONS = """
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
"""
+2
View File
@@ -29,6 +29,7 @@ from . import (
mypy_config,
quality_scale,
requirements,
sensor,
services,
ssdp,
translations,
@@ -69,6 +70,7 @@ HASS_PLUGINS = [
mdi_icons,
mypy_config,
metadata,
sensor,
]
ALL_PLUGIN_NAMES = [
+40
View File
@@ -0,0 +1,40 @@
"""Generate the sensor.json file."""
import json
from homeassistant.components.sensor.const import (
NON_NUMERIC_DEVICE_CLASSES,
SensorDeviceClass,
)
from .model import Config, Integration
PATH = "homeassistant/generated/sensor.json"
def _generate() -> str:
"""Generate the sensor data."""
numeric_device_classes = sorted(
device_class.value
for device_class in set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES
)
return json.dumps({"numeric_device_classes": numeric_device_classes}, indent=2)
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate sensor.json."""
path = config.root / PATH
config.cache["sensor"] = content = _generate()
if path.read_text() != content + "\n":
config.add_error(
"sensor",
"File sensor.json is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
def generate(integrations: dict[str, Integration], config: Config) -> None:
"""Generate sensor.json."""
path = config.root / PATH
path.write_text(f"{config.cache['sensor']}\n")
@@ -0,0 +1,6 @@
[
{
"deviceKey": "DeviceKey_1",
"name": "Device 1"
}
]
+32 -4
View File
@@ -5,10 +5,11 @@ from unittest.mock import MagicMock, Mock
from aiohttp import ClientResponseError
from freezegun.api import FrozenDateTimeFactory
from pyaqvify import AqvifyAuthException
from pyaqvify import AqvifyAuthException, AqvifyDevices
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.aqvify.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
@@ -16,7 +17,14 @@ import homeassistant.helpers.device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
async_load_json_array_fixture,
)
WATER_LEVEL_SENSOR = "sensor.device_1_water_level"
EXPECTED_WATER_LEVEL = "-0.136786005"
async def test_load_unload_entry(
@@ -100,8 +108,28 @@ async def test_setup_entry_auth_error_triggers_reauth(
assert flows[0]["step_id"] == "reauth_confirm"
WATER_LEVEL_SENSOR = "sensor.device_1_water_level"
EXPECTED_WATER_LEVEL = "-0.136786005"
async def test_autoremove_stale_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_aqvify_client: MagicMock,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test stale devices are removed."""
await setup_integration(hass, mock_config_entry)
assert len(device_registry.devices) == 2
mock_aqvify_client.async_get_devices.return_value = AqvifyDevices(
await async_load_json_array_fixture(hass, "removed_devices.json", DOMAIN)
)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(device_registry.devices) == 1
assert hass.states.get("sensor.device_2_water_level") is None
@pytest.mark.parametrize(
+121 -13
View File
@@ -9,10 +9,11 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.blebox import config_flow
from homeassistant.components.blebox.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.components.blebox.const import DEFAULT_PORT, DOMAIN
from homeassistant.config_entries import SOURCE_DHCP, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.setup import async_setup_component
@@ -26,6 +27,12 @@ from .conftest import (
from tests.common import MockConfigEntry
DHCP_SERVICE_INFO = DhcpServiceInfo(
ip="172.100.123.4",
hostname="shutterbox-348",
macaddress="4cebd61da348",
)
@pytest.fixture(name="zeroconf_data")
def zeroconf_data_fixture() -> ZeroconfServiceInfo:
@@ -98,6 +105,7 @@ async def test_flow_works(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "abcd0123ef5678"
assert result["title"] == "My gate controller"
assert result["data"] == {
config_flow.CONF_HOST: "172.2.3.4",
@@ -243,20 +251,26 @@ async def test_flow_with_zeroconf(
hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo
) -> None:
"""Test setup from zeroconf discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf_data,
)
with patch(
"homeassistant.components.blebox.config_flow.Box.async_from_host",
return_value=create_product_mock("abcd0123ef5678"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf_data,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_discovery"
with patch("homeassistant.components.blebox.async_setup_entry", return_value=True):
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {"host": "172.100.123.4", "port": 80}
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "abcd0123ef5678"
assert result["data"] == {"host": "172.100.123.4", "port": 80}
async def test_flow_with_zeroconf_when_already_configured(
@@ -274,14 +288,14 @@ async def test_flow_with_zeroconf_when_already_configured(
"homeassistant.components.blebox.config_flow.Box.async_from_host",
return_value=feature.product,
):
result2 = await hass.config_entries.flow.async_init(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf_data,
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_flow_with_zeroconf_when_device_unsupported(
@@ -339,6 +353,7 @@ def create_product_mock(unique_id: str = "abcd0123ef5678"):
"""Return a product mock with a given unique_id."""
product = create_autospec(blebox_uniapi.box.Box, True, True)
type(product).unique_id = PropertyMock(return_value=unique_id)
type(product).name = PropertyMock(return_value="BleBox device")
return product
@@ -397,6 +412,99 @@ async def test_reconfigure_flow_unique_id_mismatch(
assert result["reason"] == "unique_id_mismatch"
async def test_flow_with_dhcp(hass: HomeAssistant) -> None:
"""Test setup from DHCP discovery."""
with patch(
"homeassistant.components.blebox.config_flow.Box.async_from_host",
return_value=create_product_mock("abcd0123ef5678"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_discovery"
with patch("homeassistant.components.blebox.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "abcd0123ef5678"
assert result["data"] == {
"host": DHCP_SERVICE_INFO.ip,
"port": DEFAULT_PORT,
}
async def test_flow_with_dhcp_when_already_configured(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test that DHCP discovery updates the host when device is already configured."""
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.blebox.config_flow.Box.async_from_host",
return_value=create_product_mock("abcd0123ef5678"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert config_entry.data[config_flow.CONF_HOST] == DHCP_SERVICE_INFO.ip
@pytest.mark.parametrize(
("side_effect", "reason"),
[
pytest.param(
blebox_uniapi.error.UnsupportedBoxVersion,
"unsupported_device_version",
id="unsupported_version",
),
pytest.param(
blebox_uniapi.error.UnsupportedBoxResponse,
"unsupported_device_response",
id="unsupported_response",
),
pytest.param(
blebox_uniapi.error.UnauthorizedRequest,
"authorization_required",
id="unauthorized",
),
pytest.param(
blebox_uniapi.error.Error,
"cannot_connect",
id="cannot_connect",
),
],
)
async def test_flow_with_dhcp_aborts(
hass: HomeAssistant,
side_effect: type[Exception],
reason: str,
) -> None:
"""Test that DHCP discovery aborts on connection errors."""
with patch(
"homeassistant.components.blebox.config_flow.Box.async_from_host",
side_effect=side_effect,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
@pytest.mark.parametrize(
("exception", "expected_error"),
[
+23
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, PropertyMock, patch
from aiohttp import ClientConnectionError
from freezegun.api import FrozenDateTimeFactory
from pydaikin.exceptions import DaikinException
import pytest
from homeassistant.components.daikin import update_unique_id
@@ -224,3 +225,25 @@ async def test_timeout_error(hass: HomeAssistant, mock_daikin) -> None:
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_daikin_exception_retries(hass: HomeAssistant, mock_daikin) -> None:
"""Test that a DaikinException during setup triggers SETUP_RETRY.
A DaikinException (e.g. "Empty values." from DaikinAirBase.init when the
device HTTP endpoint returns an empty response) is a transient condition
the device is not yet ready, not a permanent configuration problem so the
entry must land in SETUP_RETRY rather than SETUP_ERROR.
"""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MAC,
data={CONF_HOST: HOST, KEY_MAC: MAC},
)
config_entry.add_to_hass(hass)
mock_daikin.side_effect = DaikinException("Empty values.")
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
+1 -1
View File
@@ -113,4 +113,4 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert await emulated_roku.async_unload_entry(hass, entry)
assert await hass.config_entries.async_unload(entry.entry_id)
@@ -147,7 +147,8 @@ async def test_stt_process_audio_stream_success(
assert call_args.kwargs["model"] == TEST_CHAT_MODEL
contents = call_args.kwargs["contents"]
assert contents[0] == TEST_PROMPT
assert TEST_PROMPT in contents[0]
assert "en-US" in contents[0]
assert isinstance(contents[1], types.Part)
assert contents[1].inline_data.mime_type == f"audio/{audio_format.value}"
if call_convert_to_wav:
@@ -256,7 +257,34 @@ async def test_stt_uses_default_prompt(
call_args = mock_genai_client.aio.models.generate_content.call_args
contents = call_args.kwargs["contents"]
assert contents[0] == DEFAULT_STT_PROMPT
assert DEFAULT_STT_PROMPT in contents[0]
assert "en-US" in contents[0]
@pytest.mark.usefixtures("setup_integration")
async def test_stt_includes_language_in_prompt(
hass: HomeAssistant,
mock_genai_client: AsyncMock,
) -> None:
"""Test that metadata language is included in the prompt sent to the model."""
entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt")
metadata = stt.SpeechMetadata(
language="he-IL",
format=stt.AudioFormats.OGG,
codec=stt.AudioCodecs.OPUS,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
)
audio_stream = _async_get_audio_stream(b"test_audio_bytes")
await entity.async_process_audio_stream(metadata, audio_stream)
call_args = mock_genai_client.aio.models.generate_content.call_args
contents = call_args.kwargs["contents"]
prompt = contents[0]
assert "he-IL" in prompt
@pytest.mark.usefixtures("mock_genai_client")
+2 -2
View File
@@ -7,7 +7,7 @@ from aiohttp.test_utils import TestClient
import pytest
from homeassistant import config_entries
from homeassistant.components import gpslogger, zone
from homeassistant.components import zone
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.device_tracker.legacy import Device
from homeassistant.components.gpslogger import DOMAIN, TRACKER_UPDATE
@@ -228,6 +228,6 @@ async def test_load_unload_entry(
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await gpslogger.async_unload_entry(hass, entry)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE]
+1
View File
@@ -0,0 +1 @@
"""Tests for the GreenCell integration."""
+103
View File
@@ -0,0 +1,103 @@
"""Shared test fixtures and constants for Greencell integration tests."""
import time
from unittest.mock import patch
import pytest
from homeassistant.components.greencell.const import (
CONF_SERIAL_NUMBER,
DOMAIN,
GREENCELL_BROADCAST_TOPIC,
GREENCELL_DISC_TOPIC,
)
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from tests.common import MockConfigEntry
# Test constants
TEST_SERIAL_NUMBER = "EVGC021A22750001ZM0001"
TEST_SERIAL_NUMBER_2 = "EVGC021A22750002ZM0002"
# MQTT topics
TEST_CURRENT_TOPIC = f"/greencell/evse/{TEST_SERIAL_NUMBER}/current"
TEST_VOLTAGE_TOPIC = f"/greencell/evse/{TEST_SERIAL_NUMBER}/voltage"
TEST_POWER_TOPIC = f"/greencell/evse/{TEST_SERIAL_NUMBER}/power"
TEST_STATUS_TOPIC = f"/greencell/evse/{TEST_SERIAL_NUMBER}/status"
TEST_DEVICE_STATE_TOPIC = f"/greencell/evse/{TEST_SERIAL_NUMBER}/device_state"
TEST_DISCOVERY_TOPIC = f"/greencell/evse/{TEST_SERIAL_NUMBER}/discovery"
# MQTT message payloads - Current (in mA)
TEST_CURRENT_PAYLOAD_3PHASE = b'{"l1": 2000, "l2": 2500, "l3": 3000}'
TEST_CURRENT_PAYLOAD_SINGLE = b'{"l1": 16500, "l2": 0, "l3": 0}'
# MQTT message payloads - Voltage (in V)
TEST_VOLTAGE_PAYLOAD_NORMAL = b'{"l1": 230.0, "l2": 229.7, "l3": 232.5}'
TEST_VOLTAGE_PAYLOAD_SINGLE = b'{"l1": 230.0, "l2": 0.0, "l3": 0.0}'
# MQTT message payloads - Power (in W)
TEST_POWER_PAYLOAD_IDLE = b'{"momentary": 0.0}'
TEST_POWER_PAYLOAD_CHARGING = b'{"momentary": 1500.5}'
TEST_POWER_PAYLOAD_HIGH = b'{"momentary": 11000.0}'
# MQTT message payloads - Status
TEST_STATUS_PAYLOAD_IDLE = b'{"state": "IDLE"}'
TEST_STATUS_PAYLOAD_CONNECTED = b'{"state": "CONNECTED"}'
TEST_STATUS_PAYLOAD_CHARGING = b'{"state": "CHARGING"}'
TEST_STATUS_PAYLOAD_FINISHED = b'{"state": "FINISHED"}'
TEST_STATUS_PAYLOAD_ERROR = b'{"state": "ERROR_EVSE"}'
TEST_STATUS_PAYLOAD_WAITING_FOR_CAR = b'{"state": "WAITING_FOR_CAR"}'
TEST_STATUS_PAYLOAD_ERROR_CAR = b'{"state": "ERROR_CAR"}'
TEST_STATUS_PAYLOAD_UNAVAILABLE = b"UNAVAILABLE"
TEST_STATUS_PAYLOAD_OFFLINE = b"OFFLINE"
@pytest.fixture
def mock_config_entry():
"""Return a mock config entry for testing."""
return MockConfigEntry(
domain=DOMAIN,
entry_id="test_entry",
data={CONF_SERIAL_NUMBER: TEST_SERIAL_NUMBER},
title=f"Greencell {TEST_SERIAL_NUMBER}",
unique_id=TEST_SERIAL_NUMBER,
)
@pytest.fixture
def mock_config_entry_2():
"""Return a second mock config entry for testing."""
return MockConfigEntry(
domain=DOMAIN,
entry_id="test_entry_2",
data={CONF_SERIAL_NUMBER: TEST_SERIAL_NUMBER_2},
title=f"Greencell {TEST_SERIAL_NUMBER_2}",
unique_id=TEST_SERIAL_NUMBER_2,
)
@pytest.fixture
def mqtt_service_info():
"""Create a factory for MqttServiceInfo objects."""
def _make(payload: str) -> MqttServiceInfo:
return MqttServiceInfo(
topic=GREENCELL_DISC_TOPIC,
payload=payload,
qos=0,
retain=False,
subscribed_topic=GREENCELL_BROADCAST_TOPIC,
timestamp=time.time(),
)
return _make
@pytest.fixture
def mock_setup_entry():
"""Override async_setup_entry to prevent the integration from starting."""
with patch(
"homeassistant.components.greencell.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup
@@ -0,0 +1,17 @@
# serializer version: 1
# name: test_sensor_states_and_snapshots
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Habu Den EVGC021A22750001ZM0001 Current phase L1',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.habu_den_evgc021a22750001zm0001_current_phase_l1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16.5',
})
# ---
@@ -0,0 +1,447 @@
"""Tests for Greencell EVSE config flow."""
from __future__ import annotations
import time
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.greencell.const import (
DOMAIN,
GREENCELL_BROADCAST_TOPIC,
GREENCELL_DISC_TOPIC,
GREENCELL_HABU_DEN,
GREENCELL_OTHER_DEVICE,
)
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import TEST_SERIAL_NUMBER, TEST_SERIAL_NUMBER_2
from tests.common import MockConfigEntry
from tests.typing import MqttMockHAClient
OTHER_DEVICE_SERIAL = "OTHER12345678"
@pytest.fixture(autouse=True)
def fast_discovery():
"""Patch discovery timeout and grace period to 0 for all tests."""
with (
patch(
"homeassistant.components.greencell.const.DISCOVERY_TIMEOUT",
0.01,
),
patch(
"homeassistant.components.greencell.DISCOVERY_TIMEOUT",
0.01,
),
patch(
"homeassistant.components.greencell.config_flow.asyncio.sleep",
return_value=None,
),
):
yield
async def _init_flow_and_fire_discovery(
hass: HomeAssistant,
payloads: list[str],
) -> config_entries.ConfigFlowResult:
"""Initialize user flow and fire discovery messages synchronously."""
async def _mock_subscribe(hass_arg, topic, msg_callback, *args, **kwargs):
"""Fire payloads immediately when subscription happens."""
for payload in payloads:
msg_callback(
ReceiveMessage(
topic=GREENCELL_DISC_TOPIC,
payload=payload,
qos=0,
retain=False,
subscribed_topic=GREENCELL_DISC_TOPIC,
timestamp=time.time(),
)
)
return lambda: None
with patch(
"homeassistant.components.greencell.config_flow.mqtt.async_subscribe",
side_effect=_mock_subscribe,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
return result
async def test_user_setup_single_device(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
) -> None:
"""Test user setup with single device creates entry."""
result = await _init_flow_and_fire_discovery(
hass,
[f'{{"id": "{TEST_SERIAL_NUMBER}"}}'],
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER}"
assert result["data"] == {"serial_number": TEST_SERIAL_NUMBER}
assert result["result"].unique_id == TEST_SERIAL_NUMBER
async def test_user_setup_multiple_devices(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
) -> None:
"""Test user setup with multiple devices triggers selection."""
result = await _init_flow_and_fire_discovery(
hass,
[
f'{{"id": "{TEST_SERIAL_NUMBER}"}}',
f'{{"id": "{TEST_SERIAL_NUMBER_2}"}}',
f'{{"id": "{OTHER_DEVICE_SERIAL}"}}',
],
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"serial_number": TEST_SERIAL_NUMBER_2}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER_2}"
assert result["data"] == {"serial_number": TEST_SERIAL_NUMBER_2}
assert result["result"].unique_id == TEST_SERIAL_NUMBER_2
async def test_user_setup_no_devices(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
) -> None:
"""Test user setup aborts when no devices respond."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_discovery_data"
async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None:
"""Test user setup aborts when MQTT is not configured."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "mqtt_not_configured"
async def test_user_setup_mqtt_not_connected(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
) -> None:
"""Test user setup aborts when MQTT is not connected."""
with patch(
"homeassistant.components.greencell.config_flow.mqtt.is_connected",
return_value=False,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "mqtt_not_connected"
async def test_duplicate_device(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that configuring same device twice aborts."""
mock_config_entry.add_to_hass(hass)
result = await _init_flow_and_fire_discovery(
hass,
[f'{{"id": "{TEST_SERIAL_NUMBER}"}}'],
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"payload",
[
"",
"{BAD JSON}",
'{"name": "device"}',
'{"id": ""}',
'{"id": " "}',
'{"id": 12345}',
],
ids=[
"empty_payload",
"bad_json",
"missing_id",
"empty_id",
"whitespace_id",
"non_string_id",
],
)
async def test_invalid_discovery_payload(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
payload: str,
) -> None:
"""Test that invalid discovery payloads are ignored."""
result = await _init_flow_and_fire_discovery(hass, [payload])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_discovery_data"
@pytest.mark.parametrize(
("serial", "expected_name"),
[
("EVGC021A12345678ZM0001", GREENCELL_HABU_DEN),
("EVGC021Z99999999ZM9999", GREENCELL_HABU_DEN),
("OTHER12345678", GREENCELL_OTHER_DEVICE),
("EVGC021a12345678ZM0001", GREENCELL_OTHER_DEVICE),
("EVGC021A1234567ZM0001", GREENCELL_OTHER_DEVICE),
("EVGC022A12345678ZM0001", GREENCELL_OTHER_DEVICE),
],
ids=[
"habu_den_valid_1",
"habu_den_valid_2",
"other_device",
"habu_den_lowercase_invalid",
"habu_den_short_digits",
"habu_den_wrong_prefix",
],
)
async def test_device_naming(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
serial: str,
expected_name: str,
) -> None:
"""Test device name is determined correctly from serial prefix."""
result = await _init_flow_and_fire_discovery(
hass,
[f'{{"id": "{serial}"}}'],
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{expected_name} {serial}"
async def test_broadcast_message_published(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
) -> None:
"""Test that discovery broadcast is published on flow init."""
await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
mqtt_mock.async_publish.assert_called_once()
call_args = mqtt_mock.async_publish.call_args
assert call_args.args[0] == GREENCELL_BROADCAST_TOPIC
assert call_args.args[1] == '{"name": "BROADCAST"}'
async def test_select_step_shows_all_discovered_devices(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
) -> None:
"""Test select step displays all discovered devices."""
result = await _init_flow_and_fire_discovery(
hass,
[
f'{{"id": "{TEST_SERIAL_NUMBER}"}}',
f'{{"id": "{TEST_SERIAL_NUMBER_2}"}}',
],
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select"
schema = result["data_schema"].schema
serial_field = schema["serial_number"]
assert TEST_SERIAL_NUMBER in serial_field.container
assert TEST_SERIAL_NUMBER_2 in serial_field.container
async def test_mqtt_discovery_confirm_creates_entry(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mqtt_service_info,
) -> None:
"""Test MQTT discovery triggers confirm step and creates entry on confirm."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_MQTT},
data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER}"
assert result["data"] == {"serial_number": TEST_SERIAL_NUMBER}
assert result["result"].unique_id == TEST_SERIAL_NUMBER
async def test_mqtt_discovery_other_device(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mqtt_service_info,
) -> None:
"""Test MQTT discovery with non-HabuDen device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_MQTT},
data=mqtt_service_info(f'{{"id": "{OTHER_DEVICE_SERIAL}"}}'),
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"{GREENCELL_OTHER_DEVICE} {OTHER_DEVICE_SERIAL}"
async def test_mqtt_discovery_duplicate_aborts(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mock_config_entry: MockConfigEntry,
mqtt_service_info,
) -> None:
"""Test MQTT discovery aborts for already configured device."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_MQTT},
data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"payload",
[
"{BAD JSON}",
'{"name": "device"}',
'{"id": ""}',
'{"id": " "}',
'{"id": 12345}',
],
ids=[
"bad_json",
"missing_id",
"empty_id",
"whitespace_id",
"non_string_id",
],
)
async def test_mqtt_discovery_invalid_payload(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mqtt_service_info,
payload: str,
) -> None:
"""Test MQTT discovery aborts on invalid payloads."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_MQTT},
data=mqtt_service_info(payload),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_discovery_data"
async def test_mqtt_discovery_attribute_error(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mqtt_service_info,
) -> None:
"""Test MQTT discovery aborts when json.loads raises AttributeError."""
with patch(
"homeassistant.components.greencell.config_flow.json.loads",
side_effect=AttributeError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_MQTT},
data=mqtt_service_info('{"id": "anything"}'),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_discovery_data"
async def test_user_setup_mqtt_subscription_value_error(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
) -> None:
"""Test user setup aborts when MQTT subscription raises ValueError."""
with patch(
"homeassistant.components.greencell.config_flow.mqtt.async_subscribe",
side_effect=ValueError("bad topic"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "mqtt_subscription_failed"
async def test_mqtt_discovery_already_in_progress(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mqtt_service_info,
) -> None:
"""Test MQTT discovery aborts when another flow for same serial is in progress."""
result1 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_MQTT},
data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'),
)
assert result1["type"] is FlowResultType.FORM
assert result1["step_id"] == "confirm"
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_MQTT},
data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'),
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_in_progress"
+149
View File
@@ -0,0 +1,149 @@
"""Greencell integration initialization test cases."""
from collections.abc import Callable
import time
from unittest.mock import patch
import pytest
from homeassistant.components.greencell.const import GREENCELL_DISC_TOPIC
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from .conftest import TEST_SERIAL_NUMBER, TEST_VOLTAGE_TOPIC
from tests.common import MockConfigEntry
from tests.typing import MqttMockHAClient
def _make_message(topic: str, payload: str) -> ReceiveMessage:
"""Build a ReceiveMessage for the mocked subscription callback."""
return ReceiveMessage(
topic=topic,
payload=payload,
qos=0,
retain=False,
subscribed_topic=topic,
timestamp=time.time(),
)
async def _mock_subscribe_fires(messages: list[tuple[str, str]]):
"""Return a mock async_subscribe that fires given messages on subscription."""
async def _subscribe(
hass: HomeAssistant, topic: str, msg_callback, *args, **kwargs
) -> Callable[[], None]:
for msg_topic, payload in messages:
if msg_topic == topic:
msg_callback(_make_message(topic, payload))
return lambda: None
return _subscribe
@pytest.mark.parametrize(
"messages",
[
pytest.param(
[(GREENCELL_DISC_TOPIC, f'{{"id": "{TEST_SERIAL_NUMBER}"}}')],
id="discovery_match",
),
pytest.param(
[(TEST_VOLTAGE_TOPIC, '{"l1": 230, "l2": 230, "l3": 230}')],
id="voltage_no_id",
),
pytest.param(
[(TEST_VOLTAGE_TOPIC, f'{{"id": "{TEST_SERIAL_NUMBER}", "l1": 230}}')],
id="voltage_matching_id",
),
],
)
async def test_async_setup_entry_ready(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mock_config_entry: MockConfigEntry,
messages: list[tuple[str, str]],
) -> None:
"""Setup completes once matching device data arrives on a subscribed topic."""
mock_config_entry.add_to_hass(hass)
subscribe = await _mock_subscribe_fires(messages)
with patch(
"homeassistant.components.greencell.mqtt.async_subscribe",
side_effect=subscribe,
):
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert result is True
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize(
"messages",
[
pytest.param(
[(GREENCELL_DISC_TOPIC, '{"id": "OTHER_SERIAL"}')],
id="discovery_wrong_serial",
),
pytest.param(
[(TEST_VOLTAGE_TOPIC, '{"id": "OTHER_SERIAL", "l1": 230}')],
id="voltage_wrong_id",
),
pytest.param(
[(TEST_VOLTAGE_TOPIC, "{INVALID JSON}")],
id="invalid_payload",
),
pytest.param([], id="no_response"),
],
)
async def test_async_setup_entry_not_ready(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mock_config_entry: MockConfigEntry,
messages: list[tuple[str, str]],
) -> None:
"""Setup retries when no matching device data arrives before the timeout."""
mock_config_entry.add_to_hass(hass)
subscribe = await _mock_subscribe_fires(messages)
with (
patch("homeassistant.components.greencell.DISCOVERY_TIMEOUT", 0),
patch(
"homeassistant.components.greencell.mqtt.async_subscribe",
side_effect=subscribe,
),
):
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_async_unload_entry_success(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test unload entry cleans up platforms."""
mock_config_entry.add_to_hass(hass)
subscribe = await _mock_subscribe_fires(
[(GREENCELL_DISC_TOPIC, f'{{"id": "{TEST_SERIAL_NUMBER}"}}')]
)
with patch(
"homeassistant.components.greencell.mqtt.async_subscribe",
side_effect=subscribe,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert result is True
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
+180
View File
@@ -0,0 +1,180 @@
"""Real integration tests for Greencell EVSE sensors."""
import time
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import mqtt as real_mqtt
from homeassistant.components.greencell.const import (
GREENCELL_DISC_TOPIC,
GREENCELL_HABU_DEN,
)
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.util import slugify
from .conftest import (
TEST_CURRENT_PAYLOAD_3PHASE,
TEST_CURRENT_PAYLOAD_SINGLE,
TEST_CURRENT_TOPIC,
TEST_POWER_PAYLOAD_CHARGING,
TEST_POWER_TOPIC,
TEST_SERIAL_NUMBER,
TEST_STATUS_PAYLOAD_CHARGING,
TEST_STATUS_PAYLOAD_CONNECTED,
TEST_STATUS_PAYLOAD_ERROR,
TEST_STATUS_PAYLOAD_ERROR_CAR,
TEST_STATUS_PAYLOAD_FINISHED,
TEST_STATUS_PAYLOAD_IDLE,
TEST_STATUS_PAYLOAD_UNAVAILABLE,
TEST_STATUS_PAYLOAD_WAITING_FOR_CAR,
TEST_STATUS_TOPIC,
TEST_VOLTAGE_PAYLOAD_NORMAL,
TEST_VOLTAGE_PAYLOAD_SINGLE,
TEST_VOLTAGE_TOPIC,
)
from tests.common import MockConfigEntry, async_fire_mqtt_message
from tests.typing import MqttMockHAClient
@pytest.fixture
async def setup_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mqtt_mock: MqttMockHAClient,
):
"""Set up the greencell integration with device-ready fired synchronously."""
mock_config_entry.add_to_hass(hass)
real_async_subscribe = real_mqtt.async_subscribe
async def _mock_init_subscribe(hass_arg, topic, msg_callback, *args, **kwargs):
"""Fire discovery payload immediately, pass everything else through."""
if topic == GREENCELL_DISC_TOPIC:
msg_callback(
ReceiveMessage(
topic=GREENCELL_DISC_TOPIC,
payload=f'{{"id": "{TEST_SERIAL_NUMBER}"}}',
qos=0,
retain=False,
subscribed_topic=GREENCELL_DISC_TOPIC,
timestamp=time.time(),
)
)
return lambda: None
return await real_async_subscribe(
hass_arg, topic, msg_callback, *args, **kwargs
)
with patch(
"homeassistant.components.greencell.mqtt.async_subscribe",
side_effect=_mock_init_subscribe,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
async def test_sensor_states_and_snapshots(
hass: HomeAssistant,
setup_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Verify all sensor states including single-phase charging and snapshots."""
prefix = f"sensor.{slugify(f'{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER}')}"
curr_l1 = f"{prefix}_current_phase_l1"
curr_l2 = f"{prefix}_current_phase_l2"
volt_l1 = f"{prefix}_voltage_phase_l1"
volt_l2 = f"{prefix}_voltage_phase_l2"
async_fire_mqtt_message(hass, TEST_CURRENT_TOPIC, TEST_CURRENT_PAYLOAD_3PHASE)
async_fire_mqtt_message(hass, TEST_VOLTAGE_TOPIC, TEST_VOLTAGE_PAYLOAD_NORMAL)
await hass.async_block_till_done()
for eid in (curr_l1, curr_l2, volt_l1, volt_l2):
await async_update_entity(hass, eid)
assert hass.states.get(curr_l1).state == "2.0"
assert hass.states.get(curr_l2).state == "2.5"
assert hass.states.get(volt_l1).state == "230.0"
async_fire_mqtt_message(hass, TEST_CURRENT_TOPIC, TEST_CURRENT_PAYLOAD_SINGLE)
async_fire_mqtt_message(hass, TEST_VOLTAGE_TOPIC, TEST_VOLTAGE_PAYLOAD_SINGLE)
await hass.async_block_till_done()
for eid in (curr_l1, curr_l2, volt_l1, volt_l2):
await async_update_entity(hass, eid)
assert hass.states.get(curr_l1).state == "16.5"
assert hass.states.get(curr_l2).state == "0.0"
assert hass.states.get(volt_l2).state == "0.0"
async_fire_mqtt_message(hass, TEST_POWER_TOPIC, TEST_POWER_PAYLOAD_CHARGING)
async_fire_mqtt_message(hass, TEST_STATUS_TOPIC, TEST_STATUS_PAYLOAD_CHARGING)
await hass.async_block_till_done()
await async_update_entity(hass, f"{prefix}_status")
assert hass.states.get(curr_l1) == snapshot
async_fire_mqtt_message(hass, TEST_STATUS_TOPIC, TEST_STATUS_PAYLOAD_UNAVAILABLE)
await hass.async_block_till_done()
await async_update_entity(hass, curr_l1)
assert hass.states.get(curr_l1).state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("payload", "expected_state"),
[
(TEST_STATUS_PAYLOAD_IDLE, "idle"),
(TEST_STATUS_PAYLOAD_CONNECTED, "connected"),
(TEST_STATUS_PAYLOAD_CHARGING, "charging"),
(TEST_STATUS_PAYLOAD_FINISHED, "finished"),
(TEST_STATUS_PAYLOAD_ERROR, "error_evse"),
(TEST_STATUS_PAYLOAD_WAITING_FOR_CAR, "waiting_for_car"),
(TEST_STATUS_PAYLOAD_ERROR_CAR, "error_car"),
],
)
async def test_sensor_status_states(
hass: HomeAssistant,
setup_integration: MockConfigEntry,
payload: bytes,
expected_state: str,
) -> None:
"""Verify all possible status states using parametrization."""
prefix = f"sensor.{slugify(f'{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER}')}"
status_id = f"{prefix}_status"
async_fire_mqtt_message(hass, TEST_STATUS_TOPIC, payload)
await hass.async_block_till_done()
await async_update_entity(hass, status_id)
await hass.async_block_till_done()
state = hass.states.get(status_id)
assert state is not None
assert state.state == expected_state
async def test_sensor_availability_and_errors(
hass: HomeAssistant,
setup_integration: MockConfigEntry,
) -> None:
"""Verify availability logic (UNAVAILABLE) and payload error handling."""
prefix = f"sensor.{slugify(f'{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER}')}"
curr_l1 = f"{prefix}_current_phase_l1"
async_fire_mqtt_message(hass, TEST_STATUS_TOPIC, TEST_STATUS_PAYLOAD_UNAVAILABLE)
await hass.async_block_till_done()
await async_update_entity(hass, curr_l1)
state = hass.states.get(curr_l1)
assert state is not None
assert state.state == STATE_UNAVAILABLE
+3 -1
View File
@@ -138,10 +138,12 @@ def mock_growatt_v1_api():
}
# Called by total coordinator during refresh
# Note: V1 API returns current_power in kW; the coordinator
# converts it to W when mapping to invTodayPpv.
mock_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 1250.0,
"current_power": 2500,
"current_power": 2.5,
}
# Called by switch/number entities during turn_on/turn_off/set_value
@@ -88,8 +88,8 @@
}),
]),
'total_coordinator': dict({
'current_power': 2500,
'invTodayPpv': 2500,
'current_power': 2.5,
'invTodayPpv': 2500.0,
'todayEnergy': 12.5,
'today_energy': 12.5,
'totalEnergy': 1250.0,
@@ -3881,7 +3881,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2500',
'state': '2500.0',
})
# ---
# name: test_min_sensors_v1_api[sensor.test_plant_total_total_money_today-entry]
@@ -15939,7 +15939,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2500',
'state': '2500.0',
})
# ---
# name: test_sph_sensors_v1_api[sensor.test_plant_total_total_money_today-entry]
@@ -169,7 +169,7 @@ async def test_sensor_coordinator_updates(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 25.0, # Changed from 12.5
"total_energy": 1250.0,
"current_power": 2500,
"current_power": 2.5,
}
# Trigger coordinator refresh
@@ -284,7 +284,7 @@ async def test_midnight_bounce_suppression(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.1,
"total_energy": 1250.1,
"current_power": 500,
"current_power": 0.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -339,7 +339,7 @@ async def test_normal_reset_no_bounce(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.1,
"total_energy": 1250.1,
"current_power": 500,
"current_power": 0.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -353,7 +353,7 @@ async def test_normal_reset_no_bounce(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 1.5,
"total_energy": 1251.5,
"current_power": 2000,
"current_power": 2.0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -452,7 +452,7 @@ async def test_midnight_bounce_repeated(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.2,
"total_energy": 1250.2,
"current_power": 1000,
"current_power": 1.0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -486,7 +486,7 @@ async def test_non_total_increasing_sensor_unaffected_by_bounce_suppression(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 0,
"current_power": 2500,
"current_power": 2.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -500,7 +500,7 @@ async def test_non_total_increasing_sensor_unaffected_by_bounce_suppression(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 1250.0,
"current_power": 2500,
"current_power": 2.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
+6 -2
View File
@@ -157,8 +157,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed(
history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids()
)
# Check we got the expected events
assert events == ["remove"]
# Check we got the expected events: the helper entity's device link is
# cleared when the source device is removed (the helper entity belongs to
# the history_stats config entry, not the removed source config entry),
# then the helper entity is removed when the history_stats config entry is
# removed. Both registry actions are observed in fire order.
assert events == ["update", "remove"]
@pytest.mark.usefixtures("recorder_mock")
@@ -1,32 +1,38 @@
# serializer version: 1
# name: test_diagnostics
dict({
'accessPointId': '3014F7110000000000000000',
'clients': dict({
'00000000-0000-0000-0000-000000000000': dict({
'config': dict({
'accessPointId': '3014F7110000000000000000',
'clients': dict({
'00000000-0000-0000-0000-000000000000': dict({
'id': '00000000-0000-0000-0000-000000000000',
'label': 'Home Assistant',
'refreshToken': None,
}),
}),
'devices': dict({
'3014F7110000000000000001': dict({
'id': '3014F7110000000000000001',
'label': 'Living Room Thermostat',
'serializedGlobalTradeItemNumber': '3014F7110000000000000002',
'type': 'WALL_MOUNTED_THERMOSTAT_PRO',
}),
}),
'home': dict({
'id': '00000000-0000-0000-0000-000000000000',
'label': 'Home Assistant',
'refreshToken': None,
'location': dict({
'city': '**REDACTED**',
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
}),
'weather': dict({
'temperature': 18.3,
}),
}),
}),
'devices': dict({
'3014F7110000000000000001': dict({
'id': '3014F7110000000000000001',
'label': 'Living Room Thermostat',
'serializedGlobalTradeItemNumber': '3014F7110000000000000002',
'type': 'WALL_MOUNTED_THERMOSTAT_PRO',
}),
}),
'home': dict({
'id': '00000000-0000-0000-0000-000000000000',
'location': dict({
'city': '**REDACTED**',
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
}),
'weather': dict({
'temperature': 18.3,
}),
'websocket': dict({
'message_count': 0,
'reconnect_attempts': 0,
}),
})
# ---
+221 -58
View File
@@ -1,5 +1,6 @@
"""Test HomematicIP Cloud accesspoint."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from homematicip.auth import Auth
@@ -240,36 +241,27 @@ async def test_auth_create_exception(hass: HomeAssistant, simple_mock_auth) -> N
async def test_get_state_after_disconnect(
hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home
) -> None:
"""Test get state after disconnect."""
"""ws_connected after a disconnect triggers a state refresh via the library."""
hass.config.components.add(DOMAIN)
hap = HomematicipHAP(hass, hmip_config_entry)
assert hap
simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True)
simple_mock_home.devices = []
hap.home = simple_mock_home
hap.home.websocket_is_connected = Mock(side_effect=[False, True])
with (
patch("asyncio.sleep", new=AsyncMock()) as mock_sleep,
patch.object(hap, "get_state") as mock_get_state,
):
assert not hap._ws_connection_closed.is_set()
await hap.ws_connected_handler()
mock_get_state.assert_not_called()
await hap.ws_disconnected_handler()
assert hap._ws_connection_closed.is_set()
with patch(
"homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected",
return_value=True,
):
await hap.ws_connected_handler()
mock_get_state.assert_called_once()
assert not hap._ws_connection_closed.is_set()
hap.home.websocket_is_connected.assert_called()
mock_sleep.assert_awaited_with(2)
await hap.ws_connected_handler()
simple_mock_home.refresh_state_after_reconnect_async.assert_not_called()
await hap.ws_disconnected_handler()
assert hap._ws_connection_closed.is_set()
await hap.ws_connected_handler()
await hass.async_block_till_done()
simple_mock_home.refresh_state_after_reconnect_async.assert_called_once()
assert not hap._ws_connection_closed.is_set()
async def test_get_state_after_ap_reconnect(
@@ -288,48 +280,42 @@ async def test_get_state_after_ap_reconnect(
simple_mock_home = MagicMock(spec=AsyncHome)
simple_mock_home.devices = []
simple_mock_home.websocket_is_connected = Mock(return_value=True)
simple_mock_home.refresh_state_after_reconnect_async = AsyncMock()
hap.home = simple_mock_home
with patch.object(hap, "get_state") as mock_get_state:
# Initially not disconnected
assert not hap._ws_connection_closed.is_set()
# Initially not disconnected
assert not hap._ws_connection_closed.is_set()
# Access point loses cloud connection
hap.home.connected = False
hap.async_update()
assert hap._ws_connection_closed.is_set()
mock_get_state.assert_not_called()
# Access point loses cloud connection
hap.home.connected = False
hap.async_update()
assert hap._ws_connection_closed.is_set()
simple_mock_home.refresh_state_after_reconnect_async.assert_not_called()
# Access point reconnects to cloud
hap.home.connected = True
hap.async_update()
# Let _try_get_state run
await hass.async_block_till_done()
mock_get_state.assert_called_once()
# Access point reconnects to cloud
hap.home.connected = True
hap.async_update()
# Let _try_get_state run
await hass.async_block_till_done()
simple_mock_home.refresh_state_after_reconnect_async.assert_called_once()
assert not hap._ws_connection_closed.is_set()
async def test_try_get_state_exponential_backoff() -> None:
"""Test _try_get_state waits for websocket connection."""
# Arrange: Create instance and mock home
async def test_try_get_state_delegates_to_library_then_post_processes() -> None:
"""_try_get_state calls refresh_state_after_reconnect_async then runs post-processing."""
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
hap.home.websocket_is_connected = Mock(return_value=True)
hap.home.refresh_state_after_reconnect_async = AsyncMock()
device_changed = MagicMock(unreach=False)
device_unchanged = MagicMock(unreach=True)
hap.home.devices = [device_changed, device_unchanged]
hap.get_state = AsyncMock(
side_effect=[HmipConnectionError, HmipConnectionError, True]
)
await hap._try_get_state()
with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep:
await hap._try_get_state()
assert mock_sleep.mock_calls[0].args[0] == 8
assert mock_sleep.mock_calls[1].args[0] == 16
assert hap.get_state.call_count == 3
hap.home.refresh_state_after_reconnect_async.assert_awaited_once()
assert device_changed.unreach is False
assert device_unchanged.unreach is False
async def test_try_get_state_handle_exception() -> None:
@@ -371,25 +357,202 @@ async def test_async_connect(
async def test_try_get_state_auth_error_triggers_reauth(
hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home
) -> None:
"""Test _try_get_state stops retrying on auth error and triggers reauth."""
"""An auth error from the library triggers a reauth flow without post-processing."""
hass.config.components.add(DOMAIN)
hmip_config_entry.add_to_hass(hass)
hap = HomematicipHAP(hass, hmip_config_entry)
assert hap
hap.home = MagicMock(spec=AsyncHome)
hap.home.websocket_is_connected = Mock(return_value=True)
hap.get_state = AsyncMock(side_effect=HmipAuthenticationError)
hap.home.devices = [MagicMock(unreach=True)]
hap.home.refresh_state_after_reconnect_async = AsyncMock(
side_effect=HmipAuthenticationError
)
assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN)
await hap._try_get_state()
await hass.async_block_till_done()
# Should have called get_state only once (no retries)
assert hap.get_state.call_count == 1
# Auth error path: post-processing must NOT have run.
assert hap.home.devices[0].unreach is True
# Should have triggered a reauth flow
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == "reauth"
def _set_diagnostic_defaults(home: MagicMock) -> None:
"""Configure quiet defaults for diagnostic methods on a mocked AsyncHome."""
home.websocket_last_disconnect_reason = Mock(return_value=None)
home.websocket_reconnect_attempt_count = Mock(return_value=None)
home.websocket_seconds_since_last_message = Mock(return_value=None)
home.websocket_message_count = Mock(return_value=None)
async def test_start_get_state_task_cancels_existing_task(
hass: HomeAssistant, hmip_config_entry: MockConfigEntry
) -> None:
"""Starting a reconnect refresh cancels any in-flight refresh."""
hass.config.components.add(DOMAIN)
hap = HomematicipHAP(hass, hmip_config_entry)
hap.home = MagicMock(spec=AsyncHome)
old_task = MagicMock()
old_task.done.return_value = False
hap._get_state_task = old_task
with patch.object(hap, "_try_get_state", new=AsyncMock()):
hap._start_get_state_task()
old_task.cancel.assert_called_once()
assert hap._get_state_task is not old_task
assert not hap._ws_connection_closed.is_set()
async def test_start_get_state_task_skips_cancel_for_completed_task(
hass: HomeAssistant, hmip_config_entry: MockConfigEntry
) -> None:
"""Starting a reconnect refresh does not cancel a completed task."""
hass.config.components.add(DOMAIN)
hap = HomematicipHAP(hass, hmip_config_entry)
hap.home = MagicMock(spec=AsyncHome)
old_task = MagicMock()
old_task.done.return_value = True
hap._get_state_task = old_task
with patch.object(hap, "_try_get_state", new=AsyncMock()):
hap._start_get_state_task()
old_task.cancel.assert_not_called()
async def test_replaced_get_state_task_cancellation_is_not_logged_as_error(
hass: HomeAssistant, hmip_config_entry: MockConfigEntry
) -> None:
"""Replacing an in-flight refresh must not log the cancelled task as error."""
hass.config.components.add(DOMAIN)
hap = HomematicipHAP(hass, hmip_config_entry)
hap.home = MagicMock(spec=AsyncHome)
hap.home.devices = []
_set_diagnostic_defaults(hap.home)
continue_refresh = asyncio.Event()
async def block_refresh() -> None:
await continue_refresh.wait()
hap.home.refresh_state_after_reconnect_async = AsyncMock(side_effect=block_refresh)
with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as logger:
hap._ws_connection_closed.set()
hap._start_get_state_task()
first_task = hap._get_state_task
assert first_task is not None
await asyncio.sleep(0)
hap._ws_connection_closed.set()
hap._start_get_state_task()
second_task = hap._get_state_task
assert second_task is not None
assert second_task is not first_task
await asyncio.sleep(0)
continue_refresh.set()
await hass.async_block_till_done()
assert first_task.cancelled()
logger.error.assert_not_called()
async def test_websocket_diagnostic_context_omits_none_values() -> None:
"""None-valued diagnostics are omitted from the context string."""
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
hap.home.websocket_last_disconnect_reason = Mock(return_value=None)
hap.home.websocket_reconnect_attempt_count = Mock(return_value=2)
hap.home.websocket_seconds_since_last_message = Mock(return_value=None)
hap.home.websocket_message_count = Mock(return_value=10)
context = hap._websocket_diagnostic_context()
assert "last_disconnect_reason" not in context
assert "reconnect_attempts=2" in context
assert "message_count=10" in context
async def test_websocket_diagnostic_context_falls_back_when_all_unknown() -> None:
"""Helper returns a non-empty fallback if every diagnostic is None."""
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
_set_diagnostic_defaults(hap.home)
assert hap._websocket_diagnostic_context() == "no diagnostics available"
async def test_on_websocket_stale_logs_warning_then_error() -> None:
"""Library callback maps severity to log level (warning vs error)."""
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
_set_diagnostic_defaults(hap.home)
with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as logger:
await hap._on_websocket_stale("warning", 400)
logger.warning.assert_called_once()
logger.error.assert_not_called()
with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as logger:
await hap._on_websocket_stale("error", 1900)
logger.error.assert_called_once()
logger.warning.assert_not_called()
async def test_async_connect_registers_stale_handler() -> None:
"""async_connect registers the library websocket-stale callback."""
hap = HomematicipHAP(MagicMock(), MagicMock())
home = MagicMock()
home.enable_events = AsyncMock()
home.set_on_websocket_stale_handler = MagicMock()
await hap.async_connect(home)
home.set_on_websocket_stale_handler.assert_called_once_with(hap._on_websocket_stale)
async def test_on_websocket_stale_log_format(caplog: pytest.LogCaptureFixture) -> None:
"""Warning has the rounded seconds; diagnostic context is at debug level."""
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
_set_diagnostic_defaults(hap.home)
hap.home.websocket_message_count = Mock(return_value=42)
with caplog.at_level("DEBUG"):
await hap._on_websocket_stale("warning", 423.7)
assert "424" in caplog.text # %.0f rounds
assert "message_count=42" in caplog.text
warning_records = [r for r in caplog.records if r.levelname == "WARNING"]
assert any("424" in r.getMessage() for r in warning_records)
assert not any("message_count" in r.getMessage() for r in warning_records)
async def test_get_state_clears_unreach_on_unchanged_devices() -> None:
"""get_state must clear stale unreach flags after a reconnect.
set_all_to_unavailable() sets unreach=True on all devices on disconnect;
get_current_state_async() only updates devices whose state actually
changed, so unchanged devices stay marked unreachable. We must clear it.
"""
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
hap.home.get_current_state_async = AsyncMock()
device_changed = MagicMock(unreach=False)
device_unchanged = MagicMock(unreach=True)
hap.home.devices = [device_changed, device_unchanged]
await hap.get_state()
assert device_changed.unreach is False
assert device_unchanged.unreach is False
+1 -1
View File
@@ -63,7 +63,7 @@ async def test_unload_entry(hass: HomeAssistant, mock_bridge_setup) -> None:
return True
mock_bridge_setup.async_reset = mock_reset
assert await hue.async_unload_entry(hass, entry)
assert await hass.config_entries.async_unload(entry.entry_id)
assert not hasattr(entry, "runtime_data")
+127
View File
@@ -1,9 +1,16 @@
"""Configure iCloud tests."""
from collections.abc import Generator
from unittest.mock import patch
from pyicloud.services.photos import AlbumContainer, PhotoAlbumFolder, PhotoAsset
import pytest
from homeassistant.components.icloud.const import DOMAIN
from tests.common import AsyncMock, MockConfigEntry
from tests.typing import MagicMock
@pytest.fixture(autouse=True)
def icloud_not_create_dir():
@@ -12,3 +19,123 @@ def icloud_not_create_dir():
"homeassistant.components.icloud.config_flow.os.path.exists", return_value=True
):
yield
@pytest.fixture(name="icloud_client")
def mock_icloud_client() -> Generator[AsyncMock]:
"""Mock iCloud client."""
with (
patch(
"homeassistant.components.icloud.account.IcloudAccount", autospec=True
) as mock_client,
patch(
"homeassistant.components.icloud.IcloudAccount",
new=mock_client,
),
):
client = mock_client.return_value
client.api = MagicMock()
client.photo_cache = None
albums = [
MagicMock(
spec=PhotoAlbumFolder, id="folder_id1", title="My Folder 1", albums=[]
),
MagicMock(
id="album_id1",
title="All Photos",
photos=[
MagicMock(
spec=PhotoAsset,
id="photo_id1",
filename="My Photo 1.JPG",
item_type="image",
versions={
"original": MagicMock(
size=123456,
width=4000,
height=3000,
)
},
),
MagicMock(
spec=PhotoAsset,
id="photo_id2",
filename="My Photo 2.heic",
item_type="image",
),
MagicMock(
spec=PhotoAsset,
id="photo_id3",
filename="My Photo 3.png",
item_type="image",
),
],
),
MagicMock(
id="album_id2",
title="My Photos",
photos=[
MagicMock(
spec=PhotoAsset,
id="photo_id2",
filename="My Photo 2.heic",
item_type="image",
),
],
),
]
shared = [
MagicMock(
id="stream_id1",
title="Favorites",
photos=[
MagicMock(
spec=PhotoAsset,
id="shared_id1",
filename="My Photo 1.jpg",
item_type="image",
),
MagicMock(
spec=PhotoAsset,
id="shared_id2",
filename="My Video 1.mp4",
item_type="movie",
),
],
),
MagicMock(
id="stream_id2",
title="Random Stream",
photos=[
MagicMock(
spec=PhotoAsset,
id="shared_id3",
filename="My Unknown file.xyz",
item_type="unknown",
),
],
),
]
client.api.photos.albums = AlbumContainer(albums)
client.api.photos.shared_streams = AlbumContainer(shared)
yield client
@pytest.fixture(name="config_entry")
def mock_config_entry() -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id="test_account_id",
title="Test iCloud Account",
data={
"username": "test_user",
"password": "test_pass",
"with_family": False,
"max_interval": 0,
"gps_accuracy_threshold": 0,
},
)
@@ -0,0 +1,990 @@
"""Tests for media source of the iCloud integration."""
from base64 import b64encode
from http import HTTPStatus
from types import SimpleNamespace
from unittest.mock import PropertyMock, patch
import urllib.parse
from aiohttp import hdrs
import pytest
from homeassistant.components.icloud.const import DOMAIN
from homeassistant.components.icloud.media_source import PhotoCache
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_source import (
URI_SCHEME,
BrowseMediaSource,
Unresolvable,
async_browse_media,
async_resolve_media,
)
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import AsyncMock, MockConfigEntry
from tests.typing import ClientSessionGenerator
@pytest.fixture(autouse=True)
async def setup_media_source(hass: HomeAssistant) -> None:
"""Setup media source component."""
await async_setup_component(hass, "media_source", {})
@pytest.mark.parametrize(
("url", "title", "media_class", "children"),
[
(
f"{URI_SCHEME}{DOMAIN}",
"iCloud Media",
MediaClass.DIRECTORY,
1,
),
(
f"{URI_SCHEME}{DOMAIN}/test_account_id",
"iCloud Media / Test iCloud Account",
MediaClass.DIRECTORY,
2,
),
(
f"{URI_SCHEME}{DOMAIN}/test_account_id/album",
"iCloud Media / Test iCloud Account / Albums",
MediaClass.DIRECTORY,
2,
),
(
f"{URI_SCHEME}{DOMAIN}/test_account_id/album/album_id1",
"iCloud Media / Test iCloud Account / Albums / All Photos",
MediaClass.DIRECTORY,
3,
),
(
f"{URI_SCHEME}{DOMAIN}/test_account_id/shared",
"iCloud Media / Test iCloud Account / Shared Streams",
MediaClass.DIRECTORY,
2,
),
(
f"{URI_SCHEME}{DOMAIN}/test_account_id/shared/stream_id1",
"iCloud Media / Test iCloud Account / Shared Streams / Favorites",
MediaClass.DIRECTORY,
2,
),
],
ids=[
"root",
"account",
"albums",
"album_photos",
"shared_streams",
"shared_stream_photos",
],
)
@pytest.mark.usefixtures("icloud_client")
async def test_browse_media(
hass: HomeAssistant,
config_entry: MockConfigEntry,
url: str,
title: str,
media_class: str,
children: int,
) -> None:
"""Test browsing media."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
browse: BrowseMediaSource = await async_browse_media(hass, url)
assert browse.title == title
assert browse.media_class == media_class
assert browse.children is not None
assert len(browse.children) == children
@pytest.mark.usefixtures("icloud_client")
async def test_browse_media_accounts(
hass: HomeAssistant,
) -> None:
"""Test browsing media with multiple accounts."""
config_entry_1 = MockConfigEntry(
domain=DOMAIN,
unique_id="test_account_id_1",
title="Test iCloud Account 1",
data={
"username": "test_user_1",
"password": "test_pass_1",
"with_family": False,
"max_interval": 0,
"gps_accuracy_threshold": 0,
},
)
config_entry_1.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry_1.entry_id)
await hass.async_block_till_done()
assert config_entry_1.state is ConfigEntryState.LOADED
config_entry_2 = MockConfigEntry(
domain=DOMAIN,
unique_id="test_account_id_2",
title="Test iCloud Account 2",
data={
"username": "test_user_2",
"password": "test_pass_2",
"with_family": True,
"max_interval": 0,
"gps_accuracy_threshold": 0,
},
)
config_entry_2.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry_2.entry_id)
await hass.async_block_till_done()
assert config_entry_2.state is ConfigEntryState.LOADED
assert len(hass.config_entries.async_loaded_entries(DOMAIN)) == 2
browse: BrowseMediaSource = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
assert browse.title == "iCloud Media"
assert browse.media_class == MediaClass.DIRECTORY
assert browse.children is not None
assert len(browse.children) == 2
@pytest.mark.parametrize(
("media_content_id", "service", "has_value", "exception"),
[
(
"/invalid_account_id",
None,
None,
Unresolvable,
),
(
"/test_account_id/invalid_view",
None,
None,
Unresolvable,
),
(
"/test_account_id/album/invalid_album_id",
None,
None,
Unresolvable,
),
(
"/test_account_id/shared/invalid_album_id",
None,
None,
Unresolvable,
),
(
"/test_account_id/album",
"api",
False,
BrowseError,
),
(
"/test_account_id/album",
"api.photos",
False,
Exception,
),
(
"/test_account_id/shared",
"api",
False,
BrowseError,
),
(
"/test_account_id/shared",
"api.photos",
False,
Exception,
),
(
"/test_account_id/album",
"api.photos.albums",
True,
Exception,
),
(
"/test_account_id/shared",
"api.photos.shared_streams",
True,
Exception,
),
(
"/test_account_id/shared/stream_id1",
"api.photos.shared_streams.get",
None,
Exception,
),
(
"/test_account_id/album/stream_id1",
"api.photos.albums.get",
None,
Exception,
),
],
ids=[
"invalid_account_id",
"invalid_view",
"invalid_album_id_in_album_view",
"invalid_album_id_in_shared_view",
"api_not_available_for_album_view",
"photos_not_available_for_album_view",
"api_not_available_for_shared_view",
"photos_not_available_for_shared_view",
"albums_not_available_for_album_view",
"shared_streams_not_available_for_shared_view",
"stream_not_available_for_shared_stream",
"album_not_available_for_album",
],
)
async def test_browse_media_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
icloud_client: AsyncMock,
media_content_id: str,
service: str,
has_value: bool,
exception: type[Exception],
) -> None:
"""Test browsing media with exceptions."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
if service and exception:
parent = icloud_client
properties = service.split(".")
while len(properties) > 1:
prop = properties.pop(0)
p_mock = PropertyMock()
setattr(parent, prop, p_mock)
parent = p_mock
setattr(
parent,
properties[0],
None if not has_value else PropertyMock(side_effect=exception()),
)
with pytest.raises(exception):
await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}{media_content_id}")
@pytest.mark.usefixtures("icloud_client")
async def test_browse_media_not_initialized_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test browsing media with account not initialized."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
config_entry.runtime_data = None
with pytest.raises(Unresolvable, match="Account not initialized: test_account_id"):
await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/test_account_id")
@pytest.mark.usefixtures("icloud_client")
async def test_browse_media_item_is_leaf(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test browsing media with item that is a leaf node."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
with pytest.raises(BrowseError, match="Unknown media item"):
await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/test_account_id/album/album_id1/photo_id1"
)
@pytest.mark.usefixtures("icloud_client")
async def test_browse_media_not_configured_exception(
hass: HomeAssistant,
) -> None:
"""Test browsing media with no account configured."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="test_account_id",
title="Test iCloud Account",
data={
"username": "test_user",
"password": "test_pass",
"with_family": False,
"max_interval": 0,
"gps_accuracy_threshold": 0,
},
disabled_by=ConfigEntryDisabler.USER,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
with pytest.raises(BrowseError, match="Config entry not loaded"):
await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
@pytest.mark.parametrize(
("media_content_id", "url", "mime_type"),
[
(
"/test_account_id/album/album_id1/photo_id1",
"/api/icloud/media_source/serve/original/dGVzdF9hY2NvdW50X2lkL2FsYnVtL2FsYnVtX2lkMS9waG90b19pZDE=",
"image/jpeg",
),
(
"/test_account_id/album/album_id2/photo_id2",
"/api/icloud/media_source/serve/original/dGVzdF9hY2NvdW50X2lkL2FsYnVtL2FsYnVtX2lkMi9waG90b19pZDI=",
"image/heic",
),
(
"/test_account_id/album/album_id1/photo_id3",
"/api/icloud/media_source/serve/original/dGVzdF9hY2NvdW50X2lkL2FsYnVtL2FsYnVtX2lkMS9waG90b19pZDM=",
"image/png",
),
(
"/test_account_id/shared/stream_id1/shared_id1",
"/api/icloud/media_source/serve/original/dGVzdF9hY2NvdW50X2lkL3NoYXJlZC9zdHJlYW1faWQxL3NoYXJlZF9pZDE=",
"image/jpeg",
),
(
"/test_account_id/shared/stream_id1/shared_id2",
"/api/icloud/media_source/serve/original/dGVzdF9hY2NvdW50X2lkL3NoYXJlZC9zdHJlYW1faWQxL3NoYXJlZF9pZDI=",
"video/mp4",
),
],
ids=[
"album_1_photo_jpeg",
"album_2_photo_heic",
"album_3_photo_png",
"shared_stream_photo",
"shared_stream_movie",
],
)
@pytest.mark.usefixtures("icloud_client")
async def test_resolve_media(
hass: HomeAssistant,
config_entry: MockConfigEntry,
media_content_id: str,
url: str,
mime_type: str,
) -> None:
"""Test resolving media."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
media = await async_resolve_media(
hass,
f"{URI_SCHEME}{DOMAIN}{media_content_id}",
None,
)
assert media.url == url
assert media.mime_type == mime_type
@pytest.mark.parametrize(
("media_content_id", "exception", "exc_message"),
[
(
"/invalid_account_id/album/album_id1/photo_id1",
Unresolvable,
"Config entry not found for account: invalid_account_id",
),
(
"/test_account_id/invalid_view/album_id1/photo_id1",
Unresolvable,
"Invalid album view type",
),
(
"/test_account_id/album/invalid_album_id/photo_id1",
Unresolvable,
"Album not found",
),
(
"/test_account_id/shared/invalid_album_id/photo_id1",
Unresolvable,
"Album not found",
),
(
"/test_account_id/shared/stream_id2/shared_id3",
Unresolvable,
"Unsupported media type",
),
(
"",
Unresolvable,
"Incomplete media source identifier",
),
],
ids=[
"invalid_account_id",
"invalid_view",
"invalid_album_id_in_album_view",
"invalid_album_id_in_shared_view",
"unknown_photo_type_in_shared_stream",
"unknown_account",
],
)
@pytest.mark.usefixtures("icloud_client")
async def test_resolve_media_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
media_content_id: str,
exception: type[Exception],
exc_message: str,
) -> None:
"""Test resolving media with exceptions."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
with pytest.raises(exception, match=exc_message):
await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}{media_content_id}", None)
@pytest.mark.parametrize(
("media_content_id", "service", "has_value", "exception", "exc_message"),
[
(
"/test_account_id/album/album_id1/photo_id1",
"api",
False,
Unresolvable,
"Account not initialized: test_account_id",
),
(
"/test_account_id/album/album_id1/photo_id1",
"api.photos",
False,
Exception,
"Account not initialized: test_account_id",
),
(
"/test_account_id/shared/album_id1/photo_id1",
"api",
False,
Unresolvable,
"Account not initialized: test_account_id",
),
(
"/test_account_id/shared/album_id1/photo_id1",
"api.photos",
False,
Exception,
"Account not initialized: test_account_id",
),
(
"/test_account_id/album/album_id1/photo_id1",
"api.photos.albums",
True,
Exception,
"Photo not found",
),
(
"/test_account_id/shared/album_id1/photo_id1",
"api.photos.shared_streams",
True,
Exception,
"Photo not found",
),
(
"/test_account_id/shared/stream_id1",
"api.photos.shared_streams.get",
None,
Exception,
"Incomplete media source identifier",
),
(
"/test_account_id/album/stream_id1",
"api.photos.albums.get",
None,
Exception,
"Incomplete media source identifier",
),
],
ids=[
"api_not_available_for_album_view",
"photos_not_available_for_album_view",
"api_not_available_for_shared_view",
"photos_not_available_for_shared_view",
"albums_not_available_for_album_view",
"shared_streams_not_available_for_shared_view",
"stream_not_available_for_shared_stream",
"album_not_available_for_album",
],
)
async def test_resolve_media_service_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
icloud_client: AsyncMock,
media_content_id: str,
service: str,
has_value: bool,
exception: type[Exception],
exc_message: str,
) -> None:
"""Test resolving media with serviceexceptions."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
parent = icloud_client
properties = service.split(".")
while len(properties) > 1:
prop = properties.pop(0)
p_mock = PropertyMock()
setattr(parent, prop, p_mock)
parent = p_mock
setattr(
parent,
properties[0],
None if not has_value else PropertyMock(side_effect=exception()),
)
with pytest.raises(exception, match=exc_message):
await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}{media_content_id}", None)
@pytest.mark.parametrize(
("album_type", "aid", "exc_message"),
[
("album", "album_id1", "Photo not found"),
("album", "album_id13", "Album not found"),
("shared", "stream_id1", "Photo not found"),
("shared", "stream_id3", "Album not found"),
("invalid_type", "stream_id2", "Invalid album view type"),
],
ids=[
"photo_not_found_in_album",
"album_not_found_in_album_view",
"photo_not_found_in_shared_view",
"album_not_found_in_shared_view",
"invalid_view_type",
],
)
@pytest.mark.usefixtures("icloud_client")
async def test_resolve_media_not_found_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
album_type: str,
aid: str,
exc_message: str,
) -> None:
"""Test resolving media with media not found exceptions."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
with pytest.raises(Unresolvable, match=exc_message):
await async_resolve_media(
hass,
f"{URI_SCHEME}{DOMAIN}/test_account_id/{album_type}/{aid}/unknown_photo_id",
None,
)
@pytest.mark.usefixtures("icloud_client")
async def test_resolve_media_not_configured(
hass: HomeAssistant,
) -> None:
"""Test resolving media with no account configured."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="test_account_id",
title="Test iCloud Account",
data={
"username": "test_user",
"password": "test_pass",
"with_family": False,
"max_interval": 0,
"gps_accuracy_threshold": 0,
},
disabled_by=ConfigEntryDisabler.USER,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
with pytest.raises(BrowseError, match="Config entry not loaded"):
await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}", None)
@pytest.mark.usefixtures("icloud_client")
async def test_resolve_media_no_cache(
hass: HomeAssistant,
) -> None:
"""Test resolving media with no photo cache configured."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="test_account_id",
title="Test iCloud Account",
data={
"username": "test_user",
"password": "test_pass",
"with_family": False,
"max_interval": 0,
"gps_accuracy_threshold": 0,
},
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
config_entry.runtime_data.photo_cache = None
with pytest.raises(Unresolvable, match="Config entry not loaded"):
await async_resolve_media(
hass,
f"{URI_SCHEME}{DOMAIN}/test_account_id/album/album_id1/photo_id1",
None,
)
@pytest.mark.usefixtures("icloud_client")
async def test_media_source_view_requires_auth(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test media source view returns 401 when no auth provided."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
image_id = b64encode(b"test_account_id/album/album_id1/photo_id1").decode()
client = await hass_client_no_auth()
resp = await client.get(f"/api/icloud/media_source/serve/original/{image_id}")
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("icloud_client")
async def test_media_source_view_bad_identifier_returns_400(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test media source view returns 400 when identifier is bad."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
client = await hass_client()
resp = await client.get("/api/icloud/media_source/serve/original/not-base64")
assert resp.status == HTTPStatus.BAD_REQUEST
@pytest.mark.usefixtures("icloud_client")
async def test_media_source_view_missing_version_returns_404(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test media source view returns 404 when photo version is missing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
image_id = b64encode(b"test_account_id/album/album_id1/photo_id1").decode()
client = await hass_client()
resp = await client.get(f"/api/icloud/media_source/serve/thumb/{image_id}")
assert resp.status == HTTPStatus.NOT_FOUND
@pytest.mark.usefixtures("icloud_client")
async def test_media_source_view_missing_photo_returns_404(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test media source view returns 404 when photo is missing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
image_id = b64encode(b"test_account_id/album/album_id1/photo_id4").decode()
client = await hass_client()
resp = await client.get(f"/api/icloud/media_source/serve/thumb/{image_id}")
assert resp.status == HTTPStatus.NOT_FOUND
@pytest.mark.parametrize(
("media_content_id", "expected_filename"),
[
("test_account_id/album/album_id1/photo_id1", "My Photo 1.JPG"),
("test_account_id/album/album_id1/photo_id2", "My Photo 2.heic"),
("test_account_id/album/album_id1/photo_id3", "My Photo 3.png"),
],
)
@pytest.mark.usefixtures("icloud_client")
async def test_media_source_view_streams_content_and_headers(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client: ClientSessionGenerator,
media_content_id: str,
expected_filename: str,
) -> None:
"""Test media source view streams content and headers correctly."""
# Mock upstream iCloud response stream
class _Content:
async def iter_chunked(self, _size: int):
yield b"abc"
yield b"def"
upstream_resp = AsyncMock()
upstream_resp.status = HTTPStatus.OK
upstream_resp.reason = "OK"
upstream_resp.headers = {
hdrs.CONTENT_TYPE: "image/jpeg",
hdrs.LAST_MODIFIED: "Mon, 01 Jan 2024 00:00:00 GMT",
hdrs.CONTENT_LENGTH: "6",
}
upstream_resp.content = _Content()
upstream_resp.release.return_value = None
mock_session = AsyncMock()
mock_session.get.return_value = upstream_resp
mock_photo = SimpleNamespace(
filename=expected_filename,
versions={"original": {"url": "https://icloud.test/original"}},
)
with (
patch(
"homeassistant.components.icloud.media_source.async_get_clientsession",
return_value=mock_session,
),
patch(
"homeassistant.components.icloud.media_source._get_photo_asset",
return_value=mock_photo,
),
):
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
image_id = b64encode(media_content_id.encode()).decode()
client = await hass_client()
resp = await client.get(f"/api/icloud/media_source/serve/original/{image_id}")
assert resp.status == HTTPStatus.OK
assert await resp.read() == b"abcdef"
assert resp.headers[hdrs.CONTENT_TYPE] == "image/jpeg"
assert (
resp.headers[hdrs.CONTENT_DISPOSITION]
== f'attachment;filename="{urllib.parse.quote(expected_filename.encode())}"'
)
@pytest.mark.usefixtures("icloud_client")
async def test_media_source_view_streams_timeout(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test media source view handles timeout during streaming."""
# Mock upstream iCloud response stream
class _Content:
async def iter_chunked(self, _size: int):
yield b"abc"
raise TimeoutError
upstream_resp = AsyncMock()
upstream_resp.status = HTTPStatus.OK
upstream_resp.reason = "OK"
upstream_resp.headers = {
hdrs.CONTENT_TYPE: "image/jpeg",
hdrs.LAST_MODIFIED: "Mon, 01 Jan 2024 00:00:00 GMT",
hdrs.CONTENT_LENGTH: "6",
}
upstream_resp.content = _Content()
upstream_resp.release.return_value = None
mock_session = AsyncMock()
mock_session.get.return_value = upstream_resp
with (
patch(
"homeassistant.components.icloud.media_source.async_get_clientsession",
return_value=mock_session,
),
):
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
image_id = b64encode(b"test_account_id/album/album_id1/photo_id1").decode()
client = await hass_client()
resp = await client.get(f"/api/icloud/media_source/serve/original/{image_id}")
assert resp.status == HTTPStatus.OK
await resp.read()
assert resp.headers[hdrs.CONTENT_TYPE] == "image/jpeg"
assert (
resp.headers[hdrs.CONTENT_DISPOSITION]
== 'attachment;filename="My%20Photo%201.JPG"'
)
@pytest.mark.usefixtures("icloud_client")
async def test_media_source_view_streams_content_and_headers_cache_tests(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test media source view streams content and headers correctly and that cache works."""
expected_filename = "My Photo 1.JPG"
media_content_id1 = "test_account_id/album/album_id1/photo_id1"
media_content_id2 = "test_account_id/album/album_id1/photo_id2"
# Mock upstream iCloud response stream
class _Content:
def __init__(self, max_size) -> None:
self.max_size = max_size
async def iter_chunked(self, _size: int):
data = b"abcdef"
chunk_size = 3
if self.max_size and len(data) > self.max_size:
yield data[: self.max_size]
else:
for i in range(0, len(data), chunk_size):
yield data[i : i + chunk_size]
def mock_get(url, *args, **kwargs):
upstream_resp = AsyncMock()
upstream_resp.status = HTTPStatus.OK
upstream_resp.reason = "OK"
upstream_resp.headers = {
hdrs.CONTENT_TYPE: "image/jpeg",
hdrs.LAST_MODIFIED: "Mon, 01 Jan 2024 00:00:00 GMT",
hdrs.CONTENT_LENGTH: "6",
}
if size := kwargs.get("headers", {}).get(hdrs.RANGE):
# to verify that headers are passed correctly
upstream_resp.headers[hdrs.CONTENT_RANGE] = f"bytes {size.split('=')[1]}/6"
upstream_resp.headers[hdrs.CONTENT_LENGTH] = size.split("/")[0].split("-")[
1
]
upstream_resp.status = HTTPStatus.PARTIAL_CONTENT
upstream_resp.reason = "Partial Content"
upstream_resp.content = _Content(
int(upstream_resp.headers[hdrs.CONTENT_LENGTH])
)
upstream_resp.release.return_value = None
return upstream_resp
mock_session = AsyncMock()
mock_session.get.side_effect = mock_get
mock_photo = SimpleNamespace(
filename=expected_filename,
versions={"original": {"url": "https://icloud.test/original"}},
)
with (
patch(
"homeassistant.components.icloud.media_source.async_get_clientsession",
return_value=mock_session,
),
patch(
"homeassistant.components.icloud.media_source._get_photo_asset",
return_value=mock_photo,
),
):
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
config_entry.runtime_data.photo_cache = PhotoCache(
max_size=1 # set cache size to 1 to test eviction of first item
)
image_id1 = b64encode(media_content_id1.encode()).decode()
image_id2 = b64encode(media_content_id2.encode()).decode()
client = await hass_client()
resp = await client.get(
f"/api/icloud/media_source/serve/original/{image_id1}",
)
assert resp.status == HTTPStatus.OK
assert await resp.read() == b"abcdef"
assert resp.headers[hdrs.CONTENT_TYPE] == "image/jpeg"
assert (
resp.headers[hdrs.CONTENT_DISPOSITION]
== f'attachment;filename="{urllib.parse.quote(expected_filename.encode())}"'
)
# get the same item from the cache to verify that works as well
resp = await client.get(
f"/api/icloud/media_source/serve/original/{image_id1}",
headers={hdrs.RANGE: "bytes=0-2"},
)
assert resp.status == HTTPStatus.PARTIAL_CONTENT
assert await resp.read() == b"ab"
# get the same item from the cache to verify that works as well
resp = await client.get(f"/api/icloud/media_source/serve/original/{image_id2}")
assert resp.status == HTTPStatus.OK
assert await resp.read() == b"abcdef"
+1 -2
View File
@@ -1,6 +1,5 @@
"""The tests for the litejet component."""
from homeassistant.components import litejet
from homeassistant.components.litejet.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -18,5 +17,5 @@ async def test_unload_entry(hass: HomeAssistant, mock_litejet) -> None:
"""Test being able to unload an entry."""
entry = await async_init_integration(hass, use_switch=True, use_scene=True)
assert await litejet.async_unload_entry(hass, entry)
assert await hass.config_entries.async_unload(entry.entry_id)
assert DOMAIN not in hass.data
+1 -2
View File
@@ -7,7 +7,6 @@ from aiohttp.test_utils import TestClient
import pytest
from homeassistant import config_entries
from homeassistant.components import locative
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.device_tracker.legacy import Device
from homeassistant.components.locative import DOMAIN, TRACKER_UPDATE
@@ -308,6 +307,6 @@ async def test_load_unload_entry(
entry = hass.config_entries.async_entries(DOMAIN)[0]
await locative.async_unload_entry(hass, entry)
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE]

Some files were not shown because too many files have changed in this diff Show More