Compare commits

..

1 Commits

793 changed files with 14025 additions and 37218 deletions

View File

@@ -190,7 +190,7 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -257,7 +257,7 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -332,14 +332,14 @@ jobs:
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant' if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -504,7 +504,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}

File diff suppressed because it is too large Load Diff

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 60 days stale PRs policy - name: 60 days stale PRs policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60 days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"

View File

@@ -203,7 +203,6 @@ homeassistant.components.feedreader.*
homeassistant.components.file_upload.* homeassistant.components.file_upload.*
homeassistant.components.filesize.* homeassistant.components.filesize.*
homeassistant.components.filter.* homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.* homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.* homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.* homeassistant.components.flux_led.*
@@ -326,7 +325,6 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.* homeassistant.components.lookin.*
homeassistant.components.lovelace.* homeassistant.components.lovelace.*
homeassistant.components.luftdaten.* homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.madvr.* homeassistant.components.madvr.*
homeassistant.components.manual.* homeassistant.components.manual.*
homeassistant.components.mastodon.* homeassistant.components.mastodon.*
@@ -555,7 +553,6 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.* homeassistant.components.vallox.*
homeassistant.components.valve.* homeassistant.components.valve.*
homeassistant.components.velbus.* homeassistant.components.velbus.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.* homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.* homeassistant.components.vodafone_station.*
homeassistant.components.volvo.* homeassistant.components.volvo.*

View File

@@ -1 +0,0 @@
.github/copilot-instructions.md

10
CODEOWNERS generated
View File

@@ -492,8 +492,6 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST /tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes /homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes /tests/components/filter/ @dgomes
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky /homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky /tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP /homeassistant/components/firmata/ @DaAwesomeP
@@ -910,8 +908,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale /homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck /homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent /homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent /tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron/ @cdheiser @wilburCForce
@@ -957,8 +953,6 @@ build.json @home-assistant/supervisor
/tests/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/homeassistant/components/meteo_lt/ @xE1H
/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch /homeassistant/components/meteoalarm/ @rolfberkenbosch
/homeassistant/components/meteoclimatic/ @adrianmo /homeassistant/components/meteoclimatic/ @adrianmo
/tests/components/meteoclimatic/ @adrianmo /tests/components/meteoclimatic/ @adrianmo
@@ -1065,8 +1059,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum /homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum
/homeassistant/components/nintendo_parental/ @pantherale0
/tests/components/nintendo_parental/ @pantherale0
/homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/nissan_leaf/ @filcole
/homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
@@ -1198,6 +1190,8 @@ build.json @home-assistant/supervisor
/tests/components/plex/ @jjlawren /tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew /homeassistant/components/plugwise/ @CoMPaTech @bouwew
/tests/components/plugwise/ @CoMPaTech @bouwew /tests/components/plugwise/ @CoMPaTech @bouwew
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike /homeassistant/components/point/ @fredrike
/tests/components/point/ @fredrike /tests/components/point/ @fredrike
/homeassistant/components/pooldose/ @lmaertin /homeassistant/components/pooldose/ @lmaertin

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.3
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View File

@@ -616,34 +616,34 @@ async def async_enable_logging(
), ),
) )
logger = logging.getLogger() # Log errors to a file if we have write access to file or config dir
logger.setLevel(logging.INFO if verbose else logging.WARNING)
if log_file is None: if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME) err_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path
else: else:
err_log_path = os.path.abspath(log_file) err_log_path = os.path.abspath(log_file)
if err_log_path: err_path_exists = os.path.isfile(err_log_path)
err_dir = os.path.dirname(err_log_path)
# Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not.
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
not err_path_exists and os.access(err_dir, os.W_OK)
):
err_handler = await hass.async_add_executor_job( err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days _create_log_file, err_log_path, log_rotate_days
) )
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger = logging.getLogger()
logger.addHandler(err_handler) logger.addHandler(err_handler)
logger.setLevel(logging.INFO if verbose else logging.WARNING)
# Save the log file location for access by other components. # Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path hass.data[DATA_LOGGING] = err_log_path
else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
async_activate_log_queue_handler(hass) async_activate_log_queue_handler(hass)

View File

@@ -1,5 +0,0 @@
{
"domain": "eltako",
"name": "Eltako",
"iot_standards": ["matter"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "ibm",
"name": "IBM",
"integrations": ["watson_iot", "watson_tts"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "konnected",
"name": "Konnected",
"integrations": ["konnected", "konnected_esphome"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "level",
"name": "Level",
"iot_standards": ["matter"]
}

View File

@@ -12,13 +12,11 @@ from homeassistant.components.bluetooth import async_get_scanner
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15) SCAN_INTERVAL = timedelta(seconds=15)
UPDATE_DEBOUNCE_TIME = 0.2
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -40,19 +38,11 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
config_entry=entry, config_entry=entry,
) )
debouncer = Debouncer(
hass=hass,
logger=_LOGGER,
cooldown=UPDATE_DEBOUNCE_TIME,
immediate=True,
function=self.async_update_listeners,
)
self._scale = AcaiaScale( self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS], address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title, name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=debouncer.async_schedule_call, notify_callback=self.async_update_listeners,
scanner=async_get_scanner(hass), scanner=async_get_scanner(hass),
) )

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos", "documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["airos==0.5.5"] "requirements": ["airos==0.5.3"]
} }

View File

@@ -23,10 +23,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
} }
) )
URL_API_INTEGRATION = {
"url": "https://dashboard.airthings.com/integrations/api-integration"
}
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Airthings.""" """Handle a config flow for Airthings."""
@@ -41,7 +37,11 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders=URL_API_INTEGRATION, description_placeholders={
"url": (
"https://dashboard.airthings.com/integrations/api-integration"
),
},
) )
errors = {} errors = {}
@@ -65,8 +65,5 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Airthings", data=user_input) return self.async_create_entry(title="Airthings", data=user_input)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders=URL_API_INTEGRATION,
) )

View File

@@ -4,10 +4,10 @@
"user": { "user": {
"data": { "data": {
"id": "ID", "id": "ID",
"secret": "Secret" "secret": "Secret",
},
"description": "Login at {url} to find your credentials" "description": "Login at {url} to find your credentials"
} }
}
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -6,13 +6,8 @@ import dataclasses
import logging import logging
from typing import Any from typing import Any
from airthings_ble import ( from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
AirthingsBluetoothDeviceData,
AirthingsDevice,
UnsupportedDeviceError,
)
from bleak import BleakError from bleak import BleakError
from habluetooth import BluetoothServiceInfoBleak
import voluptuous as vol import voluptuous as vol
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
@@ -32,7 +27,6 @@ SERVICE_UUIDS = [
"b42e4a8e-ade7-11e4-89d3-123b93f75cba", "b42e4a8e-ade7-11e4-89d3-123b93f75cba",
"b42e1c08-ade7-11e4-89d3-123b93f75cba", "b42e1c08-ade7-11e4-89d3-123b93f75cba",
"b42e3882-ade7-11e4-89d3-123b93f75cba", "b42e3882-ade7-11e4-89d3-123b93f75cba",
"b42e90a2-ade7-11e4-89d3-123b93f75cba",
] ]
@@ -43,7 +37,6 @@ class Discovery:
name: str name: str
discovery_info: BluetoothServiceInfo discovery_info: BluetoothServiceInfo
device: AirthingsDevice device: AirthingsDevice
data: AirthingsBluetoothDeviceData
def get_name(device: AirthingsDevice) -> str: def get_name(device: AirthingsDevice) -> str:
@@ -51,7 +44,7 @@ def get_name(device: AirthingsDevice) -> str:
name = device.friendly_name() name = device.friendly_name()
if identifier := device.identifier: if identifier := device.identifier:
name += f" ({device.model.value}{identifier})" name += f" ({identifier})"
return name return name
@@ -69,8 +62,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device: Discovery | None = None self._discovered_device: Discovery | None = None
self._discovered_devices: dict[str, Discovery] = {} self._discovered_devices: dict[str, Discovery] = {}
async def _get_device( async def _get_device_data(
self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo self, discovery_info: BluetoothServiceInfo
) -> AirthingsDevice: ) -> AirthingsDevice:
ble_device = bluetooth.async_ble_device_from_address( ble_device = bluetooth.async_ble_device_from_address(
self.hass, discovery_info.address self.hass, discovery_info.address
@@ -79,8 +72,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("no ble_device in _get_device_data") _LOGGER.debug("no ble_device in _get_device_data")
raise AirthingsDeviceUpdateError("No ble_device") raise AirthingsDeviceUpdateError("No ble_device")
airthings = AirthingsBluetoothDeviceData(_LOGGER)
try: try:
device = await data.update_device(ble_device) data = await airthings.update_device(ble_device)
except BleakError as err: except BleakError as err:
_LOGGER.error( _LOGGER.error(
"Error connecting to and getting data from %s: %s", "Error connecting to and getting data from %s: %s",
@@ -88,15 +83,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
err, err,
) )
raise AirthingsDeviceUpdateError("Failed getting device data") from err raise AirthingsDeviceUpdateError("Failed getting device data") from err
except UnsupportedDeviceError:
_LOGGER.debug("Skipping unsupported device: %s", discovery_info.name)
raise
except Exception as err: except Exception as err:
_LOGGER.error( _LOGGER.error(
"Unknown error occurred from %s: %s", discovery_info.address, err "Unknown error occurred from %s: %s", discovery_info.address, err
) )
raise raise
return device return data
async def async_step_bluetooth( async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo self, discovery_info: BluetoothServiceInfo
@@ -106,21 +98,17 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.address) await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try: try:
device = await self._get_device(data=data, discovery_info=discovery_info) device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError: except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
except UnsupportedDeviceError:
return self.async_abort(reason="unsupported_device")
except Exception: except Exception:
_LOGGER.exception("Unknown error occurred") _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
name = get_name(device) name = get_name(device)
self.context["title_placeholders"] = {"name": name} self.context["title_placeholders"] = {"name": name}
self._discovered_device = Discovery(name, discovery_info, device, data=data) self._discovered_device = Discovery(name, discovery_info, device)
return await self.async_step_bluetooth_confirm() return await self.async_step_bluetooth_confirm()
@@ -129,12 +117,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Confirm discovery.""" """Confirm discovery."""
if user_input is not None: if user_input is not None:
if (
self._discovered_device is not None
and self._discovered_device.device.firmware.need_firmware_upgrade
):
return self.async_abort(reason="firmware_upgrade_required")
return self.async_create_entry( return self.async_create_entry(
title=self.context["title_placeholders"]["name"], data={} title=self.context["title_placeholders"]["name"], data={}
) )
@@ -155,9 +137,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
discovery = self._discovered_devices[address] discovery = self._discovered_devices[address]
if discovery.device.firmware.need_firmware_upgrade:
return self.async_abort(reason="firmware_upgrade_required")
self.context["title_placeholders"] = { self.context["title_placeholders"] = {
"name": discovery.name, "name": discovery.name,
} }
@@ -167,53 +146,32 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={}) return self.async_create_entry(title=discovery.name, data={})
current_addresses = self._async_current_ids(include_ignore=False) current_addresses = self._async_current_ids(include_ignore=False)
devices: list[BluetoothServiceInfoBleak] = []
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue
if MFCT_ID not in discovery_info.manufacturer_data: if MFCT_ID not in discovery_info.manufacturer_data:
continue continue
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
_LOGGER.debug(
"Skipping unsupported device: %s (%s)", discovery_info.name, address
)
continue
devices.append(discovery_info)
for discovery_info in devices: if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
address = discovery_info.address continue
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try: try:
device = await self._get_device(data, discovery_info) device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError: except AirthingsDeviceUpdateError:
_LOGGER.error( return self.async_abort(reason="cannot_connect")
"Error connecting to and getting data from %s (%s)",
discovery_info.name,
discovery_info.address,
)
continue
except UnsupportedDeviceError:
_LOGGER.debug(
"Skipping unsupported device: %s (%s)",
discovery_info.name,
discovery_info.address,
)
continue
except Exception: except Exception:
_LOGGER.exception("Unknown error occurred") _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
name = get_name(device) name = get_name(device)
_LOGGER.debug("Discovered Airthings device: %s (%s)", name, address) self._discovered_devices[address] = Discovery(name, discovery_info, device)
self._discovered_devices[address] = Discovery(
name, discovery_info, device, data
)
if not self._discovered_devices: if not self._discovered_devices:
return self.async_abort(reason="no_devices_found") return self.async_abort(reason="no_devices_found")
titles = { titles = {
address: get_name(discovery.device) address: discovery.device.name
for (address, discovery) in self._discovered_devices.items() for (address, discovery) in self._discovered_devices.items()
} }
return self.async_show_form( return self.async_show_form(

View File

@@ -17,10 +17,6 @@
{ {
"manufacturer_id": 820, "manufacturer_id": 820,
"service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba"
},
{
"manufacturer_id": 820,
"service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba"
} }
], ],
"codeowners": ["@vincegio", "@LaStrada"], "codeowners": ["@vincegio", "@LaStrada"],
@@ -28,5 +24,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble", "documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airthings-ble==1.1.1"] "requirements": ["airthings-ble==0.9.2"]
} }

View File

@@ -114,8 +114,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
), ),
} }
PARALLEL_UPDATES = 0
@callback @callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:

View File

@@ -6,9 +6,6 @@
"description": "[%key:component::bluetooth::config::step::user::description%]", "description": "[%key:component::bluetooth::config::step::user::description%]",
"data": { "data": {
"address": "[%key:common::config_flow::data::device%]" "address": "[%key:common::config_flow::data::device%]"
},
"data_description": {
"address": "The Airthings devices discovered via Bluetooth."
} }
}, },
"bluetooth_confirm": { "bluetooth_confirm": {
@@ -20,8 +17,6 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.",
"unsupported_device": "Unsupported device",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },

View File

@@ -2,14 +2,17 @@
from airtouch4pyapi import AirTouch from airtouch4pyapi import AirTouch
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator from .coordinator import AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE]
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry.""" """Set up AirTouch4 from a config entry."""
@@ -19,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
info = airtouch.GetAcs() info = airtouch.GetAcs()
if not info: if not info:
raise ConfigEntryNotReady raise ConfigEntryNotReady
coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch) coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = coordinator

View File

@@ -2,34 +2,26 @@
import logging import logging
from airtouch4pyapi import AirTouch
from airtouch4pyapi.airtouch import AirTouchStatus from airtouch4pyapi.airtouch import AirTouchStatus
from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.components.climate import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Airtouch data.""" """Class to manage fetching Airtouch data."""
def __init__( def __init__(self, hass, airtouch):
self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
) -> None:
"""Initialize global Airtouch data updater.""" """Initialize global Airtouch data updater."""
self.airtouch = airtouch self.airtouch = airtouch
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )

View File

@@ -22,17 +22,6 @@ class OAuth2FlowHandler(
VERSION = CONFIG_FLOW_VERSION VERSION = CONFIG_FLOW_VERSION
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Check we have the cloud integration set up."""
if "cloud" not in self.hass.config.components:
return self.async_abort(
reason="cloud_not_enabled",
description_placeholders={"default_config": "default_config"},
)
return await super().async_step_user(user_input)
async def async_step_reauth( async def async_step_reauth(
self, user_input: Mapping[str, Any] self, user_input: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -24,8 +24,7 @@
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.", "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account."
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml."
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@@ -18,9 +18,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonConfigEntry from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity from .entity import AmazonEntity
from .utils import async_update_unique_id from .utils import async_update_unique_id
@@ -53,47 +51,11 @@ BINARY_SENSORS: Final = (
), ),
is_supported=lambda device, key: device.sensors.get(key) is not None, is_supported=lambda device, key: device.sensors.get(key) is not None,
is_available_fn=lambda device, key: ( is_available_fn=lambda device, key: (
device.online device.online and device.sensors[key].error is False
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
), ),
), ),
) )
DEPRECATED_BINARY_SENSORS: Final = (
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: False,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: False,
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -104,8 +66,6 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
entity_registry = er.async_get(hass)
# Replace unique id for "detectionState" binary sensor # Replace unique id for "detectionState" binary sensor
await async_update_unique_id( await async_update_unique_id(
hass, hass,
@@ -115,16 +75,6 @@ async def async_setup_entry(
"detectionState", "detectionState",
) )
# Clean up deprecated sensors
for sensor_desc in DEPRECATED_BINARY_SENSORS:
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{sensor_desc.key}"
if entity_id := entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, unique_id
):
_LOGGER.debug("Removing deprecated entity %s", entity_id)
entity_registry.async_remove(entity_id)
known_devices: set[str] = set() known_devices: set[str] = set()
def _check_device() -> None: def _check_device() -> None:

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioamazondevices==6.2.9"] "requirements": ["aioamazondevices==6.2.6"]
} }

View File

@@ -32,9 +32,7 @@ class AmazonSensorEntityDescription(SensorEntityDescription):
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online device.online and device.sensors[key].error is False
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
) )
@@ -42,9 +40,9 @@ SENSORS: Final = (
AmazonSensorEntityDescription( AmazonSensorEntityDescription(
key="temperature", key="temperature",
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement_fn=lambda device, key: ( native_unit_of_measurement_fn=lambda device, _key: (
UnitOfTemperature.CELSIUS UnitOfTemperature.CELSIUS
if key in device.sensors and device.sensors[key].scale == "CELSIUS" if device.sensors[_key].scale == "CELSIUS"
else UnitOfTemperature.FAHRENHEIT else UnitOfTemperature.FAHRENHEIT
), ),
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,

View File

@@ -18,11 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity from .entity import AmazonEntity
from .utils import ( from .utils import alexa_api_call, async_update_unique_id
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@@ -33,9 +29,7 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription):
is_on_fn: Callable[[AmazonDevice], bool] is_on_fn: Callable[[AmazonDevice], bool]
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online device.online and device.sensors[key].error is False
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
) )
method: str method: str
@@ -64,9 +58,6 @@ async def async_setup_entry(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd" hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
) )
# Remove DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator)
known_devices: set[str] = set() known_devices: set[str] = set()
def _check_device() -> None: def _check_device() -> None:

View File

@@ -4,10 +4,8 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps from functools import wraps
from typing import Any, Concatenate from typing import Any, Concatenate
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er import homeassistant.helpers.entity_registry as er
@@ -63,21 +61,3 @@ async def async_update_unique_id(
# Update the registry with the new unique_id # Update the registry with the new unique_id
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
async def async_remove_dnd_from_virtual_group(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
) -> None:
"""Remove entity DND from virtual group."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-do_not_disturb"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SWITCH_DOMAIN, unique_id
)
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
if entity_id and is_group:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)

View File

@@ -65,31 +65,6 @@ SENSOR_DESCRIPTIONS = [
suggested_display_precision=2, suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME280"}, translation_placeholders={"sensor_name": "BME280"},
), ),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.HUMIDITY,
key="BME680_humidity",
translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME680"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PRESSURE,
key="BME680_pressure",
translation_key="pressure",
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.MMHG,
suggested_display_precision=0,
translation_placeholders={"sensor_name": "BME680"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key="BME680_temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME680"},
),
AltruistSensorEntityDescription( AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PRESSURE, device_class=SensorDeviceClass.PRESSURE,
key="BMP_pressure", key="BMP_pressure",

View File

@@ -505,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return detailed information about entities and devices.""" """Return detailed information about entities and devices."""
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
@@ -513,8 +513,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
integration_inputs: dict[str, tuple[list[str], list[str]]] = {} integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
integration_configs: dict[str, AnalyticsModifications] = {} integration_configs: dict[str, AnalyticsModifications] = {}
removed_devices: set[str] = set()
# Get device list # Get device list
for device_entry in dev_reg.devices.values(): for device_entry in dev_reg.devices.values():
if not device_entry.primary_config_entry: if not device_entry.primary_config_entry:
@@ -527,10 +525,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
if config_entry is None: if config_entry is None:
continue continue
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
removed_devices.add(device_entry.id)
continue
integration_domain = config_entry.domain integration_domain = config_entry.domain
integration_input = integration_inputs.setdefault(integration_domain, ([], [])) integration_input = integration_inputs.setdefault(integration_domain, ([], []))
@@ -557,7 +551,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
for domain, integration_info in integration_inputs.items() for domain, integration_info in integration_inputs.items()
if (integration := integrations.get(domain)) is not None if (integration := integrations.get(domain)) is not None
and integration.is_built_in and integration.is_built_in
and integration.manifest.get("integration_type") in ("device", "hub") and integration.integration_type in ("device", "hub")
} }
# Call integrations that implement the analytics platform # Call integrations that implement the analytics platform
@@ -620,15 +614,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
device_config = integration_config.devices.get(device_id, device_config) device_config = integration_config.devices.get(device_id, device_config)
if device_config.remove: if device_config.remove:
removed_devices.add(device_id)
continue continue
device_entry = dev_reg.devices[device_id] device_entry = dev_reg.devices[device_id]
device_id_mapping[device_id] = (integration_domain, len(devices_info)) device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
devices_info.append( devices_info.append(
{ {
"entities": [],
"entry_type": device_entry.entry_type, "entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None, "has_configuration_url": device_entry.configuration_url is not None,
"hw_version": device_entry.hw_version, "hw_version": device_entry.hw_version,
@@ -637,7 +631,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
"model_id": device_entry.model_id, "model_id": device_entry.model_id,
"sw_version": device_entry.sw_version, "sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id, "via_device": device_entry.via_device_id,
"entities": [],
} }
) )
@@ -676,7 +669,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
entity_entry = ent_reg.entities[entity_id] entity_entry = ent_reg.entities[entity_id]
entity_state = hass.states.get(entity_id) entity_state = hass.states.get(entity_entry.entity_id)
entity_info = { entity_info = {
# LIMITATION: `assumed_state` can be overridden by users; # LIMITATION: `assumed_state` can be overridden by users;
@@ -697,18 +690,14 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
"unit_of_measurement": entity_entry.unit_of_measurement, "unit_of_measurement": entity_entry.unit_of_measurement,
} }
if (device_id_ := entity_entry.device_id) is not None:
if device_id_ in removed_devices:
# The device was removed, so we remove the entity too
continue
if ( if (
new_device_id := device_id_mapping.get(device_id_) ((device_id_ := entity_entry.device_id) is not None)
) is not None and (new_device_id[0] == integration_domain): and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
and (new_device_id[0] == integration_domain)
):
device_info = devices_info[new_device_id[1]] device_info = devices_info[new_device_id[1]]
device_info["entities"].append(entity_info) device_info["entities"].append(entity_info)
continue else:
entities_info.append(entity_info) entities_info.append(entity_info)
return { return {

View File

@@ -19,8 +19,9 @@ CONF_THINKING_BUDGET = "thinking_budget"
RECOMMENDED_THINKING_BUDGET = 0 RECOMMENDED_THINKING_BUDGET = 0
MIN_THINKING_BUDGET = 1024 MIN_THINKING_BUDGET = 1024
NON_THINKING_MODELS = [ THINKING_MODELS = [
"claude-3-5", # Both sonnet and haiku "claude-3-7-sonnet",
"claude-3-opus", "claude-sonnet-4-0",
"claude-3-haiku", "claude-opus-4-0",
"claude-opus-4-1",
] ]

View File

@@ -51,11 +51,11 @@ from .const import (
DOMAIN, DOMAIN,
LOGGER, LOGGER,
MIN_THINKING_BUDGET, MIN_THINKING_BUDGET,
NON_THINKING_MODELS,
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS, RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE, RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET,
THINKING_MODELS,
) )
# Max number of back and forth with the LLM to generate a response # Max number of back and forth with the LLM to generate a response
@@ -364,7 +364,7 @@ class AnthropicBaseLLMEntity(Entity):
if tools: if tools:
model_args["tools"] = tools model_args["tools"] = tools
if ( if (
not model.startswith(tuple(NON_THINKING_MODELS)) model.startswith(tuple(THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET and thinking_budget >= MIN_THINKING_BUDGET
): ):
model_args["thinking"] = ThinkingConfigEnabledParam( model_args["thinking"] = ThinkingConfigEnabledParam(

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic", "documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["anthropic==0.69.0"] "requirements": ["anthropic==0.62.0"]
} }

View File

@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any, TypeVar
T = TypeVar("T", dict[str, Any], list[Any], None)
TRANSLATION_MAP = { TRANSLATION_MAP = {
"wan_rx": "sensor_rx_bytes", "wan_rx": "sensor_rx_bytes",
@@ -34,7 +36,7 @@ def clean_dict(raw: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in raw.items() if v is not None or k.endswith("state")} return {k: v for k, v in raw.items() if v is not None or k.endswith("state")}
def translate_to_legacy[T: (dict[str, Any], list[Any], None)](raw: T) -> T: def translate_to_legacy(raw: T) -> T:
"""Translate raw data to legacy format for dicts and lists.""" """Translate raw data to legacy format for dicts and lists."""
if raw is None: if raw is None:

View File

@@ -26,6 +26,9 @@ async def async_setup_entry(
if CONF_HOST in config_entry.data: if CONF_HOST in config_entry.data:
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session) coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
config_entry.async_on_unload(
config_entry.add_update_listener(_async_update_listener)
)
else: else:
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session) coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
@@ -33,11 +36,6 @@ async def async_setup_entry(
config_entry.runtime_data = coordinator config_entry.runtime_data = coordinator
if CONF_HOST in config_entry.data:
config_entry.async_on_unload(
config_entry.add_update_listener(_async_update_listener)
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True return True

View File

@@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import frame from homeassistant.helpers import frame
from homeassistant.util import slugify from homeassistant.util import slugify
from homeassistant.util.async_iterator import AsyncIteratorReader, AsyncIteratorWriter
from . import util from . import util
from .agent import BackupAgent from .agent import BackupAgent
@@ -145,7 +144,7 @@ class DownloadBackupView(HomeAssistantView):
return Response(status=HTTPStatus.NOT_FOUND) return Response(status=HTTPStatus.NOT_FOUND)
else: else:
stream = await agent.async_download_backup(backup_id) stream = await agent.async_download_backup(backup_id)
reader = cast(IO[bytes], AsyncIteratorReader(hass.loop, stream)) reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream))
worker_done_event = asyncio.Event() worker_done_event = asyncio.Event()
@@ -153,7 +152,7 @@ class DownloadBackupView(HomeAssistantView):
"""Call by the worker thread when it's done.""" """Call by the worker thread when it's done."""
hass.loop.call_soon_threadsafe(worker_done_event.set) hass.loop.call_soon_threadsafe(worker_done_event.set)
stream = AsyncIteratorWriter(hass.loop) stream = util.AsyncIteratorWriter(hass)
worker = threading.Thread( worker = threading.Thread(
target=util.decrypt_backup, target=util.decrypt_backup,
args=[backup, reader, stream, password, on_done, 0, []], args=[backup, reader, stream, password, on_done, 0, []],

View File

@@ -38,7 +38,6 @@ from homeassistant.helpers import (
) )
from homeassistant.helpers.json import json_bytes from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util.async_iterator import AsyncIteratorReader
from . import util as backup_util from . import util as backup_util
from .agent import ( from .agent import (
@@ -73,6 +72,7 @@ from .models import (
) )
from .store import BackupStore from .store import BackupStore
from .util import ( from .util import (
AsyncIteratorReader,
DecryptedBackupStreamer, DecryptedBackupStreamer,
EncryptedBackupStreamer, EncryptedBackupStreamer,
make_backup_dir, make_backup_dir,
@@ -1525,7 +1525,7 @@ class BackupManager:
reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb") reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb")
else: else:
backup_stream = await agent.async_download_backup(backup_id) backup_stream = await agent.async_download_backup(backup_id)
reader = cast(IO[bytes], AsyncIteratorReader(self.hass.loop, backup_stream)) reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
try: try:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
validate_password_stream, reader, password validate_password_stream, reader, password

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine from collections.abc import AsyncIterator, Callable, Coroutine
from concurrent.futures import CancelledError, Future
import copy import copy
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from io import BytesIO from io import BytesIO
@@ -13,7 +14,7 @@ from pathlib import Path, PurePath
from queue import SimpleQueue from queue import SimpleQueue
import tarfile import tarfile
import threading import threading
from typing import IO, Any, cast from typing import IO, Any, Self, cast
import aiohttp import aiohttp
from securetar import SecureTarError, SecureTarFile, SecureTarReadError from securetar import SecureTarError, SecureTarFile, SecureTarReadError
@@ -22,11 +23,6 @@ from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.async_iterator import (
Abort,
AsyncIteratorReader,
AsyncIteratorWriter,
)
from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER from .const import BUF_SIZE, LOGGER
@@ -63,6 +59,12 @@ class BackupEmpty(DecryptError):
_message = "No tar files found in the backup." _message = "No tar files found in the backup."
class AbortCipher(HomeAssistantError):
"""Abort the cipher operation."""
_message = "Abort cipher operation."
def make_backup_dir(path: Path) -> None: def make_backup_dir(path: Path) -> None:
"""Create a backup directory if it does not exist.""" """Create a backup directory if it does not exist."""
path.mkdir(exist_ok=True) path.mkdir(exist_ok=True)
@@ -164,6 +166,106 @@ def validate_password(path: Path, password: str | None) -> bool:
return False return False
class AsyncIteratorReader:
"""Wrap an AsyncIterator."""
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
"""Initialize the wrapper."""
self._aborted = False
self._hass = hass
self._stream = stream
self._buffer: bytes | None = None
self._next_future: Future[bytes | None] | None = None
self._pos: int = 0
async def _next(self) -> bytes | None:
"""Get the next chunk from the iterator."""
return await anext(self._stream, None)
def abort(self) -> None:
"""Abort the reader."""
self._aborted = True
if self._next_future is not None:
self._next_future.cancel()
def read(self, n: int = -1, /) -> bytes:
"""Read data from the iterator."""
result = bytearray()
while n < 0 or len(result) < n:
if not self._buffer:
self._next_future = asyncio.run_coroutine_threadsafe(
self._next(), self._hass.loop
)
if self._aborted:
self._next_future.cancel()
raise AbortCipher
try:
self._buffer = self._next_future.result()
except CancelledError as err:
raise AbortCipher from err
self._pos = 0
if not self._buffer:
# The stream is exhausted
break
chunk = self._buffer[self._pos : self._pos + n]
result.extend(chunk)
n -= len(chunk)
self._pos += len(chunk)
if self._pos == len(self._buffer):
self._buffer = None
return bytes(result)
def close(self) -> None:
"""Close the iterator."""
class AsyncIteratorWriter:
"""Wrap an AsyncIterator."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper."""
self._aborted = False
self._hass = hass
self._pos: int = 0
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
self._write_future: Future[bytes | None] | None = None
def __aiter__(self) -> Self:
"""Return the iterator."""
return self
async def __anext__(self) -> bytes:
"""Get the next chunk from the iterator."""
if data := await self._queue.get():
return data
raise StopAsyncIteration
def abort(self) -> None:
"""Abort the writer."""
self._aborted = True
if self._write_future is not None:
self._write_future.cancel()
def tell(self) -> int:
"""Return the current position in the iterator."""
return self._pos
def write(self, s: bytes, /) -> int:
"""Write data to the iterator."""
self._write_future = asyncio.run_coroutine_threadsafe(
self._queue.put(s), self._hass.loop
)
if self._aborted:
self._write_future.cancel()
raise AbortCipher
try:
self._write_future.result()
except CancelledError as err:
raise AbortCipher from err
self._pos += len(s)
return len(s)
def validate_password_stream( def validate_password_stream(
input_stream: IO[bytes], input_stream: IO[bytes],
password: str | None, password: str | None,
@@ -240,7 +342,7 @@ def decrypt_backup(
finally: finally:
# Write an empty chunk to signal the end of the stream # Write an empty chunk to signal the end of the stream
output_stream.write(b"") output_stream.write(b"")
except Abort: except AbortCipher:
LOGGER.debug("Cipher operation aborted") LOGGER.debug("Cipher operation aborted")
finally: finally:
on_done(error) on_done(error)
@@ -328,7 +430,7 @@ def encrypt_backup(
finally: finally:
# Write an empty chunk to signal the end of the stream # Write an empty chunk to signal the end of the stream
output_stream.write(b"") output_stream.write(b"")
except Abort: except AbortCipher:
LOGGER.debug("Cipher operation aborted") LOGGER.debug("Cipher operation aborted")
finally: finally:
on_done(error) on_done(error)
@@ -455,8 +557,8 @@ class _CipherBackupStreamer:
self._hass.loop.call_soon_threadsafe(worker_status.done.set) self._hass.loop.call_soon_threadsafe(worker_status.done.set)
stream = await self._open_stream() stream = await self._open_stream()
reader = AsyncIteratorReader(self._hass.loop, stream) reader = AsyncIteratorReader(self._hass, stream)
writer = AsyncIteratorWriter(self._hass.loop) writer = AsyncIteratorWriter(self._hass)
worker = threading.Thread( worker = threading.Thread(
target=self._cipher_func, target=self._cipher_func,
args=[ args=[

View File

@@ -73,12 +73,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
# Add the websocket and API client # Add the websocket and API client
entry.runtime_data = BangOlufsenData(websocket, client) entry.runtime_data = BangOlufsenData(websocket, client)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Start WebSocket connection
# Start WebSocket connection once the platforms have been loaded.
# This ensures that the initial WebSocket notifications are dispatched to entities
await client.connect_notifications(remote_control=True, reconnect=True) await client.connect_notifications(remote_control=True, reconnect=True)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@@ -125,8 +125,7 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
new_entities=[ new_entities=[
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client) BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
], ]
update_before_add=True,
) )
# Register actions. # Register actions.
@@ -267,8 +266,34 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._software_status.software_version, self._software_status.software_version,
) )
# Get overall device state once. This is handled by WebSocket events the rest of the time.
product_state = await self._client.get_product_state()
# Get volume information.
if product_state.volume:
self._volume = product_state.volume
# Get all playback information.
# Ensure that the metadata is not None upon startup
if product_state.playback:
if product_state.playback.metadata:
self._playback_metadata = product_state.playback.metadata
self._remote_leader = product_state.playback.metadata.remote_leader
if product_state.playback.progress:
self._playback_progress = product_state.playback.progress
if product_state.playback.source:
self._source_change = product_state.playback.source
if product_state.playback.state:
self._playback_state = product_state.playback.state
# Set initial state
if self._playback_state.value:
self._state = self._playback_state.value
self._attr_media_position_updated_at = utcnow() self._attr_media_position_updated_at = utcnow()
# Get the highest resolution available of the given images.
self._media_image = get_highest_resolution_artwork(self._playback_metadata)
# If the device has been updated with new sources, then the API will fail here. # If the device has been updated with new sources, then the API will fail here.
await self._async_update_sources() await self._async_update_sources()

View File

@@ -272,13 +272,6 @@ async def async_setup_entry(
observations: list[ConfigType] = [ observations: list[ConfigType] = [
dict(subentry.data) for subentry in config_entry.subentries.values() dict(subentry.data) for subentry in config_entry.subentries.values()
] ]
for observation in observations:
if observation[CONF_PLATFORM] == CONF_TEMPLATE:
observation[CONF_VALUE_TEMPLATE] = Template(
observation[CONF_VALUE_TEMPLATE], hass
)
prior: float = config[CONF_PRIOR] prior: float = config[CONF_PRIOR]
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD] probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)

View File

@@ -19,8 +19,8 @@
"bleak-retry-connector==4.4.3", "bleak-retry-connector==4.4.3",
"bluetooth-adapters==2.1.0", "bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3", "bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.3", "bluetooth-data-tools==1.28.2",
"dbus-fast==2.44.5", "dbus-fast==2.44.3",
"habluetooth==5.7.0" "habluetooth==5.6.4"
] ]
} }

View File

@@ -315,7 +315,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.register_view(CalendarListView(component)) hass.http.register_view(CalendarListView(component))
hass.http.register_view(CalendarEventView(component)) hass.http.register_view(CalendarEventView(component))
frontend.async_register_built_in_panel(hass, "calendar", "calendar", "mdi:calendar") frontend.async_register_built_in_panel(
hass, "calendar", "calendar", "hass:calendar"
)
websocket_api.async_register_command(hass, handle_calendar_event_create) websocket_api.async_register_command(hass, handle_calendar_event_create)
websocket_api.async_register_command(hass, handle_calendar_event_delete) websocket_api.async_register_command(hass, handle_calendar_event_delete)

View File

@@ -51,6 +51,12 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
@@ -112,6 +118,12 @@ ATTR_FILENAME: Final = "filename"
ATTR_MEDIA_PLAYER: Final = "media_player" ATTR_MEDIA_PLAYER: Final = "media_player"
ATTR_FORMAT: Final = "format" ATTR_FORMAT: Final = "format"
# These constants are deprecated as of Home Assistant 2024.10
# Please use the StreamType enum instead.
_DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10")
_DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10")
_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10")
class CameraEntityFeature(IntFlag): class CameraEntityFeature(IntFlag):
"""Supported features of the camera entity.""" """Supported features of the camera entity."""
@@ -1105,3 +1117,11 @@ async def async_handle_record_service(
duration=service_call.data[CONF_DURATION], duration=service_call.data[CONF_DURATION],
lookback=service_call.data[CONF_LOOKBACK], lookback=service_call.data[CONF_LOOKBACK],
) )
# 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())

View File

@@ -53,6 +53,7 @@ from .const import (
CONF_ACME_SERVER, CONF_ACME_SERVER,
CONF_ALEXA, CONF_ALEXA,
CONF_ALIASES, CONF_ALIASES,
CONF_CLOUDHOOK_SERVER,
CONF_COGNITO_CLIENT_ID, CONF_COGNITO_CLIENT_ID,
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
CONF_FILTER, CONF_FILTER,
@@ -129,6 +130,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
vol.Optional(CONF_ACCOUNTS_SERVER): str, vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str, vol.Optional(CONF_ACME_SERVER): str,
vol.Optional(CONF_CLOUDHOOK_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str,
vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str,
vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str,

View File

@@ -78,6 +78,7 @@ CONF_USER_POOL_ID = "user_pool_id"
CONF_ACCOUNT_LINK_SERVER = "account_link_server" CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server" CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server" CONF_ACME_SERVER = "acme_server"
CONF_CLOUDHOOK_SERVER = "cloudhook_server"
CONF_RELAYER_SERVER = "relayer_server" CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_REMOTESTATE_SERVER = "remotestate_server"
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"

View File

@@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.2.0"], "requirements": ["hass-nabucasa==1.1.2"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -1,106 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
Stale docstring and test name: `test_form_home` and reusing result.
Extract `async_setup_entry` into own fixture.
Avoid importing `config_flow` in tests.
Test reauth with errors
config-flow:
status: todo
comment: |
The config flow misses data descriptions.
Remove URLs from data descriptions, they should be replaced with placeholders.
Make use of Electricity Maps zone keys in country code as dropdown.
Make use of location selector for coordinates.
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any 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: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: todo
# Silver
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
The integration does not provide any additional options.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: done
test-coverage:
status: todo
comment: |
Use `hass.config_entries.async_setup` instead of assert await `async_setup_component(hass, DOMAIN, {})`
`test_sensor` could use `snapshot_platform`
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
This integration cannot be discovered, it is a connecting to a cloud service.
discovery:
status: exempt
comment: |
This integration cannot be discovered, it is a connecting to a cloud service.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
The integration connects to a single service per configuration entry.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not raise any repairable issues.
stale-devices:
status: exempt
comment: |
This integration connect to a single device per configuration entry.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from asyncio.exceptions import TimeoutError from asyncio.exceptions import TimeoutError
from collections.abc import Mapping from collections.abc import Mapping
import re
from typing import Any from typing import Any
from aiocomelit import ( from aiocomelit import (
@@ -28,20 +27,25 @@ from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252" DEFAULT_HOST = "192.168.1.252"
DEFAULT_PIN = "111111" DEFAULT_PIN = "111111"
pin_regex = r"^[0-9]{4,10}$"
USER_SCHEMA = vol.Schema( USER_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string, vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
} }
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string}) STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_PIN): cv.matches_regex(pin_regex)}
)
STEP_RECONFIGURE = vol.Schema( STEP_RECONFIGURE = vol.Schema(
{ {
vol.Required(CONF_HOST): cv.string, vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port, vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string, vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
} }
) )
@@ -51,9 +55,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
api: ComelitCommonApi api: ComelitCommonApi
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_PIN]):
raise InvalidPin
session = await async_client_session(hass) session = await async_client_session(hass)
if data.get(CONF_TYPE, BRIDGE) == BRIDGE: if data.get(CONF_TYPE, BRIDGE) == BRIDGE:
api = ComeliteSerialBridgeApi( api = ComeliteSerialBridgeApi(
@@ -104,8 +105,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
@@ -147,8 +146,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
@@ -192,8 +189,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
@@ -215,7 +210,3 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError): class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth.""" """Error to indicate there is invalid auth."""
class InvalidPin(HomeAssistantError):
"""Error to indicate an invalid pin."""

View File

@@ -161,7 +161,7 @@ class ComelitSerialBridge(
entry: ComelitConfigEntry, entry: ComelitConfigEntry,
host: str, host: str,
port: int, port: int,
pin: str, pin: int,
session: ClientSession, session: ClientSession,
) -> None: ) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""
@@ -195,7 +195,7 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
entry: ComelitConfigEntry, entry: ComelitConfigEntry,
host: str, host: str,
port: int, port: int,
pin: str, pin: int,
session: ClientSession, session: ClientSession,
) -> None: ) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiocomelit"], "loggers": ["aiocomelit"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiocomelit==1.1.1"] "requirements": ["aiocomelit==0.12.3"]
} }

View File

@@ -43,13 +43,11 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },

View File

@@ -49,7 +49,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the config component.""" """Set up the config component."""
frontend.async_register_built_in_panel( frontend.async_register_built_in_panel(
hass, "config", "config", "mdi:cog", require_admin=True hass, "config", "config", "hass:cog", require_admin=True
) )
for panel in SECTIONS: for panel in SECTIONS:

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from http import HTTPStatus from http import HTTPStatus
import logging
from typing import Any, NoReturn from typing import Any, NoReturn
from aiohttp import web from aiohttp import web
@@ -24,12 +23,7 @@ from homeassistant.helpers.data_entry_flow import (
FlowManagerResourceView, FlowManagerResourceView,
) )
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.json import ( from homeassistant.helpers.json import json_fragment
JSON_DUMP,
find_paths_unserializable_data,
json_bytes,
json_fragment,
)
from homeassistant.loader import ( from homeassistant.loader import (
Integration, Integration,
IntegrationNotFound, IntegrationNotFound,
@@ -37,9 +31,6 @@ from homeassistant.loader import (
async_get_integrations, async_get_integrations,
async_get_loaded_integration, async_get_loaded_integration,
) )
from homeassistant.util.json import format_unserializable_data
_LOGGER = logging.getLogger(__name__)
@callback @callback
@@ -411,40 +402,18 @@ def config_entries_flow_subscribe(
connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
async_on_flow_init_remove async_on_flow_init_remove
) )
try: connection.send_message(
serialized_flows = [ websocket_api.event_message(
json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw}) msg["id"],
[
{"type": None, "flow_id": flw["flow_id"], "flow": flw}
for flw in hass.config_entries.flow.async_progress() for flw in hass.config_entries.flow.async_progress()
if flw["context"]["source"] if flw["context"]["source"]
not in ( not in (
config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_RECONFIGURE,
config_entries.SOURCE_USER, config_entries.SOURCE_USER,
) )
] ],
except (ValueError, TypeError):
# If we can't serialize, we'll filter out unserializable flows
serialized_flows = []
for flw in hass.config_entries.flow.async_progress():
if flw["context"]["source"] in (
config_entries.SOURCE_RECONFIGURE,
config_entries.SOURCE_USER,
):
continue
try:
serialized_flows.append(
json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
)
except (ValueError, TypeError):
_LOGGER.error(
"Unable to serialize to JSON. Bad data found at %s",
format_unserializable_data(
find_paths_unserializable_data(flw, dump=JSON_DUMP)
),
)
continue
connection.send_message(
websocket_api.messages.construct_event_message(
msg["id"], b"".join((b"[", b",".join(serialized_flows), b"]"))
) )
) )
connection.send_result(msg["id"]) connection.send_result(msg["id"])

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity", "integration_type": "entity",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.10.1"] "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"]
} }

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pycync==0.4.1"] "requirements": ["pycync==0.4.0"]
} }

View File

@@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.util.ssl import client_context_no_verify from homeassistant.util.ssl import client_context_no_verify
from .const import KEY_MAC, TIMEOUT_SEC from .const import KEY_MAC, TIMEOUT
from .coordinator import DaikinConfigEntry, DaikinCoordinator from .coordinator import DaikinConfigEntry, DaikinCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
host = conf[CONF_HOST] host = conf[CONF_HOST]
try: try:
async with asyncio.timeout(TIMEOUT_SEC): async with asyncio.timeout(TIMEOUT):
device: Appliance = await DaikinFactory( device: Appliance = await DaikinFactory(
host, host,
session, session,
@@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
) )
_LOGGER.debug("Connection to %s successful", host) _LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err: except TimeoutError as err:
_LOGGER.debug("Connection to %s timed out in %s seconds", host, TIMEOUT_SEC) _LOGGER.debug("Connection to %s timed out in 60 seconds", host)
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except ClientConnectionError as err: except ClientConnectionError as err:
_LOGGER.debug("ClientConnectionError to %s", host) _LOGGER.debug("ClientConnectionError to %s", host)

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.ssl import client_context_no_verify from homeassistant.util.ssl import client_context_no_verify
from .const import DOMAIN, KEY_MAC, TIMEOUT_SEC from .const import DOMAIN, KEY_MAC, TIMEOUT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -84,7 +84,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
password = None password = None
try: try:
async with asyncio.timeout(TIMEOUT_SEC): async with asyncio.timeout(TIMEOUT):
device: Appliance = await DaikinFactory( device: Appliance = await DaikinFactory(
host, host,
async_get_clientsession(self.hass), async_get_clientsession(self.hass),

View File

@@ -24,4 +24,4 @@ ATTR_STATE_OFF = "off"
KEY_MAC = "mac" KEY_MAC = "mac"
KEY_IP = "ip" KEY_IP = "ip"
TIMEOUT_SEC = 120 TIMEOUT = 60

View File

@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, TIMEOUT_SEC from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
_LOGGER, _LOGGER,
config_entry=entry, config_entry=entry,
name=device.values.get("name", DOMAIN), name=device.values.get("name", DOMAIN),
update_interval=timedelta(seconds=TIMEOUT_SEC), update_interval=timedelta(seconds=60),
) )
self.device = device self.device = device

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin", "documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pydaikin"], "loggers": ["pydaikin"],
"requirements": ["pydaikin==2.17.1"], "requirements": ["pydaikin==2.16.0"],
"zeroconf": ["_dkapi._tcp.local."] "zeroconf": ["_dkapi._tcp.local."]
} }

View File

@@ -126,7 +126,7 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
self._attr_translation_key = "button" self._attr_translation_key = "button"
self._attr_translation_placeholders = {"key": str(key)} self._attr_translation_placeholders = {"key": str(key)}
def sync_callback(self, message: tuple) -> None: def _sync(self, message: tuple) -> None:
"""Update the binary sensor state.""" """Update the binary sensor state."""
if ( if (
message[0] == self._remote_control_property.element_uid message[0] == self._remote_control_property.element_uid

View File

@@ -48,6 +48,7 @@ class DevoloDeviceEntity(Entity):
) )
self.subscriber: Subscriber | None = None self.subscriber: Subscriber | None = None
self.sync_callback = self._sync
self._value: float self._value: float
@@ -68,7 +69,7 @@ class DevoloDeviceEntity(Entity):
self._device_instance.uid, self.subscriber self._device_instance.uid, self.subscriber
) )
def sync_callback(self, message: tuple) -> None: def _sync(self, message: tuple) -> None:
"""Update the state.""" """Update the state."""
if message[0] == self._attr_unique_id: if message[0] == self._attr_unique_id:
self._value = message[1] self._value = message[1]

View File

@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
""" """
return f"{self._attr_unique_id}_{self._sensor_type}" return f"{self._attr_unique_id}_{self._sensor_type}"
def sync_callback(self, message: tuple) -> None: def _sync(self, message: tuple) -> None:
"""Update the consumption sensor state.""" """Update the consumption sensor state."""
if message[0] == self._attr_unique_id: if message[0] == self._attr_unique_id:
self._value = getattr( self._value = getattr(

View File

@@ -13,3 +13,8 @@ class Subscriber:
"""Initiate the subscriber.""" """Initiate the subscriber."""
self.name = name self.name = name
self.callback = callback self.callback = callback
def update(self, message: str) -> None:
"""Trigger hass to update the device."""
_LOGGER.debug('%s got message "%s"', self.name, message)
self.callback(message)

View File

@@ -64,7 +64,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity):
"""Switch off the device.""" """Switch off the device."""
self._binary_switch_property.set(state=False) self._binary_switch_property.set(state=False)
def sync_callback(self, message: tuple) -> None: def _sync(self, message: tuple) -> None:
"""Update the binary switch state and consumption.""" """Update the binary switch state and consumption."""
if message[0].startswith("devolo.BinarySwitch"): if message[0].startswith("devolo.BinarySwitch"):
self._attr_is_on = self._device_instance.binary_switch_property[ self._attr_is_on = self._device_instance.binary_switch_property[

View File

@@ -17,6 +17,6 @@
"requirements": [ "requirements": [
"aiodhcpwatcher==1.2.1", "aiodhcpwatcher==1.2.1",
"aiodiscover==2.7.1", "aiodiscover==2.7.1",
"cached-ipaddress==1.0.1" "cached-ipaddress==0.10.0"
] ]
} }

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address from ipaddress import IPv4Address, IPv6Address
import logging import logging
@@ -56,16 +55,16 @@ async def async_setup_entry(
hostname = entry.data[CONF_HOSTNAME] hostname = entry.data[CONF_HOSTNAME]
name = entry.data[CONF_NAME] name = entry.data[CONF_NAME]
nameserver_ipv4 = entry.options[CONF_RESOLVER] resolver_ipv4 = entry.options[CONF_RESOLVER]
nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6] resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
port_ipv4 = entry.options[CONF_PORT] port_ipv4 = entry.options[CONF_PORT]
port_ipv6 = entry.options[CONF_PORT_IPV6] port_ipv6 = entry.options[CONF_PORT_IPV6]
entities = [] entities = []
if entry.data[CONF_IPV4]: if entry.data[CONF_IPV4]:
entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4)) entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4))
if entry.data[CONF_IPV6]: if entry.data[CONF_IPV6]:
entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6)) entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6))
async_add_entities(entities, update_before_add=True) async_add_entities(entities, update_before_add=True)
@@ -77,13 +76,11 @@ class WanIpSensor(SensorEntity):
_attr_translation_key = "dnsip" _attr_translation_key = "dnsip"
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"}) _unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
resolver: aiodns.DNSResolver
def __init__( def __init__(
self, self,
name: str, name: str,
hostname: str, hostname: str,
nameserver: str, resolver: str,
ipv6: bool, ipv6: bool,
port: int, port: int,
) -> None: ) -> None:
@@ -91,12 +88,12 @@ class WanIpSensor(SensorEntity):
self._attr_name = "IPv6" if ipv6 else None self._attr_name = "IPv6" if ipv6 else None
self._attr_unique_id = f"{hostname}_{ipv6}" self._attr_unique_id = f"{hostname}_{ipv6}"
self.hostname = hostname self.hostname = hostname
self.port = port self.resolver = aiodns.DNSResolver(tcp_port=port, udp_port=port)
self.nameserver = nameserver self.resolver.nameservers = [resolver]
self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
self._retries = DEFAULT_RETRIES self._retries = DEFAULT_RETRIES
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
"resolver": nameserver, "resolver": resolver,
"querytype": self.querytype, "querytype": self.querytype,
} }
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
@@ -106,26 +103,14 @@ class WanIpSensor(SensorEntity):
model=aiodns.__version__, model=aiodns.__version__,
name=name, name=name,
) )
self.create_dns_resolver()
def create_dns_resolver(self) -> None:
"""Create the DNS resolver."""
self.resolver = aiodns.DNSResolver(
nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the current DNS IP address for hostname.""" """Get the current DNS IP address for hostname."""
if self.resolver._closed: # noqa: SLF001
self.create_dns_resolver()
response = None
try: try:
async with asyncio.timeout(10):
response = await self.resolver.query(self.hostname, self.querytype) response = await self.resolver.query(self.hostname, self.querytype)
except TimeoutError:
await self.resolver.close()
except DNSError as err: except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err) _LOGGER.warning("Exception while resolving host: %s", err)
response = None
if response: if response:
sorted_ips = sort_ips( sorted_ips = sort_ips(

View File

@@ -116,11 +116,7 @@ class EbusdData:
try: try:
_LOGGER.debug("Opening socket to ebusd %s", name) _LOGGER.debug("Opening socket to ebusd %s", name)
command_result = ebusdpy.write(self._address, self._circuit, name, value) command_result = ebusdpy.write(self._address, self._circuit, name, value)
if ( if command_result is not None and "done" not in command_result:
command_result is not None
and "done" not in command_result
and "empty" not in command_result
):
_LOGGER.warning("Write command failed: %s", name) _LOGGER.warning("Write command failed: %s", name)
except RuntimeError as err: except RuntimeError as err:
_LOGGER.error(err) _LOGGER.error(err)

View File

@@ -176,7 +176,7 @@
"description": "Sets the participating sensors for a climate program.", "description": "Sets the participating sensors for a climate program.",
"fields": { "fields": {
"preset_mode": { "preset_mode": {
"name": "Climate program", "name": "Climate Name",
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program." "description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
}, },
"device_ids": { "device_ids": {
@@ -188,7 +188,7 @@
}, },
"exceptions": { "exceptions": {
"invalid_preset": { "invalid_preset": {
"message": "Invalid climate program, available options are: {options}" "message": "Invalid climate name, available options are: {options}"
}, },
"invalid_sensor": { "invalid_sensor": {
"message": "Invalid sensor for thermostat, available options are: {options}" "message": "Invalid sensor for thermostat, available options are: {options}"

View File

@@ -116,9 +116,6 @@
} }
}, },
"select": { "select": {
"active_map": {
"default": "mdi:floor-plan"
},
"water_amount": { "water_amount": {
"default": "mdi:water" "default": "mdi:water"
}, },

View File

@@ -2,13 +2,12 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import Any
from deebot_client.capabilities import CapabilityMap, CapabilitySet, CapabilitySetTypes from deebot_client.capabilities import CapabilitySetTypes
from deebot_client.device import Device from deebot_client.device import Device
from deebot_client.events import WorkModeEvent from deebot_client.events import WorkModeEvent
from deebot_client.events.base import Event from deebot_client.events.base import Event
from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent
from deebot_client.events.water_info import WaterAmountEvent from deebot_client.events.water_info import WaterAmountEvent
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
@@ -17,11 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry from . import EcovacsConfigEntry
from .entity import ( from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
EcovacsEntity,
)
from .util import get_name_key, get_supported_entities from .util import get_name_key, get_supported_entities
@@ -71,12 +66,6 @@ async def async_setup_entry(
entities = get_supported_entities( entities = get_supported_entities(
controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS
) )
entities.extend(
EcovacsActiveMapSelectEntity(device, device.capabilities.map)
for device in controller.devices
if (map_cap := device.capabilities.map)
and isinstance(map_cap.major, CapabilitySet)
)
if entities: if entities:
async_add_entities(entities) async_add_entities(entities)
@@ -114,76 +103,3 @@ class EcovacsSelectEntity[EventT: Event](
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
await self._device.execute_command(self._capability.set(option)) await self._device.execute_command(self._capability.set(option))
class EcovacsActiveMapSelectEntity(
EcovacsEntity[CapabilityMap],
SelectEntity,
):
"""Ecovacs active map select entity."""
entity_description = SelectEntityDescription(
key="active_map",
translation_key="active_map",
entity_category=EntityCategory.CONFIG,
)
def __init__(
self,
device: Device,
capability: CapabilityMap,
**kwargs: Any,
) -> None:
"""Initialize entity."""
super().__init__(device, capability, **kwargs)
self._option_to_id: dict[str, str] = {}
self._id_to_option: dict[str, str] = {}
self._handle_on_cached_map(
device.events.get_last_event(CachedMapInfoEvent)
or CachedMapInfoEvent(set())
)
def _handle_on_cached_map(self, event: CachedMapInfoEvent) -> None:
self._id_to_option.clear()
self._option_to_id.clear()
for map_info in event.maps:
name = map_info.name if map_info.name else map_info.id
self._id_to_option[map_info.id] = name
self._option_to_id[name] = map_info.id
if map_info.using:
self._attr_current_option = name
if self._attr_current_option not in self._option_to_id:
self._attr_current_option = None
# Sort named maps first, then numeric IDs (unnamed maps during building) in ascending order.
self._attr_options = sorted(
self._option_to_id.keys(), key=lambda x: (x.isdigit(), x.lower())
)
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
async def on_cached_map(event: CachedMapInfoEvent) -> None:
self._handle_on_cached_map(event)
self.async_write_ha_state()
self._subscribe(self._capability.cached_info.event, on_cached_map)
async def on_major_map(event: MajorMapEvent) -> None:
self._attr_current_option = self._id_to_option.get(event.map_id)
self.async_write_ha_state()
self._subscribe(self._capability.major.event, on_major_map)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
if TYPE_CHECKING:
assert isinstance(self._capability.major, CapabilitySet)
await self._device.execute_command(
self._capability.major.set(self._option_to_id[option])
)

View File

@@ -2,4 +2,3 @@ raw_get_positions:
target: target:
entity: entity:
domain: vacuum domain: vacuum
integration: ecovacs

View File

@@ -178,9 +178,6 @@
} }
}, },
"select": { "select": {
"active_map": {
"name": "Active map"
},
"water_amount": { "water_amount": {
"name": "[%key:component::ecovacs::entity::number::water_amount::name%]", "name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
"state": { "state": {

View File

@@ -6,5 +6,5 @@
"dependencies": ["webhook"], "dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt", "documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["aioecowitt==2025.9.2"] "requirements": ["aioecowitt==2025.9.1"]
} }

View File

@@ -7,7 +7,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyenphase==2.4.0"], "requirements": ["pyenphase==2.3.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@@ -396,7 +396,6 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None, int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
] ]
on_phase: str | None on_phase: str | None
cttype: str | None = None
CT_NET_CONSUMPTION_SENSORS = ( CT_NET_CONSUMPTION_SENSORS = (
@@ -410,7 +409,6 @@ CT_NET_CONSUMPTION_SENSORS = (
suggested_display_precision=3, suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"), value_fn=attrgetter("energy_delivered"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="lifetime_net_production", key="lifetime_net_production",
@@ -422,7 +420,6 @@ CT_NET_CONSUMPTION_SENSORS = (
suggested_display_precision=3, suggested_display_precision=3,
value_fn=attrgetter("energy_received"), value_fn=attrgetter("energy_received"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="net_consumption", key="net_consumption",
@@ -434,7 +431,6 @@ CT_NET_CONSUMPTION_SENSORS = (
suggested_display_precision=3, suggested_display_precision=3,
value_fn=attrgetter("active_power"), value_fn=attrgetter("active_power"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="frequency", key="frequency",
@@ -446,7 +442,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"), value_fn=attrgetter("frequency"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="voltage", key="voltage",
@@ -459,7 +454,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"), value_fn=attrgetter("voltage"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="net_ct_current", key="net_ct_current",
@@ -472,7 +466,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("current"), value_fn=attrgetter("current"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="net_ct_powerfactor", key="net_ct_powerfactor",
@@ -483,7 +476,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"), value_fn=attrgetter("power_factor"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="net_consumption_ct_metering_status", key="net_consumption_ct_metering_status",
@@ -494,7 +486,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"), value_fn=attrgetter("metering_status"),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="net_consumption_ct_status_flags", key="net_consumption_ct_status_flags",
@@ -504,7 +495,6 @@ CT_NET_CONSUMPTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None, on_phase=None,
cttype=CtType.NET_CONSUMPTION,
), ),
) )
@@ -535,7 +525,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"), value_fn=attrgetter("frequency"),
on_phase=None, on_phase=None,
cttype=CtType.PRODUCTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="production_ct_voltage", key="production_ct_voltage",
@@ -548,7 +537,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"), value_fn=attrgetter("voltage"),
on_phase=None, on_phase=None,
cttype=CtType.PRODUCTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="production_ct_current", key="production_ct_current",
@@ -561,7 +549,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("current"), value_fn=attrgetter("current"),
on_phase=None, on_phase=None,
cttype=CtType.PRODUCTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="production_ct_powerfactor", key="production_ct_powerfactor",
@@ -572,7 +559,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"), value_fn=attrgetter("power_factor"),
on_phase=None, on_phase=None,
cttype=CtType.PRODUCTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="production_ct_metering_status", key="production_ct_metering_status",
@@ -583,7 +569,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"), value_fn=attrgetter("metering_status"),
on_phase=None, on_phase=None,
cttype=CtType.PRODUCTION,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="production_ct_status_flags", key="production_ct_status_flags",
@@ -593,7 +578,6 @@ CT_PRODUCTION_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None, on_phase=None,
cttype=CtType.PRODUCTION,
), ),
) )
@@ -623,7 +607,6 @@ CT_STORAGE_SENSORS = (
suggested_display_precision=3, suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"), value_fn=attrgetter("energy_delivered"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="lifetime_battery_charged", key="lifetime_battery_charged",
@@ -635,7 +618,6 @@ CT_STORAGE_SENSORS = (
suggested_display_precision=3, suggested_display_precision=3,
value_fn=attrgetter("energy_received"), value_fn=attrgetter("energy_received"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="battery_discharge", key="battery_discharge",
@@ -647,7 +629,6 @@ CT_STORAGE_SENSORS = (
suggested_display_precision=3, suggested_display_precision=3,
value_fn=attrgetter("active_power"), value_fn=attrgetter("active_power"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="storage_ct_frequency", key="storage_ct_frequency",
@@ -659,7 +640,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"), value_fn=attrgetter("frequency"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="storage_voltage", key="storage_voltage",
@@ -672,7 +652,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"), value_fn=attrgetter("voltage"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="storage_ct_current", key="storage_ct_current",
@@ -685,7 +664,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("current"), value_fn=attrgetter("current"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="storage_ct_powerfactor", key="storage_ct_powerfactor",
@@ -696,7 +674,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"), value_fn=attrgetter("power_factor"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="storage_ct_metering_status", key="storage_ct_metering_status",
@@ -707,7 +684,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"), value_fn=attrgetter("metering_status"),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
EnvoyCTSensorEntityDescription( EnvoyCTSensorEntityDescription(
key="storage_ct_status_flags", key="storage_ct_status_flags",
@@ -717,7 +693,6 @@ CT_STORAGE_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None, on_phase=None,
cttype=CtType.STORAGE,
), ),
) )
@@ -1040,31 +1015,50 @@ async def async_setup_entry(
for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase] for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase]
if phase is not None if phase is not None
) )
# Add Current Transformer entities # Add net consumption CT entities
if envoy_data.ctmeters: if ctmeter := envoy_data.ctmeter_consumption:
entities.extend( entities.extend(
EnvoyCTEntity(coordinator, description) EnvoyConsumptionCTEntity(coordinator, description)
for sensors in ( for description in CT_NET_CONSUMPTION_SENSORS
CT_NET_CONSUMPTION_SENSORS, if ctmeter.measurement_type == CtType.NET_CONSUMPTION
CT_PRODUCTION_SENSORS,
CT_STORAGE_SENSORS,
) )
for description in sensors # For each net consumption ct phase reported add net consumption entities
if description.cttype in envoy_data.ctmeters if phase_data := envoy_data.ctmeter_consumption_phases:
)
# Add Current Transformer phase entities
if ctmeters_phases := envoy_data.ctmeters_phases:
entities.extend( entities.extend(
EnvoyCTPhaseEntity(coordinator, description) EnvoyConsumptionCTPhaseEntity(coordinator, description)
for sensors in ( for use_phase, phase in phase_data.items()
CT_NET_CONSUMPTION_PHASE_SENSORS, for description in CT_NET_CONSUMPTION_PHASE_SENSORS[use_phase]
CT_PRODUCTION_PHASE_SENSORS, if phase.measurement_type == CtType.NET_CONSUMPTION
CT_STORAGE_PHASE_SENSORS,
) )
for phase, descriptions in sensors.items() # Add production CT entities
for description in descriptions if ctmeter := envoy_data.ctmeter_production:
if (cttype := description.cttype) in ctmeters_phases entities.extend(
and phase in ctmeters_phases[cttype] EnvoyProductionCTEntity(coordinator, description)
for description in CT_PRODUCTION_SENSORS
if ctmeter.measurement_type == CtType.PRODUCTION
)
# For each production ct phase reported add production ct entities
if phase_data := envoy_data.ctmeter_production_phases:
entities.extend(
EnvoyProductionCTPhaseEntity(coordinator, description)
for use_phase, phase in phase_data.items()
for description in CT_PRODUCTION_PHASE_SENSORS[use_phase]
if phase.measurement_type == CtType.PRODUCTION
)
# Add storage CT entities
if ctmeter := envoy_data.ctmeter_storage:
entities.extend(
EnvoyStorageCTEntity(coordinator, description)
for description in CT_STORAGE_SENSORS
if ctmeter.measurement_type == CtType.STORAGE
)
# For each storage ct phase reported add storage ct entities
if phase_data := envoy_data.ctmeter_storage_phases:
entities.extend(
EnvoyStorageCTPhaseEntity(coordinator, description)
for use_phase, phase in phase_data.items()
for description in CT_STORAGE_PHASE_SENSORS[use_phase]
if phase.measurement_type == CtType.STORAGE
) )
if envoy_data.inverters: if envoy_data.inverters:
@@ -1251,8 +1245,8 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity):
return self.entity_description.value_fn(system_net_consumption) return self.entity_description.value_fn(system_net_consumption)
class EnvoyCTEntity(EnvoySystemSensorEntity): class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity):
"""Envoy CT entity.""" """Envoy net consumption CT entity."""
entity_description: EnvoyCTSensorEntityDescription entity_description: EnvoyCTSensorEntityDescription
@@ -1261,13 +1255,13 @@ class EnvoyCTEntity(EnvoySystemSensorEntity):
self, self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT sensor.""" """Return the state of the CT sensor."""
if (cttype := self.entity_description.cttype) not in self.data.ctmeters: if (ctmeter := self.data.ctmeter_consumption) is None:
return None return None
return self.entity_description.value_fn(self.data.ctmeters[cttype]) return self.entity_description.value_fn(ctmeter)
class EnvoyCTPhaseEntity(EnvoySystemSensorEntity): class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity):
"""Envoy CT phase entity.""" """Envoy net consumption CT phase entity."""
entity_description: EnvoyCTSensorEntityDescription entity_description: EnvoyCTSensorEntityDescription
@@ -1278,14 +1272,78 @@ class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
"""Return the state of the CT phase sensor.""" """Return the state of the CT phase sensor."""
if TYPE_CHECKING: if TYPE_CHECKING:
assert self.entity_description.on_phase assert self.entity_description.on_phase
if (cttype := self.entity_description.cttype) not in self.data.ctmeters_phases: if (ctmeter := self.data.ctmeter_consumption_phases) is None:
return None
if (phase := self.entity_description.on_phase) not in self.data.ctmeters_phases[
cttype
]:
return None return None
return self.entity_description.value_fn( return self.entity_description.value_fn(
self.data.ctmeters_phases[cttype][phase] ctmeter[self.entity_description.on_phase]
)
class EnvoyProductionCTEntity(EnvoySystemSensorEntity):
"""Envoy net consumption CT entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT sensor."""
if (ctmeter := self.data.ctmeter_production) is None:
return None
return self.entity_description.value_fn(ctmeter)
class EnvoyProductionCTPhaseEntity(EnvoySystemSensorEntity):
"""Envoy net consumption CT phase entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT phase sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
if (ctmeter := self.data.ctmeter_production_phases) is None:
return None
return self.entity_description.value_fn(
ctmeter[self.entity_description.on_phase]
)
class EnvoyStorageCTEntity(EnvoySystemSensorEntity):
"""Envoy net storage CT entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT sensor."""
if (ctmeter := self.data.ctmeter_storage) is None:
return None
return self.entity_description.value_fn(ctmeter)
class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity):
"""Envoy net storage CT phase entity."""
entity_description: EnvoyCTSensorEntityDescription
@property
def native_value(
self,
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
"""Return the state of the CT phase sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
if (ctmeter := self.data.ctmeter_storage_phases) is None:
return None
return self.entity_description.value_fn(
ctmeter[self.entity_description.on_phase]
) )

View File

@@ -1,11 +0,0 @@
"""Analytics platform."""
from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications
from homeassistant.core import HomeAssistant
async def async_modify_analytics(
hass: HomeAssistant, analytics_input: AnalyticsInput
) -> AnalyticsModifications:
"""Modify the analytics."""
return AnalyticsModifications(remove=True)

View File

@@ -22,23 +22,19 @@ import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_ESPHOME,
SOURCE_IGNORE, SOURCE_IGNORE,
SOURCE_REAUTH, SOURCE_REAUTH,
SOURCE_RECONFIGURE, SOURCE_RECONFIGURE,
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
FlowType,
OptionsFlow, OptionsFlow,
) )
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -79,7 +75,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize flow.""" """Initialize flow."""
self._host: str | None = None self._host: str | None = None
self._connected_address: str | None = None
self.__name: str | None = None self.__name: str | None = None
self._port: int | None = None self._port: int | None = None
self._password: str | None = None self._password: str | None = None
@@ -503,55 +498,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self.hass.config_entries.async_remove( await self.hass.config_entries.async_remove(
self._entry_with_name_conflict.entry_id self._entry_with_name_conflict.entry_id
) )
return await self._async_create_entry() return self._async_create_entry()
async def _async_create_entry(self) -> ConfigFlowResult: @callback
def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry.""" """Create the config entry."""
assert self._name is not None assert self._name is not None
assert self._device_info is not None
# Check if Z-Wave capabilities are present and start discovery flow
next_flow_id: str | None = None
if self._device_info.zwave_proxy_feature_flags:
assert self._connected_address is not None
assert self._port is not None
# Start Z-Wave discovery flow and get the flow ID
zwave_result = await self.hass.config_entries.flow.async_init(
"zwave_js",
context={
"source": SOURCE_ESPHOME,
"discovery_key": discovery_flow.DiscoveryKey(
domain=DOMAIN,
key=self._device_info.mac_address,
version=1,
),
},
data=ESPHomeServiceInfo(
name=self._device_info.name,
zwave_home_id=self._device_info.zwave_home_id or None,
ip_address=self._connected_address,
port=self._port,
noise_psk=self._noise_psk,
),
)
if zwave_result["type"] in (
FlowResultType.ABORT,
FlowResultType.CREATE_ENTRY,
):
_LOGGER.debug(
"Unable to continue created Z-Wave JS config flow: %s", zwave_result
)
else:
next_flow_id = zwave_result["flow_id"]
return self.async_create_entry( return self.async_create_entry(
title=self._name, title=self._name,
data=self._async_make_config_data(), data=self._async_make_config_data(),
options={ options={
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
}, },
next_flow=(FlowType.CONFIG_FLOW, next_flow_id) if next_flow_id else None,
) )
@callback @callback
@@ -598,7 +556,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name: if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = entry self._entry_with_name_conflict = entry
return await self.async_step_name_conflict() return await self.async_step_name_conflict()
return await self._async_create_entry() return self._async_create_entry()
async def _async_reauth_validated_connection(self) -> ConfigFlowResult: async def _async_reauth_validated_connection(self) -> ConfigFlowResult:
"""Handle reauth validated connection.""" """Handle reauth validated connection."""
@@ -745,7 +703,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
try: try:
await cli.connect() await cli.connect()
self._device_info = await cli.device_info() self._device_info = await cli.device_info()
self._connected_address = cli.connected_address
except InvalidAuthAPIError: except InvalidAuthAPIError:
return ERROR_INVALID_PASSWORD_AUTH return ERROR_INVALID_PASSWORD_AUTH
except RequiresEncryptionAPIError: except RequiresEncryptionAPIError:

View File

@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==41.12.0", "aioesphomeapi==41.11.0",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0" "bleak-esphome==3.3.0"
], ],
"zeroconf": ["_esphomelib._tcp.local."] "zeroconf": ["_esphomelib._tcp.local."]
} }

View File

@@ -1,27 +0,0 @@
"""The Firefly III integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Set up Firefly III from a config entry."""
coordinator = FireflyDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -1,140 +0,0 @@
"""Config flow for the Firefly III integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_API_KEY): str,
}
)
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
"""Validate the user input allows us to connect."""
try:
client = Firefly(
api_url=data[CONF_URL],
api_key=data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
await client.get_about()
except FireflyAuthenticationError:
raise InvalidAuth from None
except FireflyConnectionError as err:
raise CannotConnect from err
except FireflyTimeoutError as err:
raise FireflyClientTimeout from err
return True
class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Firefly III."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
try:
await _validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_URL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth when Firefly III API authentication fails."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth: ask for a new API key and validate."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
await _validate_input(
self.hass,
data={
**reauth_entry.data,
CONF_API_KEY: user_input[CONF_API_KEY],
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class FireflyClientTimeout(HomeAssistantError):
"""Error to indicate a timeout occurred."""

View File

@@ -1,6 +0,0 @@
"""Constants for the Firefly III integration."""
DOMAIN = "firefly_iii"
MANUFACTURER = "Firefly III"
NAME = "Firefly III"

View File

@@ -1,137 +0,0 @@
"""Data Update Coordinator for Firefly III integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from aiohttp import CookieJar
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
from pyfirefly.models import Account, Bill, Budget, Category, Currency
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
@dataclass
class FireflyCoordinatorData:
"""Data structure for Firefly III coordinator data."""
accounts: list[Account]
categories: list[Category]
category_details: list[Category]
budgets: list[Budget]
bills: list[Bill]
primary_currency: Currency
class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
"""Coordinator to manage data updates for Firefly III integration."""
config_entry: FireflyConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.firefly = Firefly(
api_url=self.config_entry.data[CONF_URL],
api_key=self.config_entry.data[CONF_API_KEY],
session=async_create_clientsession(
self.hass,
self.config_entry.data[CONF_VERIFY_SSL],
cookie_jar=CookieJar(unsafe=True),
),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.firefly.get_about()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> FireflyCoordinatorData:
"""Fetch data from Firefly III API."""
now = datetime.now()
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
try:
accounts = await self.firefly.get_accounts()
categories = await self.firefly.get_categories()
category_details = [
await self.firefly.get_category(
category_id=int(category.id), start=start_date, end=end_date
)
for category in categories
]
primary_currency = await self.firefly.get_currency_primary()
budgets = await self.firefly.get_budgets()
bills = await self.firefly.get_bills()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
return FireflyCoordinatorData(
accounts=accounts,
categories=categories,
category_details=category_details,
budgets=budgets,
bills=bills,
primary_currency=primary_currency,
)

View File

@@ -1,40 +0,0 @@
"""Base entity for Firefly III integration."""
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_URL
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import FireflyDataUpdateCoordinator
class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
"""Base class for Firefly III entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize a Firefly entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
identifiers={
(
DOMAIN,
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
)
},
)

View File

@@ -1,18 +0,0 @@
{
"entity": {
"sensor": {
"account_type": {
"default": "mdi:bank",
"state": {
"expense": "mdi:cash-minus",
"revenue": "mdi:cash-plus",
"asset": "mdi:account-cash",
"liability": "mdi:hand-coin"
}
},
"category": {
"default": "mdi:label"
}
}
}
}

View File

@@ -1,10 +0,0 @@
{
"domain": "firefly_iii",
"name": "Firefly III",
"codeowners": ["@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.6"]
}

View File

@@ -1,68 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |
No explicit parallel updates are defined.
reauthentication-flow:
status: todo
comment: |
No reauthentication flow is defined. It will be done in a next iteration.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -1,133 +0,0 @@
"""Sensor platform for Firefly III integration."""
from __future__ import annotations
from pyfirefly.models import Account, Category
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
from .entity import FireflyBaseEntity
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="account_type",
translation_key="account",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="category",
translation_key="category",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FireflyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firefly III sensor platform."""
coordinator = entry.runtime_data
entities: list[SensorEntity] = [
FireflyAccountEntity(
coordinator=coordinator,
entity_description=description,
account=account,
)
for account in coordinator.data.accounts
for description in ACCOUNT_SENSORS
]
entities.extend(
FireflyCategoryEntity(
coordinator=coordinator,
entity_description=description,
category=category,
)
for category in coordinator.data.category_details
for description in CATEGORY_SENSORS
)
async_add_entities(entities)
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III account."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
account: Account,
) -> None:
"""Initialize Firefly account entity."""
super().__init__(coordinator, entity_description)
self._account = account
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
self._attr_name = account.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
# Account type state doesn't go well with the icons.json. Need to fix it.
if account.attributes.type == "expense":
self._attr_icon = "mdi:cash-minus"
elif account.attributes.type == "asset":
self._attr_icon = "mdi:account-cash"
elif account.attributes.type == "revenue":
self._attr_icon = "mdi:cash-plus"
elif account.attributes.type == "liability":
self._attr_icon = "mdi:hand-coin"
else:
self._attr_icon = "mdi:bank"
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
return self._account.attributes.current_balance
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III category."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
category: Category,
) -> None:
"""Initialize Firefly category entity."""
super().__init__(coordinator, entity_description)
self._category = category
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
self._attr_name = category.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
spent_items = self._category.attributes.spent or []
earned_items = self._category.attributes.earned or []
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
if spent == 0 and earned == 0:
return None
return spent + earned

View File

@@ -1,49 +0,0 @@
{
"config": {
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "The API key for authenticating with Firefly",
"verify_ssl": "Verify the SSL certificate of the Firefly instance"
},
"description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The new API access token for authenticating with Firefly III"
},
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "An error occurred while trying to connect to the Firefly instance: {error}"
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
}
}
}

View File

@@ -46,9 +46,6 @@ async def async_get_config_entry_diagnostics(
} }
for _, device in avm_wrapper.devices.items() for _, device in avm_wrapper.devices.items()
], ],
"cpu_temperatures": await hass.async_add_executor_job(
avm_wrapper.fritz_status.get_cpu_temperatures
),
"wan_link_properties": await avm_wrapper.async_get_wan_link_properties(), "wan_link_properties": await avm_wrapper.async_get_wan_link_properties(),
}, },
} }

View File

@@ -67,7 +67,7 @@ def suitable_nextchange_time(device: FritzhomeDevice) -> bool:
def suitable_temperature(device: FritzhomeDevice) -> bool: def suitable_temperature(device: FritzhomeDevice) -> bool:
"""Check suitablity for temperature sensor.""" """Check suitablity for temperature sensor."""
return bool(device.has_temperature_sensor) return device.has_temperature_sensor and not device.has_thermostat
def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | None: def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | None:

View File

@@ -452,10 +452,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.app.router.register_resource(IndexView(repo_path, hass)) hass.http.app.router.register_resource(IndexView(repo_path, hass))
async_register_built_in_panel(hass, "light")
async_register_built_in_panel(hass, "security")
async_register_built_in_panel(hass, "climate")
async_register_built_in_panel(hass, "profile") async_register_built_in_panel(hass, "profile")
async_register_built_in_panel( async_register_built_in_panel(
@@ -463,7 +459,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"developer-tools", "developer-tools",
require_admin=True, require_admin=True,
sidebar_title="developer_tools", sidebar_title="developer_tools",
sidebar_icon="mdi:hammer", sidebar_icon="hass:hammer",
) )
@callback @callback

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251001.0"] "requirements": ["home-assistant-frontend==20250926.0"]
} }

View File

@@ -54,7 +54,7 @@ async def async_setup_entry(
except aiohttp.ClientResponseError as err: except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500: if 400 <= err.status < 500:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="reauth_required" "OAuth session is not valid, reauth required"
) from err ) from err
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
@@ -76,6 +76,10 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
conversation.async_unset_agent(hass, entry) conversation.async_unset_agent(hass, entry)
return True return True

View File

@@ -26,7 +26,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
@@ -68,13 +68,7 @@ async def async_send_text_commands(
) -> list[CommandResponse]: ) -> list[CommandResponse]:
"""Send text commands to Google Assistant Service.""" """Send text commands to Google Assistant Service."""
# There can only be 1 entry (config_flow has single_instance_allowed) # There can only be 1 entry (config_flow has single_instance_allowed)
entries = hass.config_entries.async_loaded_entries(DOMAIN) entry: GoogleAssistantSDKConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
entry: GoogleAssistantSDKConfigEntry = entries[0]
session = entry.runtime_data.session session = entry.runtime_data.session
try: try:

View File

@@ -1,4 +1,4 @@
"""Services for the Google Assistant SDK integration.""" """Support for Google Assistant SDK."""
from __future__ import annotations from __future__ import annotations

View File

@@ -59,20 +59,14 @@
}, },
"media_player": { "media_player": {
"name": "Media player entity", "name": "Media player entity",
"description": "Name(s) of media player entities to play the Google Assistant's audio response on. This does not target the device for the command itself." "description": "Name(s) of media player entities to play response on."
} }
} }
} }
}, },
"exceptions": { "exceptions": {
"entry_not_loaded": {
"message": "Entry not loaded"
},
"grpc_error": { "grpc_error": {
"message": "Failed to communicate with Google Assistant" "message": "Failed to communicate with Google Assistant"
},
"reauth_required": {
"message": "Credentials are invalid, re-authentication required"
} }
} }
} }

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