Merge branch 'dev' into mill

This commit is contained in:
Daniel Hjelseth Høyer 2025-04-07 21:19:56 +02:00 committed by GitHub
commit 5700b96f53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1365 changed files with 66867 additions and 16073 deletions

View File

@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -197,7 +197,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2025.02.0
uses: home-assistant/builder@2025.03.0
with:
args: |
$BUILD_ARGS \
@ -263,7 +263,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2025.02.0
uses: home-assistant/builder@2025.03.0
with:
args: |
$BUILD_ARGS \
@ -457,7 +457,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@ -40,7 +40,7 @@ env:
CACHE_VERSION: 12
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2025.4"
HA_SHORT_VERSION: "2025.5"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
@ -249,7 +249,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -294,7 +294,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -334,7 +334,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -374,7 +374,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@ -484,7 +484,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -587,7 +587,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -620,7 +620,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -653,7 +653,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Dependency review
uses: actions/dependency-review-action@v4.5.0
uses: actions/dependency-review-action@v4.6.0
with:
license-check: false # We use our own license audit checks
@ -677,7 +677,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -720,7 +720,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -767,7 +767,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -812,7 +812,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -889,7 +889,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -949,7 +949,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -1074,7 +1074,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -1208,7 +1208,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@ -1359,7 +1359,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true

View File

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.12
uses: github/codeql-action/init@v3.28.13
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.12
uses: github/codeql-action/analyze@v3.28.13
with:
category: "/language:python"

View File

@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.4.0
uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@ -159,7 +159,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.02.0
uses: home-assistant/wheels@2025.03.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@ -219,7 +219,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.02.0
uses: home-assistant/wheels@2025.03.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

View File

@ -119,6 +119,7 @@ homeassistant.components.bluetooth_adapters.*
homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
homeassistant.components.bosch_alarm.*
homeassistant.components.braviatv.*
homeassistant.components.bring.*
homeassistant.components.brother.*
@ -363,6 +364,7 @@ homeassistant.components.notify.*
homeassistant.components.notion.*
homeassistant.components.number.*
homeassistant.components.nut.*
homeassistant.components.ohme.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.*
homeassistant.components.onedrive.*

2
.vscode/tasks.json vendored
View File

@ -4,7 +4,7 @@
{
"label": "Run Home Assistant Core",
"type": "shell",
"command": "hass -c ./config",
"command": "${command:python.interpreterPath} -m homeassistant -c ./config",
"group": "test",
"presentation": {
"reveal": "always",

6
CODEOWNERS generated
View File

@ -216,6 +216,8 @@ build.json @home-assistant/supervisor
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
/tests/components/bosch_alarm/ @mag1024 @sanjay900
/homeassistant/components/bosch_shc/ @tschamm
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/braviatv/ @bieniu @Drafteed
@ -1183,6 +1185,8 @@ build.json @home-assistant/supervisor
/tests/components/prusalink/ @balloob
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato
/tests/components/pterodactyl/ @elmurato
/homeassistant/components/pure_energie/ @klaasnicolaas
/tests/components/pure_energie/ @klaasnicolaas
/homeassistant/components/purpleair/ @bachya
@ -1476,8 +1480,6 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @Swamp-Ig
/tests/components/sun/ @Swamp-Ig
/homeassistant/components/sunweg/ @rokam
/tests/components/sunweg/ @rokam
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen

2
Dockerfile generated
View File

@ -31,7 +31,7 @@ RUN \
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.6.8
RUN pip3 install uv==0.6.10
WORKDIR /usr/src

View File

@ -19,4 +19,4 @@ labels:
org.opencontainers.image.authors: The Home Assistant Authors
org.opencontainers.image.url: https://www.home-assistant.io/
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
org.opencontainers.image.licenses: Apache License 2.0
org.opencontainers.image.licenses: Apache-2.0

View File

@ -178,6 +178,15 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = (
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=SSLContext.set_default_verify_paths,
object=SSLContext,
function="set_default_verify_paths",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.open,
object=Path,

View File

@ -859,8 +859,14 @@ async def _async_set_up_integrations(
integrations, all_integrations = await _async_resolve_domains_and_preload(
hass, config
)
all_domains = set(all_integrations)
domains = set(integrations)
# Detect all cycles
integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, all_integrations.values(), set(all_integrations)
)
)
all_domains = set(integrations_after_dependencies)
domains = set(integrations) & all_domains
_LOGGER.info(
"Domains to be set up: %s | %s",
@ -868,6 +874,8 @@ async def _async_set_up_integrations(
all_domains - domains,
)
async_set_domains_to_be_loaded(hass, all_domains)
# Initialize recorder
if "recorder" in all_domains:
recorder.async_initialize_recorder(hass)
@ -900,24 +908,12 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered = {
dep
for domain in stage_domains
for dep in all_integrations[domain].all_dependencies
for dep in integrations_after_dependencies[domain]
if dep not in stage_domains
}
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
stage_all_domains = stage_domains | stage_dep_domains
stage_all_integrations = {
domain: all_integrations[domain] for domain in stage_all_domains
}
# Detect all cycles
stage_integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, stage_all_integrations.values(), stage_all_domains
)
)
stage_all_domains = set(stage_integrations_after_dependencies)
stage_domains &= stage_all_domains
stage_dep_domains &= stage_all_domains
_LOGGER.info(
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
@ -928,8 +924,6 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered - stage_dep_domains,
)
async_set_domains_to_be_loaded(hass, stage_all_domains)
if timeout is None:
await _async_setup_multi_components(hass, stage_all_domains, config)
continue

View File

@ -0,0 +1,5 @@
{
"domain": "bosch",
"name": "Bosch",
"integrations": ["bosch_alarm", "bosch_shc", "home_connect"]
}

View File

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

View File

@ -1,5 +1,6 @@
{
"domain": "motionblinds",
"name": "Motionblinds",
"integrations": ["motion_blinds", "motionblinds_ble"]
"integrations": ["motion_blinds", "motionblinds_ble"],
"iot_standards": ["matter"]
}

View File

@ -72,10 +72,10 @@
"level": {
"name": "Level",
"state": {
"high": "High",
"low": "Low",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "Moderate",
"very_high": "Very high"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@ -89,10 +89,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@ -123,10 +123,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@ -167,10 +167,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@ -181,10 +181,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@ -195,10 +195,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}

View File

@ -5,14 +5,14 @@
"data": {
"connection_type": "Select connection type"
},
"description": "Select connection type. Local requires heaters with bluetooth"
"description": "Select connection type. Local requires heaters with Bluetooth"
},
"local": {
"data": {
"wifi_ssid": "Wi-Fi SSID",
"wifi_pswd": "Wi-Fi Password"
"wifi_pswd": "Wi-Fi password"
},
"description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes."
"description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes."
},
"cloud": {
"data": {

View File

@ -11,7 +11,7 @@
}
},
"discovery_confirm": {
"description": "Do you want to setup {model}?"
"description": "Do you want to set up {model}?"
}
},
"abort": {
@ -68,8 +68,8 @@
"led_bar_mode": {
"name": "LED bar mode",
"state": {
"off": "Off",
"co2": "Carbon dioxide",
"off": "[%key:common::state::off%]",
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "Particulate matter"
}
},
@ -143,8 +143,8 @@
"led_bar_mode": {
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
"state": {
"off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]",
"co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]",
"off": "[%key:common::state::off%]",
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
}
},

View File

@ -8,7 +8,7 @@ from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from pyairnow import WebServiceAPI
from pyairnow.conv import aqi_to_concentration
from pyairnow.errors import AirNowError
from pyairnow.errors import AirNowError, InvalidJsonError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@ -79,7 +79,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
distance=self.distance,
)
except (AirNowError, ClientConnectorError) as error:
except (AirNowError, ClientConnectorError, InvalidJsonError) as error:
raise UpdateFailed(error) from error
if not obs:

View File

@ -7,7 +7,7 @@
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station Radius (miles; optional)"
"radius": "Station radius (miles; optional)"
}
}
},
@ -25,7 +25,7 @@
"step": {
"init": {
"data": {
"radius": "Station Radius (miles)"
"radius": "Station radius (miles)"
}
}
}

View File

@ -91,7 +91,7 @@
"name": "Hydrogen fluoride"
},
"health_index": {
"name": "Health Index"
"name": "Health index"
},
"absolute_humidity": {
"name": "Absolute humidity"
@ -112,10 +112,10 @@
"name": "Oxygen"
},
"performance_index": {
"name": "Performance Index"
"name": "Performance index"
},
"hydrogen_phosphide": {
"name": "Hydrogen Phosphide"
"name": "Hydrogen phosphide"
},
"relative_pressure": {
"name": "Relative pressure"
@ -127,22 +127,22 @@
"name": "Refrigerant"
},
"silicon_hydride": {
"name": "Silicon Hydride"
"name": "Silicon hydride"
},
"noise": {
"name": "Noise"
},
"maximum_noise": {
"name": "Noise (Maximum)"
"name": "Noise (maximum)"
},
"radon": {
"name": "Radon"
},
"industrial_volatile_organic_compounds": {
"name": "VOCs (Industrial)"
"name": "VOCs (industrial)"
},
"virus_index": {
"name": "Virus Index"
"name": "Virus index"
}
}
}

View File

@ -102,7 +102,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001
except Exception:
_LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
name = get_name(device)
@ -160,7 +161,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001
except Exception:
_LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
name = get_name(device)
self._discovered_devices[address] = Discovery(name, discovery_info, device)

View File

@ -32,7 +32,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
client = Airtouch5SimpleClient(user_input[CONF_HOST])
try:
await client.test_connection()
except Exception: # noqa: BLE001
except Exception:
_LOGGER.exception("Unexpected exception")
errors = {"base": "cannot_connect"}
else:
await self.async_set_unique_id(user_input[CONF_HOST])

View File

@ -2,7 +2,7 @@
"config": {
"step": {
"geography_by_coords": {
"title": "Configure a Geography",
"title": "Configure a geography",
"description": "Use the AirVisual cloud API to monitor a latitude/longitude.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
@ -16,8 +16,8 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"city": "City",
"country": "Country",
"state": "State"
"state": "State",
"country": "[%key:common::config_flow::data::country%]"
}
},
"reauth_confirm": {
@ -56,12 +56,12 @@
"sensor": {
"pollutant_label": {
"state": {
"co": "Carbon Monoxide",
"n2": "Nitrogen Dioxide",
"o3": "Ozone",
"p1": "PM10",
"p2": "PM2.5",
"s2": "Sulfur Dioxide"
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
"p1": "[%key:component::sensor::entity_component::pm10::name%]",
"p2": "[%key:component::sensor::entity_component::pm25::name%]",
"s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
}
},
"pollutant_level": {

View File

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.9"]
"requirements": ["aioairzone==1.0.0"]
}

View File

@ -9,6 +9,8 @@ from aioairzone.const import (
AZD_HUMIDITY,
AZD_TEMP,
AZD_TEMP_UNIT,
AZD_THERMOSTAT_BATTERY,
AZD_THERMOSTAT_SIGNAL,
AZD_WEBSERVER,
AZD_WIFI_RSSI,
AZD_ZONES,
@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_THERMOSTAT_SIGNAL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="thermostat_signal",
),
)

View File

@ -76,6 +76,9 @@
"sensor": {
"rssi": {
"name": "RSSI"
},
"thermostat_signal": {
"name": "Signal strength"
}
}
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.6.10"]
"requirements": ["aioairzone-cloud==0.6.11"]
}

View File

@ -32,8 +32,8 @@
"air_quality": {
"name": "Air Quality mode",
"state": {
"off": "Off",
"on": "On",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"auto": "Auto"
}
},

View File

@ -1438,7 +1438,7 @@ class AlexaModeController(AlexaCapability):
# Fan preset_mode
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None)
if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None):
if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()):
return f"{fan.ATTR_PRESET_MODE}.{mode}"
# Humidifier mode

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
"quality_scale": "legacy",
"requirements": ["boto3==1.34.131"]
"requirements": ["boto3==1.37.1"]
}

View File

@ -240,6 +240,7 @@ SENSOR_DESCRIPTIONS = (
suggested_display_precision=0,
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key=TYPE_WINDGUSTMPH,

View File

@ -609,6 +609,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="wind_direction",
native_unit_of_measurement=DEGREE,
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key=TYPE_WINDDIR_AVG10M,

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"iot_class": "local_polling",
"requirements": ["pydroid-ipcam==2.0.0"]
"requirements": ["pydroid-ipcam==3.0.0"]
}

View File

@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"requirements": ["androidtvremote2==0.2.0"],
"requirements": ["androidtvremote2==0.2.1"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View File

@ -2,6 +2,8 @@
from __future__ import annotations
import logging
from anova_wifi import AnovaApi, InvalidLogin
import voluptuous as vol
@ -11,8 +13,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
class AnovaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Sets up a config flow for Anova."""
VERSION = 1
@ -35,7 +39,8 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
await api.authenticate()
except InvalidLogin:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(

View File

@ -57,7 +57,7 @@
"name": "Status date"
},
"dip_switch_settings": {
"name": "Dip switch settings"
"name": "DIP switch settings"
},
"low_battery_signal": {
"name": "Low battery signal"

View File

@ -20,6 +20,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_ZEROCONF,
ConfigEntry,
ConfigFlow,
@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_IDENTIFIERS: list(combined_identifiers),
},
)
if entry.source != SOURCE_IGNORE:
# Don't reload ignored entries or in the middle of reauth,
# e.g. if the user is entering a new PIN
if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
if not allow_exist:
raise DeviceAlreadyConfigured

View File

@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyaprilaire"],
"requirements": ["pyaprilaire==0.7.7"]
"requirements": ["pyaprilaire==0.8.1"]
}

View File

@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
config_entry: ApSystemsConfigEntry
device_version: str
battery_system: bool
def __init__(
self,
@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
self.api.max_power = device_info.maxPower
self.api.min_power = device_info.minPower
self.device_version = device_info.devVer
self.battery_system = device_info.isBatterySystem
async def _async_update_data(self) -> ApSystemsSensorData:
try:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apsystems",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["apsystems-ez1==2.4.0"]
"requirements": ["apsystems-ez1==2.5.0"]
}

View File

@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
super().__init__(data)
self._api = data.coordinator.api
self._attr_unique_id = f"{data.device_id}_inverter_status"
if data.coordinator.battery_system:
self._attr_available = False
async def async_update(self) -> None:
"""Update switch status and availability."""

View File

@ -60,7 +60,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:

View File

@ -36,9 +36,9 @@
"wi_fi_strength": {
"name": "Wi-Fi strength",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High"
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"high": "[%key:common::state::high%]"
}
}
}

View File

@ -6,7 +6,11 @@ import logging
from typing import Any
from homeassistant.components import mqtt
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -98,6 +102,7 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] |
DEGREE,
"mdi:compass",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
]
return None
@ -178,6 +183,7 @@ class ArwnSensor(SensorEntity):
units: str,
icon: str | None = None,
device_class: SensorDeviceClass | None = None,
state_class: SensorStateClass | None = None,
) -> None:
"""Initialize the sensor."""
self.entity_id = _slug(name)
@ -188,6 +194,7 @@ class ArwnSensor(SensorEntity):
self._attr_native_unit_of_measurement = units
self._attr_icon = icon
self._attr_device_class = device_class
self._attr_state_class = state_class
def set_event(self, event: dict[str, Any]) -> None:
"""Update the sensor with the most recent event."""

View File

@ -125,7 +125,7 @@ SAVE_DELAY = 10
@callback
def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool:
"""Filter out intents that are not local fallback."""
return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND)
return result.intent.name in (intent.INTENT_GET_STATE)
@callback

View File

@ -1,9 +1,11 @@
"""Base class for assist satellite entities."""
import logging
from pathlib import Path
import voluptuous as vol
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
@ -15,6 +17,8 @@ from .const import (
CONNECTION_TEST_DATA,
DATA_COMPONENT,
DOMAIN,
PREANNOUNCE_FILENAME,
PREANNOUNCE_URL,
AssistSatelliteEntityFeature,
)
from .entity import (
@ -56,6 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("message"): str,
vol.Optional("media_id"): str,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
}
),
cv.has_at_least_one_key("message", "media_id"),
@ -70,6 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("start_message"): str,
vol.Optional("start_media_id"): str,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
vol.Optional("extra_system_prompt"): str,
}
),
@ -82,6 +90,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_websocket_api(hass)
hass.http.register_view(ConnectionTestView())
# Default preannounce sound
await hass.http.async_register_static_paths(
[
StaticPathConfig(
PREANNOUNCE_URL, str(Path(__file__).parent / PREANNOUNCE_FILENAME)
)
]
)
return True

View File

@ -20,6 +20,9 @@ CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey(
f"{DOMAIN}_connection_tests"
)
PREANNOUNCE_FILENAME = "preannounce.mp3"
PREANNOUNCE_URL = f"/api/assist_satellite/static/{PREANNOUNCE_FILENAME}"
class AssistSatelliteEntityFeature(IntFlag):
"""Supported features of Assist satellite entity."""

View File

@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, entity
from homeassistant.helpers.entity import EntityDescription
from .const import AssistSatelliteEntityFeature
from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature
from .errors import AssistSatelliteError, SatelliteBusyError
_LOGGER = logging.getLogger(__name__)
@ -101,6 +101,9 @@ class AssistSatelliteAnnouncement:
media_id_source: Literal["url", "media_id", "tts"]
"""Source of the media ID."""
preannounce_media_id: str | None = None
"""Media ID to be played before announcement."""
class AssistSatelliteEntity(entity.Entity):
"""Entity encapsulating the state and functionality of an Assist satellite."""
@ -177,6 +180,8 @@ class AssistSatelliteEntity(entity.Entity):
self,
message: str | None = None,
media_id: str | None = None,
preannounce: bool = True,
preannounce_media_id: str = PREANNOUNCE_URL,
) -> None:
"""Play and show an announcement on the satellite.
@ -186,6 +191,9 @@ class AssistSatelliteEntity(entity.Entity):
If media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
If preannounce is True, a sound is played before the announcement.
If preannounce_media_id is provided, it overrides the default sound.
Calls async_announce with message and media id.
"""
await self._cancel_running_pipeline()
@ -193,7 +201,11 @@ class AssistSatelliteEntity(entity.Entity):
if message is None:
message = ""
announcement = await self._resolve_announcement_media_id(message, media_id)
announcement = await self._resolve_announcement_media_id(
message,
media_id,
preannounce_media_id=preannounce_media_id if preannounce else None,
)
if self._is_announcing:
raise SatelliteBusyError
@ -220,6 +232,8 @@ class AssistSatelliteEntity(entity.Entity):
start_message: str | None = None,
start_media_id: str | None = None,
extra_system_prompt: str | None = None,
preannounce: bool = True,
preannounce_media_id: str = PREANNOUNCE_URL,
) -> None:
"""Start a conversation from the satellite.
@ -229,6 +243,9 @@ class AssistSatelliteEntity(entity.Entity):
If start_media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
If preannounce is True, a sound is played before the start message or media.
If preannounce_media_id is provided, it overrides the default sound.
Calls async_start_conversation.
"""
await self._cancel_running_pipeline()
@ -244,13 +261,17 @@ class AssistSatelliteEntity(entity.Entity):
start_message = ""
announcement = await self._resolve_announcement_media_id(
start_message, start_media_id
start_message,
start_media_id,
preannounce_media_id=preannounce_media_id if preannounce else None,
)
if self._is_announcing:
raise SatelliteBusyError
self._is_announcing = True
self._set_state(AssistSatelliteState.RESPONDING)
# Provide our start info to the LLM so it understands context of incoming message
if extra_system_prompt is not None:
self._extra_system_prompt = extra_system_prompt
@ -280,6 +301,7 @@ class AssistSatelliteEntity(entity.Entity):
raise
finally:
self._is_announcing = False
self._set_state(AssistSatelliteState.IDLE)
async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement
@ -470,7 +492,10 @@ class AssistSatelliteEntity(entity.Entity):
return vad.VadSensitivity.to_seconds(vad_sensitivity)
async def _resolve_announcement_media_id(
self, message: str, media_id: str | None
self,
message: str,
media_id: str | None,
preannounce_media_id: str | None = None,
) -> AssistSatelliteAnnouncement:
"""Resolve the media ID."""
media_id_source: Literal["url", "media_id", "tts"] | None = None
@ -478,7 +503,6 @@ class AssistSatelliteEntity(entity.Entity):
if media_id:
original_media_id = media_id
else:
media_id_source = "tts"
# Synthesize audio and get URL
@ -530,10 +554,26 @@ class AssistSatelliteEntity(entity.Entity):
# Resolve to full URL
media_id = async_process_play_media_url(self.hass, media_id)
# Resolve preannounce media id
if preannounce_media_id:
if media_source.is_media_source_id(preannounce_media_id):
preannounce_media = await media_source.async_resolve_media(
self.hass,
preannounce_media_id,
None,
)
preannounce_media_id = preannounce_media.url
# Resolve to full URL
preannounce_media_id = async_process_play_media_url(
self.hass, preannounce_media_id
)
return AssistSatelliteAnnouncement(
message=message,
media_id=media_id,
original_media_id=original_media_id,
tts_token=tts_token,
media_id_source=media_id_source,
preannounce_media_id=preannounce_media_id,
)

View File

@ -8,12 +8,22 @@ announce:
message:
required: false
example: "Time to wake up!"
default: ""
selector:
text:
media_id:
required: false
selector:
text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id:
required: false
selector:
text:
start_conversation:
target:
entity:
@ -24,6 +34,7 @@ start_conversation:
start_message:
required: false
example: "You left the lights on in the living room. Turn them off?"
default: ""
selector:
text:
start_media_id:
@ -34,3 +45,12 @@ start_conversation:
required: false
selector:
text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id:
required: false
selector:
text:

View File

@ -23,6 +23,14 @@
"media_id": {
"name": "Media ID",
"description": "The media ID to announce instead of using text-to-speech."
},
"preannounce": {
"name": "Preannounce",
"description": "Play a sound before the announcement."
},
"preannounce_media_id": {
"name": "Preannounce media ID",
"description": "Custom media ID to play before the announcement."
}
}
},
@ -41,6 +49,14 @@
"extra_system_prompt": {
"name": "Extra system prompt",
"description": "Provide background information to the AI about the request."
},
"preannounce": {
"name": "Preannounce",
"description": "Play a sound before the start message or media."
},
"preannounce_media_id": {
"name": "Preannounce media ID",
"description": "Custom media ID to play before the start message or media."
}
}
}

View File

@ -198,7 +198,8 @@ async def websocket_test_connection(
hass.async_create_background_task(
satellite.async_internal_announce(
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}"
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
preannounce=False,
),
f"assist_satellite_connection_test_{msg['entity_id']}",
)

View File

@ -66,28 +66,28 @@
"name": "Upload"
},
"load_avg_1m": {
"name": "Average load (1m)"
"name": "Average load (1 min)"
},
"load_avg_5m": {
"name": "Average load (5m)"
"name": "Average load (5 min)"
},
"load_avg_15m": {
"name": "Average load (15m)"
"name": "Average load (15 min)"
},
"24ghz_temperature": {
"name": "2.4GHz Temperature"
"name": "2.4GHz temperature"
},
"5ghz_temperature": {
"name": "5GHz Temperature"
"name": "5GHz temperature"
},
"cpu_temperature": {
"name": "CPU Temperature"
"name": "CPU temperature"
},
"5ghz_2_temperature": {
"name": "5GHz Temperature (Radio 2)"
"name": "5GHz temperature (Radio 2)"
},
"6ghz_temperature": {
"name": "6GHz Temperature"
"name": "6GHz temperature"
},
"cpu_usage": {
"name": "CPU usage"

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["aiobotocore", "botocore"],
"quality_scale": "legacy",
"requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"]
"requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"]
}

View File

@ -14,7 +14,7 @@
"personal_access_token": "Personal Access Token (PAT)"
},
"description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.",
"title": "Add Azure DevOps Project"
"title": "Add Azure DevOps project"
},
"reauth_confirm": {
"data": {
@ -32,7 +32,7 @@
"entity": {
"sensor": {
"build_id": {
"name": "{definition_name} latest build id"
"name": "{definition_name} latest build ID"
},
"finish_time": {
"name": "{definition_name} latest build finish time"
@ -59,7 +59,7 @@
"name": "{definition_name} latest build start time"
},
"url": {
"name": "{definition_name} latest build url"
"name": "{definition_name} latest build URL"
},
"work_item_count": {
"name": "{item_type} {item_state} work items"
@ -68,7 +68,7 @@
},
"exceptions": {
"authentication_failed": {
"message": "Could not authorize with Azure DevOps for {title}. You will need to update your personal access token."
"message": "Could not authorize with Azure DevOps for {title}. You will need to update your Personal Access Token."
}
}
}

View File

@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent):
"""Find a blob by backup id."""
async for blob in self._client.list_blobs(include="metadata"):
if (
backup_id == blob.metadata.get("backup_id", "")
blob.metadata is not None
and backup_id == blob.metadata.get("backup_id", "")
and blob.metadata.get("metadata_version") == METADATA_VERSION
):
return blob

View File

@ -1,7 +1,9 @@
"""The Backup integration."""
from homeassistant.config_entries import SOURCE_SYSTEM
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
@ -18,10 +20,12 @@ from .agent import (
)
from .config import BackupConfig, CreateBackupParametersDict
from .const import DATA_MANAGER, DOMAIN
from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
from .http import async_register_http_views
from .manager import (
BackupManager,
BackupManagerError,
BackupPlatformEvent,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
@ -52,6 +56,7 @@ __all__ = [
"BackupConfig",
"BackupManagerError",
"BackupNotFound",
"BackupPlatformEvent",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
@ -74,6 +79,8 @@ __all__ = [
"suggested_filename_from_name_date",
]
PLATFORMS = [Platform.SENSOR]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@ -128,4 +135,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_http_views(hass)
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Set up a config entry."""
backup_manager: BackupManager = hass.data[DATA_MANAGER]
coordinator = BackupDataUpdateCoordinator(hass, entry, backup_manager)
await coordinator.async_config_entry_first_refresh()
entry.async_on_unload(coordinator.async_unsubscribe)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,21 @@
"""Config flow for Home Assistant Backup integration."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class BackupConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Backup."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Backup", data={})

View File

@ -16,8 +16,8 @@ DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN)
LOGGER = getLogger(__package__)
EXCLUDE_FROM_BACKUP = [
"__pycache__/*",
".DS_Store",
"**/__pycache__/*",
"**/.DS_Store",
".HA_RESTORE",
"*.db-shm",
"*.log.*",

View File

@ -0,0 +1,81 @@
"""Coordinator for Home Assistant Backup integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.backup import (
async_subscribe_events,
async_subscribe_platform_events,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, LOGGER
from .manager import (
BackupManager,
BackupManagerState,
BackupPlatformEvent,
ManagerStateEvent,
)
type BackupConfigEntry = ConfigEntry[BackupDataUpdateCoordinator]
@dataclass
class BackupCoordinatorData:
"""Class to hold backup data."""
backup_manager_state: BackupManagerState
last_successful_automatic_backup: datetime | None
next_scheduled_automatic_backup: datetime | None
class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
"""Class to retrieve backup status."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
backup_manager: BackupManager,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=None,
)
self.unsubscribe: list[Callable[[], None]] = [
async_subscribe_events(hass, self._on_event),
async_subscribe_platform_events(hass, self._on_event),
]
self.backup_manager = backup_manager
@callback
def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None:
"""Handle new event."""
LOGGER.debug("Received backup event: %s", event)
self.config_entry.async_create_task(self.hass, self.async_refresh())
async def _async_update_data(self) -> BackupCoordinatorData:
"""Update backup manager data."""
return BackupCoordinatorData(
self.backup_manager.state,
self.backup_manager.config.data.last_completed_automatic_backup,
self.backup_manager.config.data.schedule.next_automatic_backup,
)
@callback
def async_unsubscribe(self) -> None:
"""Unsubscribe from events."""
for unsub in self.unsubscribe:
unsub()

View File

@ -0,0 +1,27 @@
"""Diagnostics support for Home Assistant Backup integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import BackupConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: BackupConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"backup_agents": [
{"name": agent.name, "agent_id": agent.agent_id}
for agent in coordinator.backup_manager.backup_agents.values()
],
"backup_config": async_redact_data(
coordinator.backup_manager.config.data.to_dict(), [CONF_PASSWORD]
),
}

View File

@ -0,0 +1,36 @@
"""Base for backup entities."""
from __future__ import annotations
from homeassistant.const import __version__ as HA_VERSION
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
from .coordinator import BackupDataUpdateCoordinator
class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
"""Base entity for backup manager."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BackupDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = entity_description.key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "backup_manager")},
manufacturer="Home Assistant",
model="Home Assistant Backup",
sw_version=HA_VERSION,
name="Backup",
entry_type=DeviceEntryType.SERVICE,
configuration_url="homeassistant://config/backup",
)

View File

@ -229,6 +229,13 @@ class RestoreBackupEvent(ManagerStateEvent):
state: RestoreBackupState
@dataclass(frozen=True, kw_only=True, slots=True)
class BackupPlatformEvent:
"""Backup platform class."""
domain: str
@dataclass(frozen=True, kw_only=True, slots=True)
class BlockedEvent(ManagerStateEvent):
"""Backup manager blocked, Home Assistant is starting."""
@ -355,6 +362,9 @@ class BackupManager:
self._backup_event_subscriptions = hass.data[
DATA_BACKUP
].backup_event_subscriptions
self._backup_platform_event_subscriptions = hass.data[
DATA_BACKUP
].backup_platform_event_subscriptions
async def async_setup(self) -> None:
"""Set up the backup manager."""
@ -465,6 +475,9 @@ class BackupManager:
LOGGER.debug("%s platforms loaded in total", len(self.platforms))
LOGGER.debug("%s agents loaded in total", len(self.backup_agents))
LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents))
event = BackupPlatformEvent(domain=integration_domain)
for subscription in self._backup_platform_event_subscriptions:
subscription(event)
async def async_pre_backup_actions(self) -> None:
"""Perform pre backup actions."""
@ -1713,7 +1726,9 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Filter to filter excludes."""
for exclude in excludes:
if not path.match(exclude):
# The home assistant core configuration directory is added as "data"
# in the tar file, so we need to prefix that path to the filters.
if not path.full_match(f"data/{exclude}"):
continue
LOGGER.debug("Ignoring %s because of %s", path, exclude)
return True

View File

@ -5,8 +5,9 @@
"codeowners": ["@home-assistant/core"],
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/backup",
"integration_type": "system",
"integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.6", "securetar==2025.2.1"]
"requirements": ["cronsim==2.6", "securetar==2025.2.1"],
"single_config_entry": true
}

View File

@ -0,0 +1,75 @@
"""Sensor platform for Home Assistant Backup integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import BackupConfigEntry, BackupCoordinatorData
from .entity import BackupManagerEntity
from .manager import BackupManagerState
@dataclass(kw_only=True, frozen=True)
class BackupSensorEntityDescription(SensorEntityDescription):
"""Description for Home Assistant Backup sensor entities."""
value_fn: Callable[[BackupCoordinatorData], str | datetime | None]
BACKUP_MANAGER_DESCRIPTIONS = (
BackupSensorEntityDescription(
key="backup_manager_state",
translation_key="backup_manager_state",
device_class=SensorDeviceClass.ENUM,
options=[state.value for state in BackupManagerState],
value_fn=lambda data: data.backup_manager_state,
),
BackupSensorEntityDescription(
key="next_scheduled_automatic_backup",
translation_key="next_scheduled_automatic_backup",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.next_scheduled_automatic_backup,
),
BackupSensorEntityDescription(
key="last_successful_automatic_backup",
translation_key="last_successful_automatic_backup",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.last_successful_automatic_backup,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BackupConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Sensor set up for backup config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
BackupManagerSensor(coordinator, description)
for description in BACKUP_MANAGER_DESCRIPTIONS
)
class BackupManagerSensor(BackupManagerEntity, SensorEntity):
"""Sensor to track backup manager state."""
entity_description: BackupSensorEntityDescription
@property
def native_value(self) -> str | datetime | None:
"""Return native value of entity."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -22,5 +22,24 @@
"name": "Create automatic backup",
"description": "Creates a new backup with automatic backup settings."
}
},
"entity": {
"sensor": {
"backup_manager_state": {
"name": "Backup Manager state",
"state": {
"idle": "[%key:common::state::idle%]",
"create_backup": "Creating a backup",
"receive_backup": "Receiving a backup",
"restore_backup": "Restoring a backup"
}
},
"next_scheduled_automatic_backup": {
"name": "Next scheduled automatic backup"
},
"last_successful_automatic_backup": {
"name": "Last successful automatic backup"
}
}
}
}

View File

@ -0,0 +1 @@
"""Balay virtual integration."""

View File

@ -0,0 +1,6 @@
{
"domain": "balay",
"name": "Balay",
"integration_type": "virtual",
"supported_by": "home_connect"
}

View File

@ -103,8 +103,8 @@
"temperature_range": {
"name": "Temperature range",
"state": {
"low": "Low",
"high": "High"
"low": "[%key:common::state::low%]",
"high": "[%key:common::state::high%]"
}
}
},

View File

@ -124,15 +124,15 @@
"battery": {
"name": "Battery",
"state": {
"off": "Normal",
"on": "Low"
"off": "[%key:common::state::normal%]",
"on": "[%key:common::state::low%]"
}
},
"battery_charging": {
"name": "Charging",
"state": {
"off": "Not charging",
"on": "Charging"
"on": "[%key:common::state::charging%]"
}
},
"carbon_monoxide": {
@ -145,7 +145,7 @@
"cold": {
"name": "Cold",
"state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
"off": "[%key:common::state::normal%]",
"on": "Cold"
}
},
@ -180,7 +180,7 @@
"heat": {
"name": "Heat",
"state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
"off": "[%key:common::state::normal%]",
"on": "Hot"
}
},

View File

@ -37,7 +37,7 @@
"vehicle_status": {
"name": "Vehicle status",
"state": {
"standby": "Standby",
"standby": "[%key:common::state::standby%]",
"vehicle_detected": "Detected",
"ready": "Ready",
"no_power": "No power",

View File

@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
return
# presets and inputs might have the same name; presets have priority
url: str | None = None
for input_ in self._inputs:
if input_.text == source:
url = input_.url
await self._player.play_url(input_.url)
return
for preset in self._presets:
if preset.name == source:
url = preset.url
await self._player.load_preset(preset.id)
return
if url is None:
raise ServiceValidationError(f"Source {source} not found")
await self._player.play_url(url)
raise ServiceValidationError(f"Source {source} not found")
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""

View File

@ -19,8 +19,8 @@
"bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.26.1",
"dbus-fast==2.39.6",
"habluetooth==3.32.0"
"bluetooth-data-tools==1.27.0",
"dbus-fast==2.43.0",
"habluetooth==3.37.0"
]
}

View File

@ -6,7 +6,7 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"region": "ConnectedDrive Region"
"region": "ConnectedDrive region"
},
"data_description": {
"username": "The email address of your MyBMW/MINI Connected account.",
@ -113,10 +113,10 @@
},
"select": {
"ac_limit": {
"name": "AC Charging Limit"
"name": "AC charging limit"
},
"charging_mode": {
"name": "Charging Mode",
"name": "Charging mode",
"state": {
"immediate_charging": "Immediate charging",
"delayed_charging": "Delayed charging",
@ -181,7 +181,7 @@
"cooling": "Cooling",
"heating": "Heating",
"inactive": "Inactive",
"standby": "Standby",
"standby": "[%key:common::state::standby%]",
"ventilation": "Ventilation"
}
},

View File

@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_ACCESS_TOKEN] = token
try:
_, hub_name = await _validate_input(self.hass, self._discovered)
bond_id, hub_name = await _validate_input(self.hass, self._discovered)
except InputValidationError:
return
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._discovered[CONF_NAME] = hub_name
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by dhcp discovery."""
host = discovery_info.ip
bond_id = discovery_info.hostname.partition("-")[2].upper()
await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
host: str = discovery_info.host
bond_id = name.partition(".")[0]
await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_any_discovery(
self, bond_id: str, host: str
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
for entry in self._async_current_entries():
if entry.unique_id != bond_id:
continue
updates = {CONF_HOST: host}
if entry.state == ConfigEntryState.SETUP_ERROR and (
if entry.state is ConfigEntryState.SETUP_ERROR and (
token := await async_get_token(self.hass, host)
):
updates[CONF_ACCESS_TOKEN] = token
@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._discovered[CONF_HOST],
}
try:
_, hub_name = await _validate_input(self.hass, data)
bond_id, hub_name = await _validate_input(self.hass, data)
except InputValidationError as error:
errors["base"] = error.base
else:
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(
updates={CONF_HOST: self._discovered[CONF_HOST]}
)
return self.async_create_entry(
title=hub_name,
data=data,
@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
except InputValidationError as error:
errors["base"] = error.base
else:
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured()
await self.async_set_unique_id(bond_id, raise_on_progress=False)
self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_create_entry(title=hub_name, data=user_input)
return self.async_show_form(

View File

@ -3,6 +3,16 @@
"name": "Bond",
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
"config_flow": true,
"dhcp": [
{
"hostname": "bond-*",
"macaddress": "3C6A2C1*"
},
{
"hostname": "bond-*",
"macaddress": "F44E38*"
}
],
"documentation": "https://www.home-assistant.io/integrations/bond",
"iot_class": "local_push",
"loggers": ["bond_async"],

View File

@ -0,0 +1,67 @@
"""The Bosch Alarm integration."""
from __future__ import annotations
from ssl import SSLError
from bosch_alarm_mode2 import Panel
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL]
type BoschAlarmConfigEntry = ConfigEntry[Panel]
async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
"""Set up Bosch Alarm from a config entry."""
panel = Panel(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
automation_code=entry.data.get(CONF_PASSWORD),
installer_or_user_code=entry.data.get(
CONF_INSTALLER_CODE, entry.data.get(CONF_USER_CODE)
),
)
try:
await panel.connect()
except (PermissionError, ValueError) as err:
await panel.disconnect()
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
await panel.disconnect()
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
entry.runtime_data = panel
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems",
model=panel.model,
sw_version=panel.firmware_version,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.disconnect()
return unload_ok

View File

@ -0,0 +1,109 @@
"""Support for Bosch Alarm Panel."""
from __future__ import annotations
from bosch_alarm_mode2 import Panel
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up control panels for each area."""
panel = config_entry.runtime_data
async_add_entities(
AreaAlarmControlPanel(
panel,
area_id,
config_entry.unique_id or config_entry.entry_id,
)
for area_id in panel.areas
)
class AreaAlarmControlPanel(AlarmControlPanelEntity):
"""An alarm control panel entity for a bosch alarm panel."""
_attr_has_entity_name = True
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_code_arm_required = False
_attr_name = None
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
"""Initialise a Bosch Alarm control panel entity."""
self.panel = panel
self._area = panel.areas[area_id]
self._area_id = area_id
self._attr_unique_id = f"{unique_id}_area_{area_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
via_device=(
DOMAIN,
unique_id,
),
)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the alarm."""
if self._area.is_triggered():
return AlarmControlPanelState.TRIGGERED
if self._area.is_disarmed():
return AlarmControlPanelState.DISARMED
if self._area.is_arming():
return AlarmControlPanelState.ARMING
if self._area.is_pending():
return AlarmControlPanelState.PENDING
if self._area.is_part_armed():
return AlarmControlPanelState.ARMED_HOME
if self._area.is_all_armed():
return AlarmControlPanelState.ARMED_AWAY
return None
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Disarm this panel."""
await self.panel.area_disarm(self._area_id)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.panel.area_arm_part(self._area_id)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.panel.area_arm_all(self._area_id)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.panel.connection_status()
async def async_added_to_hass(self) -> None:
"""Run when entity attached to hass."""
await super().async_added_to_hass()
self._area.status_observer.attach(self.schedule_update_ha_state)
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity removed from hass."""
await super().async_will_remove_from_hass()
self._area.status_observer.detach(self.schedule_update_ha_state)
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)

View File

@ -0,0 +1,218 @@
"""Config flow for Bosch Alarm integration."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import logging
import ssl
from typing import Any
from bosch_alarm_mode2 import Panel
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_CODE,
CONF_HOST,
CONF_MODEL,
CONF_PASSWORD,
CONF_PORT,
)
import homeassistant.helpers.config_validation as cv
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=7700): cv.positive_int,
}
)
STEP_AUTH_DATA_SCHEMA_SOLUTION = vol.Schema(
{
vol.Required(CONF_USER_CODE): str,
}
)
STEP_AUTH_DATA_SCHEMA_AMAX = vol.Schema(
{
vol.Required(CONF_INSTALLER_CODE): str,
vol.Required(CONF_PASSWORD): str,
}
)
STEP_AUTH_DATA_SCHEMA_BG = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
STEP_INIT_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_CODE): str})
async def try_connect(
data: dict[str, Any], load_selector: int = 0
) -> tuple[str, int | None]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
panel = Panel(
host=data[CONF_HOST],
port=data[CONF_PORT],
automation_code=data.get(CONF_PASSWORD),
installer_or_user_code=data.get(CONF_INSTALLER_CODE, data.get(CONF_USER_CODE)),
)
try:
await panel.connect(load_selector)
finally:
await panel.disconnect()
return (panel.model, panel.serial_number)
class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bosch Alarm."""
def __init__(self) -> None:
"""Init config flow."""
self._data: dict[str, Any] = {}
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:
try:
# Use load_selector = 0 to fetch the panel model without authentication.
(model, serial) = await try_connect(user_input, 0)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
asyncio.exceptions.TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._data = user_input
self._data[CONF_MODEL] = model
return await self.async_step_auth()
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the auth step."""
errors: dict[str, str] = {}
# Each model variant requires a different authentication flow
if "Solution" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
elif "AMAX" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_AMAX
else:
schema = STEP_AUTH_DATA_SCHEMA_BG
if user_input is not None:
self._data.update(user_input)
try:
(model, serial_number) = await try_connect(
self._data, Panel.LOAD_EXTENDED_INFO
)
except (PermissionError, ValueError) as e:
errors["base"] = "invalid_auth"
_LOGGER.error("Authentication Error: %s", e)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if serial_number:
await self.async_set_unique_id(str(serial_number))
self._abort_if_unique_id_configured()
else:
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]})
return self.async_create_entry(title=f"Bosch {model}", data=self._data)
return self.async_show_form(
step_id="auth",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an authentication error."""
self._data = dict(entry_data)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reauth step."""
errors: dict[str, str] = {}
# Each model variant requires a different authentication flow
if "Solution" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
elif "AMAX" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_AMAX
else:
schema = STEP_AUTH_DATA_SCHEMA_BG
if user_input is not None:
reauth_entry = self._get_reauth_entry()
self._data.update(user_input)
try:
(_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO)
except (PermissionError, ValueError) as e:
errors["base"] = "invalid_auth"
_LOGGER.error("Authentication Error: %s", e)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
)

View File

@ -0,0 +1,6 @@
"""Constants for the Bosch Alarm integration."""
DOMAIN = "bosch_alarm"
HISTORY_ATTR = "history"
CONF_INSTALLER_CODE = "installer_code"
CONF_USER_CODE = "user_code"

View File

@ -0,0 +1,73 @@
"""Diagnostics for bosch alarm."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from . import BoschAlarmConfigEntry
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE
TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: BoschAlarmConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": {
"model": entry.runtime_data.model,
"serial_number": entry.runtime_data.serial_number,
"protocol_version": entry.runtime_data.protocol_version,
"firmware_version": entry.runtime_data.firmware_version,
"areas": [
{
"id": area_id,
"name": area.name,
"all_ready": area.all_ready,
"part_ready": area.part_ready,
"faults": area.faults,
"alarms": area.alarms,
"disarmed": area.is_disarmed(),
"arming": area.is_arming(),
"pending": area.is_pending(),
"part_armed": area.is_part_armed(),
"all_armed": area.is_all_armed(),
"armed": area.is_armed(),
"triggered": area.is_triggered(),
}
for area_id, area in entry.runtime_data.areas.items()
],
"points": [
{
"id": point_id,
"name": point.name,
"open": point.is_open(),
"normal": point.is_normal(),
}
for point_id, point in entry.runtime_data.points.items()
],
"doors": [
{
"id": door_id,
"name": door.name,
"open": door.is_open(),
"locked": door.is_locked(),
}
for door_id, door in entry.runtime_data.doors.items()
],
"outputs": [
{
"id": output_id,
"name": output.name,
"active": output.is_active(),
}
for output_id, output in entry.runtime_data.outputs.items()
],
"history_events": entry.runtime_data.events,
},
}

View File

@ -0,0 +1,11 @@
{
"domain": "bosch_alarm",
"name": "Bosch Alarm",
"codeowners": ["@mag1024", "@sanjay900"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["bosch-alarm-mode2==0.4.3"]
}

View File

@ -0,0 +1,84 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No custom actions defined
appropriate-polling:
status: exempt
comment: |
No polling
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
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: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: done
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:
status: exempt
comment: |
Device type integration
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:
status: exempt
comment: |
No repairs
stale-devices:
status: exempt
comment: |
Device type integration
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
Integration does not make any HTTP requests.
strict-typing: done

View File

@ -0,0 +1,57 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your Bosch alarm panel",
"port": "The port used to connect to your Bosch alarm panel. This is usually 7700"
}
},
"auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"installer_code": "Installer code",
"user_code": "User code"
},
"data_description": {
"password": "The Mode 2 automation code from your panel",
"installer_code": "The installer code from your panel",
"user_code": "The user code from your panel"
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]"
},
"data_description": {
"password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "Could not connect to panel."
},
"authentication_failed": {
"message": "Incorrect credentials for panel."
}
}
}

View File

@ -170,6 +170,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key="pressure",

View File

@ -74,7 +74,7 @@
},
"get_events": {
"name": "Get events",
"description": "Get events on a calendar within a time range.",
"description": "Retrieves events on a calendar within a time range.",
"fields": {
"start_date_time": {
"name": "Start time",

View File

@ -142,6 +142,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
@property
def media_artist(self) -> str | None:
"""Artist of current playing media, music track only."""
if (
not self.client.play_state.metadata.artist
and self.client.state.source == "IR"
):
# Return channel instead of artist when playing internet radio
return self.client.play_state.metadata.station
return self.client.play_state.metadata.artist
@property
@ -169,6 +175,11 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
"""Last time the media position was updated."""
return self.client.position_last_updated
@property
def media_channel(self) -> str | None:
"""Channel currently playing."""
return self.client.play_state.metadata.station
@property
def is_volume_muted(self) -> bool | None:
"""Volume mute status."""

View File

@ -2,17 +2,10 @@
from __future__ import annotations
from contextlib import suppress
import logging
from typing import TYPE_CHECKING, Literal, cast
with suppress(Exception):
# TurboJPEG imports numpy which may or may not work so
# we have to guard the import here. We still want
# to import it at top level so it gets loaded
# in the import executor and not in the event loop.
from turbojpeg import TurboJPEG
from turbojpeg import TurboJPEG
if TYPE_CHECKING:
from . import Image

View File

@ -81,7 +81,7 @@ class ChromecastInfo:
"+label%3A%22integration%3A+cast%22"
)
_LOGGER.debug(
_LOGGER.info(
(
"Fetched cast details for unknown model '%s' manufacturer:"
" '%s', type: '%s'. Please %s"

View File

@ -14,7 +14,7 @@
"documentation": "https://www.home-assistant.io/integrations/cast",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.6"],
"requirements": ["PyChromecast==14.0.7"],
"single_config_entry": true,
"zeroconf": ["_googlecast._tcp.local."]
}

View File

@ -44,7 +44,7 @@ class ChaconDioConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except DIOChaconInvalidAuthError:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

View File

@ -98,13 +98,13 @@
"name": "Preset",
"state": {
"none": "None",
"eco": "Eco",
"away": "Away",
"home": "[%key:common::state::home%]",
"away": "[%key:common::state::not_home%]",
"activity": "Activity",
"boost": "Boost",
"comfort": "Comfort",
"home": "[%key:common::state::home%]",
"sleep": "Sleep",
"activity": "Activity"
"eco": "Eco",
"sleep": "Sleep"
}
},
"preset_modes": {
@ -257,7 +257,7 @@
"selector": {
"hvac_mode": {
"options": {
"off": "Off",
"off": "[%key:common::state::off%]",
"auto": "Auto",
"cool": "Cool",
"dry": "Dry",

View File

@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
flow_id=flow_id, user_input=tokens
)
self.hass.async_create_task(await_tokens())
# It's a background task because it should be cancelled on shutdown and there's nothing else
# we can do in such case. There's also no need to wait for this during setup.
self.hass.async_create_background_task(
await_tokens(), name="Awaiting OAuth tokens"
)
return authorize_url

View File

@ -4,13 +4,14 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
from http import HTTPStatus
import logging
import random
from typing import Any
from aiohttp import ClientError
from aiohttp import ClientError, ClientResponseError
from hass_nabucasa import Cloud, CloudError
from hass_nabucasa.api import CloudApiNonRetryableError
from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError
from hass_nabucasa.cloud_api import (
FilesHandlerListEntry,
async_files_delete_file,
@ -120,6 +121,8 @@ class CloudBackupAgent(BackupAgent):
"""
if not backup.protected:
raise BackupAgentError("Cloud backups must be protected")
if self._cloud.subscription_expired:
raise BackupAgentError("Cloud subscription has expired")
size = backup.size
try:
@ -152,6 +155,13 @@ class CloudBackupAgent(BackupAgent):
) from err
raise BackupAgentError(f"Failed to upload backup {err}") from err
except CloudError as err:
if (
isinstance(err, CloudApiError)
and isinstance(err.orig_exc, ClientResponseError)
and err.orig_exc.status == HTTPStatus.FORBIDDEN
and self._cloud.subscription_expired
):
raise BackupAgentError("Cloud subscription has expired") from err
if tries == _RETRY_LIMIT:
raise BackupAgentError(f"Failed to upload backup {err}") from err
tries += 1

View File

@ -245,6 +245,10 @@ class CloudLoginView(HomeAssistantView):
name = "api:cloud:login"
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle login request."""
return await self._post(request)
@_handle_cloud_errors
@RequestDataValidator(
vol.Schema(
@ -259,7 +263,7 @@ class CloudLoginView(HomeAssistantView):
)
)
)
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle login request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@ -316,8 +320,12 @@ class CloudLogoutView(HomeAssistantView):
name = "api:cloud:logout"
@require_admin
@_handle_cloud_errors
async def post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
return await self._post(request)
@_handle_cloud_errors
async def _post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@ -400,9 +408,13 @@ class CloudForgotPasswordView(HomeAssistantView):
name = "api:cloud:forgot_password"
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle forgot password request."""
return await self._post(request)
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle forgot password request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]

View File

@ -9,7 +9,6 @@ from typing import Any
import pycfdns
import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
from homeassistant.core import HomeAssistant
@ -118,8 +117,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
persistent_notification.async_dismiss(self.hass, "cloudflare_setup")
errors: dict[str, str] = {}
if user_input is not None:

View File

@ -4,19 +4,19 @@
"step": {
"user": {
"title": "Connect to Cloudflare",
"description": "This integration requires an API Token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.",
"description": "This integration requires an API token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.",
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
}
},
"zone": {
"title": "Choose the Zone to Update",
"title": "Choose the zone to update",
"data": {
"zone": "Zone"
}
},
"records": {
"title": "Choose the Records to Update",
"title": "Choose the records to update",
"data": {
"records": "Records"
}
@ -40,7 +40,7 @@
"services": {
"update_records": {
"name": "Update records",
"description": "Manually trigger update to Cloudflare records."
"description": "Manually triggers an update of Cloudflare records."
}
}
}

View File

@ -41,6 +41,7 @@ ALARM_ACTIONS: dict[str, str] = {
ALARM_AREA_ARMED_STATUS: dict[str, int] = {
DISABLE: 0,
HOME_P1: 1,
HOME_P2: 2,
NIGHT: 3,
@ -128,20 +129,38 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
}.get(self._area.human_status)
async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None:
"""Update state after action."""
self._area.human_status = area_state
self._area.armed = armed
await self.async_update_ha_state()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
if code != str(self._api.device_pin):
return
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE])
await self._async_update_state(
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY])
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME])
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
)
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT])
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
)

View File

@ -119,10 +119,10 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)
self._update_attributes()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
if not isinstance(device.val, list):
raise HomeAssistantError(
@ -158,6 +158,12 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
self._attr_target_temperature = values[4] / 10
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_attributes()
super()._handle_coordinator_update()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (
@ -171,6 +177,8 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
await self.coordinator.api.set_clima_status(
self._device.index, ClimaComelitCommand.SET, target_temp
)
self._attr_target_temperature = target_temp
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
@ -182,3 +190,5 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
await self.coordinator.api.set_clima_status(
self._device.index, MODE_TO_ACTION[hvac_mode]
)
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()

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