mirror of
https://github.com/home-assistant/core.git
synced 2026-06-16 08:52:53 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75ec9a9058 | |||
| 2434341e04 | |||
| 047edc035d | |||
| 8b5f27e016 | |||
| 5200a8131f | |||
| 2dc1870ecd | |||
| d8f125dfe9 | |||
| 311cd56c93 | |||
| 4b17e3abcb | |||
| f2839bbf7a | |||
| 0229545184 | |||
| e8ce995560 | |||
| 46ffb3bd95 | |||
| 27677a07a6 | |||
| f619ccca4b | |||
| 09a72ac505 | |||
| 27573c5231 | |||
| d5f23fffa8 | |||
| 3b70ac987d | |||
| e00b8f154e | |||
| abc751fd1c | |||
| 6b5c7ec864 | |||
| d63bb48040 | |||
| b71b155ffb | |||
| 0f59a6070f | |||
| bb34887983 |
@@ -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
@@ -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"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
+262
@@ -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,
|
||||
)
|
||||
+12
-9
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"notify": {
|
||||
"mailto": {
|
||||
"default": "mdi:email-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+148
@@ -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*",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+3
@@ -16,6 +16,9 @@ MQTT = {
|
||||
"fully_kiosk": [
|
||||
"fully/deviceInfo/+",
|
||||
],
|
||||
"greencell": [
|
||||
"/greencell/broadcast/device",
|
||||
],
|
||||
"inels": [
|
||||
"inels/status/#",
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
Generated
+5
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the GreenCell integration."""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user