mirror of
https://github.com/home-assistant/core.git
synced 2026-06-16 17:02:57 +02:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ad8ad5715 | |||
| a45867b896 | |||
| 000e075a8e | |||
| 0899d016b9 | |||
| 3375f2ed76 | |||
| 3f5778e71b | |||
| 86c39694d3 | |||
| a53a6644c0 | |||
| 18fdfacf45 | |||
| bd9bd29f2c | |||
| 334c6614cc | |||
| aa772f6ecd | |||
| 87169921ae | |||
| 16338b8b6b | |||
| 519da3c9c9 | |||
| 6f34718c1f | |||
| e4287bb43c | |||
| d724ebac2a | |||
| dc480051db | |||
| 63b6ced9c4 | |||
| 34e9b3ff1e | |||
| 210746525e | |||
| 0134e99366 | |||
| 06de89d6a3 | |||
| 4c267617f8 | |||
| a82f1a7a1d | |||
| d234f65dd9 | |||
| 30148980e1 | |||
| 1fa9a3353c | |||
| 2dbbd70085 | |||
| 73903b0bfc | |||
| b09f54ce3b | |||
| 6d9e41da07 | |||
| f5600a602f | |||
| d83cd941a7 | |||
| 2120cad533 | |||
| fb4e72af77 | |||
| badd4130b6 | |||
| 7a4ca4dcfd | |||
| 9b47a0d440 | |||
| 4b99e81a8a | |||
| 62e5238f43 | |||
| 149c884a89 | |||
| 71ca453c42 | |||
| aad6080307 | |||
| 2db2e0b0cf | |||
| 3fc36ab6f9 | |||
| 0fad24393c | |||
| a992a58367 | |||
| f0cefe2f2e | |||
| 40264992a2 | |||
| c29aebd60e | |||
| 36b74d6f05 | |||
| 2c626fa8f0 | |||
| cab0d015f6 | |||
| c544f95979 | |||
| 2189d0ae74 | |||
| 9e96a06aff | |||
| d16e0e9867 | |||
| 2209996919 | |||
| d88767155b | |||
| 334d02077f | |||
| 2b7e9289d2 | |||
| c57358dd23 | |||
| e151478d78 | |||
| e41b1f5279 | |||
| 4203aed863 | |||
| e7e116843f | |||
| d781baca7e | |||
| 855962dcd0 | |||
| cf914f559f | |||
| a420a6c990 | |||
| 5f470d49a5 | |||
| bd2638f144 | |||
| b397d6fd05 | |||
| eb2ee43e6f | |||
| 9d16e59899 |
@@ -193,7 +193,7 @@ jobs:
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -1500,11 +1500,12 @@ jobs:
|
||||
echo "Artifact has no head_sha; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
# Recover the commit recorded in the most recent requirements-check comment.
|
||||
# Recover the commit recorded in the most recent requirements-check
|
||||
# comment from the "Checked at commit" link
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
|
||||
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
|
||||
| grep -oiE '/commit/[0-9a-f]{40}' \
|
||||
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
|
||||
if [ -z "${PRIOR}" ]; then
|
||||
echo "No previous comment with a recorded commit; running the agent."
|
||||
exit 0
|
||||
|
||||
@@ -51,11 +51,12 @@ jobs:
|
||||
echo "Artifact has no head_sha; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
# Recover the commit recorded in the most recent requirements-check comment.
|
||||
# Recover the commit recorded in the most recent requirements-check
|
||||
# comment from the "Checked at commit" link
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
|
||||
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
|
||||
| grep -oiE '/commit/[0-9a-f]{40}' \
|
||||
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
|
||||
if [ -z "${PRIOR}" ]; then
|
||||
echo "No previous comment with a recorded commit; running the agent."
|
||||
exit 0
|
||||
@@ -188,10 +189,8 @@ Then stop. Do not improvise a verdict.
|
||||
|
||||
Replace every placeholder with the resolved value and emit
|
||||
`rendered_comment` via `add_comment`. Preserve the leading
|
||||
`<!-- requirements-check -->` marker and the
|
||||
`<!-- requirements-check-sha: … -->` marker that follows it — the next
|
||||
run reads the recorded commit from it to decide whether anything changed.
|
||||
The PR target is already wired; do not pass `item_number`.
|
||||
`<!-- requirements-check -->` marker. The PR target is already wired;
|
||||
do not pass `item_number`.
|
||||
|
||||
## Check instructions
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
||||
|
||||
Generated
+2
-2
@@ -262,8 +262,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/braviatv/ @bieniu @Drafteed
|
||||
/homeassistant/components/bring/ @miaucl @tr4nt0r
|
||||
/tests/components/bring/ @miaucl @tr4nt0r
|
||||
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
|
||||
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
|
||||
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am
|
||||
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
|
||||
/homeassistant/components/brother/ @bieniu
|
||||
/tests/components/brother/ @bieniu
|
||||
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.4.7"],
|
||||
"requirements": ["aioairq==0.4.8"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"title": "Re-authenticate AirVisual"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"type": "Integration type"
|
||||
},
|
||||
"description": "Pick what type of AirVisual data you want to monitor.",
|
||||
"title": "Configure AirVisual"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import CONF_MAC, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -87,9 +87,12 @@ class AnthemAVR(MediaPlayerEntity):
|
||||
via_device=(DOMAIN, mac_address),
|
||||
)
|
||||
else:
|
||||
# Zone 1 is the physical receiver that owns the network MAC; higher
|
||||
# zones are via_device children and carry no connection.
|
||||
self._attr_unique_id = mac_address
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac_address)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac_address)},
|
||||
name=name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=model,
|
||||
|
||||
@@ -52,10 +52,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
Service integration, no discovery.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
No data updates.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
|
||||
@@ -193,6 +193,7 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, data[Attribute.MAC_ADDRESS])},
|
||||
name=self.create_device_name(data),
|
||||
manufacturer="Aprilaire",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config flow for Aquacell integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -31,6 +32,12 @@ DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Aquacell."""
|
||||
@@ -77,3 +84,48 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
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."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = AquacellApi(
|
||||
session, reauth_entry.data.get(CONF_BRAND, Brand.AQUACELL)
|
||||
)
|
||||
try:
|
||||
refresh_token = await api.authenticate(
|
||||
reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except ApiException, TimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except AuthenticationFailed:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_REFRESH_TOKEN: refresh_token,
|
||||
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_SCHEMA,
|
||||
description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from aioaquacell import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
@@ -79,7 +79,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
|
||||
|
||||
softeners = await self.aquacell_api.get_all_softeners()
|
||||
except AuthenticationFailed as err:
|
||||
raise ConfigEntryError from err
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (AquacellApiException, TimeoutError) as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,13 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "The password for {email} is no longer valid. Enter your current softener mobile app password to reconnect.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"brand": "Brand",
|
||||
|
||||
@@ -59,7 +59,9 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._get_reconfigure_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aqvify", data=user_input)
|
||||
return self.async_create_entry(
|
||||
title=account_data.name or "Aqvify", data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -152,3 +152,10 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
|
||||
devices=devices,
|
||||
device_data=device_data,
|
||||
)
|
||||
|
||||
def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]:
|
||||
"""Return newly discovered device keys and the full current device set."""
|
||||
|
||||
current_devices = set(self.data.devices.devices)
|
||||
new_devices: set[str] = current_devices - added_devices
|
||||
return (new_devices, current_devices)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyaqvify"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyaqvify==0.0.9"]
|
||||
"requirements": ["pyaqvify==0.0.11"]
|
||||
}
|
||||
|
||||
@@ -59,11 +59,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aqvify sensor entities from a config entry."""
|
||||
async_add_entities(
|
||||
AqvifySensor(entry.runtime_data, description, device_key)
|
||||
for description in ENTITIES
|
||||
for device_key in entry.runtime_data.data.devices.devices
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
added_devices: set[str] = set()
|
||||
|
||||
def _async_add_new_devices() -> None:
|
||||
nonlocal added_devices
|
||||
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
|
||||
added_devices = current_devices
|
||||
|
||||
async_add_entities(
|
||||
AqvifySensor(coordinator, description, device_key)
|
||||
for description in ENTITIES
|
||||
for device_key in new_devices_set
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
_async_add_new_devices()
|
||||
|
||||
|
||||
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["atenpdu==0.3.2"]
|
||||
"requirements": ["atenpdu==0.3.6"]
|
||||
}
|
||||
|
||||
@@ -123,7 +123,14 @@ class _BrandsBaseView(HomeAssistantView):
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "broadlink",
|
||||
"name": "Broadlink",
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["evolutionhttp==0.0.18"]
|
||||
"requirements": ["evolutionhttp==0.0.19"]
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ class EventTrigger(Trigger):
|
||||
listener = TargetCalendarEventListener(
|
||||
self._hass, target_selection, self._event_type, offset, run_action
|
||||
)
|
||||
return listener.async_setup()
|
||||
return await listener.async_setup()
|
||||
|
||||
|
||||
class EventStartedTrigger(EventTrigger):
|
||||
|
||||
@@ -785,7 +785,9 @@ class CameraView(HomeAssistantView):
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
if (camera := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
@@ -793,11 +795,15 @@ class CameraView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -27,7 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"address": address},
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass, address.upper(), BluetoothReachabilityIntent.CONNECTION
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
glow = CasperGlow(ble_device)
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"message": "An error occurred while communicating with the Casper Glow: {error}"
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "Could not find Casper Glow device with address {address}"
|
||||
"message": "Could not find Casper Glow device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ async def async_setup_entry(
|
||||
class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
"""Climate device for CCM15 coordinator."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_has_entity_name = True
|
||||
_attr_target_temperature_step = PRECISION_WHOLE
|
||||
_attr_hvac_modes = [
|
||||
@@ -93,6 +92,13 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
"""Return device data."""
|
||||
return self.coordinator.get_ac_data(self._ac_index)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement reported by the device."""
|
||||
if (data := self.data) is not None and not data.is_celsius:
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return current temperature."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["webexpythonsdk"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["webexpythonsdk==2.0.1"]
|
||||
"requirements": ["webexpythonsdk==2.0.6"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
|
||||
from data_grand_lyon_ha import (
|
||||
DataGrandLyonClient,
|
||||
TclStop,
|
||||
VelovStation,
|
||||
find_tcl_stop_by_id,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -49,12 +54,6 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STATION_ID): vol.Coerce(int),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Data Grand Lyon."""
|
||||
@@ -302,27 +301,96 @@ def _stop_label(stop: TclStop) -> str:
|
||||
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for adding a Vélo'v station."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._stations: list[VelovStation] = []
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the user step to add a new Vélo'v station."""
|
||||
entry = self._get_entry()
|
||||
"""Pick a station from the list fetched from the API, or enter one manually."""
|
||||
if not self._stations:
|
||||
if error := await self._async_load_stations():
|
||||
return self.async_abort(reason=error)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
station_id = user_input[CONF_STATION_ID]
|
||||
unique_id = f"velov_{station_id}"
|
||||
try:
|
||||
station_id = int(user_input[CONF_STATION_ID])
|
||||
except ValueError:
|
||||
errors[CONF_STATION_ID] = "invalid_station_id"
|
||||
else:
|
||||
entry = self._get_entry()
|
||||
unique_id = f"velov_{station_id}"
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Vélo'v {station_id}",
|
||||
data={CONF_STATION_ID: station_id},
|
||||
unique_id=unique_id,
|
||||
return self.async_create_entry(
|
||||
title=f"Vélo'v {station_id}",
|
||||
data={CONF_STATION_ID: station_id},
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
options = [
|
||||
SelectOptionDict(
|
||||
value=str(station.number), label=_velov_station_label(station)
|
||||
)
|
||||
|
||||
for station in sorted(
|
||||
self._stations,
|
||||
key=lambda s: (s.name, s.commune or "", s.number or 0),
|
||||
)
|
||||
]
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STATION_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
sort=False,
|
||||
custom_value=True,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_VELOV_STATION_DATA_SCHEMA,
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_load_stations(self) -> str | None:
|
||||
"""Fetch Vélo'v stations from the API, returning an error key on failure."""
|
||||
entry = self._get_entry()
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = DataGrandLyonClient(
|
||||
session=session,
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
self._stations = await client.get_velov_stations()
|
||||
except ClientResponseError as err:
|
||||
if err.status in (401, 403):
|
||||
return "invalid_auth"
|
||||
return "cannot_connect"
|
||||
except ClientError, TimeoutError:
|
||||
return "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error fetching Data Grand Lyon Vélo'v stations"
|
||||
)
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
|
||||
def _velov_station_label(station: VelovStation) -> str:
|
||||
label = station.name
|
||||
if station.address or station.commune:
|
||||
label += (
|
||||
" (" + ", ".join(filter(None, [station.address, station.commune])) + ")"
|
||||
)
|
||||
label += f" - {station.number}"
|
||||
|
||||
return label
|
||||
|
||||
@@ -76,16 +76,25 @@
|
||||
},
|
||||
"velov_station": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"entry_type": "Vélo'v station",
|
||||
"error": {
|
||||
"invalid_station_id": "Station ID must be a number."
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add Vélo'v station"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"station_id": "Station ID"
|
||||
"station_id": "Station"
|
||||
},
|
||||
"data_description": {
|
||||
"station_id": "Search by station name, address or city, or enter a station ID directly."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["doorbirdpy"],
|
||||
"requirements": ["DoorBirdPy==3.0.11"],
|
||||
"requirements": ["DoorBirdPy==3.0.12"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pdunehd"],
|
||||
"requirements": ["pdunehd==1.3.2"]
|
||||
"requirements": ["pdunehd==1.3.3"]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Please enter the API key obtained from ecobee.com."
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"domain": "eufylife_ble",
|
||||
"name": "EufyLife",
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "eufy T9120"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9130"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9140"
|
||||
},
|
||||
@@ -16,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9149"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9150"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@bdr99"],
|
||||
@@ -24,5 +33,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["eufylife-ble-client==0.1.8"]
|
||||
"requirements": ["eufylife-ble-client==0.1.10"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyfireservicerota"],
|
||||
"requirements": ["pyfireservicerota==0.0.46"]
|
||||
"requirements": ["pyfireservicerota==0.0.49"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config flow for Fluss+ integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fluss_api import (
|
||||
@@ -22,6 +23,21 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string})
|
||||
class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fluss+."""
|
||||
|
||||
async def _validate_api_key(self, api_key: str) -> dict[str, str]:
|
||||
"""Validate the API key and return any errors."""
|
||||
errors: dict[str, str] = {}
|
||||
client = FlussApiClient(api_key, session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.async_get_devices()
|
||||
except FlussApiClientCommunicationError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except FlussApiClientAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
return errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -31,18 +47,7 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
client = FlussApiClient(
|
||||
user_input[CONF_API_KEY], session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
await client.async_get_devices()
|
||||
except FlussApiClientCommunicationError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except FlussApiClientAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
errors = await self._validate_api_key(api_key)
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title="My Fluss+ Devices", data=user_input
|
||||
@@ -51,3 +56,30 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication when the API key is no longer valid."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm re-authentication with a new API key."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
errors = await self._validate_api_key(api_key)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: api_key},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from fluss_api import (
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -60,7 +60,7 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
try:
|
||||
devices = await self.api.async_get_devices()
|
||||
except FlussApiClientAuthenticationError as err:
|
||||
raise ConfigEntryError(f"Authentication failed: {err}") from err
|
||||
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||
except FlussApiClientError as err:
|
||||
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
entity-translations: done
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,15 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The API key found in the profile page of the Fluss+ app."
|
||||
},
|
||||
"description": "The Fluss+ API key is no longer valid. Get your API key from the profile page of the Fluss+ app, or generate a new one, and enter it below."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["foobot_async"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["foobot_async==1.0.0"]
|
||||
"requirements": ["foobot_async==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/gree",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["greeclimate"],
|
||||
"requirements": ["greeclimate==2.1.1"]
|
||||
"requirements": ["greeclimate==2.1.4"]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,132 @@
|
||||
"""Green Planet Energy integration for Home Assistant."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GreenPlanetEnergyUpdateCoordinator
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type GreenPlanetEnergyConfigEntry = ConfigEntry[GreenPlanetEnergyUpdateCoordinator]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
# Service constants
|
||||
SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration"
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_TIME_RANGE = "time_range"
|
||||
|
||||
# Time range options
|
||||
TIME_RANGE_DAY = "day"
|
||||
TIME_RANGE_NIGHT = "night"
|
||||
TIME_RANGE_FULL_DAY = "full_day"
|
||||
|
||||
SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DURATION): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=0.5, max=24)
|
||||
),
|
||||
vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In(
|
||||
[TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Green Planet Energy component."""
|
||||
|
||||
async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get_cheapest_duration service call."""
|
||||
# This integration has single_config_entry, so get the first entry
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
if not entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
|
||||
entry = entries[0]
|
||||
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
)
|
||||
|
||||
coordinator: GreenPlanetEnergyUpdateCoordinator = entry.runtime_data
|
||||
|
||||
duration = call.data[ATTR_DURATION]
|
||||
time_range = call.data[ATTR_TIME_RANGE]
|
||||
data = coordinator.data
|
||||
api = coordinator.api
|
||||
now = dt_util.now()
|
||||
current_hour = now.hour
|
||||
|
||||
result: tuple[float | None, int | None]
|
||||
|
||||
if time_range == TIME_RANGE_DAY:
|
||||
result = api.get_cheapest_duration_day(data, duration, current_hour)
|
||||
elif time_range == TIME_RANGE_NIGHT:
|
||||
result = api.get_cheapest_duration_night(data, duration, current_hour)
|
||||
else: # TIME_RANGE_FULL_DAY
|
||||
result = api.get_cheapest_duration(data, duration, current_hour)
|
||||
|
||||
avg_price, start_hour_result = result
|
||||
|
||||
if avg_price is None or start_hour_result is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_data_available",
|
||||
)
|
||||
|
||||
start_time = dt_util.start_of_local_day(now).replace(
|
||||
hour=start_hour_result, minute=0, second=0, microsecond=0
|
||||
)
|
||||
|
||||
# If the calculated start time is in the past, shift to tomorrow
|
||||
if start_time < now:
|
||||
start_time = start_time + timedelta(days=1)
|
||||
|
||||
end_time = start_time + timedelta(hours=duration)
|
||||
|
||||
hours_until_start = (start_time - now).total_seconds() / 3600
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"average_price": round(avg_price / 100, 4),
|
||||
"start_time": start_time.isoformat(),
|
||||
"end_time": end_time.isoformat(),
|
||||
"hours_until_start": round(hours_until_start, 1),
|
||||
"time_range": time_range,
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_CHEAPEST_DURATION,
|
||||
get_cheapest_duration,
|
||||
schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GreenPlanetEnergyConfigEntry
|
||||
@@ -21,6 +138,7 @@ async def async_setup_entry(
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"get_cheapest_duration": {
|
||||
"service": "mdi:clock-check"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Describes the format for available Green Planet Energy services
|
||||
|
||||
get_cheapest_duration:
|
||||
fields:
|
||||
duration:
|
||||
required: true
|
||||
example: 2.5
|
||||
selector:
|
||||
number:
|
||||
min: 0.5
|
||||
max: 24
|
||||
step: 0.25
|
||||
unit_of_measurement: "h"
|
||||
time_range:
|
||||
required: false
|
||||
default: "full_day"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: Full day (00:00-24:00)
|
||||
value: "full_day"
|
||||
- label: Day (06:00-18:00)
|
||||
value: "day"
|
||||
- label: Night (18:00-06:00)
|
||||
value: "night"
|
||||
@@ -43,8 +43,33 @@
|
||||
"api_error": {
|
||||
"message": "API error: {error}"
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "This integration instance is not currently loaded"
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Connection error: {error}"
|
||||
},
|
||||
"no_config_entry": {
|
||||
"message": "No matching integration instance was found"
|
||||
},
|
||||
"no_data_available": {
|
||||
"message": "No price data available for the requested duration and time range"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_cheapest_duration": {
|
||||
"description": "Retrieve electricity price data and find the cheapest consecutive time window for a given duration.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "Duration in hours for which to find the cheapest time window.",
|
||||
"name": "Duration"
|
||||
},
|
||||
"time_range": {
|
||||
"description": "Time range to search within.",
|
||||
"name": "Time range"
|
||||
}
|
||||
},
|
||||
"name": "Get cheapest duration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pygtfs"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pygtfs==0.1.9"]
|
||||
"requirements": ["pygtfs==0.1.11"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["heatmiserV3"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["heatmiserV3==2.0.4"]
|
||||
"requirements": ["heatmiserV3==2.0.6"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::destination_menu::title%]"
|
||||
},
|
||||
"destination_entity_id": {
|
||||
"destination_entity": {
|
||||
"data": {
|
||||
"destination_entity_id": "Destination using an entity"
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::origin_menu::title%]"
|
||||
},
|
||||
"origin_entity_id": {
|
||||
"origin_entity": {
|
||||
"data": {
|
||||
"origin_entity_id": "Origin using an entity"
|
||||
},
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hi-Link HLK-SW-16 device."
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhomematic"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyhomematic==0.1.77"]
|
||||
"requirements": ["pyhomematic==0.1.78"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "The Honeywell integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -41,6 +41,7 @@ class IAlarmPanel(
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
|
||||
manufacturer="Antifurto365 - Meian",
|
||||
name="iAlarm",
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==2.4.1"]
|
||||
"requirements": ["pyicloud==2.6.5"]
|
||||
}
|
||||
|
||||
@@ -326,7 +326,9 @@ class ImageView(HomeAssistantView):
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
@@ -334,11 +336,15 @@ class ImageView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or image entity access token
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/influxdb",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["influxdb", "influxdb_client"],
|
||||
"requirements": ["influxdb==5.3.1", "influxdb-client==1.50.0"],
|
||||
"requirements": ["influxdb==5.3.2", "influxdb-client==1.50.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==6.0.0"]
|
||||
"requirements": ["infrared-protocols==6.0.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["geopy", "pyipma"],
|
||||
"requirements": ["pyipma==3.0.9"]
|
||||
"requirements": ["pyipma==3.0.10"]
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"protocol": "Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of your Iskra device."
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["pykaleidescape==1.1.5"],
|
||||
"requirements": ["pykaleidescape==1.1.6"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "schemas-upnp-org:device:Basic:1",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["krakenex", "pykrakenapi"],
|
||||
"requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.8"]
|
||||
"requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.9"]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.11", "lcn-frontend==0.2.9"]
|
||||
"requirements": ["pypck==0.9.13", "lcn-frontend==0.2.9"]
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity):
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
self._attr_available = (
|
||||
await self.device_connection.request_status_led_and_logic_ops(
|
||||
await self.device_connection.request_status_leds_and_logic_ops(
|
||||
SCAN_INTERVAL.seconds
|
||||
)
|
||||
is not None
|
||||
|
||||
@@ -40,7 +40,6 @@ DEVICE_OP_MODE_TO_HA = {
|
||||
"auto": STATE_ECO,
|
||||
"heat_pump": STATE_HEAT_PUMP,
|
||||
"turbo": STATE_PERFORMANCE,
|
||||
"vacation": STATE_OFF,
|
||||
}
|
||||
HA_STATE_TO_DEVICE_OP_MODE = {v: k for k, v in DEVICE_OP_MODE_TO_HA.items()}
|
||||
|
||||
@@ -96,8 +95,6 @@ class ThinQWaterHeaterEntity(ThinQEntity, WaterHeaterEntity):
|
||||
self._attr_operation_list = [
|
||||
DEVICE_OP_MODE_TO_HA.get(mode, mode) for mode in modes
|
||||
]
|
||||
else:
|
||||
self._attr_operation_list = [STATE_HEAT_PUMP]
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status itself."""
|
||||
@@ -183,6 +180,8 @@ class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity):
|
||||
"""Initialize a water_heater entity."""
|
||||
super().__init__(coordinator, entity_description, property_id)
|
||||
self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF
|
||||
# For SYSTEM_BOILER, we only support heat pump mode and off mode.
|
||||
self._attr_operation_list = [STATE_HEAT_PUMP, STATE_OFF]
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
@@ -197,3 +196,16 @@ class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity):
|
||||
"[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id
|
||||
)
|
||||
await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id))
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Set new operation mode."""
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_operation_mode: %s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
operation_mode,
|
||||
)
|
||||
if operation_mode == STATE_OFF:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
await self.async_turn_on()
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
|
||||
},
|
||||
"step": {
|
||||
"import": {
|
||||
"import_ics_file": {
|
||||
"data": {
|
||||
"ics_file": "ICS file"
|
||||
},
|
||||
"description": "You can import events in iCal format (.ics file)."
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.9.1"],
|
||||
"requirements": ["lunatone-rest-api-client==0.9.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ACTION_LONG_PRESS,
|
||||
ACTION_MULTITAP,
|
||||
ACTION_PRESS,
|
||||
ACTION_RELEASE,
|
||||
@@ -35,6 +36,7 @@ from .const import (
|
||||
ATTR_SERIAL,
|
||||
ATTR_TYPE,
|
||||
BRIDGE_DEVICE_ID,
|
||||
BUTTON_STATUS_LONG_HOLD,
|
||||
CONF_CA_CERTS,
|
||||
CONF_CERTFILE,
|
||||
CONF_KEYFILE,
|
||||
@@ -450,6 +452,8 @@ def _async_subscribe_keypad_events(
|
||||
action = ACTION_PRESS
|
||||
elif event_type == BUTTON_STATUS_MULTITAP:
|
||||
action = ACTION_MULTITAP
|
||||
elif event_type == BUTTON_STATUS_LONG_HOLD:
|
||||
action = ACTION_LONG_PRESS
|
||||
else:
|
||||
action = ACTION_RELEASE
|
||||
|
||||
|
||||
@@ -29,10 +29,21 @@ ATTR_DEVICE_NAME = "device_name"
|
||||
ATTR_AREA_NAME = "area_name"
|
||||
ATTR_ACTION = "action"
|
||||
|
||||
ACTION_LONG_PRESS = "long_press"
|
||||
ACTION_MULTITAP = "multi_tap"
|
||||
ACTION_PRESS = "press"
|
||||
ACTION_RELEASE = "release"
|
||||
|
||||
# Raw EventType string sent by the Lutron LEAP protocol for a long hold.
|
||||
# pylutron-caseta passes all EventType values through without filtering.
|
||||
BUTTON_STATUS_LONG_HOLD = "LongHold"
|
||||
|
||||
# Bridge DeviceType strings (bridge_device["type"]) that are known to send
|
||||
# native LongHold events over LEAP. Used to gate the long_press device
|
||||
# trigger so it only appears in the automation UI for supported hardware.
|
||||
# Caseta and RadioRA3 processors do not emit LongHold.
|
||||
BRIDGE_DEVICE_TYPES_WITH_LONG_HOLD = frozenset({"HWQSProcessor"})
|
||||
|
||||
CONF_SUBTYPE = "subtype"
|
||||
|
||||
CONNECT_TIMEOUT = 9
|
||||
|
||||
@@ -19,11 +19,13 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ACTION_LONG_PRESS,
|
||||
ACTION_MULTITAP,
|
||||
ACTION_PRESS,
|
||||
ACTION_RELEASE,
|
||||
ATTR_ACTION,
|
||||
ATTR_BUTTON_TYPE,
|
||||
BRIDGE_DEVICE_TYPES_WITH_LONG_HOLD,
|
||||
CONF_SUBTYPE,
|
||||
DOMAIN,
|
||||
LUTRON_CASETA_BUTTON_EVENT,
|
||||
@@ -38,7 +40,18 @@ def _reverse_dict(forward_dict: dict) -> dict:
|
||||
return {v: k for k, v in forward_dict.items()}
|
||||
|
||||
|
||||
SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_MULTITAP, ACTION_RELEASE]
|
||||
SUPPORTED_INPUTS_EVENTS_TYPES = [
|
||||
ACTION_PRESS,
|
||||
ACTION_LONG_PRESS,
|
||||
ACTION_MULTITAP,
|
||||
ACTION_RELEASE,
|
||||
]
|
||||
|
||||
# Triggers that are only available on specific bridge types.
|
||||
# Actions absent from this dict are supported by all bridge types.
|
||||
TRIGGER_REQUIRED_BRIDGE_TYPES: dict[str, frozenset[str]] = {
|
||||
ACTION_LONG_PRESS: BRIDGE_DEVICE_TYPES_WITH_LONG_HOLD,
|
||||
}
|
||||
|
||||
LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
@@ -414,6 +427,14 @@ async def async_get_triggers(
|
||||
keypad_button_names_to_leap[keypad["lutron_device_id"]],
|
||||
)
|
||||
|
||||
bridge_type = data.bridge_device.get("type", "")
|
||||
supported_triggers = [
|
||||
t
|
||||
for t in SUPPORTED_INPUTS_EVENTS_TYPES
|
||||
if t not in TRIGGER_REQUIRED_BRIDGE_TYPES
|
||||
or bridge_type in TRIGGER_REQUIRED_BRIDGE_TYPES[t]
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
CONF_PLATFORM: "device",
|
||||
@@ -422,7 +443,7 @@ async def async_get_triggers(
|
||||
CONF_TYPE: trigger,
|
||||
CONF_SUBTYPE: subtype,
|
||||
}
|
||||
for trigger in SUPPORTED_INPUTS_EVENTS_TYPES
|
||||
for trigger in supported_triggers
|
||||
for subtype in valid_buttons
|
||||
]
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"stop_all": "Stop all"
|
||||
},
|
||||
"trigger_type": {
|
||||
"long_press": "\"{subtype}\" long pressed",
|
||||
"multi_tap": "\"{subtype}\" multi-tapped",
|
||||
"press": "\"{subtype}\" pressed",
|
||||
"release": "\"{subtype}\" released"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any, Final, Required, TypedDict, final
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
|
||||
from aiohttp.typedefs import LooseHeaders
|
||||
from propcache.api import cached_property
|
||||
@@ -1271,12 +1271,9 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
) -> web.Response:
|
||||
"""Start a get request."""
|
||||
if (player := self.component.get_entity(entity_id)) is None:
|
||||
status = (
|
||||
HTTPStatus.NOT_FOUND
|
||||
if request[KEY_AUTHENTICATED]
|
||||
else HTTPStatus.UNAUTHORIZED
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
return web.Response(status=status)
|
||||
|
||||
assert isinstance(player, MediaPlayerEntity)
|
||||
authenticated = (
|
||||
@@ -1285,7 +1282,16 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
return web.Response(status=HTTPStatus.UNAUTHORIZED)
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if media_content_type and media_content_id:
|
||||
media_image_id = request.query.get("media_image_id")
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["messagebird"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["messagebird==1.2.0"]
|
||||
"requirements": ["messagebird==1.2.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["librouteros"],
|
||||
"requirements": ["librouteros==3.2.0"]
|
||||
"requirements": ["librouteros==3.2.1"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Climate entity for Mitsubishi Comfort integration."""
|
||||
|
||||
from typing import Any
|
||||
import logging
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection
|
||||
|
||||
@@ -14,11 +15,15 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
|
||||
from .entity import MitsubishiComfortEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_MODE_TO_HVAC: dict[str, HVACMode] = {
|
||||
"off": HVACMode.OFF,
|
||||
"cool": HVACMode.COOL,
|
||||
@@ -219,32 +224,47 @@ class MitsubishiComfortClimate(MitsubishiComfortEntity, ClimateEntity):
|
||||
features |= ClimateEntityFeature.SWING_MODE
|
||||
return features
|
||||
|
||||
def _command_failed(self, translation_key: str) -> NoReturn:
|
||||
"""Raise a translated error after the device rejects a command."""
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders={"device_name": self._device.name},
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
lib_mode = _HVAC_TO_MODE.get(hvac_mode)
|
||||
if lib_mode is None:
|
||||
_LOGGER.debug("Ignoring unsupported HVAC mode %s", hvac_mode)
|
||||
return
|
||||
result = await self._device.set_mode(lib_mode)
|
||||
if result.success:
|
||||
self._optimistic[_OPT_MODE] = result.value
|
||||
self.async_write_ha_state()
|
||||
if not result.success:
|
||||
self._command_failed("set_hvac_mode_failed")
|
||||
self._optimistic[_OPT_MODE] = result.value
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the target temperature."""
|
||||
mode = self._effective_mode
|
||||
wrote = False
|
||||
failed = False
|
||||
|
||||
if ATTR_TARGET_TEMP_HIGH in kwargs:
|
||||
result = await self._device.set_cool_setpoint(kwargs[ATTR_TARGET_TEMP_HIGH])
|
||||
if result.success:
|
||||
self._optimistic[_OPT_COOL_SETPOINT] = result.value
|
||||
wrote = True
|
||||
else:
|
||||
failed = True
|
||||
|
||||
if ATTR_TARGET_TEMP_LOW in kwargs:
|
||||
result = await self._device.set_heat_setpoint(kwargs[ATTR_TARGET_TEMP_LOW])
|
||||
if result.success:
|
||||
self._optimistic[_OPT_HEAT_SETPOINT] = result.value
|
||||
wrote = True
|
||||
else:
|
||||
failed = True
|
||||
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temp is not None:
|
||||
@@ -253,34 +273,51 @@ class MitsubishiComfortClimate(MitsubishiComfortEntity, ClimateEntity):
|
||||
if result.success:
|
||||
self._optimistic[_OPT_COOL_SETPOINT] = result.value
|
||||
wrote = True
|
||||
else:
|
||||
failed = True
|
||||
elif mode in ("heat", "autoHeat"):
|
||||
result = await self._device.set_heat_setpoint(temp)
|
||||
if result.success:
|
||||
self._optimistic[_OPT_HEAT_SETPOINT] = result.value
|
||||
wrote = True
|
||||
else:
|
||||
failed = True
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Ignoring temperature for %s: no setpoint applies in mode %s",
|
||||
self._device.name,
|
||||
mode,
|
||||
)
|
||||
|
||||
# Apply whatever succeeded before surfacing the failure to the user.
|
||||
if wrote:
|
||||
self.async_write_ha_state()
|
||||
if failed:
|
||||
self._command_failed("set_temperature_failed")
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
speed = _FAN_SPEED_MAP.get(fan_mode)
|
||||
if speed is None:
|
||||
_LOGGER.debug("Ignoring unsupported fan mode %s", fan_mode)
|
||||
return
|
||||
result = await self._device.set_fan_speed(speed)
|
||||
if result.success:
|
||||
self._optimistic[_OPT_FAN_SPEED] = result.value
|
||||
self.async_write_ha_state()
|
||||
if not result.success:
|
||||
self._command_failed("set_fan_mode_failed")
|
||||
self._optimistic[_OPT_FAN_SPEED] = result.value
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set the swing mode."""
|
||||
direction = _VANE_DIR_MAP.get(swing_mode)
|
||||
if direction is None:
|
||||
_LOGGER.debug("Ignoring unsupported swing mode %s", swing_mode)
|
||||
return
|
||||
result = await self._device.set_vane_direction(direction)
|
||||
if result.success:
|
||||
self._optimistic[_OPT_VANE_DIRECTION] = result.value
|
||||
self.async_write_ha_state()
|
||||
if not result.success:
|
||||
self._command_failed("set_swing_mode_failed")
|
||||
self._optimistic[_OPT_VANE_DIRECTION] = result.value
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the entity off."""
|
||||
|
||||
@@ -29,6 +29,18 @@
|
||||
"no_devices": {
|
||||
"message": "No devices were found in your Mitsubishi Comfort account"
|
||||
},
|
||||
"set_fan_mode_failed": {
|
||||
"message": "{device_name} did not accept the requested fan mode"
|
||||
},
|
||||
"set_hvac_mode_failed": {
|
||||
"message": "{device_name} did not accept the requested HVAC mode"
|
||||
},
|
||||
"set_swing_mode_failed": {
|
||||
"message": "{device_name} did not accept the requested swing mode"
|
||||
},
|
||||
"set_temperature_failed": {
|
||||
"message": "{device_name} did not accept the requested temperature"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "{device_name} returned no data"
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
"device": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["config", "omnilogic"],
|
||||
"requirements": ["omnilogic==0.4.5"],
|
||||
"requirements": ["omnilogic==0.4.9"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"step": {
|
||||
"confirm": {
|
||||
"data": {
|
||||
"code": "Verification code (OTP)"
|
||||
"code": "Verification code (OTP)",
|
||||
"qr_code": "QR code"
|
||||
},
|
||||
"data_description": {
|
||||
"code": "The six-digit code currently displayed in your authentication app."
|
||||
|
||||
@@ -64,7 +64,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
|
||||
|
||||
# Not all AtlanticElectricalTowelDryer models support temporary presets,
|
||||
# thus we check if the command is available and then extend the presets
|
||||
if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE):
|
||||
if self.device.supports_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE):
|
||||
# Extend preset modes with supported temporary presets, avoiding duplicates
|
||||
self._attr_preset_modes += [
|
||||
mode
|
||||
|
||||
@@ -48,7 +48,7 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity):
|
||||
def is_auto_hvac_mode_available(self) -> bool:
|
||||
"""Check if auto mode is available on the ZoneControl."""
|
||||
|
||||
return self.executor.has_command(
|
||||
return self.device.supports_command(
|
||||
OverkizCommand.SET_HEATING_COOLING_AUTO_SWITCH
|
||||
) and self.executor.has_state(OverkizState.CORE_HEATING_COOLING_AUTO_SWITCH)
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone):
|
||||
def is_using_derogated_temperature_fallback(self) -> bool:
|
||||
"""Check if the device behave like the Pass APC Heating Zone."""
|
||||
|
||||
return self.executor.has_command(
|
||||
return self.device.supports_command(
|
||||
OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE
|
||||
)
|
||||
|
||||
|
||||
@@ -561,46 +561,52 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
|
||||
# and HA sets by default open/close as supported feature which conflicts
|
||||
supported_features = CoverEntityFeature(0)
|
||||
|
||||
if self.entity_description.open_command and self.executor.has_command(
|
||||
if self.entity_description.open_command and self.device.supports_command(
|
||||
self.entity_description.open_command
|
||||
):
|
||||
supported_features |= CoverEntityFeature.OPEN
|
||||
|
||||
if self.entity_description.stop_command and self.executor.has_command(
|
||||
if self.entity_description.stop_command and self.device.supports_command(
|
||||
self.entity_description.stop_command
|
||||
):
|
||||
supported_features |= CoverEntityFeature.STOP
|
||||
|
||||
if self.entity_description.close_command and self.executor.has_command(
|
||||
if self.entity_description.close_command and self.device.supports_command(
|
||||
self.entity_description.close_command
|
||||
):
|
||||
supported_features |= CoverEntityFeature.CLOSE
|
||||
|
||||
if self.entity_description.open_tilt_command and self.executor.has_command(
|
||||
if self.entity_description.open_tilt_command and self.device.supports_command(
|
||||
self.entity_description.open_tilt_command
|
||||
):
|
||||
supported_features |= CoverEntityFeature.OPEN_TILT
|
||||
|
||||
if self.entity_description.stop_tilt_command and self.executor.has_command(
|
||||
if (
|
||||
self.entity_description.stop_tilt_command
|
||||
and self.device.supports_command(
|
||||
self.entity_description.stop_tilt_command
|
||||
)
|
||||
):
|
||||
supported_features |= CoverEntityFeature.STOP_TILT
|
||||
|
||||
if self.entity_description.close_tilt_command and self.executor.has_command(
|
||||
if self.entity_description.close_tilt_command and self.device.supports_command(
|
||||
self.entity_description.close_tilt_command
|
||||
):
|
||||
supported_features |= CoverEntityFeature.CLOSE_TILT
|
||||
|
||||
if (
|
||||
self.entity_description.set_tilt_position_command
|
||||
and self.executor.has_command(
|
||||
and self.device.supports_command(
|
||||
self.entity_description.set_tilt_position_command
|
||||
)
|
||||
):
|
||||
supported_features |= CoverEntityFeature.SET_TILT_POSITION
|
||||
|
||||
if self.entity_description.set_position_command and self.executor.has_command(
|
||||
if (
|
||||
self.entity_description.set_position_command
|
||||
and self.device.supports_command(
|
||||
self.entity_description.set_position_command
|
||||
)
|
||||
):
|
||||
supported_features |= CoverEntityFeature.SET_POSITION
|
||||
|
||||
@@ -742,7 +748,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
|
||||
motor to stop between commands on some devices (e.g.
|
||||
Somfy DynamicExteriorVenetianBlind).
|
||||
"""
|
||||
if not self.executor.has_command(OverkizCommand.SET_CLOSURE_AND_ORIENTATION):
|
||||
if not self.device.supports_command(OverkizCommand.SET_CLOSURE_AND_ORIENTATION):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_set_position_and_tilt",
|
||||
|
||||
@@ -45,15 +45,6 @@ class OverkizExecutor:
|
||||
f"{self.device.identifier.base_device_url}#{index}"
|
||||
)
|
||||
|
||||
def select_command(self, *commands: str) -> str | None:
|
||||
"""Select first existing command in a list of commands."""
|
||||
existing_commands = self.device.definition.commands
|
||||
return next((c for c in commands if c in existing_commands), None)
|
||||
|
||||
def has_command(self, *commands: str) -> bool:
|
||||
"""Return True if a command exists in a list of commands."""
|
||||
return self.select_command(*commands) is not None
|
||||
|
||||
def select_definition_state(self, *states: str) -> StateDefinition | None:
|
||||
"""Select first existing definition state in a list of states."""
|
||||
for state_name in states:
|
||||
|
||||
@@ -44,9 +44,9 @@ class OverkizLight(OverkizEntity, LightEntity):
|
||||
|
||||
self._attr_supported_color_modes: set[ColorMode] = set()
|
||||
|
||||
if self.executor.has_command(OverkizCommand.SET_RGB):
|
||||
if self.device.supports_command(OverkizCommand.SET_RGB):
|
||||
self._attr_color_mode = ColorMode.RGB
|
||||
elif self.executor.has_command(OverkizCommand.SET_INTENSITY):
|
||||
elif self.device.supports_command(OverkizCommand.SET_INTENSITY):
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
|
||||
@@ -236,20 +236,22 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
"""Set new target temperature."""
|
||||
target_temperature = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
if self.executor.has_command(OverkizCommand.SET_TARGET_TEMPERATURE):
|
||||
if self.device.supports_command(OverkizCommand.SET_TARGET_TEMPERATURE):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_TARGET_TEMPERATURE, target_temperature
|
||||
)
|
||||
elif self.executor.has_command(OverkizCommand.SET_WATER_TARGET_TEMPERATURE):
|
||||
elif self.device.supports_command(OverkizCommand.SET_WATER_TARGET_TEMPERATURE):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_WATER_TARGET_TEMPERATURE, target_temperature
|
||||
)
|
||||
|
||||
if self.executor.has_command(OverkizCommand.REFRESH_TARGET_TEMPERATURE):
|
||||
if self.device.supports_command(OverkizCommand.REFRESH_TARGET_TEMPERATURE):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.REFRESH_TARGET_TEMPERATURE
|
||||
)
|
||||
elif self.executor.has_command(OverkizCommand.REFRESH_WATER_TARGET_TEMPERATURE):
|
||||
elif self.device.supports_command(
|
||||
OverkizCommand.REFRESH_WATER_TARGET_TEMPERATURE
|
||||
):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.REFRESH_WATER_TARGET_TEMPERATURE
|
||||
)
|
||||
@@ -275,12 +277,12 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
"""Set new target operation mode."""
|
||||
|
||||
if operation_mode == STATE_PERFORMANCE:
|
||||
if self.executor.has_command(OverkizCommand.SET_BOOST_MODE):
|
||||
if self.device.supports_command(OverkizCommand.SET_BOOST_MODE):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_BOOST_MODE, OverkizCommand.ON
|
||||
)
|
||||
|
||||
if self.executor.has_command(OverkizCommand.SET_BOOST_MODE_DURATION):
|
||||
if self.device.supports_command(OverkizCommand.SET_BOOST_MODE_DURATION):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_BOOST_MODE_DURATION, 7
|
||||
)
|
||||
@@ -288,7 +290,7 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
OverkizCommand.REFRESH_BOOST_MODE_DURATION
|
||||
)
|
||||
|
||||
if self.executor.has_command(OverkizCommand.SET_CURRENT_OPERATING_MODE):
|
||||
if self.device.supports_command(OverkizCommand.SET_CURRENT_OPERATING_MODE):
|
||||
current_operating_mode = self.executor.select_state(
|
||||
OverkizState.CORE_OPERATING_MODE
|
||||
)
|
||||
@@ -307,12 +309,12 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
if self._is_boost_mode_on:
|
||||
# We're setting a non Boost mode and the device is currently in Boost mode
|
||||
# The following code removes all boost operations
|
||||
if self.executor.has_command(OverkizCommand.SET_BOOST_MODE):
|
||||
if self.device.supports_command(OverkizCommand.SET_BOOST_MODE):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_BOOST_MODE, OverkizCommand.OFF
|
||||
)
|
||||
|
||||
if self.executor.has_command(OverkizCommand.SET_CURRENT_OPERATING_MODE):
|
||||
if self.device.supports_command(OverkizCommand.SET_CURRENT_OPERATING_MODE):
|
||||
current_operating_mode = self.executor.select_state(
|
||||
OverkizState.CORE_OPERATING_MODE
|
||||
)
|
||||
@@ -330,10 +332,10 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
OverkizCommand.SET_DHW_MODE, self.operation_mode_to_overkiz[operation_mode]
|
||||
)
|
||||
|
||||
if self.executor.has_command(OverkizCommand.REFRESH_BOOST_MODE_DURATION):
|
||||
if self.device.supports_command(OverkizCommand.REFRESH_BOOST_MODE_DURATION):
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.REFRESH_BOOST_MODE_DURATION
|
||||
)
|
||||
|
||||
if self.executor.has_command(OverkizCommand.REFRESH_DHW_MODE):
|
||||
if self.device.supports_command(OverkizCommand.REFRESH_DHW_MODE):
|
||||
await self.executor.async_execute_command(OverkizCommand.REFRESH_DHW_MODE)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Platform for PAJ GPS sensor integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pajgps_api.models.trackpoint import TrackPoint
|
||||
|
||||
@@ -11,12 +11,12 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfSpeed
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfSpeed
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PajGpsConfigEntry
|
||||
from .coordinator import PajGpsCoordinator
|
||||
from .coordinator import Device, PajGpsCoordinator
|
||||
from .entity import PajGpsEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -27,6 +27,7 @@ class PajGpsSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a PAJ GPS sensor entity."""
|
||||
|
||||
value_fn: Callable[[TrackPoint], int | None]
|
||||
supported_fn: Callable[[Device], bool] = field(default=lambda _: True)
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[PajGpsSensorEntityDescription, ...] = (
|
||||
@@ -38,6 +39,16 @@ SENSOR_DESCRIPTIONS: tuple[PajGpsSensorEntityDescription, ...] = (
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda tp: tp.speed,
|
||||
),
|
||||
PajGpsSensorEntityDescription(
|
||||
key="battery",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda tp: tp.battery_level,
|
||||
supported_fn=lambda device: device.has_battery,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +73,7 @@ async def async_setup_entry(
|
||||
PajGpsSensor(coordinator, device_id, description)
|
||||
for device_id in sorted_new_ids
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if description.supported_fn(coordinator.data.devices[device_id])
|
||||
)
|
||||
known_device_ids.update(sorted_new_ids)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pencompy"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pencompy==0.0.3"]
|
||||
"requirements": ["pencompy==0.0.4"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["hole"],
|
||||
"requirements": ["hole==0.9.0"]
|
||||
"requirements": ["hole==0.9.2"]
|
||||
}
|
||||
|
||||
@@ -352,6 +352,8 @@ class PS4Device(MediaPlayerEntity):
|
||||
for device in d_registry.devices.get_devices_for_config_entry_id(
|
||||
self._entry_id
|
||||
):
|
||||
# Rebuilt from the existing device entry, which already carries
|
||||
# the network MAC connection added by the live-status branch.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers=device.identifiers,
|
||||
manufacturer=device.manufacturer,
|
||||
@@ -365,7 +367,9 @@ class PS4Device(MediaPlayerEntity):
|
||||
_sw_version = status["system-version"]
|
||||
_sw_version = _sw_version[1:4]
|
||||
sw_version = f"{_sw_version[0]}.{_sw_version[1:]}"
|
||||
# status["host-id"] is the console's network MAC address.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, status["host-id"])},
|
||||
identifiers={(DOMAIN, status["host-id"])},
|
||||
manufacturer="Sony Interactive Entertainment Inc.",
|
||||
model="PlayStation 4",
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
from rabbitair import Model
|
||||
|
||||
from homeassistant.const import CONF_MAC
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -36,6 +36,7 @@ class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
|
||||
self._attr_unique_id = entry.unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.data[CONF_MAC])},
|
||||
connections={(CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])},
|
||||
manufacturer="Rabbit Air",
|
||||
model=MODELS.get(coordinator.data.model),
|
||||
name=entry.title,
|
||||
|
||||
@@ -13,9 +13,10 @@ from pyrainbird.async_client import (
|
||||
)
|
||||
from pyrainbird.data import ModelAndVersion, Schedule
|
||||
|
||||
from homeassistant.const import CONF_MAC
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS
|
||||
@@ -104,13 +105,18 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
|
||||
"""Return information about the device."""
|
||||
if self._unique_id is None:
|
||||
return None
|
||||
return DeviceInfo(
|
||||
device_info = DeviceInfo(
|
||||
name=self.device_name,
|
||||
identifiers={(DOMAIN, self._unique_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model=self._model_info.model_name,
|
||||
sw_version=f"{self._model_info.major}.{self._model_info.minor}",
|
||||
)
|
||||
# The unique id is the formatted MAC for current config entries, but was
|
||||
# historically the serial number, so derive the connection from the MAC.
|
||||
if mac_address := self.config_entry.data.get(CONF_MAC):
|
||||
device_info["connections"] = {(CONNECTION_NETWORK_MAC, mac_address)}
|
||||
return device_info
|
||||
|
||||
async def _async_update_data(self) -> RainbirdDeviceState:
|
||||
"""Fetch data from Rain Bird device."""
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["paho_mqtt", "roombapy"],
|
||||
"requirements": ["roombapy==1.9.0"],
|
||||
"requirements": ["roombapy==1.9.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "irobot-*",
|
||||
|
||||
@@ -58,6 +58,38 @@
|
||||
"user": "Add sensor"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"index": "[%key:component::scrape::config_subentries::entity::step::user::data::index%]",
|
||||
"select": "[%key:component::scrape::config_subentries::entity::step::user::data::select%]"
|
||||
},
|
||||
"data_description": {
|
||||
"index": "[%key:component::scrape::config_subentries::entity::step::user::data_description::index%]",
|
||||
"select": "[%key:component::scrape::config_subentries::entity::step::user::data_description::select%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::attribute%]",
|
||||
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::availability%]",
|
||||
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::device_class%]",
|
||||
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::value_template%]"
|
||||
},
|
||||
"data_description": {
|
||||
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::attribute%]",
|
||||
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::availability%]",
|
||||
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::device_class%]",
|
||||
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::value_template%]"
|
||||
},
|
||||
"description": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::description%]",
|
||||
"name": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"index": "Index",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pysesame2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pysesame2==1.0.1"]
|
||||
"requirements": ["pysesame2==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"additional_account": {
|
||||
"add_account": {
|
||||
"data": {
|
||||
"account": "[%key:component::sia::config::step::user::data::account%]",
|
||||
"additional_account": "[%key:component::sia::config::step::user::data::additional_account%]",
|
||||
|
||||
@@ -29,6 +29,7 @@ VEHICLE_STATUS = "vehicle_status"
|
||||
API_GEN_1 = "g1"
|
||||
API_GEN_2 = "g2"
|
||||
API_GEN_3 = "g3"
|
||||
API_GEN_4 = "g4"
|
||||
MANUFACTURER = "Subaru"
|
||||
|
||||
PLATFORMS = [
|
||||
|
||||
@@ -24,6 +24,7 @@ from . import get_device_info
|
||||
from .const import (
|
||||
API_GEN_2,
|
||||
API_GEN_3,
|
||||
API_GEN_4,
|
||||
VEHICLE_API_GEN,
|
||||
VEHICLE_HAS_EV,
|
||||
VEHICLE_STATUS,
|
||||
@@ -153,10 +154,10 @@ def create_vehicle_sensors(
|
||||
sensor_descriptions_to_add = []
|
||||
sensor_descriptions_to_add.extend(SAFETY_SENSORS)
|
||||
|
||||
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]:
|
||||
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3, API_GEN_4]:
|
||||
sensor_descriptions_to_add.extend(API_GEN_2_SENSORS)
|
||||
|
||||
if vehicle_info[VEHICLE_API_GEN] == API_GEN_3:
|
||||
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_3, API_GEN_4]:
|
||||
sensor_descriptions_to_add.extend(API_GEN_3_SENSORS)
|
||||
|
||||
if vehicle_info[VEHICLE_HAS_EV]:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "assumed_state",
|
||||
"loggers": ["tellcore"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.2"]
|
||||
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.light import (
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
ATTR_XY_COLOR,
|
||||
DEFAULT_MAX_KELVIN,
|
||||
DEFAULT_MIN_KELVIN,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
@@ -26,7 +27,14 @@ from homeassistant.components.light import (
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EFFECT, CONF_HS, CONF_NAME, CONF_RGB, CONF_STATE
|
||||
from homeassistant.const import (
|
||||
CONF_EFFECT,
|
||||
CONF_HS,
|
||||
CONF_NAME,
|
||||
CONF_RGB,
|
||||
CONF_STATE,
|
||||
CONF_XY,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
@@ -74,6 +82,7 @@ CONF_ON_ACTION = "turn_on"
|
||||
CONF_SUPPORTS_TRANSITION = "supports_transition"
|
||||
CONF_TEMPERATURE_ACTION = "set_temperature"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
CONF_XY_ACTION = "set_xy"
|
||||
|
||||
DEFAULT_MIN_MIREDS = 153
|
||||
DEFAULT_MAX_MIREDS = 500
|
||||
@@ -90,6 +99,7 @@ SCRIPT_FIELDS = (
|
||||
CONF_RGBW_ACTION,
|
||||
CONF_RGBWW_ACTION,
|
||||
CONF_TEMPERATURE_ACTION,
|
||||
CONF_XY_ACTION,
|
||||
)
|
||||
|
||||
LIGHT_COMMON_SCHEMA = vol.Schema(
|
||||
@@ -115,6 +125,8 @@ LIGHT_COMMON_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template,
|
||||
vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA,
|
||||
vol.Optional(CONF_TEMPERATURE): cv.template,
|
||||
vol.Optional(CONF_XY): cv.template,
|
||||
vol.Optional(CONF_XY_ACTION): cv.SCRIPT_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -184,10 +196,21 @@ def _string_to_list(result: str) -> list[float]:
|
||||
return [float(v) for v in result.split(",")]
|
||||
|
||||
|
||||
def hs_color_list(entity: AbstractTemplateLight) -> Callable[[Any], list[int] | None]:
|
||||
"""Convert the result to a list of numbers that represent hue and saturation."""
|
||||
def two_color_list(
|
||||
entity: AbstractTemplateLight,
|
||||
option: str,
|
||||
option_one: str,
|
||||
min_1: int,
|
||||
max_1: int,
|
||||
option_two: str,
|
||||
min_2: int,
|
||||
max_2: int,
|
||||
) -> Callable[[Any], list[int | float] | None]:
|
||||
"""Convert the result to a list of 2 numbers that represent a color."""
|
||||
|
||||
def convert(result: Any) -> list[int] | None:
|
||||
option_range = f"({min_1}-{max_1}, {min_2}-{max_2})"
|
||||
|
||||
def convert(result: Any) -> list[int | float] | None:
|
||||
if template_validators.check_result_for_none(result):
|
||||
return None
|
||||
|
||||
@@ -200,15 +223,15 @@ def hs_color_list(entity: AbstractTemplateLight) -> Callable[[Any], list[int] |
|
||||
and len(result) == 2
|
||||
and all(isinstance(value, (int, float)) for value in result)
|
||||
):
|
||||
hue, saturation = result
|
||||
if not (0 <= hue <= 360) or not (0 <= saturation <= 100):
|
||||
one, two = result
|
||||
if not (min_1 <= one <= max_1) or not (min_2 <= two <= max_2):
|
||||
template_validators.log_validation_result_error(
|
||||
entity,
|
||||
CONF_HS,
|
||||
option,
|
||||
result,
|
||||
(
|
||||
"expected a hue value between 0 and 360 and "
|
||||
"a saturation value between 0 and 100: (0-360, 0-100)"
|
||||
f"expected {option_one} value between {min_1} and {max_1} and "
|
||||
f"{option_two} value between {min_2} and {max_2}: {option_range}"
|
||||
),
|
||||
)
|
||||
return None
|
||||
@@ -217,9 +240,9 @@ def hs_color_list(entity: AbstractTemplateLight) -> Callable[[Any], list[int] |
|
||||
|
||||
template_validators.log_validation_result_error(
|
||||
entity,
|
||||
CONF_HS,
|
||||
option,
|
||||
result,
|
||||
"expected a list of numbers: (0-360, 0-100)",
|
||||
f"expected a list of numbers: {option_range}",
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -299,7 +322,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
self.setup_template(
|
||||
CONF_HS,
|
||||
"_attr_hs_color",
|
||||
hs_color_list(self),
|
||||
two_color_list(self, CONF_HS, "a hue", 0, 360, "a saturation", 0, 100),
|
||||
self._update_color("_attr_hs_color", ColorMode.HS),
|
||||
render_complex=True,
|
||||
)
|
||||
@@ -318,6 +341,15 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
render_complex=True,
|
||||
)
|
||||
|
||||
# Setup XY Color
|
||||
self.setup_template(
|
||||
CONF_XY,
|
||||
"_attr_xy_color",
|
||||
two_color_list(self, CONF_XY, "an x", 0, 1, "a y", 0, 1),
|
||||
self._update_color("_attr_xy_color", ColorMode.XY),
|
||||
render_complex=True,
|
||||
)
|
||||
|
||||
# Setup Effect templates
|
||||
self.setup_template(
|
||||
CONF_EFFECT_LIST,
|
||||
@@ -371,6 +403,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
(CONF_RGB_ACTION, ColorMode.RGB),
|
||||
(CONF_RGBW_ACTION, ColorMode.RGBW),
|
||||
(CONF_RGBWW_ACTION, ColorMode.RGBWW),
|
||||
(CONF_XY_ACTION, ColorMode.XY),
|
||||
):
|
||||
if (action_config := config.get(action_id)) is not None:
|
||||
self.add_script(action_id, action_config, name, DOMAIN)
|
||||
@@ -482,6 +515,15 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
)
|
||||
optimistic_set = True
|
||||
|
||||
if CONF_XY not in self._templates and ATTR_XY_COLOR in kwargs:
|
||||
self._set_optimistic_color(
|
||||
"xy color",
|
||||
"_attr_xy_color",
|
||||
kwargs[ATTR_XY_COLOR],
|
||||
ColorMode.XY,
|
||||
)
|
||||
optimistic_set = True
|
||||
|
||||
if optimistic_set and not self._attr_assumed_state:
|
||||
# If we are optmistically setting color or level but the state template
|
||||
# has not rendered, optimisically set the state to 'on'.
|
||||
@@ -507,6 +549,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
(CONF_RGB, "_attr_rgb_color"),
|
||||
(CONF_RGBW, "_attr_rgbw_color"),
|
||||
(CONF_RGBWW, "_attr_rgbww_color"),
|
||||
(CONF_XY, "_attr_xy_color"),
|
||||
):
|
||||
if attribute == attr:
|
||||
continue
|
||||
@@ -619,6 +662,17 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
|
||||
return (script, common_params)
|
||||
|
||||
if (
|
||||
ATTR_XY_COLOR in kwargs
|
||||
and (script := CONF_XY_ACTION) in self._action_scripts
|
||||
):
|
||||
xy_value = kwargs[ATTR_XY_COLOR]
|
||||
common_params["xy"] = xy_value
|
||||
common_params["x"] = float(xy_value[0])
|
||||
common_params["y"] = float(xy_value[1])
|
||||
|
||||
return (script, common_params)
|
||||
|
||||
if (
|
||||
ATTR_BRIGHTNESS in kwargs
|
||||
and (script := CONF_LEVEL_ACTION) in self._action_scripts
|
||||
|
||||
@@ -137,7 +137,7 @@ class TimeRemainingTrigger(Trigger):
|
||||
state = self._hass.states.get(entity_id)
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
state_change_listener,
|
||||
|
||||
@@ -153,7 +153,7 @@ class ItemTriggerBase(Trigger, abc.ABC):
|
||||
functools.partial(self._handle_item_change, run_action=run_action),
|
||||
self._handle_entities_updated,
|
||||
)
|
||||
return listener.async_setup()
|
||||
return await listener.async_setup()
|
||||
|
||||
@callback
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user