mirror of
https://github.com/home-assistant/core.git
synced 2025-12-22 15:58:52 +00:00
Merge branch 'dev' into tibber_data
This commit is contained in:
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -51,6 +51,9 @@ rules:
|
|||||||
- **Missing imports** - We use static analysis tooling to catch that
|
- **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)
|
- **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
|
## Python Requirements
|
||||||
|
|
||||||
- **Compatibility**: Python 3.13+
|
- **Compatibility**: Python 3.13+
|
||||||
|
|||||||
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -15,7 +15,7 @@ env:
|
|||||||
UV_HTTP_TIMEOUT: 60
|
UV_HTTP_TIMEOUT: 60
|
||||||
UV_SYSTEM_PYTHON: "true"
|
UV_SYSTEM_PYTHON: "true"
|
||||||
# Base image version from https://github.com/home-assistant/docker
|
# 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"]'
|
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||||
|
|
||||||
- name: Upload translations
|
- name: Upload translations
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: translations
|
name: translations
|
||||||
path: translations.tar.gz
|
path: translations.tar.gz
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Download translations
|
- name: Download translations
|
||||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: translations
|
name: translations
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ jobs:
|
|||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
- name: Download translations
|
- name: Download translations
|
||||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: translations
|
name: translations
|
||||||
|
|
||||||
|
|||||||
10
.github/workflows/ci.yaml
vendored
10
.github/workflows/ci.yaml
vendored
@@ -263,7 +263,7 @@ jobs:
|
|||||||
check-latest: true
|
check-latest: true
|
||||||
- name: Restore base Python virtual environment
|
- name: Restore base Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: &actions-cache actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: &key-pre-commit-venv >-
|
key: &key-pre-commit-venv >-
|
||||||
@@ -304,7 +304,7 @@ jobs:
|
|||||||
- &cache-restore-pre-commit-venv
|
- &cache-restore-pre-commit-venv
|
||||||
name: Restore base Python virtual environment
|
name: Restore base Python virtual environment
|
||||||
id: cache-venv
|
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:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
@@ -511,7 +511,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Save apt cache
|
- name: Save apt cache
|
||||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
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:
|
with:
|
||||||
path: *path-apt-cache
|
path: *path-apt-cache
|
||||||
key: *key-apt-cache
|
key: *key-apt-cache
|
||||||
@@ -534,7 +534,7 @@ jobs:
|
|||||||
python --version
|
python --version
|
||||||
uv pip freeze >> pip_freeze.txt
|
uv pip freeze >> pip_freeze.txt
|
||||||
- name: Upload pip_freeze artifact
|
- 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:
|
with:
|
||||||
name: pip-freeze-${{ matrix.python-version }}
|
name: pip-freeze-${{ matrix.python-version }}
|
||||||
path: pip_freeze.txt
|
path: pip_freeze.txt
|
||||||
@@ -864,7 +864,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||||
- name: Download pytest_buckets
|
- 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:
|
with:
|
||||||
name: pytest_buckets
|
name: pytest_buckets
|
||||||
- &compile-english-translations
|
- &compile-english-translations
|
||||||
|
|||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
|||||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'home-assistant'
|
if: github.repository_owner == 'home-assistant'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
|
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
issue-inactive-days: "30"
|
issue-inactive-days: "30"
|
||||||
|
|||||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
|||||||
) > .env_file
|
) > .env_file
|
||||||
|
|
||||||
- name: Upload 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:
|
with:
|
||||||
name: env_file
|
name: env_file
|
||||||
path: ./.env_file
|
path: ./.env_file
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
|
|
||||||
- &download-env-file
|
- &download-env-file
|
||||||
name: 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:
|
with:
|
||||||
name: env_file
|
name: env_file
|
||||||
|
|
||||||
|
|||||||
@@ -567,6 +567,7 @@ homeassistant.components.wake_word.*
|
|||||||
homeassistant.components.wallbox.*
|
homeassistant.components.wallbox.*
|
||||||
homeassistant.components.waqi.*
|
homeassistant.components.waqi.*
|
||||||
homeassistant.components.water_heater.*
|
homeassistant.components.water_heater.*
|
||||||
|
homeassistant.components.watts.*
|
||||||
homeassistant.components.watttime.*
|
homeassistant.components.watttime.*
|
||||||
homeassistant.components.weather.*
|
homeassistant.components.weather.*
|
||||||
homeassistant.components.webhook.*
|
homeassistant.components.webhook.*
|
||||||
|
|||||||
9
CODEOWNERS
generated
9
CODEOWNERS
generated
@@ -664,7 +664,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/heos/ @andrewsayre
|
/tests/components/heos/ @andrewsayre
|
||||||
/homeassistant/components/here_travel_time/ @eifinger
|
/homeassistant/components/here_travel_time/ @eifinger
|
||||||
/tests/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/hikvisioncam/ @fbradyirl
|
||||||
/homeassistant/components/hisense_aehw4a1/ @bannhead
|
/homeassistant/components/hisense_aehw4a1/ @bannhead
|
||||||
/tests/components/hisense_aehw4a1/ @bannhead
|
/tests/components/hisense_aehw4a1/ @bannhead
|
||||||
@@ -1194,8 +1195,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/ourgroceries/ @OnFreund
|
/tests/components/ourgroceries/ @OnFreund
|
||||||
/homeassistant/components/overkiz/ @imicknl
|
/homeassistant/components/overkiz/ @imicknl
|
||||||
/tests/components/overkiz/ @imicknl
|
/tests/components/overkiz/ @imicknl
|
||||||
/homeassistant/components/overseerr/ @joostlek
|
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
||||||
/tests/components/overseerr/ @joostlek
|
/tests/components/overseerr/ @joostlek @AmGarera
|
||||||
/homeassistant/components/ovo_energy/ @timmo001
|
/homeassistant/components/ovo_energy/ @timmo001
|
||||||
/tests/components/ovo_energy/ @timmo001
|
/tests/components/ovo_energy/ @timmo001
|
||||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||||
@@ -1797,6 +1798,8 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/watergate/ @adam-the-hero
|
/homeassistant/components/watergate/ @adam-the-hero
|
||||||
/tests/components/watergate/ @adam-the-hero
|
/tests/components/watergate/ @adam-the-hero
|
||||||
/homeassistant/components/watson_tts/ @rutkai
|
/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
|
/homeassistant/components/watttime/ @bachya
|
||||||
/tests/components/watttime/ @bachya
|
/tests/components/watttime/ @bachya
|
||||||
/homeassistant/components/waze_travel_time/ @eifinger
|
/homeassistant/components/waze_travel_time/ @eifinger
|
||||||
|
|||||||
4
Dockerfile
generated
4
Dockerfile
generated
@@ -24,13 +24,13 @@ ENV \
|
|||||||
COPY rootfs /
|
COPY rootfs /
|
||||||
|
|
||||||
# Add go2rtc binary
|
# 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 \
|
RUN \
|
||||||
# Verify go2rtc can be executed
|
# Verify go2rtc can be executed
|
||||||
go2rtc --version \
|
go2rtc --version \
|
||||||
# Install uv
|
# Install uv
|
||||||
&& pip3 install uv==0.9.6
|
&& pip3 install uv==0.9.17
|
||||||
|
|
||||||
WORKDIR /usr/src
|
WORKDIR /usr/src
|
||||||
|
|
||||||
|
|||||||
@@ -624,13 +624,16 @@ async def async_enable_logging(
|
|||||||
|
|
||||||
if log_file is None:
|
if log_file is None:
|
||||||
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||||
if "SUPERVISOR" in os.environ:
|
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
|
||||||
_LOGGER.info("Running in Supervisor, not logging to file")
|
|
||||||
# Rename the default log file if it exists, since previous versions created
|
# Rename the default log file if it exists, since previous versions created
|
||||||
# it even on Supervisor
|
# it even on Supervisor
|
||||||
if os.path.isfile(default_log_path):
|
def rename_old_file() -> None:
|
||||||
with contextlib.suppress(OSError):
|
"""Rename old log file in executor."""
|
||||||
os.rename(default_log_path, f"{default_log_path}.old")
|
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
|
err_log_path = None
|
||||||
else:
|
else:
|
||||||
err_log_path = default_log_path
|
err_log_path = default_log_path
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ from actron_neo_api import (
|
|||||||
|
|
||||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
|
|
||||||
from .const import _LOGGER
|
from .const import _LOGGER, DOMAIN
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
ActronAirConfigEntry,
|
ActronAirConfigEntry,
|
||||||
ActronAirRuntimeData,
|
ActronAirRuntimeData,
|
||||||
ActronAirSystemCoordinator,
|
ActronAirSystemCoordinator,
|
||||||
)
|
)
|
||||||
|
|
||||||
PLATFORM = [Platform.CLIMATE]
|
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||||
@@ -29,12 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
|||||||
try:
|
try:
|
||||||
systems = await api.get_ac_systems()
|
systems = await api.get_ac_systems()
|
||||||
await api.update_status()
|
await api.update_status()
|
||||||
except ActronAirAuthError:
|
except ActronAirAuthError as err:
|
||||||
_LOGGER.error("Authentication error while setting up Actron Air integration")
|
raise ConfigEntryAuthFailed(
|
||||||
raise
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="auth_error",
|
||||||
|
) from err
|
||||||
except ActronAirAPIError as err:
|
except ActronAirAPIError as err:
|
||||||
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
|
raise ConfigEntryNotReady from err
|
||||||
raise
|
|
||||||
|
|
||||||
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
|
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
|
||||||
for system in systems:
|
for system in systems:
|
||||||
@@ -48,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
|||||||
system_coordinators=system_coordinators,
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class ActronSystemClimate(BaseClimateEntity):
|
|||||||
@property
|
@property
|
||||||
def fan_mode(self) -> str | None:
|
def fan_mode(self) -> str | None:
|
||||||
"""Return the current fan mode."""
|
"""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)
|
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Setup config flow for Actron Air integration."""
|
"""Setup config flow for Actron Air integration."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from actron_neo_api import ActronAirAPI, ActronAirAuthError
|
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.const import CONF_API_TOKEN
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
@@ -95,8 +96,16 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
unique_id = str(user_data["id"])
|
unique_id = str(user_data["id"])
|
||||||
await self.async_set_unique_id(unique_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(
|
return self.async_create_entry(
|
||||||
title=user_data["email"],
|
title=user_data["email"],
|
||||||
data={CONF_API_TOKEN: self._api.refresh_token_value},
|
data={CONF_API_TOKEN: self._api.refresh_token_value},
|
||||||
@@ -114,6 +123,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
del self.login_task
|
del self.login_task
|
||||||
return await self.async_step_user()
|
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(
|
async def async_step_connection_error(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -5,16 +5,23 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
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.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from homeassistant.util import dt as dt_util
|
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_NO_SYSTEMS_FOUND = "no_systems_found"
|
||||||
ERROR_UNKNOWN = "unknown_error"
|
ERROR_UNKNOWN = "unknown_error"
|
||||||
|
|
||||||
@@ -29,9 +36,6 @@ class ActronAirRuntimeData:
|
|||||||
|
|
||||||
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
|
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
|
||||||
|
|
||||||
AUTH_ERROR_THRESHOLD = 3
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
|
||||||
|
|
||||||
|
|
||||||
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||||
"""System coordinator for Actron Air integration."""
|
"""System coordinator for Actron Air integration."""
|
||||||
@@ -59,7 +63,14 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> ActronAirStatus:
|
async def _async_update_data(self) -> ActronAirStatus:
|
||||||
"""Fetch updates and merge incremental changes into the full state."""
|
"""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.status = self.api.state_manager.get_status(self.serial_number)
|
||||||
self.last_seen = dt_util.utcnow()
|
self.last_seen = dt_util.utcnow()
|
||||||
return self.status
|
return self.status
|
||||||
|
|||||||
30
homeassistant/components/actron_air/icons.json
Normal file
30
homeassistant/components/actron_air/icons.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,5 +13,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["actron-neo-api==0.1.87"]
|
"requirements": ["actron-neo-api==0.4.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ rules:
|
|||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: done
|
log-when-unavailable: done
|
||||||
parallel-updates: done
|
parallel-updates: done
|
||||||
reauthentication-flow: todo
|
reauthentication-flow: done
|
||||||
test-coverage: todo
|
test-coverage: todo
|
||||||
|
|
||||||
# Gold
|
# Gold
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
"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": {
|
"error": {
|
||||||
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
|
"oauth2_error": "Failed to start authentication flow. Please try again later."
|
||||||
},
|
},
|
||||||
"progress": {
|
"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."
|
"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.",
|
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
|
||||||
"title": "Connection error"
|
"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": {
|
"timeout": {
|
||||||
"data": {},
|
"data": {},
|
||||||
"description": "The authorization process timed out. Please try again.",
|
"description": "The authentication process timed out. Please try again.",
|
||||||
"title": "Authorization timeout"
|
"title": "Authentication timeout"
|
||||||
},
|
},
|
||||||
"user": {
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
homeassistant/components/actron_air/switch.py
Normal file
110
homeassistant/components/actron_air/switch.py
Normal 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)
|
||||||
@@ -88,21 +88,11 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
|
|||||||
super().__init__(coordinator, unit_id)
|
super().__init__(coordinator, unit_id)
|
||||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{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
|
@property
|
||||||
def params(self) -> dict[str, Any]:
|
def params(self) -> dict[str, Any]:
|
||||||
"""Return the current parameters for the climate entity."""
|
"""Return the current parameters for the climate entity."""
|
||||||
return self.climate_data.get("ParametersData") or {}
|
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
|
@property
|
||||||
def current_humidity(self) -> float | None:
|
def current_humidity(self) -> float | None:
|
||||||
"""Return the current humidity."""
|
"""Return the current humidity."""
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from homeassistant.const import Platform
|
|||||||
DOMAIN = "airpatrol"
|
DOMAIN = "airpatrol"
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
PLATFORMS = [Platform.CLIMATE]
|
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||||
SCAN_INTERVAL = timedelta(minutes=1)
|
SCAN_INTERVAL = timedelta(minutes=1)
|
||||||
|
|
||||||
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)
|
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)
|
||||||
|
|||||||
@@ -38,7 +38,17 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
|
|||||||
"""Return the device data."""
|
"""Return the device data."""
|
||||||
return self.coordinator.data[self._unit_id]
|
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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available."""
|
"""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
|
||||||
|
)
|
||||||
|
|||||||
89
homeassistant/components/airpatrol/sensor.py
Normal file
89
homeassistant/components/airpatrol/sensor.py
Normal 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
|
||||||
@@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity import get_supported_features
|
from homeassistant.helpers.entity import get_supported_features
|
||||||
from homeassistant.helpers.trigger import (
|
from homeassistant.helpers.trigger import (
|
||||||
EntityStateTriggerBase,
|
EntityTargetStateTriggerBase,
|
||||||
Trigger,
|
Trigger,
|
||||||
make_conditional_entity_state_trigger,
|
make_entity_target_state_trigger,
|
||||||
make_entity_state_trigger,
|
make_entity_transition_trigger,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
|
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
|
||||||
@@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
|
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
|
||||||
"""Trigger for entity state changes."""
|
"""Trigger for entity state changes."""
|
||||||
|
|
||||||
_required_features: int
|
_required_features: int
|
||||||
@@ -38,7 +38,7 @@ class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
|
|||||||
|
|
||||||
def make_entity_state_trigger_required_features(
|
def make_entity_state_trigger_required_features(
|
||||||
domain: str, to_state: str, required_features: int
|
domain: str, to_state: str, required_features: int
|
||||||
) -> type[EntityStateTriggerBase]:
|
) -> type[EntityTargetStateTriggerBase]:
|
||||||
"""Create an entity state trigger class."""
|
"""Create an entity state trigger class."""
|
||||||
|
|
||||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||||
@@ -52,7 +52,7 @@ def make_entity_state_trigger_required_features(
|
|||||||
|
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
TRIGGERS: dict[str, type[Trigger]] = {
|
||||||
"armed": make_conditional_entity_state_trigger(
|
"armed": make_entity_transition_trigger(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
from_states={
|
from_states={
|
||||||
AlarmControlPanelState.ARMING,
|
AlarmControlPanelState.ARMING,
|
||||||
@@ -89,8 +89,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
|||||||
AlarmControlPanelState.ARMED_VACATION,
|
AlarmControlPanelState.ARMED_VACATION,
|
||||||
AlarmControlPanelEntityFeature.ARM_VACATION,
|
AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||||
),
|
),
|
||||||
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
|
"disarmed": make_entity_target_state_trigger(
|
||||||
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
|
DOMAIN, AlarmControlPanelState.DISARMED
|
||||||
|
),
|
||||||
|
"triggered": make_entity_target_state_trigger(
|
||||||
|
DOMAIN, AlarmControlPanelState.TRIGGERED
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,28 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pyanglianwater import AnglianWater
|
from pyanglianwater import AnglianWater
|
||||||
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
|
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.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import UnitOfVolume
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
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
|
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||||
|
|
||||||
@@ -44,6 +59,107 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
async def _async_update_data(self) -> None:
|
async def _async_update_data(self) -> None:
|
||||||
"""Update data from Anglian Water's API."""
|
"""Update data from Anglian Water's API."""
|
||||||
try:
|
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:
|
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
|
||||||
raise UpdateFailed from 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)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "anglian_water",
|
"domain": "anglian_water",
|
||||||
"name": "Anglian Water",
|
"name": "Anglian Water",
|
||||||
|
"after_dependencies": ["recorder"],
|
||||||
"codeowners": ["@pantherale0"],
|
"codeowners": ["@pantherale0"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
||||||
@@ -8,5 +9,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyanglianwater"],
|
"loggers": ["pyanglianwater"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["pyanglianwater==3.0.0"]
|
"requirements": ["pyanglianwater==3.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@yuxincs"],
|
"codeowners": ["@yuxincs"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
|
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["apcaccess"],
|
"loggers": ["apcaccess"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
"""Provides triggers for assist satellites."""
|
"""Provides triggers for assist satellites."""
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
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 .const import DOMAIN
|
||||||
from .entity import AssistSatelliteState
|
from .entity import AssistSatelliteState
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
TRIGGERS: dict[str, type[Trigger]] = {
|
||||||
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
|
"idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
|
||||||
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
|
"listening": make_entity_target_state_trigger(
|
||||||
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
|
DOMAIN, AssistSatelliteState.LISTENING
|
||||||
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
|
),
|
||||||
|
"processing": make_entity_target_state_trigger(
|
||||||
|
DOMAIN, AssistSatelliteState.PROCESSING
|
||||||
|
),
|
||||||
|
"responding": make_entity_target_state_trigger(
|
||||||
|
DOMAIN, AssistSatelliteState.RESPONDING
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"codeowners": ["@klaasnicolaas"],
|
"codeowners": ["@klaasnicolaas"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/autarco",
|
"documentation": "https://www.home-assistant.io/integrations/autarco",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
|
"quality_scale": "silver",
|
||||||
"requirements": ["autarco==3.2.0"]
|
"requirements": ["autarco==3.2.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,14 +125,17 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
|||||||
"alarm_control_panel",
|
"alarm_control_panel",
|
||||||
"assist_satellite",
|
"assist_satellite",
|
||||||
"binary_sensor",
|
"binary_sensor",
|
||||||
|
"button",
|
||||||
"climate",
|
"climate",
|
||||||
"cover",
|
"cover",
|
||||||
|
"device_tracker",
|
||||||
"fan",
|
"fan",
|
||||||
"lawn_mower",
|
"lawn_mower",
|
||||||
"light",
|
"light",
|
||||||
"media_player",
|
"media_player",
|
||||||
"switch",
|
"switch",
|
||||||
"text",
|
"text",
|
||||||
|
"update",
|
||||||
"vacuum",
|
"vacuum",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity import get_device_class
|
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 homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||||
|
|
||||||
from . import DOMAIN, BinarySensorDeviceClass
|
from . import DOMAIN, BinarySensorDeviceClass
|
||||||
@@ -20,7 +20,7 @@ def get_device_class_or_undefined(
|
|||||||
return UNDEFINED
|
return UNDEFINED
|
||||||
|
|
||||||
|
|
||||||
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
|
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
|
||||||
"""Class for binary sensor on/off triggers."""
|
"""Class for binary sensor on/off triggers."""
|
||||||
|
|
||||||
_device_class: BinarySensorDeviceClass | None
|
_device_class: BinarySensorDeviceClass | None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"domain": "blackbird",
|
"domain": "blackbird",
|
||||||
"name": "Monoprice Blackbird Matrix Switch",
|
"name": "Monoprice Blackbird Matrix Switch",
|
||||||
"codeowners": [],
|
"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",
|
"documentation": "https://www.home-assistant.io/integrations/blackbird",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyblackbird"],
|
"loggers": ["pyblackbird"],
|
||||||
|
|||||||
@@ -17,5 +17,10 @@
|
|||||||
"press": {
|
"press": {
|
||||||
"service": "mdi:gesture-tap-button"
|
"service": "mdi:gesture-tap-button"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"pressed": {
|
||||||
|
"trigger": "mdi:gesture-tap-button"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,5 +27,11 @@
|
|||||||
"name": "Press"
|
"name": "Press"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Button"
|
"title": "Button",
|
||||||
|
"triggers": {
|
||||||
|
"pressed": {
|
||||||
|
"description": "Triggers when a button was pressed",
|
||||||
|
"name": "Button pressed"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
homeassistant/components/button/trigger.py
Normal file
42
homeassistant/components/button/trigger.py
Normal 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
|
||||||
4
homeassistant/components/button/triggers.yaml
Normal file
4
homeassistant/components/button/triggers.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pressed:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
domain: button
|
||||||
@@ -3,22 +3,22 @@
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.trigger import (
|
from homeassistant.helpers.trigger import (
|
||||||
Trigger,
|
Trigger,
|
||||||
make_conditional_entity_state_trigger,
|
make_entity_target_state_attribute_trigger,
|
||||||
make_entity_state_attribute_trigger,
|
make_entity_target_state_trigger,
|
||||||
make_entity_state_trigger,
|
make_entity_transition_trigger,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
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
|
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
|
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||||
),
|
),
|
||||||
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
|
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
|
||||||
"turned_on": make_conditional_entity_state_trigger(
|
"turned_on": make_entity_transition_trigger(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
from_states={
|
from_states={
|
||||||
HVACMode.OFF,
|
HVACMode.OFF,
|
||||||
@@ -32,7 +32,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
|||||||
HVACMode.HEAT_COOL,
|
HVACMode.HEAT_COOL,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
"started_heating": make_entity_state_attribute_trigger(
|
"started_heating": make_entity_target_state_attribute_trigger(
|
||||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["compit"],
|
"loggers": ["compit"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["compit-inext-api==0.3.1"]
|
"requirements": ["compit-inext-api==0.3.4"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ def websocket_create_area(
|
|||||||
data.pop("id")
|
data.pop("id")
|
||||||
|
|
||||||
if "aliases" in data:
|
if "aliases" in data:
|
||||||
# Convert aliases to a set
|
# Create a set for the aliases without:
|
||||||
data["aliases"] = set(data["aliases"])
|
# - 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:
|
if "labels" in data:
|
||||||
# Convert labels to a set
|
# Convert labels to a set
|
||||||
@@ -133,8 +135,10 @@ def websocket_update_area(
|
|||||||
data.pop("id")
|
data.pop("id")
|
||||||
|
|
||||||
if "aliases" in data:
|
if "aliases" in data:
|
||||||
# Convert aliases to a set
|
# Create a set for the aliases without:
|
||||||
data["aliases"] = set(data["aliases"])
|
# - 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:
|
if "labels" in data:
|
||||||
# Convert labels to a set
|
# Convert labels to a set
|
||||||
|
|||||||
@@ -227,8 +227,10 @@ def websocket_update_entity(
|
|||||||
changes[key] = msg[key]
|
changes[key] = msg[key]
|
||||||
|
|
||||||
if "aliases" in msg:
|
if "aliases" in msg:
|
||||||
# Convert aliases to a set
|
# Create a set for the aliases without:
|
||||||
changes["aliases"] = set(msg["aliases"])
|
# - 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:
|
if "labels" in msg:
|
||||||
# Convert labels to a set
|
# Convert labels to a set
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ def websocket_create_floor(
|
|||||||
data.pop("id")
|
data.pop("id")
|
||||||
|
|
||||||
if "aliases" in data:
|
if "aliases" in data:
|
||||||
# Convert aliases to a set
|
# Create a set for the aliases without:
|
||||||
data["aliases"] = set(data["aliases"])
|
# - 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:
|
try:
|
||||||
entry = registry.async_create(**data)
|
entry = registry.async_create(**data)
|
||||||
@@ -117,8 +119,10 @@ def websocket_update_floor(
|
|||||||
data.pop("id")
|
data.pop("id")
|
||||||
|
|
||||||
if "aliases" in data:
|
if "aliases" in data:
|
||||||
# Convert aliases to a set
|
# Create a set for the aliases without:
|
||||||
data["aliases"] = set(data["aliases"])
|
# - 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:
|
try:
|
||||||
entry = registry.async_update(**data)
|
entry = registry.async_update(**data)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import device_registry as dr
|
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
|
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
|
||||||
|
|
||||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
|
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."""
|
"""Set up Coolmaster from a config entry."""
|
||||||
host = entry.data[CONF_HOST]
|
host = entry.data[CONF_HOST]
|
||||||
port = entry.data[CONF_PORT]
|
port = entry.data[CONF_PORT]
|
||||||
|
send_wakeup_prompt = entry.data.get(CONF_SEND_WAKEUP_PROMPT, False)
|
||||||
if not entry.data.get(CONF_SWING_SUPPORT):
|
if not entry.data.get(CONF_SWING_SUPPORT):
|
||||||
coolmaster = CoolMasterNet(
|
coolmaster = CoolMasterNet(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
|
send_initial_line_feed=send_wakeup_prompt,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Swing support adds an additional request per unit. The requests are
|
# 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(
|
coolmaster = CoolMasterNet(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
|
send_initial_line_feed=send_wakeup_prompt,
|
||||||
read_timeout=5,
|
read_timeout=5,
|
||||||
swing_support=True,
|
swing_support=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
|||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
from homeassistant.core import callback
|
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 = [
|
AVAILABLE_MODES = [
|
||||||
HVACMode.OFF.value,
|
HVACMode.OFF.value,
|
||||||
@@ -25,17 +31,15 @@ AVAILABLE_MODES = [
|
|||||||
|
|
||||||
MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES}
|
MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES}
|
||||||
|
|
||||||
DATA_SCHEMA = vol.Schema(
|
DATA_SCHEMA = {
|
||||||
{
|
vol.Required(CONF_HOST): str,
|
||||||
vol.Required(CONF_HOST): str,
|
**MODES_SCHEMA,
|
||||||
**MODES_SCHEMA,
|
vol.Required(CONF_SWING_SUPPORT, default=False): bool,
|
||||||
vol.Required(CONF_SWING_SUPPORT, default=False): bool,
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_connection(host: str) -> bool:
|
async def _validate_connection(host: str, send_wakeup_prompt: bool) -> bool:
|
||||||
cool = CoolMasterNet(host, DEFAULT_PORT)
|
cool = CoolMasterNet(host, DEFAULT_PORT, send_initial_line_feed=send_wakeup_prompt)
|
||||||
units = await cool.status()
|
units = await cool.status()
|
||||||
return bool(units)
|
return bool(units)
|
||||||
|
|
||||||
@@ -45,6 +49,14 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = 1
|
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
|
@callback
|
||||||
def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||||
supported_modes = [
|
supported_modes = [
|
||||||
@@ -57,6 +69,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_PORT: DEFAULT_PORT,
|
CONF_PORT: DEFAULT_PORT,
|
||||||
CONF_SUPPORTED_MODES: supported_modes,
|
CONF_SUPPORTED_MODES: supported_modes,
|
||||||
CONF_SWING_SUPPORT: data[CONF_SWING_SUPPORT],
|
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
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle a flow initialized by the user."""
|
"""Handle a flow initialized by the user."""
|
||||||
|
data_schema = self._get_data_schema()
|
||||||
|
|
||||||
if user_input is None:
|
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 = {}
|
errors = {}
|
||||||
|
|
||||||
host = user_input[CONF_HOST]
|
host = user_input[CONF_HOST]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await _validate_connection(host)
|
result = await _validate_connection(
|
||||||
|
host, user_input.get(CONF_SEND_WAKEUP_PROMPT, False)
|
||||||
|
)
|
||||||
if not result:
|
if not result:
|
||||||
errors["base"] = "no_units"
|
errors["base"] = "no_units"
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -80,7 +97,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return self.async_show_form(
|
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)
|
return self._async_get_entry(user_input)
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ DEFAULT_PORT = 10102
|
|||||||
|
|
||||||
CONF_SUPPORTED_MODES = "supported_modes"
|
CONF_SUPPORTED_MODES = "supported_modes"
|
||||||
CONF_SWING_SUPPORT = "swing_support"
|
CONF_SWING_SUPPORT = "swing_support"
|
||||||
|
CONF_SEND_WAKEUP_PROMPT = "send_wakeup_prompt"
|
||||||
MAX_RETRIES = 3
|
MAX_RETRIES = 3
|
||||||
BACKOFF_BASE_DELAY = 2
|
BACKOFF_BASE_DELAY = 2
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"codeowners": ["@OnFreund"],
|
"codeowners": ["@OnFreund"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
|
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pycoolmasternet_async"],
|
"loggers": ["pycoolmasternet_async"],
|
||||||
"requirements": ["pycoolmasternet-async==0.2.2"]
|
"requirements": ["pycoolmasternet-async==0.2.4"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
"heat_cool": "Support automatic heat/cool mode",
|
"heat_cool": "Support automatic heat/cool mode",
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"off": "Can be turned off",
|
"off": "Can be turned off",
|
||||||
|
"send_wakeup_prompt": "Send wakeup prompt",
|
||||||
"swing_support": "Control swing mode"
|
"swing_support": "Control swing mode"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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."
|
"description": "Set up your CoolMasterNet connection details."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -11,18 +11,15 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.condition import (
|
from homeassistant.helpers.condition import (
|
||||||
Condition,
|
Condition,
|
||||||
|
ConditionChecker,
|
||||||
ConditionCheckerType,
|
ConditionCheckerType,
|
||||||
ConditionConfig,
|
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 . import DeviceAutomationType, async_get_device_automation_platform
|
||||||
from .helpers import async_validate_device_automation_config
|
from .helpers import async_validate_device_automation_config
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from homeassistant.helpers import condition
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceAutomationConditionProtocol(Protocol):
|
class DeviceAutomationConditionProtocol(Protocol):
|
||||||
"""Define the format of device_condition modules.
|
"""Define the format of device_condition modules.
|
||||||
@@ -90,15 +87,21 @@ class DeviceCondition(Condition):
|
|||||||
assert config.options is not None
|
assert config.options is not None
|
||||||
self._config = config.options
|
self._config = config.options
|
||||||
|
|
||||||
async def async_get_checker(self) -> condition.ConditionCheckerType:
|
async def async_get_checker(self) -> ConditionChecker:
|
||||||
"""Test a device condition."""
|
"""Test a device condition."""
|
||||||
platform = await async_get_device_automation_platform(
|
platform = await async_get_device_automation_platform(
|
||||||
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
||||||
)
|
)
|
||||||
return trace_condition_function(
|
platform_checker = platform.async_condition_from_config(
|
||||||
platform.async_condition_from_config(self._hass, self._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]] = {
|
CONDITIONS: dict[str, type[Condition]] = {
|
||||||
"_device": DeviceCondition,
|
"_device": DeviceCondition,
|
||||||
|
|||||||
@@ -11,5 +11,13 @@
|
|||||||
"see": {
|
"see": {
|
||||||
"service": "mdi:account-eye"
|
"service": "mdi:account-eye"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"entered_home": {
|
||||||
|
"trigger": "mdi:account-arrow-left"
|
||||||
|
},
|
||||||
|
"left_home": {
|
||||||
|
"trigger": "mdi:account-arrow-right"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
|
||||||
|
"trigger_behavior_name": "Behavior"
|
||||||
|
},
|
||||||
"device_automation": {
|
"device_automation": {
|
||||||
"condition_type": {
|
"condition_type": {
|
||||||
"is_home": "{entity_name} is home",
|
"is_home": "{entity_name} is home",
|
||||||
@@ -44,6 +48,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"selector": {
|
||||||
|
"trigger_behavior": {
|
||||||
|
"options": {
|
||||||
|
"any": "Any",
|
||||||
|
"first": "First",
|
||||||
|
"last": "Last"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"see": {
|
"see": {
|
||||||
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
|
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
|
||||||
@@ -80,5 +93,27 @@
|
|||||||
"name": "See"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
homeassistant/components/device_tracker/trigger.py
Normal file
21
homeassistant/components/device_tracker/trigger.py
Normal 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
|
||||||
18
homeassistant/components/device_tracker/triggers.yaml
Normal file
18
homeassistant/components/device_tracker/triggers.yaml
Normal 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
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": [],
|
"codeowners": [],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/directv",
|
"documentation": "https://www.home-assistant.io/integrations/directv",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["directv"],
|
"loggers": ["directv"],
|
||||||
"requirements": ["directv==0.4.0"],
|
"requirements": ["directv==0.4.0"],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["async_upnp_client"],
|
"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": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["async-upnp-client==0.46.0"],
|
"requirements": ["async-upnp-client==0.46.1"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["aiodns==3.6.0"]
|
"requirements": ["aiodns==3.6.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["recorder"],
|
"dependencies": ["recorder"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
|
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["aiodukeenergy==0.3.0"]
|
"requirements": ["aiodukeenergy==0.3.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["ekey-bionyxpy==1.0.0"]
|
"requirements": ["ekey-bionyxpy==1.0.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["elkm1_lib"],
|
"loggers": ["elkm1_lib"],
|
||||||
"requirements": ["elkm1-lib==2.2.13"]
|
"requirements": ["elkm1-lib==2.2.13"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@borpin", "@alexandrecuer"],
|
"codeowners": ["@borpin", "@alexandrecuer"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pyemoncms==0.1.3"]
|
"requirements": ["pyemoncms==0.1.3"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/emonitor",
|
"documentation": "https://www.home-assistant.io/integrations/emonitor",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["aioemonitor"],
|
"loggers": ["aioemonitor"],
|
||||||
"requirements": ["aioemonitor==1.0.5"]
|
"requirements": ["aioemonitor==1.0.5"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": [],
|
"codeowners": [],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/enocean",
|
"documentation": "https://www.home-assistant.io/integrations/enocean",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["enocean"],
|
"loggers": ["enocean"],
|
||||||
"requirements": ["enocean==0.50"],
|
"requirements": ["enocean==0.50"],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@bdraco", "@cgarwood", "@catsmanac"],
|
"codeowners": ["@bdraco", "@cgarwood", "@catsmanac"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyenphase"],
|
"loggers": ["pyenphase"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@pszafer"],
|
"codeowners": ["@pszafer"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/epson",
|
"documentation": "https://www.home-assistant.io/integrations/epson",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["epson_projector"],
|
"loggers": ["epson_projector"],
|
||||||
"requirements": ["epson-projector==0.6.0"]
|
"requirements": ["epson-projector==0.6.0"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"homekit": {
|
"homekit": {
|
||||||
"models": ["Escea"]
|
"models": ["Escea"]
|
||||||
},
|
},
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["pescea==1.0.12"]
|
"requirements": ["pescea==1.0.12"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"mqtt": ["esphome/discover/#"],
|
"mqtt": ["esphome/discover/#"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==43.0.0",
|
"aioesphomeapi==43.3.0",
|
||||||
"esphome-dashboard-api==1.3.0",
|
"esphome-dashboard-api==1.3.0",
|
||||||
"bleak-esphome==3.4.0"
|
"bleak-esphome==3.4.0"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": [],
|
"codeowners": [],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/evil_genius_labs",
|
"documentation": "https://www.home-assistant.io/integrations/evil_genius_labs",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pyevilgenius==2.0.0"]
|
"requirements": ["pyevilgenius==2.0.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@ntilley905"],
|
"codeowners": ["@ntilley905"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/faa_delays",
|
"documentation": "https://www.home-assistant.io/integrations/faa_delays",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["faadelays"],
|
"loggers": ["faadelays"],
|
||||||
"requirements": ["faadelays==2023.9.1"]
|
"requirements": ["faadelays==2023.9.1"]
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
from homeassistant.const import STATE_OFF, STATE_ON
|
from homeassistant.const import STATE_OFF, STATE_ON
|
||||||
from homeassistant.core import HomeAssistant
|
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
|
from . import DOMAIN
|
||||||
|
|
||||||
TRIGGERS: dict[str, type[Trigger]] = {
|
TRIGGERS: dict[str, type[Trigger]] = {
|
||||||
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
|
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||||
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
|
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@Lorenzo-Gasparini"],
|
"codeowners": ["@Lorenzo-Gasparini"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/fing",
|
"documentation": "https://www.home-assistant.io/integrations/fing",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["fing_agent_api==1.0.3"]
|
"requirements": ["fing_agent_api==1.0.3"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@cyberjunky"],
|
"codeowners": ["@cyberjunky"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/fireservicerota",
|
"documentation": "https://www.home-assistant.io/integrations/fireservicerota",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyfireservicerota"],
|
"loggers": ["pyfireservicerota"],
|
||||||
"requirements": ["pyfireservicerota==0.0.46"]
|
"requirements": ["pyfireservicerota==0.0.46"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@Sander0542"],
|
"codeowners": ["@Sander0542"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/fivem",
|
"documentation": "https://www.home-assistant.io/integrations/fivem",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["fivem-api==0.1.2"]
|
"requirements": ["fivem-api==0.1.2"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/fjaraskupan",
|
"documentation": "https://www.home-assistant.io/integrations/fjaraskupan",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["bleak", "fjaraskupan"],
|
"loggers": ["bleak", "fjaraskupan"],
|
||||||
"requirements": ["fjaraskupan==2.3.3"]
|
"requirements": ["fjaraskupan==2.3.3"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@cnico"],
|
"codeowners": ["@cnico"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/flipr",
|
"documentation": "https://www.home-assistant.io/integrations/flipr",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["flipr_api"],
|
"loggers": ["flipr_api"],
|
||||||
"requirements": ["flipr-api==1.6.1"]
|
"requirements": ["flipr-api==1.6.1"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@dmulcahey"],
|
"codeowners": ["@dmulcahey"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/flo",
|
"documentation": "https://www.home-assistant.io/integrations/flo",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioflo"],
|
"loggers": ["aioflo"],
|
||||||
"requirements": ["aioflo==2021.11.0"]
|
"requirements": ["aioflo==2021.11.0"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/flume",
|
"documentation": "https://www.home-assistant.io/integrations/flume",
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyflume"],
|
"loggers": ["pyflume"],
|
||||||
"requirements": ["PyFlume==0.6.5"]
|
"requirements": ["PyFlume==0.6.5"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@Foscam-wangzhengyu"],
|
"codeowners": ["@Foscam-wangzhengyu"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/foscam",
|
"documentation": "https://www.home-assistant.io/integrations/foscam",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["libpyfoscamcgi"],
|
"loggers": ["libpyfoscamcgi"],
|
||||||
"requirements": ["libpyfoscamcgi==0.0.9"]
|
"requirements": ["libpyfoscamcgi==0.0.9"]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["ffmpeg"],
|
"dependencies": ["ffmpeg"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/freebox",
|
"documentation": "https://www.home-assistant.io/integrations/freebox",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["freebox_api"],
|
"loggers": ["freebox_api"],
|
||||||
"requirements": ["freebox-api==1.2.2"],
|
"requirements": ["freebox-api==1.2.2"],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
|
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 requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -27,6 +27,7 @@ class FritzboxCoordinatorData:
|
|||||||
|
|
||||||
devices: dict[str, FritzhomeDevice]
|
devices: dict[str, FritzhomeDevice]
|
||||||
templates: dict[str, FritzhomeTemplate]
|
templates: dict[str, FritzhomeTemplate]
|
||||||
|
triggers: dict[str, FritzhomeTrigger]
|
||||||
supported_color_properties: dict[str, tuple[dict, list]]
|
supported_color_properties: dict[str, tuple[dict, list]]
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
configuration_url: str
|
configuration_url: str
|
||||||
fritz: Fritzhome
|
fritz: Fritzhome
|
||||||
has_templates: bool
|
has_templates: bool
|
||||||
|
has_triggers: bool
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
|
||||||
"""Initialize the Fritzbox Smarthome device coordinator."""
|
"""Initialize the Fritzbox Smarthome device coordinator."""
|
||||||
@@ -50,8 +52,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
|
|
||||||
self.new_devices: set[str] = set()
|
self.new_devices: set[str] = set()
|
||||||
self.new_templates: 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:
|
async def async_setup(self) -> None:
|
||||||
"""Set up the coordinator."""
|
"""Set up the coordinator."""
|
||||||
@@ -74,6 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
)
|
)
|
||||||
LOGGER.debug("enable smarthome templates: %s", self.has_templates)
|
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()
|
self.configuration_url = self.fritz.get_prefixed_host()
|
||||||
|
|
||||||
await self.async_config_entry_first_refresh()
|
await self.async_config_entry_first_refresh()
|
||||||
@@ -92,7 +100,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
|
|
||||||
available_main_ains = [
|
available_main_ains = [
|
||||||
ain
|
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
|
if dev.device_and_unit_id[1] is None
|
||||||
]
|
]
|
||||||
device_reg = dr.async_get(self.hass)
|
device_reg = dr.async_get(self.hass)
|
||||||
@@ -112,6 +120,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
self.fritz.update_devices(ignore_removed=False)
|
self.fritz.update_devices(ignore_removed=False)
|
||||||
if self.has_templates:
|
if self.has_templates:
|
||||||
self.fritz.update_templates(ignore_removed=False)
|
self.fritz.update_templates(ignore_removed=False)
|
||||||
|
if self.has_triggers:
|
||||||
|
self.fritz.update_triggers(ignore_removed=False)
|
||||||
|
|
||||||
except RequestConnectionError as ex:
|
except RequestConnectionError as ex:
|
||||||
raise UpdateFailed from ex
|
raise UpdateFailed from ex
|
||||||
except HTTPError:
|
except HTTPError:
|
||||||
@@ -123,6 +134,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
self.fritz.update_devices(ignore_removed=False)
|
self.fritz.update_devices(ignore_removed=False)
|
||||||
if self.has_templates:
|
if self.has_templates:
|
||||||
self.fritz.update_templates(ignore_removed=False)
|
self.fritz.update_templates(ignore_removed=False)
|
||||||
|
if self.has_triggers:
|
||||||
|
self.fritz.update_triggers(ignore_removed=False)
|
||||||
|
|
||||||
devices = self.fritz.get_devices()
|
devices = self.fritz.get_devices()
|
||||||
device_data = {}
|
device_data = {}
|
||||||
@@ -156,12 +169,20 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
for template in templates:
|
for template in templates:
|
||||||
template_data[template.ain] = template
|
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_devices = device_data.keys() - self.data.devices.keys()
|
||||||
self.new_templates = template_data.keys() - self.data.templates.keys()
|
self.new_templates = template_data.keys() - self.data.templates.keys()
|
||||||
|
self.new_triggers = trigger_data.keys() - self.data.triggers.keys()
|
||||||
|
|
||||||
return FritzboxCoordinatorData(
|
return FritzboxCoordinatorData(
|
||||||
devices=device_data,
|
devices=device_data,
|
||||||
templates=template_data,
|
templates=template_data,
|
||||||
|
triggers=trigger_data,
|
||||||
supported_color_properties=supported_color_properties,
|
supported_color_properties=supported_color_properties,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -193,6 +214,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
|||||||
if (
|
if (
|
||||||
self.data.devices.keys() - new_data.devices.keys()
|
self.data.devices.keys() - new_data.devices.keys()
|
||||||
or self.data.templates.keys() - new_data.templates.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)
|
self.cleanup_removed_devices(new_data)
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pyfritzhome.devicetypes import FritzhomeTrigger
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity
|
from homeassistant.components.switch import SwitchEntity
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import FritzboxConfigEntry
|
from .coordinator import FritzboxConfigEntry
|
||||||
from .entity import FritzBoxDeviceEntity
|
from .entity import FritzBoxDeviceEntity, FritzBoxEntity
|
||||||
|
|
||||||
# Coordinator handles data updates, so we can allow unlimited parallel updates
|
# Coordinator handles data updates, so we can allow unlimited parallel updates
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
@@ -26,21 +29,27 @@ async def async_setup_entry(
|
|||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _add_entities(devices: set[str] | None = None) -> None:
|
def _add_entities(
|
||||||
"""Add devices."""
|
devices: set[str] | None = None, triggers: set[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Add devices and triggers."""
|
||||||
if devices is None:
|
if devices is None:
|
||||||
devices = coordinator.new_devices
|
devices = coordinator.new_devices
|
||||||
if not devices:
|
if triggers is None:
|
||||||
|
triggers = coordinator.new_triggers
|
||||||
|
if not devices and not triggers:
|
||||||
return
|
return
|
||||||
async_add_entities(
|
entities = [
|
||||||
FritzboxSwitch(coordinator, ain)
|
FritzboxSwitch(coordinator, ain)
|
||||||
for ain in devices
|
for ain in devices
|
||||||
if coordinator.data.devices[ain].has_switch
|
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))
|
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):
|
class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
|
||||||
@@ -70,3 +79,42 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
|
|||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="manual_switching_disabled",
|
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()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
|
|||||||
NumberEntityDescription(
|
NumberEntityDescription(
|
||||||
key="timeToScreensaverV2",
|
key="timeToScreensaverV2",
|
||||||
translation_key="screensaver_time",
|
translation_key="screensaver_time",
|
||||||
native_max_value=9999,
|
native_max_value=86400,
|
||||||
native_step=1,
|
native_step=1,
|
||||||
native_min_value=0,
|
native_min_value=0,
|
||||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||||
@@ -34,7 +34,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
|
|||||||
NumberEntityDescription(
|
NumberEntityDescription(
|
||||||
key="timeToScreenOffV2",
|
key="timeToScreenOffV2",
|
||||||
translation_key="screen_off_time",
|
translation_key="screen_off_time",
|
||||||
native_max_value=9999,
|
native_max_value=86400,
|
||||||
native_step=1,
|
native_step=1,
|
||||||
native_min_value=0,
|
native_min_value=0,
|
||||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||||
|
|||||||
@@ -2,15 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
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.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.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
from .const import CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, SECTION_ADVANCED
|
||||||
|
|
||||||
DOMAIN = "generic"
|
DOMAIN = "generic"
|
||||||
PLATFORMS = [Platform.CAMERA]
|
PLATFORMS = [Platform.CAMERA]
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
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."""
|
"""Unload a config entry."""
|
||||||
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
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
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from .const import (
|
|||||||
CONF_STILL_IMAGE_URL,
|
CONF_STILL_IMAGE_URL,
|
||||||
CONF_STREAM_SOURCE,
|
CONF_STREAM_SOURCE,
|
||||||
GET_IMAGE_TIMEOUT,
|
GET_IMAGE_TIMEOUT,
|
||||||
|
SECTION_ADVANCED,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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."""
|
"""Generate httpx.Auth object from credentials."""
|
||||||
username: str | None = device_info.get(CONF_USERNAME)
|
username: str | None = device_info.get(CONF_USERNAME)
|
||||||
password: str | None = device_info.get(CONF_PASSWORD)
|
password: str | None = device_info.get(CONF_PASSWORD)
|
||||||
authentication = device_info.get(CONF_AUTHENTICATION)
|
|
||||||
if username and password:
|
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.DigestAuth(username=username, password=password)
|
||||||
return httpx.BasicAuth(username=username, password=password)
|
return httpx.BasicAuth(username=username, password=password)
|
||||||
return None
|
return None
|
||||||
@@ -99,14 +102,16 @@ class GenericCamera(Camera):
|
|||||||
if self._stream_source:
|
if self._stream_source:
|
||||||
self._stream_source = Template(self._stream_source, hass)
|
self._stream_source = Template(self._stream_source, hass)
|
||||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||||
self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
|
self._limit_refetch = device_info[SECTION_ADVANCED].get(
|
||||||
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
|
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.content_type = device_info[CONF_CONTENT_TYPE]
|
||||||
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
self.verify_ssl = device_info[SECTION_ADVANCED][CONF_VERIFY_SSL]
|
||||||
if device_info.get(CONF_RTSP_TRANSPORT):
|
if rtsp_transport := device_info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
|
||||||
self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT]
|
self.stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
|
||||||
self._auth = generate_auth(device_info)
|
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.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
|
||||||
|
|
||||||
self._last_url = None
|
self._last_url = None
|
||||||
|
|||||||
@@ -50,10 +50,18 @@ from homeassistant.const import (
|
|||||||
HTTP_DIGEST_AUTHENTICATION,
|
HTTP_DIGEST_AUTHENTICATION,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import section
|
||||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||||
from homeassistant.helpers import config_validation as cv, template as template_helper
|
from homeassistant.helpers import config_validation as cv, template as template_helper
|
||||||
from homeassistant.helpers.entity_platform import PlatformData
|
from homeassistant.helpers.entity_platform import PlatformData
|
||||||
from homeassistant.helpers.httpx_client import get_async_client
|
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 homeassistant.util import slugify
|
||||||
|
|
||||||
from .camera import GenericCamera, generate_auth
|
from .camera import GenericCamera, generate_auth
|
||||||
@@ -67,16 +75,20 @@ from .const import (
|
|||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
GET_IMAGE_TIMEOUT,
|
GET_IMAGE_TIMEOUT,
|
||||||
|
SECTION_ADVANCED,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_DATA = {
|
DEFAULT_DATA = {
|
||||||
CONF_NAME: DEFAULT_NAME,
|
CONF_NAME: DEFAULT_NAME,
|
||||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
SECTION_ADVANCED: {
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
CONF_FRAMERATE: 2,
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||||
CONF_VERIFY_SSL: True,
|
CONF_FRAMERATE: 2,
|
||||||
|
CONF_VERIFY_SSL: True,
|
||||||
|
CONF_RTSP_TRANSPORT: "tcp",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
|
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
|
||||||
@@ -93,58 +105,47 @@ class InvalidStreamException(HomeAssistantError):
|
|||||||
|
|
||||||
|
|
||||||
def build_schema(
|
def build_schema(
|
||||||
user_input: Mapping[str, Any],
|
|
||||||
is_options_flow: bool = False,
|
is_options_flow: bool = False,
|
||||||
show_advanced_options: bool = False,
|
show_advanced_options: bool = False,
|
||||||
) -> vol.Schema:
|
) -> vol.Schema:
|
||||||
"""Create schema for camera config setup."""
|
"""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 = {
|
spec = {
|
||||||
vol.Optional(
|
vol.Optional(CONF_STREAM_SOURCE): str,
|
||||||
CONF_STILL_IMAGE_URL,
|
vol.Optional(CONF_STILL_IMAGE_URL): str,
|
||||||
description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
|
vol.Optional(CONF_USERNAME): str,
|
||||||
): str,
|
vol.Optional(CONF_PASSWORD): str,
|
||||||
vol.Optional(
|
vol.Required(SECTION_ADVANCED): section(
|
||||||
CONF_STREAM_SOURCE,
|
vol.Schema(advanced_section), {"collapsed": True}
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
if is_options_flow:
|
if is_options_flow:
|
||||||
spec[
|
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
|
||||||
vol.Required(
|
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
|
||||||
default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
|
|
||||||
)
|
|
||||||
] = bool
|
|
||||||
if show_advanced_options:
|
if show_advanced_options:
|
||||||
spec[
|
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
|
||||||
vol.Required(
|
|
||||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
|
||||||
default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
|
|
||||||
)
|
|
||||||
] = bool
|
|
||||||
return vol.Schema(spec)
|
return vol.Schema(spec)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +187,7 @@ async def async_test_still(
|
|||||||
return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
|
return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
|
||||||
if not yarl_url.is_absolute():
|
if not yarl_url.is_absolute():
|
||||||
return {CONF_STILL_IMAGE_URL: "relative_url"}, None
|
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)
|
auth = generate_auth(info)
|
||||||
try:
|
try:
|
||||||
async_client = get_async_client(hass, verify_ssl=verify_ssl)
|
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)
|
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
|
||||||
raise InvalidStreamException("template_error") from err
|
raise InvalidStreamException("template_error") from err
|
||||||
stream_options: dict[str, str | bool | float] = {}
|
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
|
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
|
stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -325,7 +326,7 @@ def register_still_preview(hass: HomeAssistant) -> None:
|
|||||||
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Config flow for generic IP camera."""
|
"""Config flow for generic IP camera."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 2
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize Generic ConfigFlow."""
|
"""Initialize Generic ConfigFlow."""
|
||||||
@@ -380,7 +381,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
user_input = DEFAULT_DATA.copy()
|
user_input = DEFAULT_DATA.copy()
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=build_schema(user_input),
|
data_schema=self.add_suggested_values_to_schema(build_schema(), user_input),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -448,13 +449,19 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
|||||||
self.preview_stream = None
|
self.preview_stream = None
|
||||||
if not errors:
|
if not errors:
|
||||||
data = {
|
data = {
|
||||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
|
|
||||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
|
|
||||||
),
|
|
||||||
**user_input,
|
**user_input,
|
||||||
CONF_CONTENT_TYPE: still_format
|
CONF_CONTENT_TYPE: still_format
|
||||||
or self.config_entry.options.get(CONF_CONTENT_TYPE),
|
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
|
self.user_input = data
|
||||||
# temporary preview for user to check the image
|
# temporary preview for user to check the image
|
||||||
self.preview_image_settings = data
|
self.preview_image_settings = data
|
||||||
@@ -463,10 +470,12 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
|||||||
user_input = self.user_input
|
user_input = self.user_input
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
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,
|
user_input or self.config_entry.options,
|
||||||
True,
|
|
||||||
self.show_advanced_options,
|
|
||||||
),
|
),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
@@ -582,7 +591,8 @@ async def ws_start_preview(
|
|||||||
_LOGGER.debug("Got preview still URL: %s", ha_still_url)
|
_LOGGER.debug("Got preview still URL: %s", ha_still_url)
|
||||||
|
|
||||||
if ha_stream := flow.preview_stream:
|
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)
|
_LOGGER.debug("Got preview stream URL: %s", ha_stream_url)
|
||||||
|
|
||||||
connection.send_message(
|
connection.send_message(
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ CONF_STILL_IMAGE_URL = "still_image_url"
|
|||||||
CONF_STREAM_SOURCE = "stream_source"
|
CONF_STREAM_SOURCE = "stream_source"
|
||||||
CONF_FRAMERATE = "framerate"
|
CONF_FRAMERATE = "framerate"
|
||||||
GET_IMAGE_TIMEOUT = 10
|
GET_IMAGE_TIMEOUT = 10
|
||||||
|
SECTION_ADVANCED = "advanced"
|
||||||
|
|||||||
@@ -26,17 +26,24 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"authentication": "Authentication",
|
|
||||||
"framerate": "Frame rate (Hz)",
|
|
||||||
"limit_refetch_to_url_change": "Limit refetch to URL change",
|
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"rtsp_transport": "RTSP transport protocol",
|
|
||||||
"still_image_url": "Still image URL (e.g. http://...)",
|
"still_image_url": "Still image URL (e.g. http://...)",
|
||||||
"stream_source": "Stream source URL (e.g. rtsp://...)",
|
"stream_source": "Stream source URL (e.g. rtsp://...)",
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]"
|
||||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
|
||||||
},
|
},
|
||||||
"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": {
|
"user_confirm": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -70,19 +77,27 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"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%]",
|
"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%]",
|
"still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]",
|
||||||
"stream_source": "[%key:component::generic::config::step::user::data::stream_source%]",
|
"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%]"
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
|
||||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
|
||||||
},
|
},
|
||||||
"data_description": {
|
"sections": {
|
||||||
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
|
"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": {
|
"user_confirm": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
import botocore.exceptions
|
import botocore.exceptions
|
||||||
from homelink.auth.srp_auth import SRPAuth
|
from homelink.auth.srp_auth import SRPAuth
|
||||||
|
import jwt
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlowResult
|
||||||
@@ -38,8 +39,6 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
|||||||
"""Ask for username and password."""
|
"""Ask for username and password."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
|
|
||||||
|
|
||||||
srp_auth = SRPAuth()
|
srp_auth = SRPAuth()
|
||||||
try:
|
try:
|
||||||
tokens = await self.hass.async_add_executor_job(
|
tokens = await self.hass.async_add_executor_job(
|
||||||
@@ -48,12 +47,17 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
|||||||
user_input[CONF_PASSWORD],
|
user_input[CONF_PASSWORD],
|
||||||
)
|
)
|
||||||
except botocore.exceptions.ClientError:
|
except botocore.exceptions.ClientError:
|
||||||
_LOGGER.exception("Error authenticating homelink account")
|
|
||||||
errors["base"] = "srp_auth_failed"
|
errors["base"] = "srp_auth_failed"
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("An unexpected error occurred")
|
_LOGGER.exception("An unexpected error occurred")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
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}
|
self.external_data = {"tokens": tokens}
|
||||||
return await self.async_step_creation()
|
return await self.async_step_creation()
|
||||||
|
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from homelink.model.device import Device
|
from homelink.model.device import Device
|
||||||
@@ -14,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.util.ssl import get_default_context
|
from homeassistant.util.ssl import get_default_context
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator]
|
type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator]
|
||||||
type EventCallback = Callable[[HomeLinkEventData], None]
|
type EventCallback = Callable[[HomeLinkEventData], None]
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=gios.station_name,
|
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):
|
except (ApiError, ClientConnectorError, TimeoutError):
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
@@ -79,8 +83,7 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
sort=True,
|
sort=True,
|
||||||
mode=SelectSelectorMode.DROPDOWN,
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
vol.Optional(CONF_NAME, default=self.hass.config.location_name): str,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from aiohttp.client_exceptions import ClientConnectorError
|
from aiohttp.client_exceptions import ClientConnectorError
|
||||||
from gios import Gios
|
from gios import Gios
|
||||||
@@ -12,10 +13,12 @@ from gios.exceptions import GiosError
|
|||||||
from gios.model import GiosSensors
|
from gios.model import GiosSensors
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -51,6 +54,21 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]):
|
|||||||
update_interval=SCAN_INTERVAL,
|
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:
|
async def _async_update_data(self) -> GiosSensors:
|
||||||
"""Update data via library."""
|
"""Update data via library."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ from homeassistant.components.sensor import (
|
|||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
SensorStateClass,
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
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.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
@@ -36,8 +35,6 @@ from .const import (
|
|||||||
ATTR_SO2,
|
ATTR_SO2,
|
||||||
ATTRIBUTION,
|
ATTRIBUTION,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
MANUFACTURER,
|
|
||||||
URL,
|
|
||||||
)
|
)
|
||||||
from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator
|
from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator
|
||||||
|
|
||||||
@@ -184,8 +181,6 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a GIOS entities from a config_entry."""
|
"""Add a GIOS entities from a config_entry."""
|
||||||
name = entry.data[CONF_NAME]
|
|
||||||
|
|
||||||
coordinator = entry.runtime_data.coordinator
|
coordinator = entry.runtime_data.coordinator
|
||||||
# Due to the change of the attribute name of one sensor, it is necessary to migrate
|
# Due to the change of the attribute name of one sensor, it is necessary to migrate
|
||||||
# the unique_id to the new name.
|
# the unique_id to the new name.
|
||||||
@@ -208,7 +203,7 @@ async def async_setup_entry(
|
|||||||
for description in SENSOR_TYPES:
|
for description in SENSOR_TYPES:
|
||||||
if getattr(coordinator.data, description.key) is None:
|
if getattr(coordinator.data, description.key) is None:
|
||||||
continue
|
continue
|
||||||
sensors.append(GiosSensor(name, coordinator, description))
|
sensors.append(GiosSensor(coordinator, description))
|
||||||
|
|
||||||
async_add_entities(sensors)
|
async_add_entities(sensors)
|
||||||
|
|
||||||
@@ -222,19 +217,13 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
|
||||||
coordinator: GiosDataUpdateCoordinator,
|
coordinator: GiosDataUpdateCoordinator,
|
||||||
description: GiosSensorEntityDescription,
|
description: GiosSensorEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
self._attr_device_info = coordinator.device_info
|
||||||
identifiers={(DOMAIN, str(coordinator.gios.station_id))},
|
|
||||||
manufacturer=MANUFACTURER,
|
|
||||||
name=name,
|
|
||||||
configuration_url=URL.format(station_id=coordinator.gios.station_id),
|
|
||||||
)
|
|
||||||
if description.subkey:
|
if description.subkey:
|
||||||
self._attr_unique_id = (
|
self._attr_unique_id = (
|
||||||
f"{coordinator.gios.station_id}-{description.key}-{description.subkey}"
|
f"{coordinator.gios.station_id}-{description.key}-{description.subkey}"
|
||||||
|
|||||||
@@ -11,11 +11,9 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"name": "[%key:common::config_flow::data::name%]",
|
|
||||||
"station_id": "Measuring station"
|
"station_id": "Measuring station"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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."
|
"station_id": "The name of the measuring station where the environmental data is collected."
|
||||||
},
|
},
|
||||||
"title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)"
|
"title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)"
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ HA_MANAGED_API_PORT = 11984
|
|||||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||||
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
|
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
|
||||||
# in script/hassfest/docker.py.
|
# in script/hassfest/docker.py.
|
||||||
RECOMMENDED_VERSION = "1.9.12"
|
RECOMMENDED_VERSION = "1.9.13"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"homekit": {
|
"homekit": {
|
||||||
"models": ["iSmartGate"]
|
"models": ["iSmartGate"]
|
||||||
},
|
},
|
||||||
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["ismartgate"],
|
"loggers": ["ismartgate"],
|
||||||
"requirements": ["ismartgate==5.0.2"]
|
"requirements": ["ismartgate==5.0.2"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@mletenay", "@starkillerOG"],
|
"codeowners": ["@mletenay", "@starkillerOG"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/goodwe",
|
"documentation": "https://www.home-assistant.io/integrations/goodwe",
|
||||||
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["goodwe"],
|
"loggers": ["goodwe"],
|
||||||
"requirements": ["goodwe==0.4.8"]
|
"requirements": ["goodwe==0.4.8"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ ATTR_CC = "cc"
|
|||||||
ATTR_ENABLED = "enabled"
|
ATTR_ENABLED = "enabled"
|
||||||
ATTR_END = "end"
|
ATTR_END = "end"
|
||||||
ATTR_FROM = "from"
|
ATTR_FROM = "from"
|
||||||
|
ATTR_ALIAS_FROM = "alias_from"
|
||||||
ATTR_ME = "me"
|
ATTR_ME = "me"
|
||||||
ATTR_MESSAGE = "message"
|
ATTR_MESSAGE = "message"
|
||||||
ATTR_PLAIN_TEXT = "plain_text"
|
ATTR_PLAIN_TEXT = "plain_text"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
from email.utils import formataddr
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from googleapiclient.http import HttpRequest
|
from googleapiclient.http import HttpRequest
|
||||||
@@ -17,10 +18,20 @@ from homeassistant.components.notify import (
|
|||||||
BaseNotificationService,
|
BaseNotificationService,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .api import AsyncConfigEntryAuth
|
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(
|
async def async_get_service(
|
||||||
@@ -47,7 +58,17 @@ class GMailNotificationService(BaseNotificationService):
|
|||||||
email = MIMEText(message, "html")
|
email = MIMEText(message, "html")
|
||||||
if to_addrs := kwargs.get(ATTR_TARGET):
|
if to_addrs := kwargs.get(ATTR_TARGET):
|
||||||
email["To"] = ", ".join(to_addrs)
|
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["Subject"] = title
|
||||||
email[ATTR_CC] = ", ".join(data.get(ATTR_CC, []))
|
email[ATTR_CC] = ", ".join(data.get(ATTR_CC, []))
|
||||||
email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, []))
|
email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, []))
|
||||||
@@ -57,9 +78,9 @@ class GMailNotificationService(BaseNotificationService):
|
|||||||
msg: HttpRequest
|
msg: HttpRequest
|
||||||
users = (await self.auth.get_resource()).users()
|
users = (await self.auth.get_resource()).users()
|
||||||
if data.get(ATTR_SEND) is False:
|
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:
|
else:
|
||||||
if not to_addrs:
|
if not to_addrs:
|
||||||
raise ValueError("recipient address required")
|
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)
|
await self.hass.async_add_executor_job(msg.execute)
|
||||||
|
|||||||
@@ -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": {
|
"services": {
|
||||||
"set_vacation": {
|
"set_vacation": {
|
||||||
"description": "Sets vacation responder settings for Google Mail.",
|
"description": "Sets vacation responder settings for Google Mail.",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["application_credentials"],
|
"dependencies": ["application_credentials"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/google_photos",
|
"documentation": "https://www.home-assistant.io/integrations/google_photos",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["google_photos_library_api"],
|
"loggers": ["google_photos_library_api"],
|
||||||
"requirements": ["google-photos-library-api==0.12.1"]
|
"requirements": ["google-photos-library-api==0.12.1"]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["application_credentials"],
|
"dependencies": ["application_credentials"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/google_tasks",
|
"documentation": "https://www.home-assistant.io/integrations/google_tasks",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["google-api-python-client==2.71.0"]
|
"requirements": ["google-api-python-client==2.71.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"codeowners": ["@eifinger"],
|
"codeowners": ["@eifinger"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/google_travel_time",
|
"documentation": "https://www.home-assistant.io/integrations/google_travel_time",
|
||||||
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["google", "homeassistant.helpers.location"],
|
"loggers": ["google", "homeassistant.helpers.location"],
|
||||||
"requirements": ["google-maps-routing==0.6.15"]
|
"requirements": ["google-maps-routing==0.6.15"]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user