Merge branch 'dev' into tibber_data

This commit is contained in:
Daniel Hjelseth Høyer
2025-12-19 09:58:28 +01:00
committed by GitHub
674 changed files with 20520 additions and 4420 deletions

View File

@@ -51,6 +51,9 @@ rules:
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+

View File

@@ -15,7 +15,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2025.11.3"
BASE_IMAGE_VERSION: "2025.12.0"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:
@@ -70,7 +70,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: translations
path: translations.tar.gz
@@ -169,7 +169,7 @@ jobs:
fi
- name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: translations
@@ -482,7 +482,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: translations

View File

@@ -263,7 +263,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-pre-commit-venv >-
@@ -304,7 +304,7 @@ jobs:
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
fail-on-cache-miss: true
@@ -511,7 +511,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -534,7 +534,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -864,7 +864,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: pytest_buckets
- &compile-english-translations

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: "/language:python"

View File

@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"

View File

@@ -74,7 +74,7 @@ jobs:
) > .env_file
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: env_file
path: ./.env_file
@@ -119,7 +119,7 @@ jobs:
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: env_file

View File

@@ -567,6 +567,7 @@ homeassistant.components.wake_word.*
homeassistant.components.wallbox.*
homeassistant.components.waqi.*
homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.*
homeassistant.components.weather.*
homeassistant.components.webhook.*

9
CODEOWNERS generated
View File

@@ -664,7 +664,8 @@ build.json @home-assistant/supervisor
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64
/homeassistant/components/hikvision/ @mezz64 @ptarjan
/tests/components/hikvision/ @mezz64 @ptarjan
/homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead
@@ -1194,8 +1195,8 @@ build.json @home-assistant/supervisor
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1797,6 +1798,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya
/homeassistant/components/waze_travel_time/ @eifinger

4
Dockerfile generated
View File

@@ -24,13 +24,13 @@ ENV \
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.9.6
&& pip3 install uv==0.9.17
WORKDIR /usr/src

View File

@@ -624,13 +624,16 @@ async def async_enable_logging(
if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
def rename_old_file() -> None:
"""Rename old log file in executor."""
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
await hass.async_add_executor_job(rename_old_file)
err_log_path = None
else:
err_log_path = default_log_path

View File

@@ -9,15 +9,16 @@ from actron_neo_api import (
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import _LOGGER
from .const import _LOGGER, DOMAIN
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
ActronAirSystemCoordinator,
)
PLATFORM = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
@@ -29,12 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
try:
systems = await api.get_ac_systems()
await api.update_status()
except ActronAirAuthError:
_LOGGER.error("Authentication error while setting up Actron Air integration")
raise
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
raise ConfigEntryNotReady from err
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
@@ -48,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators=system_coordinators,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -148,7 +148,7 @@ class ActronSystemClimate(BaseClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.fan_mode
fan_mode = self._status.user_aircon_settings.base_fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property

View File

@@ -1,11 +1,12 @@
"""Setup config flow for Actron Air integration."""
import asyncio
from collections.abc import Mapping
from typing import Any
from actron_neo_api import ActronAirAPI, ActronAirAuthError
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
@@ -95,8 +96,16 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Check if this is a reauth flow
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
@@ -114,6 +123,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
del self.login_task
return await self.async_step_user()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reauth_confirm")
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -5,16 +5,23 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAuthError,
ActronAirStatus,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import _LOGGER
from .const import _LOGGER, DOMAIN
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
SCAN_INTERVAL = timedelta(seconds=30)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error"
@@ -29,9 +36,6 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration."""
@@ -59,7 +63,14 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
async def _async_update_data(self) -> ActronAirStatus:
"""Fetch updates and merge incremental changes into the full state."""
await self.api.update_status()
try:
await self.api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status

View File

@@ -0,0 +1,30 @@
{
"entity": {
"switch": {
"away_mode": {
"default": "mdi:home-export-outline",
"state": {
"off": "mdi:home-import-outline"
}
},
"continuous_fan": {
"default": "mdi:fan",
"state": {
"off": "mdi:fan-off"
}
},
"quiet_mode": {
"default": "mdi:volume-low",
"state": {
"off": "mdi:volume-high"
}
},
"turbo_mode": {
"default": "mdi:fan-plus",
"state": {
"off": "mdi:fan"
}
}
}
}
}

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.1.87"]
"requirements": ["actron-neo-api==0.4.1"]
}

View File

@@ -36,7 +36,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: todo
# Gold

View File

@@ -2,10 +2,12 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start OAuth2 flow"
"oauth2_error": "Failed to start authentication flow",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
},
"error": {
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
"oauth2_error": "Failed to start authentication flow. Please try again later."
},
"progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
@@ -16,14 +18,39 @@
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"title": "Connection error"
},
"reauth_confirm": {
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
"title": "Authentication expired"
},
"timeout": {
"data": {},
"description": "The authorization process timed out. Please try again.",
"title": "Authorization timeout"
"description": "The authentication process timed out. Please try again.",
"title": "Authentication timeout"
},
"user": {
"title": "Actron Air OAuth2 Authorization"
"title": "Actron Air Authentication"
}
}
},
"entity": {
"switch": {
"away_mode": {
"name": "Away mode"
},
"continuous_fan": {
"name": "Continuous fan"
},
"quiet_mode": {
"name": "Quiet mode"
},
"turbo_mode": {
"name": "Turbo mode"
}
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed, please reauthenticate"
}
}
}

View File

@@ -0,0 +1,110 @@
"""Switch platform for Actron Air integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirSwitchEntityDescription(SwitchEntityDescription):
"""Class describing Actron Air switch entities."""
is_on_fn: Callable[[ActronAirSystemCoordinator], bool]
set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]]
is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True
SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
ActronAirSwitchEntityDescription(
key="away_mode",
translation_key="away_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="continuous_fan",
translation_key="continuous_fan",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="quiet_mode",
translation_key="quiet_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="turbo_mode",
translation_key="turbo_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air switch entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirSwitch(coordinator, description)
for coordinator in system_coordinators.values()
for description in SWITCHES
if description.is_supported_fn(coordinator)
)
class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity):
"""Actron Air switch."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: ActronAirSwitchEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.serial_number)},
manufacturer="Actron Air",
name=coordinator.data.ac_system.system_name,
)
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -88,21 +88,11 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
super().__init__(coordinator, unit_id)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data."""
return self.device_data.get("climate") or {}
@property
def params(self) -> dict[str, Any]:
"""Return the current parameters for the climate entity."""
return self.climate_data.get("ParametersData") or {}
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and bool(self.climate_data)
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""

View File

@@ -10,7 +10,7 @@ from homeassistant.const import Platform
DOMAIN = "airpatrol"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=1)
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)

View File

@@ -38,7 +38,17 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
"""Return the device data."""
return self.coordinator.data[self._unit_id]
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data for this unit."""
return self.device_data["climate"]
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._unit_id in self.coordinator.data
return (
super().available
and self._unit_id in self.coordinator.data
and "climate" in self.device_data
and self.climate_data is not None
)

View File

@@ -0,0 +1,89 @@
"""Sensors for AirPatrol integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirPatrolConfigEntry
from .coordinator import AirPatrolDataUpdateCoordinator
from .entity import AirPatrolEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirPatrolSensorEntityDescription(SensorEntityDescription):
"""Describes AirPatrol sensor entity."""
data_field: str
SENSOR_DESCRIPTIONS = (
AirPatrolSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
data_field="RoomTemp",
),
AirPatrolSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
data_field="RoomHumidity",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirPatrolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirPatrol sensors."""
coordinator = config_entry.runtime_data
units = coordinator.data
async_add_entities(
AirPatrolSensor(coordinator, unit_id, description)
for unit_id, unit in units.items()
for description in SENSOR_DESCRIPTIONS
if "climate" in unit and unit["climate"] is not None
)
class AirPatrolSensor(AirPatrolEntity, SensorEntity):
"""AirPatrol sensor entity."""
entity_description: AirPatrolSensorEntityDescription
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
description: AirPatrolSensorEntityDescription,
) -> None:
"""Initialize AirPatrol sensor."""
super().__init__(coordinator, unit_id)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}"
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
if value := self.climate_data.get(self.entity_description.data_field):
return float(value)
return None

View File

@@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityStateTriggerBase,
EntityTargetStateTriggerBase,
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
@@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool
return False
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
@@ -38,7 +38,7 @@ class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityStateTriggerBase]:
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
@@ -52,7 +52,7 @@ def make_entity_state_trigger_required_features(
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_conditional_entity_state_trigger(
"armed": make_entity_transition_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
@@ -89,8 +89,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
"disarmed": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.DISARMED
),
"triggered": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}

View File

@@ -4,13 +4,28 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyanglianwater import AnglianWater
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import VolumeConverter
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
@@ -44,6 +59,107 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Update data from Anglian Water's API."""
try:
return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self._insert_statistics()
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
raise UpdateFailed from err
async def _insert_statistics(self) -> None:
"""Insert statistics for water meters into Home Assistant."""
for meter in self.api.meters.values():
id_prefix = (
f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}"
)
usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower()
_LOGGER.debug("Updating statistics for meter %s", meter.serial_number)
name_prefix = (
f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} "
f"{meter.serial_number}"
)
usage_metadata = StatisticMetaData(
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Usage",
source=DOMAIN,
statistic_id=usage_statistic_id,
unit_class=VolumeConverter.UNIT_CLASS,
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
)
last_stat = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, usage_statistic_id, True, set()
)
if not last_stat:
_LOGGER.debug("Updating statistics for the first time")
usage_sum = 0.0
last_stats_time = None
else:
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
continue
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping update",
meter.readings[0]["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
_LOGGER.debug("Getting statistics at %s", start)
for end in (start + timedelta(seconds=1), None):
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
start,
end,
{
usage_statistic_id,
},
"hour",
None,
{"sum"},
)
if stats:
break
if end:
_LOGGER.debug(
"Not found, trying to find oldest statistic after %s",
start,
)
assert stats
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
last_stats_time = stats[usage_statistic_id][0]["start"]
usage_statistics = []
for read in meter.readings:
parsed_read_at = dt_util.parse_datetime(read["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping reading",
read["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
if last_stats_time is not None and start.timestamp() <= last_stats_time:
continue
usage_state = max(0, read["consumption"] / 1000)
usage_sum = max(0, read["read"])
usage_statistics.append(
StatisticData(
start=start,
state=usage_state,
sum=usage_sum,
)
)
_LOGGER.debug(
"Adding %s statistics for %s", len(usage_statistics), usage_statistic_id
)
async_add_external_statistics(self.hass, usage_metadata, usage_statistics)

View File

@@ -1,6 +1,7 @@
{
"domain": "anglian_water",
"name": "Anglian Water",
"after_dependencies": ["recorder"],
"codeowners": ["@pantherale0"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
@@ -8,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.0.0"]
"requirements": ["pyanglianwater==3.1.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@yuxincs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
"quality_scale": "platinum",

View File

@@ -1,16 +1,22 @@
"""Provides triggers for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
from .entity import AssistSatelliteState
TRIGGERS: dict[str, type[Trigger]] = {
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
"idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.LISTENING
),
"processing": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.PROCESSING
),
"responding": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.RESPONDING
),
}

View File

@@ -4,6 +4,8 @@
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/autarco",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["autarco==3.2.0"]
}

View File

@@ -125,14 +125,17 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"binary_sensor",
"button",
"climate",
"cover",
"device_tracker",
"fan",
"lawn_mower",
"light",
"media_player",
"switch",
"text",
"update",
"vacuum",
}

View File

@@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
@@ -20,7 +20,7 @@ def get_device_class_or_undefined(
return UNDEFINED
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None

View File

@@ -2,6 +2,7 @@
"domain": "blackbird",
"name": "Monoprice Blackbird Matrix Switch",
"codeowners": [],
"disabled": "This integration is disabled because it references pyserial-asyncio, which does blocking I/O in the asyncio loop and is not maintained.",
"documentation": "https://www.home-assistant.io/integrations/blackbird",
"iot_class": "local_polling",
"loggers": ["pyblackbird"],

View File

@@ -17,5 +17,10 @@
"press": {
"service": "mdi:gesture-tap-button"
}
},
"triggers": {
"pressed": {
"trigger": "mdi:gesture-tap-button"
}
}
}

View File

@@ -27,5 +27,11 @@
"name": "Press"
}
},
"title": "Button"
"title": "Button",
"triggers": {
"pressed": {
"description": "Triggers when a button was pressed",
"name": "Button pressed"
}
}
}

View File

@@ -0,0 +1,42 @@
"""Provides triggers for buttons."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain = DOMAIN
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
"pressed": ButtonPressedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for buttons."""
return TRIGGERS

View File

@@ -0,0 +1,4 @@
pressed:
target:
entity:
domain: button

View File

@@ -3,22 +3,22 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_attribute_trigger,
make_entity_state_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
TRIGGERS: dict[str, type[Trigger]] = {
"started_cooling": make_entity_state_attribute_trigger(
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_state_attribute_trigger(
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_conditional_entity_state_trigger(
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,
from_states={
HVACMode.OFF,
@@ -32,7 +32,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
HVACMode.HEAT_COOL,
},
),
"started_heating": make_entity_state_attribute_trigger(
"started_heating": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.3.1"]
"requirements": ["compit-inext-api==0.3.4"]
}

View File

@@ -65,8 +65,10 @@ def websocket_create_area(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set
@@ -133,8 +135,10 @@ def websocket_update_area(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set

View File

@@ -227,8 +227,10 @@ def websocket_update_entity(
changes[key] = msg[key]
if "aliases" in msg:
# Convert aliases to a set
changes["aliases"] = set(msg["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
if "labels" in msg:
# Convert labels to a set

View File

@@ -61,8 +61,10 @@ def websocket_create_floor(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
try:
entry = registry.async_create(**data)
@@ -117,8 +119,10 @@ def websocket_update_floor(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
try:
entry = registry.async_update(**data)

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import CONF_SWING_SUPPORT, DOMAIN
from .const import CONF_SEND_WAKEUP_PROMPT, CONF_SWING_SUPPORT, DOMAIN
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
@@ -17,10 +17,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
"""Set up Coolmaster from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
send_wakeup_prompt = entry.data.get(CONF_SEND_WAKEUP_PROMPT, False)
if not entry.data.get(CONF_SWING_SUPPORT):
coolmaster = CoolMasterNet(
host,
port,
send_initial_line_feed=send_wakeup_prompt,
)
else:
# Swing support adds an additional request per unit. The requests are
@@ -29,6 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
coolmaster = CoolMasterNet(
host,
port,
send_initial_line_feed=send_wakeup_prompt,
read_timeout=5,
swing_support=True,
)

View File

@@ -12,7 +12,13 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import callback
from .const import CONF_SUPPORTED_MODES, CONF_SWING_SUPPORT, DEFAULT_PORT, DOMAIN
from .const import (
CONF_SEND_WAKEUP_PROMPT,
CONF_SUPPORTED_MODES,
CONF_SWING_SUPPORT,
DEFAULT_PORT,
DOMAIN,
)
AVAILABLE_MODES = [
HVACMode.OFF.value,
@@ -25,17 +31,15 @@ AVAILABLE_MODES = [
MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES}
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
**MODES_SCHEMA,
vol.Required(CONF_SWING_SUPPORT, default=False): bool,
}
)
DATA_SCHEMA = {
vol.Required(CONF_HOST): str,
**MODES_SCHEMA,
vol.Required(CONF_SWING_SUPPORT, default=False): bool,
}
async def _validate_connection(host: str) -> bool:
cool = CoolMasterNet(host, DEFAULT_PORT)
async def _validate_connection(host: str, send_wakeup_prompt: bool) -> bool:
cool = CoolMasterNet(host, DEFAULT_PORT, send_initial_line_feed=send_wakeup_prompt)
units = await cool.status()
return bool(units)
@@ -45,6 +49,14 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
def _get_data_schema(self) -> vol.Schema:
schema_dict = DATA_SCHEMA.copy()
if self.show_advanced_options:
schema_dict[vol.Required(CONF_SEND_WAKEUP_PROMPT, default=False)] = bool
return vol.Schema(schema_dict)
@callback
def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
supported_modes = [
@@ -57,6 +69,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: DEFAULT_PORT,
CONF_SUPPORTED_MODES: supported_modes,
CONF_SWING_SUPPORT: data[CONF_SWING_SUPPORT],
CONF_SEND_WAKEUP_PROMPT: data.get(CONF_SEND_WAKEUP_PROMPT, False),
},
)
@@ -64,15 +77,19 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
data_schema = self._get_data_schema()
if user_input is None:
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
return self.async_show_form(step_id="user", data_schema=data_schema)
errors = {}
host = user_input[CONF_HOST]
try:
result = await _validate_connection(host)
result = await _validate_connection(
host, user_input.get(CONF_SEND_WAKEUP_PROMPT, False)
)
if not result:
errors["base"] = "no_units"
except OSError:
@@ -80,7 +97,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user", data_schema=data_schema, errors=errors
)
return self._async_get_entry(user_input)

View File

@@ -6,5 +6,6 @@ DEFAULT_PORT = 10102
CONF_SUPPORTED_MODES = "supported_modes"
CONF_SWING_SUPPORT = "swing_support"
CONF_SEND_WAKEUP_PROMPT = "send_wakeup_prompt"
MAX_RETRIES = 3
BACKOFF_BASE_DELAY = 2

View File

@@ -4,7 +4,8 @@
"codeowners": ["@OnFreund"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pycoolmasternet_async"],
"requirements": ["pycoolmasternet-async==0.2.2"]
"requirements": ["pycoolmasternet-async==0.2.4"]
}

View File

@@ -14,10 +14,12 @@
"heat_cool": "Support automatic heat/cool mode",
"host": "[%key:common::config_flow::data::host%]",
"off": "Can be turned off",
"send_wakeup_prompt": "Send wakeup prompt",
"swing_support": "Control swing mode"
},
"data_description": {
"host": "The hostname or IP address of your CoolMasterNet device."
"host": "The hostname or IP address of your CoolMasterNet device.",
"send_wakeup_prompt": "Send the coolmaster unit an empty commaand before issuing any actual command. This is required for serial models."
},
"description": "Set up your CoolMasterNet connection details."
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol
from typing import Any, Protocol
import voluptuous as vol
@@ -11,18 +11,15 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionChecker,
ConditionCheckerType,
ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import DeviceAutomationType, async_get_device_automation_platform
from .helpers import async_validate_device_automation_config
if TYPE_CHECKING:
from homeassistant.helpers import condition
class DeviceAutomationConditionProtocol(Protocol):
"""Define the format of device_condition modules.
@@ -90,15 +87,21 @@ class DeviceCondition(Condition):
assert config.options is not None
self._config = config.options
async def async_get_checker(self) -> condition.ConditionCheckerType:
async def async_get_checker(self) -> ConditionChecker:
"""Test a device condition."""
platform = await async_get_device_automation_platform(
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
return trace_condition_function(
platform.async_condition_from_config(self._hass, self._config)
platform_checker = platform.async_condition_from_config(
self._hass, self._config
)
def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool:
result = platform_checker(self._hass, variables)
return result is not False
return checker
CONDITIONS: dict[str, type[Condition]] = {
"_device": DeviceCondition,

View File

@@ -11,5 +11,13 @@
"see": {
"service": "mdi:account-eye"
}
},
"triggers": {
"entered_home": {
"trigger": "mdi:account-arrow-left"
},
"left_home": {
"trigger": "mdi:account-arrow-right"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_home": "{entity_name} is home",
@@ -44,6 +48,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
@@ -80,5 +93,27 @@
"name": "See"
}
},
"title": "Device tracker"
"title": "Device tracker",
"triggers": {
"entered_home": {
"description": "Triggers when one or more device trackers enter home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Entered home"
},
"left_home": {
"description": "Triggers when one or more device trackers leave home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Left home"
}
}
}

View File

@@ -0,0 +1,21 @@
"""Provides triggers for device_trackers."""
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_origin_state_trigger,
make_entity_target_state_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for device trackers."""
return TRIGGERS

View File

@@ -0,0 +1,18 @@
.trigger_common: &trigger_common
target:
entity:
domain: device_tracker
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
entered_home: *trigger_common
left_home: *trigger_common

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/directv",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["directv"],
"requirements": ["directv==0.4.0"],

View File

@@ -9,7 +9,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.46.0"],
"requirements": ["async-upnp-client==0.46.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodns==3.6.0"]
"requirements": ["aiodns==3.6.1"]
}

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodukeenergy==0.3.0"]
}

View File

@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["ekey-bionyxpy==1.0.0"]
"requirements": ["ekey-bionyxpy==1.0.1"]
}

View File

@@ -13,6 +13,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/elkm1",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.13"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@borpin", "@alexandrecuer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/emoncms",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["pyemoncms==0.1.3"]
}

View File

@@ -13,6 +13,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/emonitor",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aioemonitor"],
"requirements": ["aioemonitor==1.0.5"]

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/enocean",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["enocean"],
"requirements": ["enocean==0.50"],

View File

@@ -4,6 +4,7 @@
"codeowners": ["@bdraco", "@cgarwood", "@catsmanac"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",

View File

@@ -4,6 +4,7 @@
"codeowners": ["@pszafer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/epson",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["epson_projector"],
"requirements": ["epson-projector==0.6.0"]

View File

@@ -7,6 +7,7 @@
"homekit": {
"models": ["Escea"]
},
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pescea==1.0.12"]
}

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==43.0.0",
"aioesphomeapi==43.3.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/evil_genius_labs",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyevilgenius==2.0.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@ntilley905"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/faa_delays",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["faadelays"],
"requirements": ["faadelays==2023.9.1"]

View File

@@ -2,13 +2,13 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@Lorenzo-Gasparini"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fing",
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@cyberjunky"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fireservicerota",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.46"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@Sander0542"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fivem",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["fivem-api==0.1.2"]
}

View File

@@ -12,6 +12,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/fjaraskupan",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["bleak", "fjaraskupan"],
"requirements": ["fjaraskupan==2.3.3"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@cnico"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flipr",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["flipr_api"],
"requirements": ["flipr-api==1.6.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dmulcahey"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flo",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioflo"],
"requirements": ["aioflo==2021.11.0"]

View File

@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/flume",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyflume"],
"requirements": ["PyFlume==0.6.5"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@Foscam-wangzhengyu"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/foscam",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["libpyfoscamcgi"],
"requirements": ["libpyfoscamcgi==0.0.9"]

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/freebox",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["freebox_api"],
"requirements": ["freebox-api==1.2.2"],

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import timedelta
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
from pyfritzhome.devicetypes import FritzhomeTemplate
from pyfritzhome.devicetypes import FritzhomeTemplate, FritzhomeTrigger
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
from homeassistant.config_entries import ConfigEntry
@@ -27,6 +27,7 @@ class FritzboxCoordinatorData:
devices: dict[str, FritzhomeDevice]
templates: dict[str, FritzhomeTemplate]
triggers: dict[str, FritzhomeTrigger]
supported_color_properties: dict[str, tuple[dict, list]]
@@ -37,6 +38,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
configuration_url: str
fritz: Fritzhome
has_templates: bool
has_triggers: bool
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
"""Initialize the Fritzbox Smarthome device coordinator."""
@@ -50,8 +52,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices: set[str] = set()
self.new_templates: set[str] = set()
self.new_triggers: set[str] = set()
self.data = FritzboxCoordinatorData({}, {}, {})
self.data = FritzboxCoordinatorData({}, {}, {}, {})
async def async_setup(self) -> None:
"""Set up the coordinator."""
@@ -74,6 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
)
LOGGER.debug("enable smarthome templates: %s", self.has_templates)
self.has_triggers = await self.hass.async_add_executor_job(
self.fritz.has_triggers
)
LOGGER.debug("enable smarthome triggers: %s", self.has_triggers)
self.configuration_url = self.fritz.get_prefixed_host()
await self.async_config_entry_first_refresh()
@@ -92,7 +100,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
available_main_ains = [
ain
for ain, dev in data.devices.items() | data.templates.items()
for ain, dev in (data.devices | data.templates | data.triggers).items()
if dev.device_and_unit_id[1] is None
]
device_reg = dr.async_get(self.hass)
@@ -112,6 +120,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
except RequestConnectionError as ex:
raise UpdateFailed from ex
except HTTPError:
@@ -123,6 +134,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
devices = self.fritz.get_devices()
device_data = {}
@@ -156,12 +169,20 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
for template in templates:
template_data[template.ain] = template
trigger_data = {}
if self.has_triggers:
triggers = self.fritz.get_triggers()
for trigger in triggers:
trigger_data[trigger.ain] = trigger
self.new_devices = device_data.keys() - self.data.devices.keys()
self.new_templates = template_data.keys() - self.data.templates.keys()
self.new_triggers = trigger_data.keys() - self.data.triggers.keys()
return FritzboxCoordinatorData(
devices=device_data,
templates=template_data,
triggers=trigger_data,
supported_color_properties=supported_color_properties,
)
@@ -193,6 +214,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
if (
self.data.devices.keys() - new_data.devices.keys()
or self.data.templates.keys() - new_data.templates.keys()
or self.data.triggers.keys() - new_data.triggers.keys()
):
self.cleanup_removed_devices(new_data)

View File

@@ -4,14 +4,17 @@ from __future__ import annotations
from typing import Any
from pyfritzhome.devicetypes import FritzhomeTrigger
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import FritzboxConfigEntry
from .entity import FritzBoxDeviceEntity
from .entity import FritzBoxDeviceEntity, FritzBoxEntity
# Coordinator handles data updates, so we can allow unlimited parallel updates
PARALLEL_UPDATES = 0
@@ -26,21 +29,27 @@ async def async_setup_entry(
coordinator = entry.runtime_data
@callback
def _add_entities(devices: set[str] | None = None) -> None:
"""Add devices."""
def _add_entities(
devices: set[str] | None = None, triggers: set[str] | None = None
) -> None:
"""Add devices and triggers."""
if devices is None:
devices = coordinator.new_devices
if not devices:
if triggers is None:
triggers = coordinator.new_triggers
if not devices and not triggers:
return
async_add_entities(
entities = [
FritzboxSwitch(coordinator, ain)
for ain in devices
if coordinator.data.devices[ain].has_switch
)
] + [FritzboxTrigger(coordinator, ain) for ain in triggers]
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
_add_entities(set(coordinator.data.devices))
_add_entities(set(coordinator.data.devices), set(coordinator.data.triggers))
class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
@@ -70,3 +79,42 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
translation_domain=DOMAIN,
translation_key="manual_switching_disabled",
)
class FritzboxTrigger(FritzBoxEntity, SwitchEntity):
"""The switch class for FRITZ!SmartHome triggers."""
@property
def data(self) -> FritzhomeTrigger:
"""Return the trigger data entity."""
return self.coordinator.data.triggers[self.ain]
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
return DeviceInfo(
name=self.data.name,
identifiers={(DOMAIN, self.ain)},
configuration_url=self.coordinator.configuration_url,
manufacturer="FRITZ!",
model="SmartHome Routine",
)
@property
def is_on(self) -> bool:
"""Return true if the trigger is active."""
return self.data.active # type: ignore [no-any-return]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Activate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_active, self.ain
)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Deactivate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_inactive, self.ain
)
await self.coordinator.async_refresh()

View File

@@ -17,7 +17,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(
key="timeToScreensaverV2",
translation_key="screensaver_time",
native_max_value=9999,
native_max_value=86400,
native_step=1,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -34,7 +34,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(
key="timeToScreenOffV2",
translation_key="screen_off_time",
native_max_value=9999,
native_max_value=86400,
native_step=1,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,

View File

@@ -2,15 +2,23 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import CONF_AUTHENTICATION, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from .const import CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, SECTION_ADVANCED
DOMAIN = "generic"
PLATFORMS = [Platform.CAMERA]
_LOGGER = logging.getLogger(__name__)
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -47,3 +55,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 1:
# Migrate to advanced section
new_options = {**entry.options}
advanced = new_options[SECTION_ADVANCED] = {
CONF_FRAMERATE: new_options.pop(CONF_FRAMERATE),
CONF_VERIFY_SSL: new_options.pop(CONF_VERIFY_SSL),
}
# migrate optional fields
for key in (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
CONF_AUTHENTICATION,
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
):
if key in new_options:
advanced[key] = new_options.pop(key)
hass.config_entries.async_update_entry(entry, options=new_options, version=2)
_LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -41,6 +41,7 @@ from .const import (
CONF_STILL_IMAGE_URL,
CONF_STREAM_SOURCE,
GET_IMAGE_TIMEOUT,
SECTION_ADVANCED,
)
_LOGGER = logging.getLogger(__name__)
@@ -62,9 +63,11 @@ def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None:
"""Generate httpx.Auth object from credentials."""
username: str | None = device_info.get(CONF_USERNAME)
password: str | None = device_info.get(CONF_PASSWORD)
authentication = device_info.get(CONF_AUTHENTICATION)
if username and password:
if authentication == HTTP_DIGEST_AUTHENTICATION:
if (
device_info[SECTION_ADVANCED].get(CONF_AUTHENTICATION)
== HTTP_DIGEST_AUTHENTICATION
):
return httpx.DigestAuth(username=username, password=password)
return httpx.BasicAuth(username=username, password=password)
return None
@@ -99,14 +102,16 @@ class GenericCamera(Camera):
if self._stream_source:
self._stream_source = Template(self._stream_source, hass)
self._attr_supported_features = CameraEntityFeature.STREAM
self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
self._limit_refetch = device_info[SECTION_ADVANCED].get(
CONF_LIMIT_REFETCH_TO_URL_CHANGE, False
)
self._attr_frame_interval = 1 / device_info[SECTION_ADVANCED][CONF_FRAMERATE]
self.content_type = device_info[CONF_CONTENT_TYPE]
self.verify_ssl = device_info[CONF_VERIFY_SSL]
if device_info.get(CONF_RTSP_TRANSPORT):
self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT]
self.verify_ssl = device_info[SECTION_ADVANCED][CONF_VERIFY_SSL]
if rtsp_transport := device_info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
self.stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
self._auth = generate_auth(device_info)
if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
if device_info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
self._last_url = None

View File

@@ -50,10 +50,18 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.entity_platform import PlatformData
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.network import get_url
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.util import slugify
from .camera import GenericCamera, generate_auth
@@ -67,16 +75,20 @@ from .const import (
DEFAULT_NAME,
DOMAIN,
GET_IMAGE_TIMEOUT,
SECTION_ADVANCED,
)
_LOGGER = logging.getLogger(__name__)
DEFAULT_DATA = {
CONF_NAME: DEFAULT_NAME,
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
CONF_FRAMERATE: 2,
CONF_VERIFY_SSL: True,
SECTION_ADVANCED: {
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
CONF_FRAMERATE: 2,
CONF_VERIFY_SSL: True,
CONF_RTSP_TRANSPORT: "tcp",
},
}
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
@@ -93,58 +105,47 @@ class InvalidStreamException(HomeAssistantError):
def build_schema(
user_input: Mapping[str, Any],
is_options_flow: bool = False,
show_advanced_options: bool = False,
) -> vol.Schema:
"""Create schema for camera config setup."""
rtsp_options = [
SelectOptionDict(
value=value,
label=name,
)
for value, name in RTSP_TRANSPORTS.items()
]
advanced_section = {
vol.Required(CONF_FRAMERATE): vol.All(
vol.Range(min=0, min_included=False), cv.positive_float
),
vol.Required(CONF_VERIFY_SSL): bool,
vol.Optional(CONF_RTSP_TRANSPORT): SelectSelector(
SelectSelectorConfig(
options=rtsp_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_AUTHENTICATION): vol.In(
[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]
),
}
spec = {
vol.Optional(
CONF_STILL_IMAGE_URL,
description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
): str,
vol.Optional(
CONF_STREAM_SOURCE,
description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")},
): str,
vol.Optional(
CONF_RTSP_TRANSPORT,
description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)},
): vol.In(RTSP_TRANSPORTS),
vol.Optional(
CONF_AUTHENTICATION,
description={"suggested_value": user_input.get(CONF_AUTHENTICATION)},
): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
vol.Optional(
CONF_USERNAME,
description={"suggested_value": user_input.get(CONF_USERNAME, "")},
): str,
vol.Optional(
CONF_PASSWORD,
description={"suggested_value": user_input.get(CONF_PASSWORD, "")},
): str,
vol.Required(
CONF_FRAMERATE,
description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)},
): vol.All(vol.Range(min=0, min_included=False), cv.positive_float),
vol.Required(
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
): bool,
vol.Optional(CONF_STREAM_SOURCE): str,
vol.Optional(CONF_STILL_IMAGE_URL): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED): section(
vol.Schema(advanced_section), {"collapsed": True}
),
}
if is_options_flow:
spec[
vol.Required(
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
)
] = bool
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
if show_advanced_options:
spec[
vol.Required(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
)
] = bool
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
return vol.Schema(spec)
@@ -186,7 +187,7 @@ async def async_test_still(
return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
if not yarl_url.is_absolute():
return {CONF_STILL_IMAGE_URL: "relative_url"}, None
verify_ssl = info[CONF_VERIFY_SSL]
verify_ssl = info[SECTION_ADVANCED][CONF_VERIFY_SSL]
auth = generate_auth(info)
try:
async_client = get_async_client(hass, verify_ssl=verify_ssl)
@@ -267,9 +268,9 @@ async def async_test_and_preview_stream(
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
raise InvalidStreamException("template_error") from err
stream_options: dict[str, str | bool | float] = {}
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
if rtsp_transport := info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
if info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
try:
@@ -325,7 +326,7 @@ def register_still_preview(hass: HomeAssistant) -> None:
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for generic IP camera."""
VERSION = 1
VERSION = 2
def __init__(self) -> None:
"""Initialize Generic ConfigFlow."""
@@ -380,7 +381,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
user_input = DEFAULT_DATA.copy()
return self.async_show_form(
step_id="user",
data_schema=build_schema(user_input),
data_schema=self.add_suggested_values_to_schema(build_schema(), user_input),
errors=errors,
)
@@ -448,13 +449,19 @@ class GenericOptionsFlowHandler(OptionsFlow):
self.preview_stream = None
if not errors:
data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
),
**user_input,
CONF_CONTENT_TYPE: still_format
or self.config_entry.options.get(CONF_CONTENT_TYPE),
}
if (
CONF_USE_WALLCLOCK_AS_TIMESTAMPS
not in user_input[SECTION_ADVANCED]
):
data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = (
self.config_entry.options[SECTION_ADVANCED].get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
)
)
self.user_input = data
# temporary preview for user to check the image
self.preview_image_settings = data
@@ -463,10 +470,12 @@ class GenericOptionsFlowHandler(OptionsFlow):
user_input = self.user_input
return self.async_show_form(
step_id="init",
data_schema=build_schema(
data_schema=self.add_suggested_values_to_schema(
build_schema(
True,
self.show_advanced_options,
),
user_input or self.config_entry.options,
True,
self.show_advanced_options,
),
errors=errors,
)
@@ -582,7 +591,8 @@ async def ws_start_preview(
_LOGGER.debug("Got preview still URL: %s", ha_still_url)
if ha_stream := flow.preview_stream:
ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER)
# HLS player needs an absolute URL as base for constructing child playlist URLs
ha_stream_url = f"{get_url(hass)}{ha_stream.endpoint_url(HLS_PROVIDER)}"
_LOGGER.debug("Got preview stream URL: %s", ha_stream_url)
connection.send_message(

View File

@@ -9,3 +9,4 @@ CONF_STILL_IMAGE_URL = "still_image_url"
CONF_STREAM_SOURCE = "stream_source"
CONF_FRAMERATE = "framerate"
GET_IMAGE_TIMEOUT = 10
SECTION_ADVANCED = "advanced"

View File

@@ -26,17 +26,24 @@
"step": {
"user": {
"data": {
"authentication": "Authentication",
"framerate": "Frame rate (Hz)",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"password": "[%key:common::config_flow::data::password%]",
"rtsp_transport": "RTSP transport protocol",
"still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream source URL (e.g. rtsp://...)",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Enter the settings to connect to the camera."
"sections": {
"advanced": {
"data": {
"authentication": "Authentication",
"framerate": "Frame rate (Hz)",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"rtsp_transport": "RTSP transport protocol",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.",
"name": "Advanced settings"
}
}
},
"user_confirm": {
"data": {
@@ -70,19 +77,27 @@
"step": {
"init": {
"data": {
"authentication": "[%key:component::generic::config::step::user::data::authentication%]",
"framerate": "[%key:component::generic::config::step::user::data::framerate%]",
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]",
"password": "[%key:common::config_flow::data::password%]",
"rtsp_transport": "[%key:component::generic::config::step::user::data::rtsp_transport%]",
"still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]",
"stream_source": "[%key:component::generic::config::step::user::data::stream_source%]",
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
"sections": {
"advanced": {
"data": {
"authentication": "[%key:component::generic::config::step::user::sections::advanced::data::authentication%]",
"framerate": "[%key:component::generic::config::step::user::sections::advanced::data::framerate%]",
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::sections::advanced::data::limit_refetch_to_url_change%]",
"rtsp_transport": "[%key:component::generic::config::step::user::sections::advanced::data::rtsp_transport%]",
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
},
"description": "[%key:component::generic::config::step::user::sections::advanced::description%]",
"name": "[%key:component::generic::config::step::user::sections::advanced::name%]"
}
}
},
"user_confirm": {

View File

@@ -5,6 +5,7 @@ from typing import Any
import botocore.exceptions
from homelink.auth.srp_auth import SRPAuth
import jwt
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
@@ -38,8 +39,6 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Ask for username and password."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
srp_auth = SRPAuth()
try:
tokens = await self.hass.async_add_executor_job(
@@ -48,12 +47,17 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
user_input[CONF_PASSWORD],
)
except botocore.exceptions.ClientError:
_LOGGER.exception("Error authenticating homelink account")
errors["base"] = "srp_auth_failed"
except Exception:
_LOGGER.exception("An unexpected error occurred")
errors["base"] = "unknown"
else:
access_token = jwt.decode(
tokens["AuthenticationResult"]["AccessToken"],
options={"verify_signature": False},
)
await self.async_set_unique_id(access_token["sub"])
self._abort_if_unique_id_configured()
self.external_data = {"tokens": tokens}
return await self.async_step_creation()

View File

@@ -1,10 +1,9 @@
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
"""Establish MQTT connection and listen for event data."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
import logging
from typing import TypedDict
from homelink.model.device import Device
@@ -14,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.ssl import get_default_context
_LOGGER = logging.getLogger(__name__)
type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator]
type EventCallback = Callable[[HomeLinkEventData], None]

View File

@@ -54,7 +54,11 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=gios.station_name,
data=user_input,
# CONF_NAME is still used, but its value is preserved
# primarily for backward compatibility. This allows older
# versions of the software to read the entry data without
# raising errors.
data={**user_input, CONF_NAME: gios.station_name},
)
except (ApiError, ClientConnectorError, TimeoutError):
errors["base"] = "cannot_connect"
@@ -79,8 +83,7 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN):
sort=True,
mode=SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(CONF_NAME, default=self.hass.config.location_name): str,
)
}
)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from aiohttp.client_exceptions import ClientConnectorError
from gios import Gios
@@ -12,10 +13,12 @@ from gios.exceptions import GiosError
from gios.model import GiosSensors
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import API_TIMEOUT, DOMAIN, SCAN_INTERVAL
from .const import API_TIMEOUT, DOMAIN, MANUFACTURER, SCAN_INTERVAL, URL
_LOGGER = logging.getLogger(__name__)
@@ -51,6 +54,21 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]):
update_interval=SCAN_INTERVAL,
)
station_id = gios.station_id
if TYPE_CHECKING:
# Station ID is Optional in the library, but here we know it is set for sure
# so we can safely assert it is not None for type checking purposes
# Gios instance is created only with a valid station ID in the async_setup_entry.
assert station_id is not None
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(station_id))},
manufacturer=MANUFACTURER,
name=config_entry.data[CONF_NAME],
configuration_url=URL.format(station_id=station_id),
)
async def _async_update_data(self) -> GiosSensors:
"""Update data via library."""
try:

View File

@@ -15,10 +15,9 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -36,8 +35,6 @@ from .const import (
ATTR_SO2,
ATTRIBUTION,
DOMAIN,
MANUFACTURER,
URL,
)
from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator
@@ -184,8 +181,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a GIOS entities from a config_entry."""
name = entry.data[CONF_NAME]
coordinator = entry.runtime_data.coordinator
# Due to the change of the attribute name of one sensor, it is necessary to migrate
# the unique_id to the new name.
@@ -208,7 +203,7 @@ async def async_setup_entry(
for description in SENSOR_TYPES:
if getattr(coordinator.data, description.key) is None:
continue
sensors.append(GiosSensor(name, coordinator, description))
sensors.append(GiosSensor(coordinator, description))
async_add_entities(sensors)
@@ -222,19 +217,13 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity):
def __init__(
self,
name: str,
coordinator: GiosDataUpdateCoordinator,
description: GiosSensorEntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(coordinator.gios.station_id))},
manufacturer=MANUFACTURER,
name=name,
configuration_url=URL.format(station_id=coordinator.gios.station_id),
)
self._attr_device_info = coordinator.device_info
if description.subkey:
self._attr_unique_id = (
f"{coordinator.gios.station_id}-{description.key}-{description.subkey}"

View File

@@ -11,11 +11,9 @@
"step": {
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"station_id": "Measuring station"
},
"data_description": {
"name": "Config entry name, by default, this is the name of your Home Assistant instance.",
"station_id": "The name of the measuring station where the environmental data is collected."
},
"title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)"

View File

@@ -8,4 +8,4 @@ HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
# in script/hassfest/docker.py.
RECOMMENDED_VERSION = "1.9.12"
RECOMMENDED_VERSION = "1.9.13"

View File

@@ -12,6 +12,7 @@
"homekit": {
"models": ["iSmartGate"]
},
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["ismartgate"],
"requirements": ["ismartgate==5.0.2"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@mletenay", "@starkillerOG"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/goodwe",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["goodwe"],
"requirements": ["goodwe==0.4.8"]

View File

@@ -7,6 +7,7 @@ ATTR_CC = "cc"
ATTR_ENABLED = "enabled"
ATTR_END = "end"
ATTR_FROM = "from"
ATTR_ALIAS_FROM = "alias_from"
ATTR_ME = "me"
ATTR_MESSAGE = "message"
ATTR_PLAIN_TEXT = "plain_text"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import base64
from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Any
from googleapiclient.http import HttpRequest
@@ -17,10 +18,20 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .api import AsyncConfigEntryAuth
from .const import ATTR_BCC, ATTR_CC, ATTR_FROM, ATTR_ME, ATTR_SEND, DATA_AUTH
from .const import (
ATTR_ALIAS_FROM,
ATTR_BCC,
ATTR_CC,
ATTR_FROM,
ATTR_ME,
ATTR_SEND,
DATA_AUTH,
DOMAIN,
)
async def async_get_service(
@@ -47,7 +58,17 @@ class GMailNotificationService(BaseNotificationService):
email = MIMEText(message, "html")
if to_addrs := kwargs.get(ATTR_TARGET):
email["To"] = ", ".join(to_addrs)
email["From"] = data.get(ATTR_FROM, ATTR_ME)
email_from = data.get(ATTR_FROM, ATTR_ME)
if alias := data.get(ATTR_ALIAS_FROM):
if email_from == ATTR_ME:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_from_for_alias",
)
email["From"] = formataddr((alias, email_from))
else:
email["From"] = email_from
email["Subject"] = title
email[ATTR_CC] = ", ".join(data.get(ATTR_CC, []))
email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, []))
@@ -57,9 +78,9 @@ class GMailNotificationService(BaseNotificationService):
msg: HttpRequest
users = (await self.auth.get_resource()).users()
if data.get(ATTR_SEND) is False:
msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body})
msg = users.drafts().create(userId=email_from, body={ATTR_MESSAGE: body})
else:
if not to_addrs:
raise ValueError("recipient address required")
msg = users.messages().send(userId=email["From"], body=body)
msg = users.messages().send(userId=email_from, body=body)
await self.hass.async_add_executor_job(msg.execute)

View File

@@ -47,6 +47,11 @@
}
}
},
"exceptions": {
"missing_from_for_alias": {
"message": "Missing 'from' email when setting an alias to show. You have to provide a 'from' email"
}
},
"services": {
"set_vacation": {
"description": "Sets vacation responder settings for Google Mail.",

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google_photos",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google_photos_library_api"],
"requirements": ["google-photos-library-api==0.12.1"]

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google_tasks",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-api-python-client==2.71.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@eifinger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_travel_time",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google", "homeassistant.helpers.location"],
"requirements": ["google-maps-routing==0.6.15"]

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