mirror of
https://github.com/home-assistant/core.git
synced 2026-02-03 22:05:35 +01:00
Compare commits
4 Commits
2026.2.0b2
...
knx-time-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ed52fe31 | ||
|
|
34589fcda7 | ||
|
|
da027c063d | ||
|
|
66ba2819e1 |
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -192,10 +192,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Environment
|
||||
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
|
||||
- **Dev container**: No activation needed, the environment is pre-configured
|
||||
|
||||
### Code Quality & Linting
|
||||
- **Run all linters on all files**: `prek run --all-files`
|
||||
- **Run linters on staged files only**: `prek run`
|
||||
|
||||
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -551,7 +551,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -254,7 +254,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@564dda4cfa5e96aafdc4a5696c4bf7b46baae5ac # v1.1.0
|
||||
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -376,7 +376,6 @@ homeassistant.components.no_ip.*
|
||||
homeassistant.components.nordpool.*
|
||||
homeassistant.components.notify.*
|
||||
homeassistant.components.notion.*
|
||||
homeassistant.components.nrgkick.*
|
||||
homeassistant.components.ntfy.*
|
||||
homeassistant.components.number.*
|
||||
homeassistant.components.nut.*
|
||||
|
||||
@@ -189,10 +189,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Environment
|
||||
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
|
||||
- **Dev container**: No activation needed, the environment is pre-configured
|
||||
|
||||
### Code Quality & Linting
|
||||
- **Run all linters on all files**: `prek run --all-files`
|
||||
- **Run linters on staged files only**: `prek run`
|
||||
|
||||
8
CODEOWNERS
generated
8
CODEOWNERS
generated
@@ -288,8 +288,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cloud/ @home-assistant/cloud
|
||||
/homeassistant/components/cloudflare/ @ludeeus @ctalkington
|
||||
/tests/components/cloudflare/ @ludeeus @ctalkington
|
||||
/homeassistant/components/cloudflare_r2/ @corrreia
|
||||
/tests/components/cloudflare_r2/ @corrreia
|
||||
/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/tests/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/homeassistant/components/coinbase/ @tombrien
|
||||
@@ -1128,8 +1126,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/notify_events/ @matrozov @papajojo
|
||||
/homeassistant/components/notion/ @bachya
|
||||
/tests/components/notion/ @bachya
|
||||
/homeassistant/components/nrgkick/ @andijakl
|
||||
/tests/components/nrgkick/ @andijakl
|
||||
/homeassistant/components/nsw_fuel_station/ @nickw444
|
||||
/tests/components/nsw_fuel_station/ @nickw444
|
||||
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
|
||||
@@ -1265,8 +1261,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/powerfox/ @klaasnicolaas
|
||||
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/homeassistant/components/prana/ @prana-dev-official
|
||||
/tests/components/prana/ @prana-dev-official
|
||||
/homeassistant/components/private_ble_device/ @Jc2k
|
||||
/tests/components/private_ble_device/ @Jc2k
|
||||
/homeassistant/components/probe_plus/ @pantherale0
|
||||
@@ -1730,8 +1724,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen
|
||||
/homeassistant/components/twitch/ @joostlek
|
||||
/tests/components/twitch/ @joostlek
|
||||
/homeassistant/components/uhoo/ @getuhoo @joshsmonta
|
||||
/tests/components/uhoo/ @getuhoo @joshsmonta
|
||||
/homeassistant/components/ukraine_alarm/ @PaulAnnekov
|
||||
/tests/components/ukraine_alarm/ @PaulAnnekov
|
||||
/homeassistant/components/unifi/ @Kane610
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "cloudflare",
|
||||
"name": "Cloudflare",
|
||||
"integrations": ["cloudflare", "cloudflare_r2"]
|
||||
}
|
||||
@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER
|
||||
from .const import CONF_POLLING, DOMAIN, LOGGER
|
||||
from .services import async_setup_services
|
||||
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
@@ -99,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
|
||||
|
||||
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
|
||||
hass.data[DOMAIN] = AbodeSystem(abode, polling)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -113,12 +113,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop)
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout)
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop)
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout)
|
||||
|
||||
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
|
||||
logout_listener()
|
||||
hass.data.pop(DOMAIN_DATA)
|
||||
hass.data[DOMAIN].logout_listener()
|
||||
hass.data.pop(DOMAIN)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -128,16 +127,16 @@ async def setup_hass_events(hass: HomeAssistant) -> None:
|
||||
|
||||
def logout(event: Event) -> None:
|
||||
"""Logout of Abode."""
|
||||
if not hass.data[DOMAIN_DATA].polling:
|
||||
hass.data[DOMAIN_DATA].abode.events.stop()
|
||||
if not hass.data[DOMAIN].polling:
|
||||
hass.data[DOMAIN].abode.events.stop()
|
||||
|
||||
hass.data[DOMAIN_DATA].abode.logout()
|
||||
hass.data[DOMAIN].abode.logout()
|
||||
LOGGER.info("Logged out of Abode")
|
||||
|
||||
if not hass.data[DOMAIN_DATA].polling:
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
|
||||
if not hass.data[DOMAIN].polling:
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start)
|
||||
|
||||
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
|
||||
hass.data[DOMAIN].logout_listener = hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, logout
|
||||
)
|
||||
|
||||
@@ -179,6 +178,6 @@ def setup_abode_events(hass: HomeAssistant) -> None:
|
||||
]
|
||||
|
||||
for event in events:
|
||||
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
|
||||
hass.data[DOMAIN].abode.events.add_event_callback(
|
||||
event, partial(event_callback, event)
|
||||
)
|
||||
|
||||
@@ -13,7 +13,8 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN_DATA
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -23,7 +24,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode alarm control panel device."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
async_add_entities(
|
||||
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
|
||||
)
|
||||
|
||||
@@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import DOMAIN_DATA
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -25,7 +26,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode binary sensor devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
|
||||
device_types = [
|
||||
"connectivity",
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN_DATA, LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .entity import AbodeDevice
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
|
||||
@@ -31,7 +31,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode camera devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
"""Constants for the Abode Security System component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AbodeSystem
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "abode"
|
||||
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
|
||||
ATTRIBUTION = "Data provided by goabode.com"
|
||||
|
||||
CONF_POLLING = "polling"
|
||||
|
||||
@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN_DATA
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -19,7 +20,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode cover devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
AbodeCover(data, device)
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA
|
||||
from .const import ATTRIBUTION, DOMAIN
|
||||
|
||||
|
||||
class AbodeEntity(Entity):
|
||||
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
|
||||
self._update_connection_status,
|
||||
)
|
||||
|
||||
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
|
||||
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from Abode connection status updates."""
|
||||
|
||||
@@ -20,7 +20,8 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN_DATA
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -30,7 +31,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode light devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
AbodeLight(data, device)
|
||||
@@ -99,7 +100,7 @@ class AbodeLight(AbodeDevice, LightEntity):
|
||||
return _hs
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode | None:
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device.is_dimmable and self._device.is_color_capable:
|
||||
if self.hs_color is not None:
|
||||
@@ -110,7 +111,7 @@ class AbodeLight(AbodeDevice, LightEntity):
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode] | None:
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
"""Flag supported color modes."""
|
||||
if self._device.is_dimmable and self._device.is_color_capable:
|
||||
return {ColorMode.COLOR_TEMP, ColorMode.HS}
|
||||
|
||||
@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN_DATA
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -19,7 +20,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode lock devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
AbodeLock(data, device)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN_DATA
|
||||
from .const import DOMAIN
|
||||
from .entity import AbodeDevice
|
||||
|
||||
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
|
||||
@@ -66,7 +66,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode sensor devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
AbodeSensor(data, device, description)
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
from .const import DOMAIN, DOMAIN_DATA, LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
SERVICE_SETTINGS = "change_setting"
|
||||
SERVICE_CAPTURE_IMAGE = "capture_image"
|
||||
@@ -35,7 +35,7 @@ def _change_setting(call: ServiceCall) -> None:
|
||||
value = call.data[ATTR_VALUE]
|
||||
|
||||
try:
|
||||
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
|
||||
call.hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||
except AbodeException as ex:
|
||||
LOGGER.warning(ex)
|
||||
|
||||
@@ -46,7 +46,7 @@ def _capture_image(call: ServiceCall) -> None:
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
|
||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
@@ -61,7 +61,7 @@ def _trigger_automation(call: ServiceCall) -> None:
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
|
||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN_DATA
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .entity import AbodeAutomation, AbodeDevice
|
||||
|
||||
DEVICE_TYPES = ["switch", "valve"]
|
||||
@@ -24,7 +25,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode switch devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
|
||||
entities: list[SwitchEntity] = [
|
||||
AbodeSwitch(data, device)
|
||||
|
||||
@@ -107,7 +107,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_hassio(
|
||||
self, discovery_info: HassioServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Prepare configuration for a Hass.io AdGuard Home app.
|
||||
"""Prepare configuration for a Hass.io AdGuard Home add-on.
|
||||
|
||||
This flow is triggered by the discovery component.
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the app: {addon}?",
|
||||
"title": "AdGuard Home via Home Assistant app"
|
||||
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the add-on: {addon}?",
|
||||
"title": "AdGuard Home via Home Assistant add-on"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
|
||||
@@ -13,15 +13,6 @@
|
||||
"performance_index": {
|
||||
"default": "mdi:head-check"
|
||||
},
|
||||
"r32": {
|
||||
"default": "mdi:hvac"
|
||||
},
|
||||
"r454b": {
|
||||
"default": "mdi:hvac"
|
||||
},
|
||||
"r454c": {
|
||||
"default": "mdi:hvac"
|
||||
},
|
||||
"radon": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
|
||||
@@ -326,25 +326,11 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
value=lambda data: data.get("c3h8_MIPEX"),
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="r32",
|
||||
translation_key="r32",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
key="refigerant",
|
||||
translation_key="refigerant",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("r32"),
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="r454b",
|
||||
translation_key="r454b",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("r454b"),
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="r454c",
|
||||
translation_key="r454c",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("r454c"),
|
||||
value=lambda data: data.get("refigerant"),
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="sih4",
|
||||
|
||||
@@ -119,18 +119,12 @@
|
||||
"propane": {
|
||||
"name": "Propane"
|
||||
},
|
||||
"r32": {
|
||||
"name": "Refrigerant R-32"
|
||||
},
|
||||
"r454b": {
|
||||
"name": "Refrigerant R-454B"
|
||||
},
|
||||
"r454c": {
|
||||
"name": "Refrigerant R-454C"
|
||||
},
|
||||
"radon": {
|
||||
"name": "Radon"
|
||||
},
|
||||
"refigerant": {
|
||||
"name": "Refrigerant"
|
||||
},
|
||||
"relative_pressure": {
|
||||
"name": "Relative pressure"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"connectivity_mode": {
|
||||
"default": "mdi:bluetooth-off",
|
||||
"state": {
|
||||
"bluetooth": "mdi:bluetooth",
|
||||
"not_configured": "mdi:alert-circle",
|
||||
"smartlink": "mdi:hub"
|
||||
}
|
||||
},
|
||||
"radon_1day_avg": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import logging
|
||||
|
||||
from airthings_ble import AirthingsConnectivityMode, AirthingsDevice
|
||||
from airthings_ble import AirthingsDevice
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -41,12 +41,6 @@ from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordina
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONNECTIVITY_MODE_MAP = {
|
||||
AirthingsConnectivityMode.BLE.value: "bluetooth",
|
||||
AirthingsConnectivityMode.SMARTLINK.value: "smartlink",
|
||||
AirthingsConnectivityMode.NOT_CONFIGURED.value: "not_configured",
|
||||
}
|
||||
|
||||
SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
"radon_1day_avg": SensorEntityDescription(
|
||||
key="radon_1day_avg",
|
||||
@@ -135,14 +129,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
"connectivity_mode": SensorEntityDescription(
|
||||
key="connectivity_mode",
|
||||
translation_key="connectivity_mode",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CONNECTIVITY_MODE_MAP.values()),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -270,12 +256,4 @@ class AirthingsSensor(
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
value = self.coordinator.data.sensors[self.entity_description.key]
|
||||
|
||||
# Map connectivity mode to enum values
|
||||
if self.entity_description.key == "connectivity_mode":
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
return CONNECTIVITY_MODE_MAP.get(value)
|
||||
|
||||
return value
|
||||
return self.coordinator.data.sensors[self.entity_description.key]
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"ambient_noise": {
|
||||
"name": "Ambient noise"
|
||||
},
|
||||
"connectivity_mode": {
|
||||
"name": "Connectivity mode",
|
||||
"state": {
|
||||
"bluetooth": "Bluetooth",
|
||||
"not_configured": "Not configured",
|
||||
"smartlink": "SmartLink"
|
||||
}
|
||||
},
|
||||
"illuminance": {
|
||||
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
},
|
||||
"services": {
|
||||
"alarm_arm_away": {
|
||||
"description": "Arms an alarm in the away mode.",
|
||||
"description": "Arms the alarm in the away mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
|
||||
@@ -176,7 +176,7 @@
|
||||
"name": "Arm away"
|
||||
},
|
||||
"alarm_arm_custom_bypass": {
|
||||
"description": "Arms an alarm while allowing to bypass a custom area.",
|
||||
"description": "Arms the alarm while allowing to bypass a custom area.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "Code to arm the alarm.",
|
||||
@@ -186,7 +186,7 @@
|
||||
"name": "Arm with custom bypass"
|
||||
},
|
||||
"alarm_arm_home": {
|
||||
"description": "Arms an alarm in the home mode.",
|
||||
"description": "Arms the alarm in the home mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
|
||||
@@ -196,7 +196,7 @@
|
||||
"name": "Arm home"
|
||||
},
|
||||
"alarm_arm_night": {
|
||||
"description": "Arms an alarm in the night mode.",
|
||||
"description": "Arms the alarm in the night mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
|
||||
@@ -206,7 +206,7 @@
|
||||
"name": "Arm night"
|
||||
},
|
||||
"alarm_arm_vacation": {
|
||||
"description": "Arms an alarm in the vacation mode.",
|
||||
"description": "Arms the alarm in the vacation mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
|
||||
@@ -216,7 +216,7 @@
|
||||
"name": "Arm vacation"
|
||||
},
|
||||
"alarm_disarm": {
|
||||
"description": "Disarms an alarm.",
|
||||
"description": "Disarms the alarm.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "Code to disarm the alarm.",
|
||||
@@ -226,7 +226,7 @@
|
||||
"name": "Disarm"
|
||||
},
|
||||
"alarm_trigger": {
|
||||
"description": "Triggers an alarm manually.",
|
||||
"description": "Triggers the alarm manually.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -18,13 +18,7 @@ from .analytics import (
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .const import (
|
||||
ATTR_ONBOARDED,
|
||||
ATTR_PREFERENCES,
|
||||
ATTR_SNAPSHOTS,
|
||||
DOMAIN,
|
||||
PREFERENCE_SCHEMA,
|
||||
)
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
@@ -50,55 +44,29 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
# For now we want to enable device analytics only if the url option
|
||||
# is explicitly listed in YAML.
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
disable_snapshots = False
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
disable_snapshots = True
|
||||
snapshots_url = None
|
||||
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
analytics = Analytics(hass, snapshots_url, disable_snapshots)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
|
||||
started = False
|
||||
|
||||
async def _async_handle_labs_update(
|
||||
event: Event[labs.EventLabsUpdatedData],
|
||||
) -> None:
|
||||
"""Handle labs feature toggle."""
|
||||
await analytics.save_preferences({ATTR_SNAPSHOTS: event.data["enabled"]})
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
@callback
|
||||
def _async_labs_event_filter(event_data: labs.EventLabsUpdatedData) -> bool:
|
||||
"""Filter labs events for this integration's snapshot feature."""
|
||||
return (
|
||||
event_data["domain"] == DOMAIN
|
||||
and event_data["preview_feature"] == LABS_SNAPSHOT_FEATURE
|
||||
)
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
|
||||
hass.bus.async_listen(
|
||||
labs.EVENT_LABS_UPDATED,
|
||||
_async_handle_labs_update,
|
||||
event_filter=_async_labs_event_filter,
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.components.energy import (
|
||||
DOMAIN as ENERGY_DOMAIN,
|
||||
is_configured as energy_is_configured,
|
||||
)
|
||||
from homeassistant.components.labs import async_is_preview_feature_enabled
|
||||
from homeassistant.components.recorder import (
|
||||
DOMAIN as RECORDER_DOMAIN,
|
||||
get_instance as get_recorder_instance,
|
||||
@@ -242,10 +241,12 @@ class Analytics:
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
snapshots_url: str | None = None,
|
||||
disable_snapshots: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the Analytics class."""
|
||||
self._hass: HomeAssistant = hass
|
||||
self._snapshots_url = snapshots_url
|
||||
self._disable_snapshots = disable_snapshots
|
||||
|
||||
self._session = async_get_clientsession(hass)
|
||||
self._data = AnalyticsData(False, {})
|
||||
@@ -257,13 +258,15 @@ class Analytics:
|
||||
def preferences(self) -> dict:
|
||||
"""Return the current active preferences."""
|
||||
preferences = self._data.preferences
|
||||
return {
|
||||
result = {
|
||||
ATTR_BASE: preferences.get(ATTR_BASE, False),
|
||||
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
|
||||
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
|
||||
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
|
||||
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
|
||||
}
|
||||
if not self._disable_snapshots:
|
||||
result[ATTR_SNAPSHOTS] = preferences.get(ATTR_SNAPSHOTS, False)
|
||||
return result
|
||||
|
||||
@property
|
||||
def onboarded(self) -> bool:
|
||||
@@ -288,11 +291,6 @@ class Analytics:
|
||||
"""Return bool if a supervisor is present."""
|
||||
return is_hassio(self._hass)
|
||||
|
||||
@property
|
||||
def _snapshots_enabled(self) -> bool:
|
||||
"""Check if snapshots feature is enabled via labs."""
|
||||
return async_is_preview_feature_enabled(self._hass, DOMAIN, "snapshots")
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Load preferences."""
|
||||
stored = await self._store.async_load()
|
||||
@@ -647,10 +645,7 @@ class Analytics:
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
not self.preferences.get(ATTR_SNAPSHOTS, False)
|
||||
or not self._snapshots_enabled
|
||||
):
|
||||
if not self.preferences.get(ATTR_SNAPSHOTS, False) or self._disable_snapshots:
|
||||
LOGGER.debug("Snapshot analytics not scheduled")
|
||||
if self._snapshot_scheduled:
|
||||
self._snapshot_scheduled()
|
||||
|
||||
@@ -7,12 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"feedback_url": "https://forms.gle/GqvRmgmghSDco8M46",
|
||||
"learn_more_url": "https://www.home-assistant.io/blog/2026/02/02/about-device-database/",
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
|
||||
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
|
||||
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
|
||||
"name": "Device database"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,9 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_TRACKED_APPS, CONF_TRACKED_INTEGRATIONS
|
||||
from .const import CONF_TRACKED_INTEGRATIONS
|
||||
from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
@@ -60,30 +59,6 @@ async def async_setup_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate to a new version."""
|
||||
# Migration for switching add-ons to apps
|
||||
if entry.version < 2:
|
||||
ent_reg = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if not entity_entry.unique_id.startswith("addon_"):
|
||||
continue
|
||||
|
||||
ent_reg.async_update_entity(
|
||||
entity_entry.entity_id,
|
||||
new_unique_id=entity_entry.unique_id.replace("addon_", "app_"),
|
||||
)
|
||||
|
||||
options = dict(entry.options)
|
||||
options[CONF_TRACKED_APPS] = options.pop("tracked_addons", [])
|
||||
|
||||
hass.config_entries.async_update_entry(entry, version=2, options=options)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
|
||||
) -> bool:
|
||||
|
||||
@@ -26,7 +26,7 @@ from homeassistant.helpers.selector import (
|
||||
|
||||
from . import AnalyticsInsightsConfigEntry
|
||||
from .const import (
|
||||
CONF_TRACKED_APPS,
|
||||
CONF_TRACKED_ADDONS,
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
@@ -43,8 +43,6 @@ INTEGRATION_TYPES_WITHOUT_ANALYTICS = (
|
||||
class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Homeassistant Analytics."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -61,7 +59,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
if all(
|
||||
[
|
||||
not user_input.get(CONF_TRACKED_APPS),
|
||||
not user_input.get(CONF_TRACKED_ADDONS),
|
||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
||||
]
|
||||
@@ -72,7 +70,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title="Home Assistant Analytics Insights",
|
||||
data={},
|
||||
options={
|
||||
CONF_TRACKED_APPS: user_input.get(CONF_TRACKED_APPS, []),
|
||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
@@ -86,7 +84,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
apps = await client.get_addons()
|
||||
addons = await client.get_addons()
|
||||
integrations = await client.get_integrations(Environment.NEXT)
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
@@ -109,9 +107,9 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_APPS): SelectSelector(
|
||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(apps),
|
||||
options=list(addons),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
@@ -146,7 +144,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
if user_input is not None:
|
||||
if all(
|
||||
[
|
||||
not user_input.get(CONF_TRACKED_APPS),
|
||||
not user_input.get(CONF_TRACKED_ADDONS),
|
||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
||||
]
|
||||
@@ -156,7 +154,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_TRACKED_APPS: user_input.get(CONF_TRACKED_APPS, []),
|
||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
@@ -170,7 +168,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
apps = await client.get_addons()
|
||||
addons = await client.get_addons()
|
||||
integrations = await client.get_integrations(Environment.NEXT)
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
@@ -191,9 +189,9 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_APPS): SelectSelector(
|
||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(apps),
|
||||
options=list(addons),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
DOMAIN = "analytics_insights"
|
||||
|
||||
CONF_TRACKED_APPS = "tracked_apps"
|
||||
CONF_TRACKED_ADDONS = "tracked_addons"
|
||||
CONF_TRACKED_INTEGRATIONS = "tracked_integrations"
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_TRACKED_APPS,
|
||||
CONF_TRACKED_ADDONS,
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
@@ -35,7 +35,7 @@ class AnalyticsData:
|
||||
|
||||
active_installations: int
|
||||
reports_integrations: int
|
||||
apps: dict[str, int]
|
||||
addons: dict[str, int]
|
||||
core_integrations: dict[str, int]
|
||||
custom_integrations: dict[str, int]
|
||||
|
||||
@@ -60,7 +60,7 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
update_interval=timedelta(hours=12),
|
||||
)
|
||||
self._client = client
|
||||
self._tracked_apps = self.config_entry.options.get(CONF_TRACKED_APPS, [])
|
||||
self._tracked_addons = self.config_entry.options.get(CONF_TRACKED_ADDONS, [])
|
||||
self._tracked_integrations = self.config_entry.options[
|
||||
CONF_TRACKED_INTEGRATIONS
|
||||
]
|
||||
@@ -70,9 +70,7 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
|
||||
async def _async_update_data(self) -> AnalyticsData:
|
||||
try:
|
||||
apps_data = (
|
||||
await self._client.get_addons()
|
||||
) # Still add method name. Needs library update
|
||||
addons_data = await self._client.get_addons()
|
||||
data = await self._client.get_current_analytics()
|
||||
custom_data = await self._client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError as err:
|
||||
@@ -81,7 +79,9 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
) from err
|
||||
except HomeassistantAnalyticsNotModifiedError:
|
||||
return self.data
|
||||
apps = {app: get_app_value(apps_data, app) for app in self._tracked_apps}
|
||||
addons = {
|
||||
addon: get_addon_value(addons_data, addon) for addon in self._tracked_addons
|
||||
}
|
||||
core_integrations = {
|
||||
integration: data.integrations.get(integration, 0)
|
||||
for integration in self._tracked_integrations
|
||||
@@ -93,14 +93,14 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
return AnalyticsData(
|
||||
data.active_installations,
|
||||
data.reports_integrations,
|
||||
apps,
|
||||
addons,
|
||||
core_integrations,
|
||||
custom_integrations,
|
||||
)
|
||||
|
||||
|
||||
def get_app_value(data: dict[str, Addon], name_slug: str) -> int:
|
||||
"""Get app value."""
|
||||
def get_addon_value(data: dict[str, Addon], name_slug: str) -> int:
|
||||
"""Get addon value."""
|
||||
if name_slug in data:
|
||||
return data[name_slug].total
|
||||
return 0
|
||||
|
||||
@@ -29,17 +29,17 @@ class AnalyticsSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[AnalyticsData], StateType]
|
||||
|
||||
|
||||
def get_app_entity_description(
|
||||
def get_addon_entity_description(
|
||||
name_slug: str,
|
||||
) -> AnalyticsSensorEntityDescription:
|
||||
"""Get app entity description."""
|
||||
"""Get addon entity description."""
|
||||
return AnalyticsSensorEntityDescription(
|
||||
key=f"app_{name_slug}_active_installations",
|
||||
translation_key="apps",
|
||||
key=f"addon_{name_slug}_active_installations",
|
||||
translation_key="addons",
|
||||
name=name_slug,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
native_unit_of_measurement="active installations",
|
||||
value_fn=lambda data: data.apps.get(name_slug),
|
||||
value_fn=lambda data: data.addons.get(name_slug),
|
||||
)
|
||||
|
||||
|
||||
@@ -106,9 +106,9 @@ async def async_setup_entry(
|
||||
entities.extend(
|
||||
HomeassistantAnalyticsSensor(
|
||||
coordinator,
|
||||
get_app_entity_description(app_name_slug),
|
||||
get_addon_entity_description(addon_name_slug),
|
||||
)
|
||||
for app_name_slug in coordinator.data.apps
|
||||
for addon_name_slug in coordinator.data.addons
|
||||
)
|
||||
entities.extend(
|
||||
HomeassistantAnalyticsSensor(
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"tracked_apps": "Apps",
|
||||
"tracked_addons": "Add-ons",
|
||||
"tracked_custom_integrations": "Custom integrations",
|
||||
"tracked_integrations": "Integrations"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_apps": "Select the apps you want to track",
|
||||
"tracked_addons": "Select the add-ons you want to track",
|
||||
"tracked_custom_integrations": "Select the custom integrations you want to track",
|
||||
"tracked_integrations": "Select the integrations you want to track"
|
||||
}
|
||||
@@ -45,12 +45,12 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"tracked_apps": "[%key:component::analytics_insights::config::step::user::data::tracked_apps%]",
|
||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data::tracked_addons%]",
|
||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]",
|
||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_apps": "[%key:component::analytics_insights::config::step::user::data_description::tracked_apps%]",
|
||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data_description::tracked_addons%]",
|
||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]",
|
||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]"
|
||||
}
|
||||
|
||||
@@ -540,17 +540,7 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
data = self.coordinator.data[key]
|
||||
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
# The date could be "N/A" for certain fields (e.g., XOFFBATT), indicating there is no value yet.
|
||||
if data == "N/A":
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
try:
|
||||
self._attr_native_value = dateutil.parser.parse(data)
|
||||
except (dateutil.parser.ParserError, OverflowError):
|
||||
# If parsing fails we should mark it as unknown, with a log for further debugging.
|
||||
_LOGGER.warning('Failed to parse date for %s: "%s"', key, data)
|
||||
self._attr_native_value = None
|
||||
self._attr_native_value = dateutil.parser.parse(data)
|
||||
return
|
||||
|
||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_invalid_user": "Reauthenticate must use the same account.",
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol, cast
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -25,11 +25,18 @@ from homeassistant.const import (
|
||||
CONF_ACTIONS,
|
||||
CONF_ALIAS,
|
||||
CONF_CONDITIONS,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT_DATA,
|
||||
CONF_ID,
|
||||
CONF_MODE,
|
||||
CONF_OPTIONS,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
CONF_TRIGGERS,
|
||||
CONF_VARIABLES,
|
||||
CONF_ZONE,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
SERVICE_RELOAD,
|
||||
SERVICE_TOGGLE,
|
||||
@@ -46,13 +53,10 @@ from homeassistant.core import (
|
||||
ServiceCall,
|
||||
callback,
|
||||
split_entity_id,
|
||||
valid_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
|
||||
from homeassistant.helpers import (
|
||||
condition as condition_helper,
|
||||
config_validation as cv,
|
||||
trigger as trigger_helper,
|
||||
)
|
||||
from homeassistant.helpers import condition as condition_helper, config_validation as cv
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
@@ -82,6 +86,7 @@ from homeassistant.helpers.trace import (
|
||||
trace_get,
|
||||
trace_path,
|
||||
)
|
||||
from homeassistant.helpers.trigger import async_initialize_triggers
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util.dt import parse_datetime
|
||||
@@ -120,18 +125,12 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
|
||||
_EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"climate",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"person",
|
||||
"siren",
|
||||
"switch",
|
||||
"vacuum",
|
||||
}
|
||||
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
@@ -613,7 +612,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_LABEL_ID))
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
@@ -628,7 +627,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_FLOOR_ID))
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
@@ -641,7 +640,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
referenced |= condition_helper.async_extract_targets(conf, ATTR_AREA_ID)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_AREA_ID))
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
|
||||
return referenced
|
||||
|
||||
@property
|
||||
@@ -661,7 +660,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
referenced |= condition_helper.async_extract_devices(conf)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(trigger_helper.async_extract_devices(conf))
|
||||
referenced |= set(_trigger_extract_devices(conf))
|
||||
|
||||
return referenced
|
||||
|
||||
@@ -675,7 +674,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
referenced |= condition_helper.async_extract_entities(conf)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
for entity_id in trigger_helper.async_extract_entities(conf):
|
||||
for entity_id in _trigger_extract_entities(conf):
|
||||
referenced.add(entity_id)
|
||||
|
||||
return referenced
|
||||
@@ -949,7 +948,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
self._logger.error("Error rendering trigger variables: %s", err)
|
||||
return None
|
||||
|
||||
return await trigger_helper.async_initialize_triggers(
|
||||
return await async_initialize_triggers(
|
||||
self.hass,
|
||||
self._trigger_config,
|
||||
self._async_trigger_if_enabled,
|
||||
@@ -1233,6 +1232,78 @@ async def _async_process_if(
|
||||
return result
|
||||
|
||||
|
||||
@callback
|
||||
def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
|
||||
"""Extract devices from a trigger config."""
|
||||
if trigger_conf[CONF_PLATFORM] == "device":
|
||||
return [trigger_conf[CONF_DEVICE_ID]]
|
||||
|
||||
if (
|
||||
trigger_conf[CONF_PLATFORM] == "event"
|
||||
and CONF_EVENT_DATA in trigger_conf
|
||||
and CONF_DEVICE_ID in trigger_conf[CONF_EVENT_DATA]
|
||||
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID], str)
|
||||
):
|
||||
return [trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
|
||||
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
|
||||
|
||||
if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID):
|
||||
return target_devices
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@callback
|
||||
def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
|
||||
"""Extract entities from a trigger config."""
|
||||
if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"):
|
||||
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "calendar":
|
||||
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "zone":
|
||||
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "geo_location":
|
||||
return [trigger_conf[CONF_ZONE]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "sun":
|
||||
return ["sun.sun"]
|
||||
|
||||
if (
|
||||
trigger_conf[CONF_PLATFORM] == "event"
|
||||
and CONF_EVENT_DATA in trigger_conf
|
||||
and CONF_ENTITY_ID in trigger_conf[CONF_EVENT_DATA]
|
||||
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID], str)
|
||||
and valid_entity_id(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID])
|
||||
):
|
||||
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
|
||||
|
||||
if target_entities := _get_targets_from_trigger_config(
|
||||
trigger_conf, CONF_ENTITY_ID
|
||||
):
|
||||
return target_entities
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@callback
|
||||
def _get_targets_from_trigger_config(
|
||||
config: dict,
|
||||
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
|
||||
) -> list[str]:
|
||||
"""Extract targets from a target config."""
|
||||
if not (target_conf := config.get(CONF_TARGET)):
|
||||
return []
|
||||
if not (targets := target_conf.get(target)):
|
||||
return []
|
||||
|
||||
return [targets] if isinstance(targets, str) else targets
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
|
||||
def websocket_config(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -13,7 +13,14 @@ from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import ATTR_MASTER, DOMAIN, SERVICE_JOIN, SERVICE_UNJOIN
|
||||
from .const import (
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .coordinator import (
|
||||
BluesoundConfigEntry,
|
||||
BluesoundCoordinator,
|
||||
@@ -30,6 +37,22 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Bluesound."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_increase_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_clear_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
|
||||
@@ -5,5 +5,7 @@ INTEGRATION_TITLE = "Bluesound"
|
||||
ATTR_BLUESOUND_GROUP = "bluesound_group"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||
SERVICE_UNJOIN = "unjoin"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"services": {
|
||||
"clear_sleep_timer": {
|
||||
"service": "mdi:sleep-off"
|
||||
},
|
||||
"join": {
|
||||
"service": "mdi:link-variant"
|
||||
},
|
||||
"set_sleep_timer": {
|
||||
"service": "mdi:sleep"
|
||||
},
|
||||
"unjoin": {
|
||||
"service": "mdi:link-variant-off"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ from .const import (
|
||||
ATTR_BLUESOUND_GROUP,
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .coordinator import BluesoundCoordinator
|
||||
@@ -601,6 +603,42 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
"""Remove follower to leader."""
|
||||
await self._player.remove_follower(host, port)
|
||||
|
||||
async def async_increase_timer(self) -> int:
|
||||
"""Increase sleep time on player."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_SET_TIMER}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_set_sleep_timer",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
return await self._player.sleep_timer()
|
||||
|
||||
async def async_clear_timer(self) -> None:
|
||||
"""Clear sleep timer on player."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_CLEAR_TIMER}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_clear_sleep_timer",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
sleep = 1
|
||||
while sleep > 0:
|
||||
sleep = await self._player.sleep_timer()
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Enable or disable shuffle mode."""
|
||||
await self._player.shuffle(shuffle)
|
||||
|
||||
@@ -19,3 +19,19 @@ unjoin:
|
||||
entity:
|
||||
integration: bluesound
|
||||
domain: media_player
|
||||
|
||||
set_sleep_timer:
|
||||
fields:
|
||||
entity_id:
|
||||
selector:
|
||||
entity:
|
||||
integration: bluesound
|
||||
domain: media_player
|
||||
|
||||
clear_sleep_timer:
|
||||
fields:
|
||||
entity_id:
|
||||
selector:
|
||||
entity:
|
||||
integration: bluesound
|
||||
domain: media_player
|
||||
|
||||
@@ -37,16 +37,34 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_service_clear_sleep_timer": {
|
||||
"description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.clear_sleep_timer"
|
||||
},
|
||||
"deprecated_service_join": {
|
||||
"description": "Use the `media_player.join` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.join"
|
||||
},
|
||||
"deprecated_service_set_sleep_timer": {
|
||||
"description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.set_sleep_timer"
|
||||
},
|
||||
"deprecated_service_unjoin": {
|
||||
"description": "Use the `media_player.unjoin` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.unjoin"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clear_sleep_timer": {
|
||||
"description": "Clears a Bluesound timer.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Name(s) of entities that will have the timer cleared.",
|
||||
"name": "Entity"
|
||||
}
|
||||
},
|
||||
"name": "Clear sleep timer"
|
||||
},
|
||||
"join": {
|
||||
"description": "Groups players together under a single master speaker.",
|
||||
"fields": {
|
||||
@@ -61,6 +79,16 @@
|
||||
},
|
||||
"name": "Join"
|
||||
},
|
||||
"set_sleep_timer": {
|
||||
"description": "Sets a Bluesound timer that will turn off the speaker. It will increase in steps: 15, 30, 45, 60, 90, 0.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Name(s) of entities that will have a timer set.",
|
||||
"name": "Entity"
|
||||
}
|
||||
},
|
||||
"name": "Set sleep timer"
|
||||
},
|
||||
"unjoin": {
|
||||
"description": "Separates a player from a group.",
|
||||
"fields": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==2.1.1",
|
||||
"bleak==2.0.0",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
|
||||
@@ -32,7 +32,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_track_point_in_time
|
||||
@@ -516,26 +516,6 @@ class CalendarEntity(Entity):
|
||||
|
||||
_alarm_unsubs: list[CALLBACK_TYPE] | None = None
|
||||
|
||||
_attr_initial_color: str | None = None
|
||||
|
||||
@property
|
||||
def initial_color(self) -> str | None:
|
||||
"""Return the initial color for the calendar entity."""
|
||||
return self._attr_initial_color
|
||||
|
||||
def get_initial_entity_options(self) -> er.EntityOptionsType | None:
|
||||
"""Return initial entity options."""
|
||||
if self.initial_color is None:
|
||||
return None
|
||||
|
||||
# Validate that it's a valid hex color string with # prefix
|
||||
try:
|
||||
validated_color = cv.color_hex(self.initial_color)
|
||||
except vol.Invalid:
|
||||
return None
|
||||
|
||||
return {DOMAIN: {"color": validated_color}}
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
@@ -553,8 +533,8 @@ class CalendarEntity(Entity):
|
||||
"all_day": event.all_day,
|
||||
"start_time": event.start_datetime_local.strftime(DATE_STR_FORMAT),
|
||||
"end_time": event.end_datetime_local.strftime(DATE_STR_FORMAT),
|
||||
"location": event.location or "",
|
||||
"description": event.description or "",
|
||||
"location": event.location if event.location else "",
|
||||
"description": event.description if event.description else "",
|
||||
}
|
||||
|
||||
@final
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Provides conditions for climates."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
make_entity_state_attribute_condition,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
|
||||
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
|
||||
"is_on": make_entity_state_condition(
|
||||
DOMAIN,
|
||||
{
|
||||
HVACMode.AUTO,
|
||||
HVACMode.COOL,
|
||||
HVACMode.DRY,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"is_cooling": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"is_drying": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"is_heating": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the climate conditions."""
|
||||
return CONDITIONS
|
||||
@@ -1,20 +0,0 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_off: *condition_common
|
||||
is_on: *condition_common
|
||||
is_cooling: *condition_common
|
||||
is_drying: *condition_common
|
||||
is_heating: *condition_common
|
||||
@@ -1,21 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_cooling": {
|
||||
"condition": "mdi:snowflake"
|
||||
},
|
||||
"is_drying": {
|
||||
"condition": "mdi:water-percent"
|
||||
},
|
||||
"is_heating": {
|
||||
"condition": "mdi:fire"
|
||||
},
|
||||
"is_off": {
|
||||
"condition": "mdi:power-off"
|
||||
},
|
||||
"is_on": {
|
||||
"condition": "mdi:power-on"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:thermostat",
|
||||
|
||||
@@ -1,62 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted climate-control devices.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted climates to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_cooling": {
|
||||
"description": "Tests if one or more climate-control devices are cooling.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device is cooling"
|
||||
},
|
||||
"is_drying": {
|
||||
"description": "Tests if one or more climate-control devices are drying.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device is drying"
|
||||
},
|
||||
"is_heating": {
|
||||
"description": "Tests if one or more climate-control devices are heating.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device is heating"
|
||||
},
|
||||
"is_off": {
|
||||
"description": "Tests if one or more climate-control devices are off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more climate-control devices are on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device is on"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"set_hvac_mode": "Change HVAC mode on {entity_name}",
|
||||
@@ -235,12 +181,6 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"hvac_mode": {
|
||||
"options": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
|
||||
@@ -12,25 +12,14 @@ from hass_nabucasa import Cloud, NabuCasaBaseError
|
||||
from hass_nabucasa.llm import (
|
||||
LLMAuthenticationError,
|
||||
LLMRateLimitError,
|
||||
LLMResponseCompletedEvent,
|
||||
LLMResponseError,
|
||||
LLMResponseErrorEvent,
|
||||
LLMResponseFailedEvent,
|
||||
LLMResponseFunctionCallArgumentsDeltaEvent,
|
||||
LLMResponseFunctionCallArgumentsDoneEvent,
|
||||
LLMResponseFunctionCallOutputItem,
|
||||
LLMResponseImageOutputItem,
|
||||
LLMResponseIncompleteEvent,
|
||||
LLMResponseMessageOutputItem,
|
||||
LLMResponseOutputItemAddedEvent,
|
||||
LLMResponseOutputItemDoneEvent,
|
||||
LLMResponseOutputTextDeltaEvent,
|
||||
LLMResponseReasoningOutputItem,
|
||||
LLMResponseReasoningSummaryTextDeltaEvent,
|
||||
LLMResponseWebSearchCallOutputItem,
|
||||
LLMResponseWebSearchCallSearchingEvent,
|
||||
LLMServiceError,
|
||||
)
|
||||
from litellm import (
|
||||
ResponseFunctionToolCall,
|
||||
ResponseInputParam,
|
||||
ResponsesAPIStreamEvents,
|
||||
)
|
||||
from openai.types.responses import (
|
||||
FunctionToolParam,
|
||||
ResponseInputItemParam,
|
||||
@@ -71,9 +60,9 @@ class ResponseItemType(str, Enum):
|
||||
|
||||
def _convert_content_to_param(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[ResponseInputItemParam]:
|
||||
) -> ResponseInputParam:
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
messages: list[ResponseInputItemParam] = []
|
||||
messages: ResponseInputParam = []
|
||||
reasoning_summary: list[str] = []
|
||||
web_search_calls: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -249,7 +238,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
"""Transform stream result into HA format."""
|
||||
last_summary_index = None
|
||||
last_role: Literal["assistant", "tool_result"] | None = None
|
||||
current_tool_call: LLMResponseFunctionCallOutputItem | None = None
|
||||
current_tool_call: ResponseFunctionToolCall | None = None
|
||||
|
||||
# Non-reasoning models don't follow our request to remove citations, so we remove
|
||||
# them manually here. They always follow the same pattern: the citation is always
|
||||
@@ -259,10 +248,19 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
citation_regexp = re.compile(r"\(\[([^\]]+)\]\((https?:\/\/[^\)]+)\)")
|
||||
|
||||
async for event in stream:
|
||||
_LOGGER.debug("Event[%s]", getattr(event, "type", None))
|
||||
event_type = getattr(event, "type", None)
|
||||
event_item = getattr(event, "item", None)
|
||||
event_item_type = getattr(event_item, "type", None) if event_item else None
|
||||
|
||||
if isinstance(event, LLMResponseOutputItemAddedEvent):
|
||||
if isinstance(event.item, LLMResponseFunctionCallOutputItem):
|
||||
_LOGGER.debug(
|
||||
"Event[%s] | item: %s",
|
||||
event_type,
|
||||
event_item_type,
|
||||
)
|
||||
|
||||
if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED:
|
||||
# Detect function_call even when it's a BaseLiteLLMOpenAIResponseObject
|
||||
if event_item_type == ResponseItemType.FUNCTION_CALL:
|
||||
# OpenAI has tool calls as individual events
|
||||
# while HA puts tool calls inside the assistant message.
|
||||
# We turn them into individual assistant content for HA
|
||||
@@ -270,11 +268,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
yield {"role": "assistant"}
|
||||
last_role = "assistant"
|
||||
last_summary_index = None
|
||||
current_tool_call = event.item
|
||||
current_tool_call = cast(ResponseFunctionToolCall, event.item)
|
||||
elif (
|
||||
isinstance(event.item, LLMResponseMessageOutputItem)
|
||||
event_item_type == ResponseItemType.MESSAGE
|
||||
or (
|
||||
isinstance(event.item, LLMResponseReasoningOutputItem)
|
||||
event_item_type == ResponseItemType.REASONING
|
||||
and last_summary_index is not None
|
||||
) # Subsequent ResponseReasoningItem
|
||||
or last_role != "assistant"
|
||||
@@ -283,14 +281,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
last_role = "assistant"
|
||||
last_summary_index = None
|
||||
|
||||
elif isinstance(event, LLMResponseOutputItemDoneEvent):
|
||||
if isinstance(event.item, LLMResponseReasoningOutputItem):
|
||||
encrypted_content = event.item.encrypted_content
|
||||
summary = event.item.summary
|
||||
elif event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
|
||||
if event_item_type == ResponseItemType.REASONING:
|
||||
encrypted_content = getattr(event.item, "encrypted_content", None)
|
||||
summary = getattr(event.item, "summary", []) or []
|
||||
|
||||
yield {
|
||||
"native": LLMResponseReasoningOutputItem(
|
||||
type=event.item.type,
|
||||
"native": ResponseReasoningItem(
|
||||
type="reasoning",
|
||||
id=event.item.id,
|
||||
summary=[],
|
||||
encrypted_content=encrypted_content,
|
||||
@@ -298,8 +296,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
}
|
||||
|
||||
last_summary_index = len(summary) - 1 if summary else None
|
||||
elif isinstance(event.item, LLMResponseWebSearchCallOutputItem):
|
||||
action_dict = event.item.action
|
||||
elif event_item_type == ResponseItemType.WEB_SEARCH_CALL:
|
||||
action = getattr(event.item, "action", None)
|
||||
if isinstance(action, dict):
|
||||
action_dict = action
|
||||
elif action is not None:
|
||||
action_dict = action.to_dict()
|
||||
else:
|
||||
action_dict = {}
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
@@ -317,11 +321,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
"tool_result": {"status": event.item.status},
|
||||
}
|
||||
last_role = "tool_result"
|
||||
elif isinstance(event.item, LLMResponseImageOutputItem):
|
||||
yield {"native": event.item.raw}
|
||||
elif event_item_type == ResponseItemType.IMAGE:
|
||||
yield {"native": event.item}
|
||||
last_summary_index = -1 # Trigger new assistant message on next turn
|
||||
|
||||
elif isinstance(event, LLMResponseOutputTextDeltaEvent):
|
||||
elif event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA:
|
||||
data = event.delta
|
||||
if remove_parentheses:
|
||||
data = data.removeprefix(")")
|
||||
@@ -340,7 +344,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
if data:
|
||||
yield {"content": data}
|
||||
|
||||
elif isinstance(event, LLMResponseReasoningSummaryTextDeltaEvent):
|
||||
elif event_type == ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA:
|
||||
# OpenAI can output several reasoning summaries
|
||||
# in a single ResponseReasoningItem. We split them as separate
|
||||
# AssistantContent messages. Only last of them will have
|
||||
@@ -354,14 +358,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
last_summary_index = event.summary_index
|
||||
yield {"thinking_content": event.delta}
|
||||
|
||||
elif isinstance(event, LLMResponseFunctionCallArgumentsDeltaEvent):
|
||||
elif event_type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA:
|
||||
if current_tool_call is not None:
|
||||
current_tool_call.arguments += event.delta
|
||||
|
||||
elif isinstance(event, LLMResponseWebSearchCallSearchingEvent):
|
||||
elif event_type == ResponsesAPIStreamEvents.WEB_SEARCH_CALL_SEARCHING:
|
||||
yield {"role": "assistant"}
|
||||
|
||||
elif isinstance(event, LLMResponseFunctionCallArgumentsDoneEvent):
|
||||
elif event_type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE:
|
||||
if current_tool_call is not None:
|
||||
current_tool_call.status = "completed"
|
||||
|
||||
@@ -381,36 +385,35 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
]
|
||||
}
|
||||
|
||||
elif isinstance(event, LLMResponseCompletedEvent):
|
||||
response = event.response
|
||||
if response and "usage" in response:
|
||||
usage = response["usage"]
|
||||
elif event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": usage.get("input_tokens"),
|
||||
"output_tokens": usage.get("output_tokens"),
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
elif isinstance(event, LLMResponseIncompleteEvent):
|
||||
response = event.response
|
||||
if response and "usage" in response:
|
||||
usage = response["usage"]
|
||||
elif event_type == ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE:
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": usage.get("input_tokens"),
|
||||
"output_tokens": usage.get("output_tokens"),
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
incomplete_details = response.get("incomplete_details")
|
||||
reason = "unknown reason"
|
||||
if incomplete_details is not None and incomplete_details.get("reason"):
|
||||
reason = incomplete_details["reason"]
|
||||
if (
|
||||
event.response.incomplete_details
|
||||
and event.response.incomplete_details.reason
|
||||
):
|
||||
reason: str = event.response.incomplete_details.reason
|
||||
else:
|
||||
reason = "unknown reason"
|
||||
|
||||
if reason == "max_output_tokens":
|
||||
reason = "max output tokens reached"
|
||||
@@ -419,24 +422,22 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
|
||||
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
|
||||
|
||||
elif isinstance(event, LLMResponseFailedEvent):
|
||||
response = event.response
|
||||
if response and "usage" in response:
|
||||
usage = response["usage"]
|
||||
elif event_type == ResponsesAPIStreamEvents.RESPONSE_FAILED:
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": usage.get("input_tokens"),
|
||||
"output_tokens": usage.get("output_tokens"),
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
reason = "unknown reason"
|
||||
if isinstance(error := response.get("error"), dict):
|
||||
reason = error.get("message") or reason
|
||||
if event.response.error is not None:
|
||||
reason = event.response.error.message
|
||||
raise HomeAssistantError(f"OpenAI response failed: {reason}")
|
||||
|
||||
elif isinstance(event, LLMResponseErrorEvent):
|
||||
elif event_type == ResponsesAPIStreamEvents.ERROR:
|
||||
raise HomeAssistantError(f"OpenAI response error: {event.message}")
|
||||
|
||||
|
||||
@@ -451,7 +452,7 @@ class BaseCloudLLMEntity(Entity):
|
||||
async def _prepare_chat_for_generation(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
messages: list[ResponseInputItemParam],
|
||||
messages: ResponseInputParam,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Prepare kwargs for Cloud LLM from the chat log."""
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.12.0", "openai==2.15.0"],
|
||||
"requirements": ["hass-nabucasa==1.11.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""The Cloudflare R2 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiobotocore.client import AioBaseClient as S3Client
|
||||
from aiobotocore.session import AioSession
|
||||
from botocore.exceptions import (
|
||||
ClientError,
|
||||
ConnectionError,
|
||||
EndpointConnectionError,
|
||||
ParamValidationError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type R2ConfigEntry = ConfigEntry[S3Client]
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: R2ConfigEntry) -> bool:
|
||||
"""Set up Cloudflare R2 from a config entry."""
|
||||
|
||||
data = cast(dict, entry.data)
|
||||
try:
|
||||
session = AioSession()
|
||||
# pylint: disable-next=unnecessary-dunder-call
|
||||
client = await session.create_client(
|
||||
"s3",
|
||||
endpoint_url=data.get(CONF_ENDPOINT_URL),
|
||||
aws_secret_access_key=data[CONF_SECRET_ACCESS_KEY],
|
||||
aws_access_key_id=data[CONF_ACCESS_KEY_ID],
|
||||
).__aenter__()
|
||||
await client.head_bucket(Bucket=data[CONF_BUCKET])
|
||||
except ClientError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
) from err
|
||||
except ParamValidationError as err:
|
||||
if "Invalid bucket name" in str(err):
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
) from err
|
||||
except (ConnectionError, EndpointConnectionError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
def notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: R2ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
client = entry.runtime_data
|
||||
await client.__aexit__(None, None, None)
|
||||
return True
|
||||
@@ -1,346 +0,0 @@
|
||||
"""Backup platform for the Cloudflare R2 integration."""
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
from botocore.exceptions import BotoCoreError
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import R2ConfigEntry
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CACHE_TTL = 300
|
||||
|
||||
# S3 part size requirements: 5 MiB to 5 GiB per part
|
||||
# We set the threshold to 20 MiB to avoid too many parts.
|
||||
# Note that each part is allocated in the memory.
|
||||
MULTIPART_MIN_PART_SIZE_BYTES = 20 * 2**20
|
||||
|
||||
|
||||
def handle_boto_errors[T](
|
||||
func: Callable[..., Coroutine[Any, Any, T]],
|
||||
) -> Callable[..., Coroutine[Any, Any, T]]:
|
||||
"""Handle BotoCoreError exceptions by converting them to BackupAgentError."""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||
"""Catch BotoCoreError and raise BackupAgentError."""
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except BotoCoreError as err:
|
||||
error_msg = f"Failed during {func.__name__}"
|
||||
raise BackupAgentError(error_msg) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries: list[R2ConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return [R2BackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed.
|
||||
|
||||
:return: A function to unregister the listener.
|
||||
"""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
|
||||
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata files."""
|
||||
base_name = suggested_filename(backup).rsplit(".", 1)[0]
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
class R2BackupAgent(BackupAgent):
|
||||
"""Backup agent for the Cloudflare R2 integration."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: R2ConfigEntry) -> None:
|
||||
"""Initialize the R2 agent."""
|
||||
super().__init__()
|
||||
self._client = entry.runtime_data
|
||||
self._bucket: str = entry.data[CONF_BUCKET]
|
||||
self.name = entry.title
|
||||
self.unique_id = entry.entry_id
|
||||
self._backup_cache: dict[str, AgentBackup] = {}
|
||||
self._cache_expiration = time()
|
||||
self._prefix: str = entry.data.get(CONF_PREFIX, "").strip("/")
|
||||
|
||||
def _with_prefix(self, key: str) -> str:
|
||||
if not self._prefix:
|
||||
return key
|
||||
return f"{self._prefix}/{key}"
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file.
|
||||
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
:return: An async iterator that yields bytes.
|
||||
"""
|
||||
backup = await self._find_backup_by_id(backup_id)
|
||||
tar_filename, _ = suggested_filenames(backup)
|
||||
|
||||
response = await self._client.get_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
|
||||
)
|
||||
return response["Body"].iter_chunks()
|
||||
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
:param backup: Metadata about the backup that should be uploaded.
|
||||
"""
|
||||
tar_filename, metadata_filename = suggested_filenames(backup)
|
||||
|
||||
try:
|
||||
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
await self._upload_simple(tar_filename, open_stream)
|
||||
else:
|
||||
await self._upload_multipart(tar_filename, open_stream)
|
||||
|
||||
# Upload the metadata file
|
||||
metadata_content = json.dumps(backup.as_dict())
|
||||
await self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(metadata_filename),
|
||||
Body=metadata_content,
|
||||
)
|
||||
except BotoCoreError as err:
|
||||
raise BackupAgentError("Failed to upload backup") from err
|
||||
else:
|
||||
# Reset cache after successful upload
|
||||
self._cache_expiration = time()
|
||||
|
||||
async def _upload_simple(
|
||||
self,
|
||||
tar_filename: str,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
) -> None:
|
||||
"""Upload a small file using simple upload.
|
||||
|
||||
:param tar_filename: The target filename for the backup.
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
"""
|
||||
_LOGGER.debug("Starting simple upload for %s", tar_filename)
|
||||
stream = await open_stream()
|
||||
file_data = bytearray()
|
||||
async for chunk in stream:
|
||||
file_data.extend(chunk)
|
||||
|
||||
await self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
Body=bytes(file_data),
|
||||
)
|
||||
|
||||
async def _upload_multipart(
|
||||
self,
|
||||
tar_filename: str,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
):
|
||||
"""Upload a large file using multipart upload.
|
||||
|
||||
:param tar_filename: The target filename for the backup.
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
"""
|
||||
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
|
||||
multipart_upload = await self._client.create_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
)
|
||||
upload_id = multipart_upload["UploadId"]
|
||||
try:
|
||||
parts = []
|
||||
part_number = 1
|
||||
buffer_size = 0 # bytes
|
||||
buffer: list[bytes] = []
|
||||
|
||||
stream = await open_stream()
|
||||
async for chunk in stream:
|
||||
buffer_size += len(chunk)
|
||||
buffer.append(chunk)
|
||||
|
||||
# If buffer size meets minimum part size, upload it as a part
|
||||
if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
_LOGGER.debug(
|
||||
"Uploading part number %d, size %d", part_number, buffer_size
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
part_number += 1
|
||||
buffer_size = 0
|
||||
buffer = []
|
||||
|
||||
# Upload the final buffer as the last part (no minimum size requirement)
|
||||
if buffer:
|
||||
_LOGGER.debug(
|
||||
"Uploading final part number %d, size %d", part_number, buffer_size
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
|
||||
await self._client.complete_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
UploadId=upload_id,
|
||||
MultipartUpload={"Parts": parts},
|
||||
)
|
||||
|
||||
except BotoCoreError:
|
||||
try:
|
||||
await self._client.abort_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
UploadId=upload_id,
|
||||
)
|
||||
except BotoCoreError:
|
||||
_LOGGER.exception("Failed to abort multipart upload")
|
||||
raise
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file.
|
||||
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
"""
|
||||
backup = await self._find_backup_by_id(backup_id)
|
||||
tar_filename, metadata_filename = suggested_filenames(backup)
|
||||
|
||||
# Delete both the backup file and its metadata file
|
||||
await self._client.delete_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
|
||||
)
|
||||
await self._client.delete_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(metadata_filename)
|
||||
)
|
||||
|
||||
# Reset cache after successful deletion
|
||||
self._cache_expiration = time()
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
backups = await self._list_backups()
|
||||
return list(backups.values())
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup:
|
||||
"""Return a backup."""
|
||||
return await self._find_backup_by_id(backup_id)
|
||||
|
||||
async def _find_backup_by_id(self, backup_id: str) -> AgentBackup:
|
||||
"""Find a backup by its backup ID."""
|
||||
backups = await self._list_backups()
|
||||
if backup := backups.get(backup_id):
|
||||
return backup
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
async def _list_backups(self) -> dict[str, AgentBackup]:
|
||||
"""List backups, using a cache if possible."""
|
||||
if time() <= self._cache_expiration:
|
||||
return self._backup_cache
|
||||
|
||||
backups = {}
|
||||
# Only pass Prefix if a prefix is configured; some S3-compatible APIs
|
||||
# (and type checkers) do not like Prefix=None.
|
||||
list_kwargs = {"Bucket": self._bucket}
|
||||
if self._prefix:
|
||||
list_kwargs["Prefix"] = self._prefix + "/"
|
||||
response = await self._client.list_objects_v2(**list_kwargs)
|
||||
|
||||
# Filter for metadata files only
|
||||
metadata_files = [
|
||||
obj
|
||||
for obj in response.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
]
|
||||
|
||||
for metadata_file in metadata_files:
|
||||
try:
|
||||
# Download and parse metadata file
|
||||
metadata_response = await self._client.get_object(
|
||||
Bucket=self._bucket, Key=metadata_file["Key"]
|
||||
)
|
||||
metadata_content = await metadata_response["Body"].read()
|
||||
metadata_json = json.loads(metadata_content)
|
||||
except (BotoCoreError, json.JSONDecodeError) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to process metadata file %s: %s",
|
||||
metadata_file["Key"],
|
||||
err,
|
||||
)
|
||||
continue
|
||||
backup = AgentBackup.from_dict(metadata_json)
|
||||
backups[backup.backup_id] = backup
|
||||
|
||||
self._backup_cache = backups
|
||||
self._cache_expiration = time() + CACHE_TTL
|
||||
|
||||
return self._backup_cache
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Config flow for the Cloudflare R2 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiobotocore.session import AioSession
|
||||
from botocore.exceptions import (
|
||||
ClientError,
|
||||
ConnectionError,
|
||||
EndpointConnectionError,
|
||||
ParamValidationError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CLOUDFLARE_R2_DOMAIN,
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_R2_AUTH_DOCS_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCESS_KEY_ID): cv.string,
|
||||
vol.Required(CONF_SECRET_ACCESS_KEY): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_BUCKET): cv.string,
|
||||
vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
vol.Optional(CONF_PREFIX, default=""): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class R2ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cloudflare R2."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_BUCKET: user_input[CONF_BUCKET],
|
||||
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
|
||||
}
|
||||
)
|
||||
|
||||
parsed = urlparse(user_input[CONF_ENDPOINT_URL])
|
||||
if not parsed.hostname or not parsed.hostname.endswith(
|
||||
CLOUDFLARE_R2_DOMAIN
|
||||
):
|
||||
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
|
||||
else:
|
||||
try:
|
||||
session = AioSession()
|
||||
async with session.create_client(
|
||||
"s3",
|
||||
endpoint_url=user_input.get(CONF_ENDPOINT_URL),
|
||||
aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY],
|
||||
aws_access_key_id=user_input[CONF_ACCESS_KEY_ID],
|
||||
) as client:
|
||||
await client.head_bucket(Bucket=user_input[CONF_BUCKET])
|
||||
except ClientError:
|
||||
errors["base"] = "invalid_credentials"
|
||||
except ParamValidationError as err:
|
||||
if "Invalid bucket name" in str(err):
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except ValueError:
|
||||
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
|
||||
except EndpointConnectionError:
|
||||
errors[CONF_ENDPOINT_URL] = "cannot_connect"
|
||||
except ConnectionError:
|
||||
errors[CONF_ENDPOINT_URL] = "cannot_connect"
|
||||
else:
|
||||
# Do not persist empty optional values
|
||||
data = dict(user_input)
|
||||
if not data.get(CONF_PREFIX):
|
||||
data.pop(CONF_PREFIX, None)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_BUCKET], data=data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"auth_docs_url": DESCRIPTION_R2_AUTH_DOCS_URL,
|
||||
},
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Constants for the Cloudflare R2 integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "cloudflare_r2"
|
||||
|
||||
CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
# R2 is S3-compatible. Endpoint should be like:
|
||||
# https://<accountid>.r2.cloudflarestorage.com
|
||||
CLOUDFLARE_R2_DOMAIN: Final = "r2.cloudflarestorage.com"
|
||||
DEFAULT_ENDPOINT_URL: Final = "https://ACCOUNT_ID." + CLOUDFLARE_R2_DOMAIN + "/"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
|
||||
|
||||
DESCRIPTION_R2_AUTH_DOCS_URL: Final = "https://developers.cloudflare.com/r2/api/tokens/"
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "cloudflare_r2",
|
||||
"name": "Cloudflare R2",
|
||||
"codeowners": ["@corrreia"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloudflare_r2",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiobotocore"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiobotocore==2.21.1"]
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not have any custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities of this integration do not explicitly subscribe to events.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Cloudflare R2 is a cloud service that is not discovered on the network.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Cloudflare R2 is a cloud service that is not discovered on the network.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: The integration extends core functionality and does not require examples.
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: No known limitations.
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: This integration does not support physical devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting:
|
||||
status: exempt
|
||||
comment: There are no more detailed troubleshooting instructions available than what is already included in strings.json.
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration does not have devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: There are no issues which can be repaired.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: This integration does not have devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:component::cloudflare_r2::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::cloudflare_r2::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_credentials": "[%key:component::cloudflare_r2::exceptions::invalid_credentials::message%]",
|
||||
"invalid_endpoint_url": "[%key:component::cloudflare_r2::exceptions::invalid_endpoint_url::message%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"access_key_id": "Access key ID",
|
||||
"bucket": "Bucket name",
|
||||
"endpoint_url": "Endpoint URL",
|
||||
"prefix": "Folder prefix (optional)",
|
||||
"secret_access_key": "Secret access key"
|
||||
},
|
||||
"data_description": {
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2 (this is your Account ID)",
|
||||
"bucket": "Bucket must already exist and be writable by the provided credentials.",
|
||||
"endpoint_url": "Cloudflare R2 S3-compatible endpoint.",
|
||||
"prefix": "Optional folder path inside the bucket. Example: backups/homeassistant",
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Docs]({auth_docs_url})"
|
||||
},
|
||||
"title": "Add Cloudflare R2 bucket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
"invalid_bucket_name": {
|
||||
"message": "Invalid bucket name"
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Bucket cannot be accessed using provided access key ID and secret."
|
||||
},
|
||||
"invalid_endpoint_url": {
|
||||
"message": "Invalid endpoint URL. Please enter a valid Cloudflare R2 endpoint URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"aero_by_pass": {
|
||||
"default": "mdi:valve",
|
||||
"state": {
|
||||
"off": "mdi:valve-closed",
|
||||
"on": "mdi:valve-open"
|
||||
}
|
||||
},
|
||||
"buffer_mode": {
|
||||
"default": "mdi:database",
|
||||
"state": {
|
||||
"disabled": "mdi:water-boiler-off",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"dhw_circulation": {
|
||||
"default": "mdi:pump",
|
||||
"state": {
|
||||
"disabled": "mdi:pump-off",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"heating_source_of_correction": {
|
||||
"default": "mdi:tune-variant",
|
||||
"state": {
|
||||
"disabled": "mdi:cancel",
|
||||
"nano_nr_1": "mdi:thermostat-box",
|
||||
"nano_nr_2": "mdi:thermostat-box",
|
||||
"nano_nr_3": "mdi:thermostat-box",
|
||||
"nano_nr_4": "mdi:thermostat-box",
|
||||
"nano_nr_5": "mdi:thermostat-box",
|
||||
"no_corrections": "mdi:cancel",
|
||||
"schedule": "mdi:calendar-clock",
|
||||
"thermostat": "mdi:thermostat"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"default": "mdi:translate"
|
||||
},
|
||||
"mixer_mode": {
|
||||
"default": "mdi:valve",
|
||||
"state": {
|
||||
"disabled": "mdi:cancel",
|
||||
"nano_nr_1": "mdi:thermostat-box",
|
||||
"nano_nr_2": "mdi:thermostat-box",
|
||||
"nano_nr_3": "mdi:thermostat-box",
|
||||
"nano_nr_4": "mdi:thermostat-box",
|
||||
"nano_nr_5": "mdi:thermostat-box",
|
||||
"schedule": "mdi:calendar-clock",
|
||||
"thermostat": "mdi:thermostat"
|
||||
}
|
||||
},
|
||||
"mixer_mode_zone": {
|
||||
"default": "mdi:valve",
|
||||
"state": {
|
||||
"disabled": "mdi:cancel",
|
||||
"nano_nr_1": "mdi:thermostat-box",
|
||||
"nano_nr_2": "mdi:thermostat-box",
|
||||
"nano_nr_3": "mdi:thermostat-box",
|
||||
"nano_nr_4": "mdi:thermostat-box",
|
||||
"nano_nr_5": "mdi:thermostat-box",
|
||||
"schedule": "mdi:calendar-clock",
|
||||
"thermostat": "mdi:thermostat"
|
||||
}
|
||||
},
|
||||
"nano_work_mode": {
|
||||
"default": "mdi:cog-outline",
|
||||
"state": {
|
||||
"christmas": "mdi:pine-tree",
|
||||
"manual_0": "mdi:home-floor-0",
|
||||
"manual_1": "mdi:home-floor-1",
|
||||
"manual_2": "mdi:home-floor-2",
|
||||
"manual_3": "mdi:home-floor-3",
|
||||
"out_of_home": "mdi:home-export-outline",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"operating_mode": {
|
||||
"default": "mdi:cog",
|
||||
"state": {
|
||||
"disabled": "mdi:cog-off",
|
||||
"eco": "mdi:leaf"
|
||||
}
|
||||
},
|
||||
"solarcomp_operating_mode": {
|
||||
"default": "mdi:heating-coil",
|
||||
"state": {
|
||||
"de_icing": "mdi:snowflake-melt",
|
||||
"disabled": "mdi:cancel",
|
||||
"holiday": "mdi:beach"
|
||||
}
|
||||
},
|
||||
"work_mode": {
|
||||
"default": "mdi:cog-outline",
|
||||
"state": {
|
||||
"cooling": "mdi:snowflake-thermometer",
|
||||
"summer": "mdi:weather-sunny",
|
||||
"winter": "mdi:snowflake"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,10 @@ rules:
|
||||
This integration does not have any entities that should disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no need for icon translations.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
"""Select platform for Compit integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from compit_inext_api.consts import CompitParameter
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER_NAME
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CompitDeviceDescription:
|
||||
"""Class to describe a Compit device."""
|
||||
|
||||
name: str
|
||||
"""Name of the device."""
|
||||
|
||||
parameters: dict[CompitParameter, SelectEntityDescription]
|
||||
"""Parameters of the device."""
|
||||
|
||||
|
||||
DESCRIPTIONS: dict[CompitParameter, SelectEntityDescription] = {
|
||||
CompitParameter.LANGUAGE: SelectEntityDescription(
|
||||
key=CompitParameter.LANGUAGE.value,
|
||||
translation_key="language",
|
||||
options=[
|
||||
"polish",
|
||||
"english",
|
||||
],
|
||||
),
|
||||
CompitParameter.AEROKONFBYPASS: SelectEntityDescription(
|
||||
key=CompitParameter.AEROKONFBYPASS.value,
|
||||
translation_key="aero_by_pass",
|
||||
options=[
|
||||
"off",
|
||||
"auto",
|
||||
"on",
|
||||
],
|
||||
),
|
||||
CompitParameter.NANO_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.NANO_MODE.value,
|
||||
translation_key="nano_work_mode",
|
||||
options=[
|
||||
"manual_3",
|
||||
"manual_2",
|
||||
"manual_1",
|
||||
"manual_0",
|
||||
"schedule",
|
||||
"christmas",
|
||||
"out_of_home",
|
||||
],
|
||||
),
|
||||
CompitParameter.R900_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R900_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"eco",
|
||||
"hybrid",
|
||||
],
|
||||
),
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.SOLAR_COMP_OPERATING_MODE.value,
|
||||
translation_key="solarcomp_operating_mode",
|
||||
options=[
|
||||
"auto",
|
||||
"de_icing",
|
||||
"holiday",
|
||||
"disabled",
|
||||
],
|
||||
),
|
||||
CompitParameter.R490_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R490_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"eco",
|
||||
"hybrid",
|
||||
],
|
||||
),
|
||||
CompitParameter.WORK_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.WORK_MODE.value,
|
||||
translation_key="work_mode",
|
||||
options=[
|
||||
"winter",
|
||||
"summer",
|
||||
"cooling",
|
||||
],
|
||||
),
|
||||
CompitParameter.R470_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R470_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"auto",
|
||||
"eco",
|
||||
],
|
||||
),
|
||||
CompitParameter.HEATING_SOURCE_OF_CORRECTION: SelectEntityDescription(
|
||||
key=CompitParameter.HEATING_SOURCE_OF_CORRECTION.value,
|
||||
translation_key="heating_source_of_correction",
|
||||
options=[
|
||||
"no_corrections",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
),
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: SelectEntityDescription(
|
||||
key=CompitParameter.BIOMAX_MIXER_MODE_ZONE_1.value,
|
||||
translation_key="mixer_mode_zone",
|
||||
options=[
|
||||
"disabled",
|
||||
"without_thermostat",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
translation_placeholders={"zone": "1"},
|
||||
),
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2: SelectEntityDescription(
|
||||
key=CompitParameter.BIOMAX_MIXER_MODE_ZONE_2.value,
|
||||
translation_key="mixer_mode_zone",
|
||||
options=[
|
||||
"disabled",
|
||||
"without_thermostat",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
translation_placeholders={"zone": "2"},
|
||||
),
|
||||
CompitParameter.DHW_CIRCULATION_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.DHW_CIRCULATION_MODE.value,
|
||||
translation_key="dhw_circulation",
|
||||
options=[
|
||||
"disabled",
|
||||
"constant",
|
||||
"schedule",
|
||||
],
|
||||
),
|
||||
CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION: SelectEntityDescription(
|
||||
key=CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION.value,
|
||||
translation_key="heating_source_of_correction",
|
||||
options=[
|
||||
"disabled",
|
||||
"no_corrections",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
),
|
||||
CompitParameter.MIXER_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.MIXER_MODE.value,
|
||||
translation_key="mixer_mode",
|
||||
options=[
|
||||
"no_corrections",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
),
|
||||
CompitParameter.R480_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R480_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"eco",
|
||||
"hybrid",
|
||||
],
|
||||
),
|
||||
CompitParameter.BUFFER_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.BUFFER_MODE.value,
|
||||
translation_key="buffer_mode",
|
||||
options=[
|
||||
"schedule",
|
||||
"manual",
|
||||
"disabled",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
|
||||
223: CompitDeviceDescription(
|
||||
name="Nano Color 2",
|
||||
parameters={
|
||||
CompitParameter.LANGUAGE: DESCRIPTIONS[CompitParameter.LANGUAGE],
|
||||
CompitParameter.AEROKONFBYPASS: DESCRIPTIONS[
|
||||
CompitParameter.AEROKONFBYPASS
|
||||
],
|
||||
},
|
||||
),
|
||||
12: CompitDeviceDescription(
|
||||
name="Nano Color",
|
||||
parameters={
|
||||
CompitParameter.LANGUAGE: DESCRIPTIONS[CompitParameter.LANGUAGE],
|
||||
CompitParameter.AEROKONFBYPASS: DESCRIPTIONS[
|
||||
CompitParameter.AEROKONFBYPASS
|
||||
],
|
||||
},
|
||||
),
|
||||
7: CompitDeviceDescription(
|
||||
name="Nano One",
|
||||
parameters={
|
||||
CompitParameter.LANGUAGE: DESCRIPTIONS[CompitParameter.LANGUAGE],
|
||||
CompitParameter.NANO_MODE: DESCRIPTIONS[CompitParameter.NANO_MODE],
|
||||
},
|
||||
),
|
||||
224: CompitDeviceDescription(
|
||||
name="R 900",
|
||||
parameters={
|
||||
CompitParameter.R900_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R900_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
45: CompitDeviceDescription(
|
||||
name="SolarComp971",
|
||||
parameters={
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
99: CompitDeviceDescription(
|
||||
name="SolarComp971C",
|
||||
parameters={
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
44: CompitDeviceDescription(
|
||||
name="SolarComp 951",
|
||||
parameters={
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
92: CompitDeviceDescription(
|
||||
name="r490",
|
||||
parameters={
|
||||
CompitParameter.R490_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R490_OPERATING_MODE
|
||||
],
|
||||
CompitParameter.WORK_MODE: DESCRIPTIONS[CompitParameter.WORK_MODE],
|
||||
},
|
||||
),
|
||||
34: CompitDeviceDescription(
|
||||
name="r470",
|
||||
parameters={
|
||||
CompitParameter.R470_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R470_OPERATING_MODE
|
||||
],
|
||||
CompitParameter.HEATING_SOURCE_OF_CORRECTION: DESCRIPTIONS[
|
||||
CompitParameter.HEATING_SOURCE_OF_CORRECTION
|
||||
],
|
||||
},
|
||||
),
|
||||
201: CompitDeviceDescription(
|
||||
name="BioMax775",
|
||||
parameters={
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1
|
||||
],
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2
|
||||
],
|
||||
CompitParameter.DHW_CIRCULATION_MODE: DESCRIPTIONS[
|
||||
CompitParameter.DHW_CIRCULATION_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
36: CompitDeviceDescription(
|
||||
name="BioMax742",
|
||||
parameters={
|
||||
CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION
|
||||
],
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1
|
||||
],
|
||||
CompitParameter.DHW_CIRCULATION_MODE: DESCRIPTIONS[
|
||||
CompitParameter.DHW_CIRCULATION_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
75: CompitDeviceDescription(
|
||||
name="BioMax772",
|
||||
parameters={
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1
|
||||
],
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2
|
||||
],
|
||||
CompitParameter.DHW_CIRCULATION_MODE: DESCRIPTIONS[
|
||||
CompitParameter.DHW_CIRCULATION_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
5: CompitDeviceDescription(
|
||||
name="R350 T3",
|
||||
parameters={
|
||||
CompitParameter.MIXER_MODE: DESCRIPTIONS[CompitParameter.MIXER_MODE],
|
||||
},
|
||||
),
|
||||
215: CompitDeviceDescription(
|
||||
name="R480",
|
||||
parameters={
|
||||
CompitParameter.R480_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R480_OPERATING_MODE
|
||||
],
|
||||
CompitParameter.BUFFER_MODE: DESCRIPTIONS[CompitParameter.BUFFER_MODE],
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CompitConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Compit select entities from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
select_entities = []
|
||||
for device_id, device in coordinator.connector.all_devices.items():
|
||||
device_definition = DEVICE_DEFINITIONS.get(device.definition.code)
|
||||
|
||||
if not device_definition:
|
||||
continue
|
||||
|
||||
for code, entity_description in device_definition.parameters.items():
|
||||
param = next(
|
||||
(p for p in device.state.params if p.code == entity_description.key),
|
||||
None,
|
||||
)
|
||||
|
||||
if param is None:
|
||||
continue
|
||||
|
||||
select_entities.append(
|
||||
CompitSelect(
|
||||
coordinator,
|
||||
device_id,
|
||||
device_definition.name,
|
||||
code,
|
||||
entity_description,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_devices(select_entities)
|
||||
|
||||
|
||||
class CompitSelect(CoordinatorEntity[CompitDataUpdateCoordinator], SelectEntity):
|
||||
"""Representation of a Compit select entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CompitDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
device_name: str,
|
||||
parameter_code: CompitParameter,
|
||||
entity_description: SelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator)
|
||||
self.device_id = device_id
|
||||
self.entity_description = entity_description
|
||||
self._attr_has_entity_name = True
|
||||
self._attr_unique_id = f"{device_id}_{entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(device_id))},
|
||||
name=device_name,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
model=device_name,
|
||||
)
|
||||
self.parameter_code = parameter_code
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.connector.get_device(self.device_id) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current option."""
|
||||
return self.coordinator.connector.get_current_option(
|
||||
self.device_id, self.parameter_code
|
||||
)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.coordinator.connector.select_device_option(
|
||||
self.device_id, self.parameter_code, option
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -31,120 +31,5 @@
|
||||
"title": "Connect to Compit iNext"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"select": {
|
||||
"aero_by_pass": {
|
||||
"name": "Bypass",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"buffer_mode": {
|
||||
"name": "Buffer mode",
|
||||
"state": {
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"manual": "[%key:common::state::manual%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"dhw_circulation": {
|
||||
"name": "Domestic hot water circulation",
|
||||
"state": {
|
||||
"constant": "Constant",
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"heating_source_of_correction": {
|
||||
"name": "Heating source of correction",
|
||||
"state": {
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"no_corrections": "No corrections",
|
||||
"schedule": "Schedule",
|
||||
"thermostat": "Thermostat"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"name": "Language",
|
||||
"state": {
|
||||
"english": "English",
|
||||
"polish": "Polish"
|
||||
}
|
||||
},
|
||||
"mixer_mode": {
|
||||
"name": "Mixer mode",
|
||||
"state": {
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"no_corrections": "No corrections",
|
||||
"schedule": "Schedule",
|
||||
"thermostat": "Thermostat"
|
||||
}
|
||||
},
|
||||
"mixer_mode_zone": {
|
||||
"name": "Zone {zone} mixer mode",
|
||||
"state": {
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"no_corrections": "No corrections",
|
||||
"schedule": "Schedule",
|
||||
"thermostat": "Thermostat",
|
||||
"without_thermostat": "Without thermostat"
|
||||
}
|
||||
},
|
||||
"nano_work_mode": {
|
||||
"name": "Nano work mode",
|
||||
"state": {
|
||||
"christmas": "Christmas",
|
||||
"manual_0": "Manual 0",
|
||||
"manual_1": "Manual 1",
|
||||
"manual_2": "Manual 2",
|
||||
"manual_3": "Manual 3",
|
||||
"out_of_home": "Out of home",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"operating_mode": {
|
||||
"name": "Operating mode",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"eco": "Eco",
|
||||
"hybrid": "Hybrid"
|
||||
}
|
||||
},
|
||||
"solarcomp_operating_mode": {
|
||||
"name": "Operating mode",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"de_icing": "De-icing",
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"holiday": "Holiday"
|
||||
}
|
||||
},
|
||||
"work_mode": {
|
||||
"name": "Current season",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"summer": "Summer",
|
||||
"winter": "Winter"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +58,12 @@ C4_TO_HA_HVAC_MODE = {
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map the five known Control4 HVAC states to Home Assistant HVAC actions
|
||||
# Map Control4 HVAC state to Home Assistant HVAC action
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"heating": HVACAction.HEATING,
|
||||
"cooling": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"off": HVACAction.OFF,
|
||||
"heat": HVACAction.HEATING,
|
||||
"cool": HVACAction.COOLING,
|
||||
"dry": HVACAction.DRYING,
|
||||
"fan": HVACAction.FAN,
|
||||
}
|
||||
|
||||
|
||||
@@ -237,10 +236,7 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
if action is None:
|
||||
_LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state)
|
||||
return action
|
||||
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from pyControl4.account import C4Account
|
||||
from pyControl4.director import C4Director
|
||||
from pyControl4.error_handling import BadCredentials, NotFound, Unauthorized
|
||||
from pyControl4.error_handling import NotFound, Unauthorized
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -22,7 +22,8 @@ from homeassistant.const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
@@ -45,107 +46,106 @@ DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
class Control4Validator:
|
||||
"""Validates that config details can be used to authenticate and communicate with Control4."""
|
||||
|
||||
def __init__(
|
||||
self, host: str, username: str, password: str, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.controller_unique_id = None
|
||||
self.director_bearer_token = None
|
||||
self.hass = hass
|
||||
|
||||
async def authenticate(self) -> bool:
|
||||
"""Test if we can authenticate with the Control4 account API."""
|
||||
try:
|
||||
account_session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
account = C4Account(self.username, self.password, account_session)
|
||||
# Authenticate with Control4 account
|
||||
await account.getAccountBearerToken()
|
||||
|
||||
# Get controller name
|
||||
account_controllers = await account.getAccountControllers()
|
||||
self.controller_unique_id = account_controllers["controllerCommonName"]
|
||||
|
||||
# Get bearer token to communicate with controller locally
|
||||
self.director_bearer_token = (
|
||||
await account.getDirectorBearerToken(self.controller_unique_id)
|
||||
)["token"]
|
||||
except (Unauthorized, NotFound):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def connect_to_director(self) -> bool:
|
||||
"""Test if we can connect to the local Control4 Director."""
|
||||
try:
|
||||
director_session = aiohttp_client.async_get_clientsession(
|
||||
self.hass, verify_ssl=False
|
||||
)
|
||||
director = C4Director(
|
||||
self.host, self.director_bearer_token, director_session
|
||||
)
|
||||
await director.getAllItemInfo()
|
||||
except (Unauthorized, ClientError, TimeoutError):
|
||||
_LOGGER.error("Failed to connect to the Control4 controller")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Control4ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Control4."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _async_try_connect(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, Any] | None, dict[str, str]]:
|
||||
"""Try to connect to Control4 and return errors, data, and placeholders."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
username = user_input[CONF_USERNAME]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
|
||||
# Step 1: Authenticate with Control4 cloud API
|
||||
account_session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
account = C4Account(username, password, account_session)
|
||||
try:
|
||||
await account.getAccountBearerToken()
|
||||
|
||||
account_controllers = await account.getAccountControllers()
|
||||
controller_unique_id = account_controllers["controllerCommonName"]
|
||||
|
||||
director_bearer_token = (
|
||||
await account.getDirectorBearerToken(controller_unique_id)
|
||||
)["token"]
|
||||
except (BadCredentials, Unauthorized):
|
||||
errors["base"] = "invalid_auth"
|
||||
return errors, data, description_placeholders
|
||||
except NotFound:
|
||||
errors["base"] = "controller_not_found"
|
||||
return errors, data, description_placeholders
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected exception during Control4 account authentication"
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
return errors, data, description_placeholders
|
||||
|
||||
# Step 2: Connect to local Control4 Director
|
||||
director_session = aiohttp_client.async_get_clientsession(
|
||||
self.hass, verify_ssl=False
|
||||
)
|
||||
director = C4Director(host, director_bearer_token, director_session)
|
||||
try:
|
||||
await director.getAllItemInfo()
|
||||
except Unauthorized:
|
||||
errors["base"] = "director_auth_failed"
|
||||
return errors, data, description_placeholders
|
||||
except (ClientError, TimeoutError):
|
||||
errors["base"] = "cannot_connect"
|
||||
description_placeholders["host"] = host
|
||||
return errors, data, description_placeholders
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected exception during Control4 director connection"
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
return errors, data, description_placeholders
|
||||
|
||||
# Success - return the data needed for entry creation
|
||||
data = {
|
||||
CONF_HOST: host,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_CONTROLLER_UNIQUE_ID: controller_unique_id,
|
||||
}
|
||||
|
||||
return errors, data, description_placeholders
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
errors, data, description_placeholders = await self._async_try_connect(
|
||||
user_input
|
||||
hub = Control4Validator(
|
||||
user_input[CONF_HOST],
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
self.hass,
|
||||
)
|
||||
try:
|
||||
if not await hub.authenticate():
|
||||
raise InvalidAuth # noqa: TRY301
|
||||
if not await hub.connect_to_director():
|
||||
raise CannotConnect # noqa: TRY301
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if not errors and data is not None:
|
||||
controller_unique_id = data[CONF_CONTROLLER_UNIQUE_ID]
|
||||
if not errors:
|
||||
controller_unique_id = hub.controller_unique_id
|
||||
if TYPE_CHECKING:
|
||||
assert hub.controller_unique_id
|
||||
mac = (controller_unique_id.split("_", 3))[2]
|
||||
formatted_mac = format_mac(mac)
|
||||
await self.async_set_unique_id(formatted_mac)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=controller_unique_id,
|
||||
data=data,
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_CONTROLLER_UNIQUE_ID: controller_unique_id,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -178,3 +178,11 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
@@ -221,7 +221,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
|
||||
def _create_api_object(self) -> C4Room:
|
||||
def _create_api_object(self):
|
||||
"""Create a pyControl4 device object.
|
||||
|
||||
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
|
||||
@@ -254,7 +254,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
return media_info["mediainfo"]
|
||||
return None
|
||||
|
||||
def _get_current_source_state(self) -> MediaPlayerState | None:
|
||||
def _get_current_source_state(self) -> str | None:
|
||||
current_source = self._get_current_playing_device_id()
|
||||
while current_source:
|
||||
current_data = self.coordinator.data.get(current_source, None)
|
||||
@@ -277,7 +277,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
return MediaPlayerDeviceClass.SPEAKER
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
def state(self):
|
||||
"""Return whether this room is on or idle."""
|
||||
|
||||
if source_state := self._get_current_source_state():
|
||||
@@ -289,7 +289,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
def source(self):
|
||||
"""Get the current source."""
|
||||
current_source = self._get_current_playing_device_id()
|
||||
if not current_source or current_source not in self._sources:
|
||||
@@ -310,7 +310,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
return self._sources[current_source].name
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
def media_content_type(self):
|
||||
"""Get current content type if available."""
|
||||
current_source = self._get_current_playing_device_id()
|
||||
if not current_source:
|
||||
@@ -319,7 +319,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
return MediaType.VIDEO
|
||||
return MediaType.MUSIC
|
||||
|
||||
async def async_media_play_pause(self) -> None:
|
||||
async def async_media_play_pause(self):
|
||||
"""If possible, toggle the current play/pause state.
|
||||
|
||||
Not every source supports play/pause.
|
||||
@@ -335,16 +335,16 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
return [x.name for x in self._sources.values()]
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float:
|
||||
def volume_level(self):
|
||||
"""Get the volume level."""
|
||||
return self.coordinator.data[self._idx][CONTROL4_VOLUME_STATE] / 100
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool:
|
||||
def is_volume_muted(self):
|
||||
"""Check if the volume is muted."""
|
||||
return bool(self.coordinator.data[self._idx][CONTROL4_MUTED_STATE])
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
async def async_select_source(self, source):
|
||||
"""Select a new source."""
|
||||
for avail_source in self._sources.values():
|
||||
if avail_source.name == source:
|
||||
@@ -359,12 +359,12 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
async def async_turn_off(self):
|
||||
"""Turn off the room."""
|
||||
await self._create_api_object().setRoomOff()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
async def async_mute_volume(self, mute):
|
||||
"""Mute the room."""
|
||||
if mute:
|
||||
await self._create_api_object().setMuteOn()
|
||||
@@ -372,32 +372,32 @@ class Control4Room(Control4Entity, MediaPlayerEntity):
|
||||
await self._create_api_object().setMuteOff()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
async def async_set_volume_level(self, volume):
|
||||
"""Set room volume, 0-1 scale."""
|
||||
await self._create_api_object().setVolume(int(volume * 100))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
async def async_volume_up(self):
|
||||
"""Increase the volume by 1."""
|
||||
await self._create_api_object().setIncrementVolume()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
async def async_volume_down(self):
|
||||
"""Decrease the volume by 1."""
|
||||
await self._create_api_object().setDecrementVolume()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
async def async_media_pause(self):
|
||||
"""Issue a pause command."""
|
||||
await self._create_api_object().setPause()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
async def async_media_play(self):
|
||||
"""Issue a play command."""
|
||||
await self._create_api_object().setPlay()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
async def async_media_stop(self):
|
||||
"""Issue a stop command."""
|
||||
await self._create_api_object().setStop()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the Control4 director at {host}",
|
||||
"controller_not_found": "No Control4 controller found on this account",
|
||||
"director_auth_failed": "The Control4 director rejected the authentication token",
|
||||
"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%]"
|
||||
},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.6"]
|
||||
}
|
||||
|
||||
@@ -90,14 +90,14 @@ class CrownstoneLightEntity(CrownstoneEntity, LightEntity):
|
||||
return crownstone_state_to_hass(self.device.state) > 0
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
def color_mode(self) -> str:
|
||||
"""Return the color mode of the light."""
|
||||
if self.device.abilities.get(DIMMING_ABILITY).is_enabled:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
"""Flag supported color modes."""
|
||||
return {self.color_mode}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ class CyncLightEntity(CyncBaseEntity, LightEntity):
|
||||
return self._device.rgb
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the active color mode."""
|
||||
|
||||
if (
|
||||
|
||||
@@ -154,7 +154,7 @@ class DeconzSceneMixin(DeconzDevice[PydeconzScene]):
|
||||
"""Set up a scene."""
|
||||
super().__init__(device, hub)
|
||||
|
||||
self.deconz_group = self.hub.api.groups[device.group_id]
|
||||
self.group = self.hub.api.groups[device.group_id]
|
||||
|
||||
self._attr_name = device.name
|
||||
self._group_identifier = self.get_parent_identifier()
|
||||
@@ -165,7 +165,7 @@ class DeconzSceneMixin(DeconzDevice[PydeconzScene]):
|
||||
|
||||
def get_parent_identifier(self) -> str:
|
||||
"""Describe a unique identifier for group this scene belongs to."""
|
||||
return f"{self.hub.bridgeid}-{self.deconz_group.deconz_id}"
|
||||
return f"{self.hub.bridgeid}-{self.group.deconz_id}"
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
@@ -179,6 +179,6 @@ class DeconzSceneMixin(DeconzDevice[PydeconzScene]):
|
||||
identifiers={(DOMAIN, self._group_identifier)},
|
||||
manufacturer="dresden elektronik",
|
||||
model="deCONZ group",
|
||||
name=self.deconz_group.name,
|
||||
name=self.group.name,
|
||||
via_device=(DOMAIN, self.hub.api.config.bridge_id),
|
||||
)
|
||||
|
||||
@@ -244,7 +244,7 @@ class DeconzBaseLight[_LightDeviceT: Group | Light](
|
||||
self._attr_effect_list = XMAS_LIGHT_EFFECTS
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device.color_mode in DECONZ_TO_COLOR_MODE:
|
||||
color_mode = DECONZ_TO_COLOR_MODE[self._device.color_mode]
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"flow_title": "{host}",
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the app {addon}?",
|
||||
"title": "deCONZ Zigbee gateway via Home Assistant app"
|
||||
"description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the add-on {addon}?",
|
||||
"title": "deCONZ Zigbee gateway via Home Assistant add-on"
|
||||
},
|
||||
"link": {
|
||||
"description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Select the **Authenticate app** button",
|
||||
|
||||
@@ -103,14 +103,14 @@ class DecoraWifiLight(LightEntity):
|
||||
self._attr_unique_id = switch.serial
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
def color_mode(self) -> str:
|
||||
"""Return the color mode of the light."""
|
||||
if self._switch.canSetLevel:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
"""Flag supported color modes."""
|
||||
return {self.color_mode}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ class DemoLight(LightEntity):
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode | None:
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the color mode of the light."""
|
||||
return self._color_mode
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""The Dexcom integration."""
|
||||
|
||||
from pydexcom import Dexcom, Region
|
||||
from pydexcom.errors import AccountError, SessionError
|
||||
from pydexcom import AccountError, Dexcom, SessionError
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -15,13 +14,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: DexcomConfigEntry) -> bo
|
||||
"""Set up Dexcom from a config entry."""
|
||||
try:
|
||||
dexcom = await hass.async_add_executor_job(
|
||||
lambda: Dexcom(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
region=Region.OUS
|
||||
if entry.data[CONF_SERVER] == SERVER_OUS
|
||||
else Region.US,
|
||||
)
|
||||
Dexcom,
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
entry.data[CONF_SERVER] == SERVER_OUS,
|
||||
)
|
||||
except AccountError:
|
||||
return False
|
||||
|
||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydexcom import Dexcom, Region
|
||||
from pydexcom.errors import AccountError, SessionError
|
||||
from pydexcom import AccountError, Dexcom, SessionError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -38,13 +37,10 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
lambda: Dexcom(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
region=Region.OUS
|
||||
if user_input[CONF_SERVER] == SERVER_OUS
|
||||
else Region.US,
|
||||
)
|
||||
Dexcom,
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
user_input[CONF_SERVER] == SERVER_OUS,
|
||||
)
|
||||
except SessionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
@@ -18,7 +18,7 @@ _SCAN_INTERVAL = timedelta(seconds=180)
|
||||
type DexcomConfigEntry = ConfigEntry[DexcomCoordinator]
|
||||
|
||||
|
||||
class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading | None]):
|
||||
class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]):
|
||||
"""Dexcom Coordinator."""
|
||||
|
||||
def __init__(
|
||||
@@ -37,7 +37,7 @@ class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading | None]):
|
||||
)
|
||||
self.dexcom = dexcom
|
||||
|
||||
async def _async_update_data(self) -> GlucoseReading | None:
|
||||
async def _async_update_data(self) -> GlucoseReading:
|
||||
"""Fetch data from API endpoint."""
|
||||
return await self.hass.async_add_executor_job(
|
||||
self.dexcom.get_current_glucose_reading
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydexcom"],
|
||||
"requirements": ["pydexcom==0.5.1"]
|
||||
"requirements": ["pydexcom==0.2.3"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from fnmatch import translate
|
||||
from functools import lru_cache
|
||||
from functools import lru_cache, partial
|
||||
from ipaddress import IPv4Address
|
||||
import itertools
|
||||
import logging
|
||||
@@ -50,6 +50,12 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
)
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstant,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.discovery_flow import DiscoveryKey
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -74,6 +80,13 @@ SCAN_INTERVAL = timedelta(minutes=60)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEPRECATED_DhcpServiceInfo = DeprecatedConstant(
|
||||
_DhcpServiceInfo,
|
||||
"homeassistant.helpers.service_info.dhcp.DhcpServiceInfo",
|
||||
"2026.2",
|
||||
)
|
||||
|
||||
|
||||
def async_index_integration_matchers(
|
||||
integration_matchers: list[DHCPMatcher],
|
||||
) -> DhcpMatchers:
|
||||
@@ -490,3 +503,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool:
|
||||
since the devices will not change frequently
|
||||
"""
|
||||
return bool(_compile_fnmatch(pattern).match(name))
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
||||
@@ -74,7 +74,7 @@ class DiagnosticsData:
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Diagnostics integration."""
|
||||
"""Set up Diagnostics from a config entry."""
|
||||
hass.data[_DIAGNOSTICS_DATA] = DiagnosticsData()
|
||||
|
||||
await integration_platform.async_process_integration_platforms(
|
||||
|
||||
@@ -12,7 +12,6 @@ from doorbirdpy import DoorBird
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
@@ -219,9 +218,6 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
if existing_entry:
|
||||
if existing_entry.source == SOURCE_IGNORE:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Check if the host is actually changing
|
||||
if existing_entry.data.get(CONF_HOST) != host:
|
||||
await self._async_verify_existing_device_for_discovery(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.1.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
|
||||
@@ -85,7 +85,7 @@ class ElgatoLight(ElgatoEntity, LightEntity):
|
||||
return color_util.color_temperature_mired_to_kelvin(mired_temperature)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the color mode of the light."""
|
||||
if self.coordinator.data.state.hue is not None:
|
||||
return ColorMode.HS
|
||||
|
||||
@@ -59,38 +59,13 @@ class FlowToGridSourceType(TypedDict):
|
||||
number_energy_price: float | None # Price for energy ($/kWh)
|
||||
|
||||
|
||||
class PowerConfig(TypedDict, total=False):
|
||||
"""Dictionary holding power sensor configuration options.
|
||||
|
||||
Users can configure power sensors in three ways:
|
||||
1. Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
|
||||
2. Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
|
||||
3. Two sensors: separate positive sensors for each direction
|
||||
"""
|
||||
|
||||
# Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
|
||||
stat_rate: str
|
||||
|
||||
# Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
|
||||
stat_rate_inverted: str
|
||||
|
||||
# Two sensors: separate positive sensors for each direction
|
||||
# Result = stat_rate_from - stat_rate_to (positive when net outflow)
|
||||
stat_rate_from: str # Battery: discharge, Grid: consumption
|
||||
stat_rate_to: str # Battery: charge, Grid: return
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict, total=False):
|
||||
class GridPowerSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid power consumption."""
|
||||
|
||||
# statistic_id of a power meter (kW)
|
||||
# negative values indicate grid return
|
||||
# This is either the original sensor or a generated template sensor
|
||||
stat_rate: str
|
||||
|
||||
# User's original power sensor configuration
|
||||
power_config: PowerConfig
|
||||
|
||||
|
||||
class GridSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid energy consumption."""
|
||||
@@ -122,12 +97,8 @@ class BatterySourceType(TypedDict):
|
||||
stat_energy_from: str
|
||||
stat_energy_to: str
|
||||
# positive when discharging, negative when charging
|
||||
# This is either the original sensor or a generated template sensor
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# User's original power sensor configuration
|
||||
power_config: NotRequired[PowerConfig]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
"""Dictionary holding the source of gas consumption."""
|
||||
@@ -240,53 +211,10 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_power_config(val: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate power_config has exactly one configuration method."""
|
||||
if not val:
|
||||
raise vol.Invalid("power_config must have at least one option")
|
||||
|
||||
# Ensure only one configuration method is used
|
||||
has_single = "stat_rate" in val
|
||||
has_inverted = "stat_rate_inverted" in val
|
||||
has_combined = "stat_rate_from" in val
|
||||
|
||||
methods_count = sum([has_single, has_inverted, has_combined])
|
||||
if methods_count > 1:
|
||||
raise vol.Invalid(
|
||||
"power_config must use only one configuration method: "
|
||||
"stat_rate, stat_rate_inverted, or stat_rate_from/stat_rate_to"
|
||||
)
|
||||
|
||||
return val
|
||||
|
||||
|
||||
POWER_CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive("stat_rate", "power_source"): str,
|
||||
vol.Exclusive("stat_rate_inverted", "power_source"): str,
|
||||
# stat_rate_from/stat_rate_to: two sensors for bidirectional power
|
||||
# Battery: from=discharge (out), to=charge (in)
|
||||
# Grid: from=consumption, to=return
|
||||
vol.Inclusive("stat_rate_from", "two_sensors"): str,
|
||||
vol.Inclusive("stat_rate_to", "two_sensors"): str,
|
||||
}
|
||||
),
|
||||
_validate_power_config,
|
||||
)
|
||||
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
# stat_rate and power_config are both optional schema keys, but the validator
|
||||
# requires that at least one is provided; power_config takes precedence
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("stat_rate", "power_config"),
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_rate"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -297,7 +225,7 @@ def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[di
|
||||
val: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Ensure that the user doesn't add duplicate values."""
|
||||
counts = Counter(item.get(key) for item in val if item.get(key) is not None)
|
||||
counts = Counter(flow_from[key] for flow_from in val)
|
||||
|
||||
for value, count in counts.items():
|
||||
if count > 1:
|
||||
@@ -339,10 +267,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Required("type"): "battery",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Required("stat_energy_to"): str,
|
||||
# Both stat_rate and power_config are optional
|
||||
# If power_config is provided, it takes precedence and stat_rate is overwritten
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
}
|
||||
)
|
||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
@@ -462,12 +387,6 @@ class EnergyManager:
|
||||
if key in update:
|
||||
data[key] = update[key]
|
||||
|
||||
# Process energy sources and set stat_rate for power configs
|
||||
if "energy_sources" in update:
|
||||
data["energy_sources"] = self._process_energy_sources(
|
||||
data["energy_sources"]
|
||||
)
|
||||
|
||||
self.data = data
|
||||
self._store.async_delay_save(lambda: data, 60)
|
||||
|
||||
@@ -476,68 +395,6 @@ class EnergyManager:
|
||||
|
||||
await asyncio.gather(*(listener() for listener in self._update_listeners))
|
||||
|
||||
def _process_energy_sources(self, sources: list[SourceType]) -> list[SourceType]:
|
||||
"""Process energy sources and set stat_rate for power configs."""
|
||||
from .helpers import generate_power_sensor_entity_id # noqa: PLC0415
|
||||
|
||||
processed: list[SourceType] = []
|
||||
for source in sources:
|
||||
if source["type"] == "battery":
|
||||
source = self._process_battery_power(
|
||||
source, generate_power_sensor_entity_id
|
||||
)
|
||||
elif source["type"] == "grid":
|
||||
source = self._process_grid_power(
|
||||
source, generate_power_sensor_entity_id
|
||||
)
|
||||
processed.append(source)
|
||||
return processed
|
||||
|
||||
def _process_battery_power(
|
||||
self,
|
||||
source: BatterySourceType,
|
||||
generate_entity_id: Callable[[str, PowerConfig], str],
|
||||
) -> BatterySourceType:
|
||||
"""Set stat_rate for battery if power_config is specified."""
|
||||
if "power_config" not in source:
|
||||
return source
|
||||
|
||||
config = source["power_config"]
|
||||
|
||||
# If power_config has stat_rate (standard), just use it directly
|
||||
if "stat_rate" in config:
|
||||
return {**source, "stat_rate": config["stat_rate"]}
|
||||
|
||||
# For inverted or two-sensor config, set stat_rate to the generated entity_id
|
||||
return {**source, "stat_rate": generate_entity_id("battery", config)}
|
||||
|
||||
def _process_grid_power(
|
||||
self,
|
||||
source: GridSourceType,
|
||||
generate_entity_id: Callable[[str, PowerConfig], str],
|
||||
) -> GridSourceType:
|
||||
"""Set stat_rate for grid power sources if power_config is specified."""
|
||||
if "power" not in source:
|
||||
return source
|
||||
|
||||
processed_power: list[GridPowerSourceType] = []
|
||||
for power in source["power"]:
|
||||
if "power_config" in power:
|
||||
config = power["power_config"]
|
||||
|
||||
# If power_config has stat_rate (standard), just use it directly
|
||||
if "stat_rate" in config:
|
||||
processed_power.append({**power, "stat_rate": config["stat_rate"]})
|
||||
else:
|
||||
# For inverted or two-sensor config, set stat_rate to generated entity_id
|
||||
processed_power.append(
|
||||
{**power, "stat_rate": generate_entity_id("grid", config)}
|
||||
)
|
||||
else:
|
||||
processed_power.append(power)
|
||||
|
||||
return {**source, "power": processed_power}
|
||||
|
||||
@callback
|
||||
def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None:
|
||||
"""Listen for data updates."""
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Helpers for the Energy integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .data import PowerConfig
|
||||
|
||||
|
||||
def generate_power_sensor_unique_id(source_type: str, config: PowerConfig) -> str:
|
||||
"""Generate a unique ID for a power transform sensor."""
|
||||
if "stat_rate_inverted" in config:
|
||||
sensor_id = config["stat_rate_inverted"].replace(".", "_")
|
||||
return f"energy_power_{source_type}_inverted_{sensor_id}"
|
||||
if "stat_rate_from" in config and "stat_rate_to" in config:
|
||||
from_id = config["stat_rate_from"].replace(".", "_")
|
||||
to_id = config["stat_rate_to"].replace(".", "_")
|
||||
return f"energy_power_{source_type}_combined_{from_id}_{to_id}"
|
||||
# This case is impossible: schema validation (vol.Inclusive) ensures
|
||||
# stat_rate_from and stat_rate_to are always present together
|
||||
raise RuntimeError("Invalid power config: missing required keys")
|
||||
|
||||
|
||||
def generate_power_sensor_entity_id(source_type: str, config: PowerConfig) -> str:
|
||||
"""Generate an entity ID for a power transform sensor."""
|
||||
if "stat_rate_inverted" in config:
|
||||
# Use source sensor name with _inverted suffix
|
||||
source = config["stat_rate_inverted"]
|
||||
if source.startswith("sensor."):
|
||||
return f"{source}_inverted"
|
||||
return f"sensor.{source.replace('.', '_')}_inverted"
|
||||
if "stat_rate_from" in config and "stat_rate_to" in config:
|
||||
# Use both sensors in entity ID to ensure uniqueness when multiple
|
||||
# combined configs exist. The entity represents net power (from - to),
|
||||
# e.g., discharge - charge for battery.
|
||||
from_sensor = config["stat_rate_from"].removeprefix("sensor.")
|
||||
to_sensor = config["stat_rate_to"].removeprefix("sensor.")
|
||||
return f"sensor.energy_{source_type}_{from_sensor}_{to_sensor}_net_power"
|
||||
# This case is impossible: schema validation (vol.Inclusive) ensures
|
||||
# stat_rate_from and stat_rate_to are always present together
|
||||
raise RuntimeError("Invalid power config: missing required keys")
|
||||
@@ -19,12 +19,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import
|
||||
reset_detected,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
State,
|
||||
@@ -41,8 +36,7 @@ from homeassistant.util import dt as dt_util, unit_conversion
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import EnergyManager, PowerConfig, async_get_manager
|
||||
from .helpers import generate_power_sensor_entity_id, generate_power_sensor_unique_id
|
||||
from .data import EnergyManager, async_get_manager
|
||||
|
||||
SUPPORTED_STATE_CLASSES = {
|
||||
SensorStateClass.MEASUREMENT,
|
||||
@@ -143,7 +137,6 @@ class SensorManager:
|
||||
self.manager = manager
|
||||
self.async_add_entities = async_add_entities
|
||||
self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {}
|
||||
self.current_power_entities: dict[str, EnergyPowerSensor] = {}
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start."""
|
||||
@@ -154,9 +147,8 @@ class SensorManager:
|
||||
|
||||
async def _process_manager_data(self) -> None:
|
||||
"""Process manager data."""
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor] = []
|
||||
to_add: list[EnergyCostSensor] = []
|
||||
to_remove = dict(self.current_entities)
|
||||
power_to_remove = dict(self.current_power_entities)
|
||||
|
||||
async def finish() -> None:
|
||||
if to_add:
|
||||
@@ -167,13 +159,6 @@ class SensorManager:
|
||||
self.current_entities.pop(key)
|
||||
await entity.async_remove()
|
||||
|
||||
for power_key, power_entity in power_to_remove.items():
|
||||
self.current_power_entities.pop(power_key)
|
||||
await power_entity.async_remove()
|
||||
|
||||
# This guard is for the optional typing of EnergyManager.data.
|
||||
# In practice, data is always set to default preferences in async_update
|
||||
# before listeners are called, so this case should never happen.
|
||||
if not self.manager.data:
|
||||
await finish()
|
||||
return
|
||||
@@ -200,13 +185,6 @@ class SensorManager:
|
||||
to_remove,
|
||||
)
|
||||
|
||||
# Process power sensors for battery and grid sources
|
||||
self._process_power_sensor_data(
|
||||
energy_source,
|
||||
to_add,
|
||||
power_to_remove,
|
||||
)
|
||||
|
||||
await finish()
|
||||
|
||||
@callback
|
||||
@@ -214,7 +192,7 @@ class SensorManager:
|
||||
self,
|
||||
adapter: SourceAdapter,
|
||||
config: Mapping[str, Any],
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_add: list[EnergyCostSensor],
|
||||
to_remove: dict[tuple[str, str | None, str], EnergyCostSensor],
|
||||
) -> None:
|
||||
"""Process sensor data."""
|
||||
@@ -242,64 +220,6 @@ class SensorManager:
|
||||
)
|
||||
to_add.append(self.current_entities[key])
|
||||
|
||||
@callback
|
||||
def _process_power_sensor_data(
|
||||
self,
|
||||
energy_source: Mapping[str, Any],
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[str, EnergyPowerSensor],
|
||||
) -> None:
|
||||
"""Process power sensor data for battery and grid sources."""
|
||||
source_type = energy_source.get("type")
|
||||
|
||||
if source_type == "battery":
|
||||
power_config = energy_source.get("power_config")
|
||||
if power_config and self._needs_power_sensor(power_config):
|
||||
self._create_or_keep_power_sensor(
|
||||
source_type, power_config, to_add, to_remove
|
||||
)
|
||||
|
||||
elif source_type == "grid":
|
||||
for power in energy_source.get("power", []):
|
||||
power_config = power.get("power_config")
|
||||
if power_config and self._needs_power_sensor(power_config):
|
||||
self._create_or_keep_power_sensor(
|
||||
source_type, power_config, to_add, to_remove
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _needs_power_sensor(power_config: PowerConfig) -> bool:
|
||||
"""Check if power_config needs a transform sensor."""
|
||||
# Only create sensors for inverted or two-sensor configs
|
||||
# Standard stat_rate configs don't need a transform sensor
|
||||
return "stat_rate_inverted" in power_config or (
|
||||
"stat_rate_from" in power_config and "stat_rate_to" in power_config
|
||||
)
|
||||
|
||||
def _create_or_keep_power_sensor(
|
||||
self,
|
||||
source_type: str,
|
||||
power_config: PowerConfig,
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[str, EnergyPowerSensor],
|
||||
) -> None:
|
||||
"""Create a power sensor or keep an existing one."""
|
||||
unique_id = generate_power_sensor_unique_id(source_type, power_config)
|
||||
|
||||
# If entity already exists, keep it
|
||||
if unique_id in to_remove:
|
||||
to_remove.pop(unique_id)
|
||||
return
|
||||
|
||||
sensor = EnergyPowerSensor(
|
||||
source_type,
|
||||
power_config,
|
||||
unique_id,
|
||||
generate_power_sensor_entity_id(source_type, power_config),
|
||||
)
|
||||
self.current_power_entities[unique_id] = sensor
|
||||
to_add.append(sensor)
|
||||
|
||||
|
||||
def _set_result_unless_done(future: asyncio.Future[None]) -> None:
|
||||
"""Set the result of a future unless it is done."""
|
||||
@@ -575,197 +495,3 @@ class EnergyCostSensor(SensorEntity):
|
||||
prefix = self._config[self._adapter.stat_energy_key]
|
||||
|
||||
return f"{prefix}_{self._adapter.source_type}_{self._adapter.entity_id_suffix}"
|
||||
|
||||
|
||||
class EnergyPowerSensor(SensorEntity):
|
||||
"""Transform power sensor values (invert or combine two sensors).
|
||||
|
||||
This sensor handles non-standard power sensor configurations for the energy
|
||||
dashboard by either inverting polarity or combining two positive sensors.
|
||||
"""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_device_class = SensorDeviceClass.POWER
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_type: str,
|
||||
config: PowerConfig,
|
||||
unique_id: str,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__()
|
||||
self._source_type = source_type
|
||||
self._config: PowerConfig = config
|
||||
self._attr_unique_id = unique_id
|
||||
self.entity_id = entity_id
|
||||
self._source_sensors: list[str] = []
|
||||
self._is_inverted = "stat_rate_inverted" in config
|
||||
self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config
|
||||
|
||||
# Determine source sensors
|
||||
if self._is_inverted:
|
||||
self._source_sensors = [config["stat_rate_inverted"]]
|
||||
elif self._is_combined:
|
||||
self._source_sensors = [
|
||||
config["stat_rate_from"],
|
||||
config["stat_rate_to"],
|
||||
]
|
||||
|
||||
# add_finished is set when either async_added_to_hass or add_to_platform_abort
|
||||
# is called
|
||||
self.add_finished: asyncio.Future[None] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if self._is_inverted:
|
||||
source = self.hass.states.get(self._source_sensors[0])
|
||||
return source is not None and source.state not in (
|
||||
"unknown",
|
||||
"unavailable",
|
||||
)
|
||||
if self._is_combined:
|
||||
discharge = self.hass.states.get(self._source_sensors[0])
|
||||
charge = self.hass.states.get(self._source_sensors[1])
|
||||
return (
|
||||
discharge is not None
|
||||
and charge is not None
|
||||
and discharge.state not in ("unknown", "unavailable")
|
||||
and charge.state not in ("unknown", "unavailable")
|
||||
)
|
||||
return True
|
||||
|
||||
@callback
|
||||
def _update_state(self) -> None:
|
||||
"""Update the sensor state based on source sensors."""
|
||||
if self._is_inverted:
|
||||
source_state = self.hass.states.get(self._source_sensors[0])
|
||||
if source_state is None or source_state.state in ("unknown", "unavailable"):
|
||||
self._attr_native_value = None
|
||||
return
|
||||
try:
|
||||
value = float(source_state.state)
|
||||
except ValueError:
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
self._attr_native_value = value * -1
|
||||
|
||||
elif self._is_combined:
|
||||
discharge_state = self.hass.states.get(self._source_sensors[0])
|
||||
charge_state = self.hass.states.get(self._source_sensors[1])
|
||||
|
||||
if (
|
||||
discharge_state is None
|
||||
or charge_state is None
|
||||
or discharge_state.state in ("unknown", "unavailable")
|
||||
or charge_state.state in ("unknown", "unavailable")
|
||||
):
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
try:
|
||||
discharge = float(discharge_state.state)
|
||||
charge = float(charge_state.state)
|
||||
except ValueError:
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
# Get units from state attributes
|
||||
discharge_unit = discharge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
# Convert to Watts if units are present
|
||||
if discharge_unit:
|
||||
discharge = unit_conversion.PowerConverter.convert(
|
||||
discharge, discharge_unit, UnitOfPower.WATT
|
||||
)
|
||||
if charge_unit:
|
||||
charge = unit_conversion.PowerConverter.convert(
|
||||
charge, charge_unit, UnitOfPower.WATT
|
||||
)
|
||||
|
||||
self._attr_native_value = discharge - charge
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
# Set name based on source sensor(s)
|
||||
if self._source_sensors:
|
||||
entity_reg = er.async_get(self.hass)
|
||||
device_id = None
|
||||
source_name = None
|
||||
# Check first sensor
|
||||
if source_entry := entity_reg.async_get(self._source_sensors[0]):
|
||||
device_id = source_entry.device_id
|
||||
# For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit
|
||||
if self._is_combined:
|
||||
self._attr_native_unit_of_measurement = UnitOfPower.WATT
|
||||
else:
|
||||
self._attr_native_unit_of_measurement = (
|
||||
source_entry.unit_of_measurement
|
||||
)
|
||||
# Get source name from registry
|
||||
source_name = source_entry.name or source_entry.original_name
|
||||
# Assign power sensor to same device as source sensor(s)
|
||||
# Note: We use manual entity registry update instead of _attr_device_info
|
||||
# because device assignment depends on runtime information from the entity
|
||||
# registry (which source sensor has a device). This information isn't
|
||||
# available during __init__, and the entity is already registered before
|
||||
# async_added_to_hass runs, making the standard _attr_device_info pattern
|
||||
# incompatible with this use case.
|
||||
# If first sensor has no device and we have a second sensor, check it
|
||||
if not device_id and len(self._source_sensors) > 1:
|
||||
if source_entry := entity_reg.async_get(self._source_sensors[1]):
|
||||
device_id = source_entry.device_id
|
||||
# Update entity registry entry with device_id
|
||||
if device_id and (power_entry := entity_reg.async_get(self.entity_id)):
|
||||
entity_reg.async_update_entity(
|
||||
power_entry.entity_id, device_id=device_id
|
||||
)
|
||||
else:
|
||||
self._attr_has_entity_name = False
|
||||
|
||||
# Set name for inverted mode
|
||||
if self._is_inverted:
|
||||
if source_name:
|
||||
self._attr_name = f"{source_name} Inverted"
|
||||
else:
|
||||
# Fall back to entity_id if no name in registry
|
||||
sensor_name = split_entity_id(self._source_sensors[0])[1].replace(
|
||||
"_", " "
|
||||
)
|
||||
self._attr_name = f"{sensor_name.title()} Inverted"
|
||||
|
||||
# Set name for combined mode
|
||||
if self._is_combined:
|
||||
self._attr_name = f"{self._source_type.title()} Power"
|
||||
|
||||
self._update_state()
|
||||
|
||||
# Track state changes on all source sensors
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
self._source_sensors,
|
||||
self._async_state_changed_listener,
|
||||
)
|
||||
)
|
||||
_set_result_unless_done(self.add_finished)
|
||||
|
||||
@callback
|
||||
def _async_state_changed_listener(self, *_: Any) -> None:
|
||||
"""Handle source sensor state changes."""
|
||||
self._update_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def add_to_platform_abort(self) -> None:
|
||||
"""Abort adding an entity to a platform."""
|
||||
_set_result_unless_done(self.add_finished)
|
||||
super().add_to_platform_abort()
|
||||
|
||||
@@ -76,8 +76,7 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
|
||||
}
|
||||
return self.radar_object.image
|
||||
|
||||
async def async_set_radar_type(self, radar_type: str) -> None:
|
||||
async def async_set_radar_type(self, radar_type: str):
|
||||
"""Set the type of radar to retrieve."""
|
||||
self.radar_object.clear_cache()
|
||||
self.radar_object.precip_type = radar_type.lower()
|
||||
await self.radar_object.update()
|
||||
|
||||
@@ -44,7 +44,6 @@ from aioesphomeapi import (
|
||||
UpdateInfo,
|
||||
UserService,
|
||||
ValveInfo,
|
||||
WaterHeaterInfo,
|
||||
build_unique_id,
|
||||
)
|
||||
from aioesphomeapi.model import ButtonInfo
|
||||
@@ -97,7 +96,6 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
TimeInfo: Platform.TIME,
|
||||
UpdateInfo: Platform.UPDATE,
|
||||
ValveInfo: Platform.VALVE,
|
||||
WaterHeaterInfo: Platform.WATER_HEATER,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def color_mode(self) -> ColorMode:
|
||||
def color_mode(self) -> str:
|
||||
"""Return the color mode of the light."""
|
||||
if not self._supports_color_mode:
|
||||
supported_color_modes = self.supported_color_modes
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.14.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.5.0"
|
||||
"bleak-esphome==3.4.1"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ _STATE_CLASSES: EsphomeEnumMapper[EsphomeSensorStateClass, SensorStateClass | No
|
||||
EsphomeSensorStateClass.MEASUREMENT: SensorStateClass.MEASUREMENT,
|
||||
EsphomeSensorStateClass.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING,
|
||||
EsphomeSensorStateClass.TOTAL: SensorStateClass.TOTAL,
|
||||
EsphomeSensorStateClass.MEASUREMENT_ANGLE: SensorStateClass.MEASUREMENT_ANGLE,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user