Compare commits

..

1 Commits

Author SHA1 Message Date
epenet
37170796c3 Drop ignore-missing-annotations from pylint 2025-12-02 11:00:59 +01:00
477 changed files with 3109 additions and 23382 deletions

View File

@@ -13,7 +13,6 @@ core: &core
# Our base platforms, that are used by other integrations # Our base platforms, that are used by other integrations
base_platforms: &base_platforms base_platforms: &base_platforms
- homeassistant/components/ai_task/**
- homeassistant/components/air_quality/** - homeassistant/components/air_quality/**
- homeassistant/components/alarm_control_panel/** - homeassistant/components/alarm_control_panel/**
- homeassistant/components/assist_satellite/** - homeassistant/components/assist_satellite/**

View File

@@ -27,6 +27,7 @@
"charliermarsh.ruff", "charliermarsh.ruff",
"ms-python.pylint", "ms-python.pylint",
"ms-python.vscode-pylance", "ms-python.vscode-pylance",
"visualstudioexptteam.vscodeintellicode",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"GitHub.vscode-pull-request-github", "GitHub.vscode-pull-request-github",

View File

@@ -30,7 +30,7 @@ jobs:
architectures: ${{ env.ARCHITECTURES }} architectures: ${{ env.ARCHITECTURES }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
@@ -96,7 +96,7 @@ jobs:
os: ubuntu-24.04-arm os: ubuntu-24.04-arm
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
@@ -273,7 +273,7 @@ jobs:
- green - green
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set build additional args - name: Set build additional args
run: | run: |
@@ -311,7 +311,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize git - name: Initialize git
uses: home-assistant/actions/helpers/git-init@master uses: home-assistant/actions/helpers/git-init@master
@@ -416,19 +416,9 @@ jobs:
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]') ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
for arch in $ARCHS; do for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..." echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do docker buildx imagetools create \
if docker buildx imagetools create \ --tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \ "ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"; then
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
sleep 10
if [ "${attempt}" -eq 3 ]; then
echo "Failed after 3 attempts"
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
done done
@@ -474,7 +464,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
@@ -519,7 +509,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View File

@@ -41,8 +41,8 @@ env:
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.1" HA_SHORT_VERSION: "2026.1"
DEFAULT_PYTHON: "3.13.9" DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13.9', '3.14.0']" ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support # 10.6 is the current long-term-support
@@ -99,7 +99,7 @@ jobs:
steps: steps:
- &checkout - &checkout
name: Check out code from GitHub name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Generate partial Python venv restore key - name: Generate partial Python venv restore key
id: generate_python_cache_key id: generate_python_cache_key
run: | run: |
@@ -686,14 +686,14 @@ jobs:
run: | run: |
. venv/bin/activate . venv/bin/activate
python --version python --version
pylint --ignore-missing-annotations=y homeassistant pylint homeassistant
- name: Run pylint (partially) - name: Run pylint (partially)
if: needs.info.outputs.test_full_suite == 'false' if: needs.info.outputs.test_full_suite == 'false'
shell: bash shell: bash
run: | run: |
. venv/bin/activate . venv/bin/activate
python --version python --version
pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }} pylint homeassistant/components/${{ needs.info.outputs.integrations_glob }}
pylint-tests: pylint-tests:
name: Check pylint on tests name: Check pylint on tests

View File

@@ -21,7 +21,7 @@ jobs:
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6

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@997185467fa4f803885201cee163a9f38240193d # v10.1.1 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.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@997185467fa4f803885201cee163a9f38240193d # v10.1.1 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.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@997185467fa4f803885201cee163a9f38240193d # v10.1.1 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.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

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0

View File

@@ -31,7 +31,7 @@ jobs:
steps: steps:
- &checkout - &checkout
name: Checkout the repository name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python

9
CODEOWNERS generated
View File

@@ -571,8 +571,6 @@ build.json @home-assistant/supervisor
/tests/components/generic_hygrostat/ @Shulyaka /tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti /homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti /tests/components/geniushub/ @manzanotti
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
/homeassistant/components/geo_json_events/ @exxamalte /homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core /homeassistant/components/geo_location/ @home-assistant/core
@@ -1358,8 +1356,8 @@ build.json @home-assistant/supervisor
/tests/components/ring/ @sdb9696 /tests/components/ring/ @sdb9696
/homeassistant/components/risco/ @OnFreund /homeassistant/components/risco/ @OnFreund
/tests/components/risco/ @OnFreund /tests/components/risco/ @OnFreund
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm /homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm /tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi /homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @Lash-L @allenporter /homeassistant/components/roborock/ @Lash-L @allenporter
@@ -1765,7 +1763,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/vilfo/ @ManneW /homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel /homeassistant/components/vivotek/ @HarlemSquirrel
/tests/components/vivotek/ @HarlemSquirrel
/homeassistant/components/vizio/ @raman325 /homeassistant/components/vizio/ @raman325
/tests/components/vizio/ @raman325 /tests/components/vizio/ @raman325
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
@@ -1805,8 +1802,6 @@ build.json @home-assistant/supervisor
/tests/components/weatherflow_cloud/ @jeeftor /tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner /homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner
/homeassistant/components/web_rtc/ @home-assistant/core
/tests/components/web_rtc/ @home-assistant/core
/homeassistant/components/webdav/ @jpbede /homeassistant/components/webdav/ @jpbede
/tests/components/webdav/ @jpbede /tests/components/webdav/ @jpbede
/homeassistant/components/webhook/ @home-assistant/core /homeassistant/components/webhook/ @home-assistant/core

View File

@@ -101,8 +101,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}), vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
_validate_structure_fields, _validate_structure_fields,
), ),
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector( vol.Optional(ATTR_ATTACHMENTS): vol.All(
{"accept": ["*/*"], "multiple": True} cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
), ),
} }
), ),
@@ -118,8 +118,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Required(ATTR_TASK_NAME): cv.string, vol.Required(ATTR_TASK_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_id, vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_INSTRUCTIONS): cv.string, vol.Required(ATTR_INSTRUCTIONS): cv.string,
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector( vol.Optional(ATTR_ATTACHMENTS): vol.All(
{"accept": ["*/*"], "multiple": True} cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
), ),
} }
), ),

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:

View File

@@ -44,7 +44,7 @@ rules:
discovery: done discovery: done
docs-data-update: done docs-data-update: done
docs-examples: todo docs-examples: todo
docs-known-limitations: done docs-known-limitations: todo
docs-supported-devices: done docs-supported-devices: done
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: done docs-troubleshooting: done
@@ -54,7 +54,7 @@ rules:
comment: Single device integration, no dynamic device discovery needed. comment: Single device integration, no dynamic device discovery needed.
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: todo
entity-translations: todo entity-translations: todo
exception-translations: done exception-translations: done
icon-translations: todo icon-translations: todo

View File

@@ -1,134 +0,0 @@
"""Sensor platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyairobotrest.models import ThermostatStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AirobotConfigEntry
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotSensorEntityDescription(SensorEntityDescription):
"""Describes Airobot sensor entity."""
value_fn: Callable[[ThermostatStatus], StateType]
supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True
SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
AirobotSensorEntityDescription(
key="air_temperature",
translation_key="air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_air,
),
AirobotSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.hum_air,
),
AirobotSensorEntityDescription(
key="floor_temperature",
translation_key="floor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_floor,
supported_fn=lambda status: status.has_floor_sensor,
),
AirobotSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.co2,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="air_quality_index",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.aqi,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="heating_uptime",
translation_key="heating_uptime",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.heating_uptime,
entity_registry_enabled_default=False,
),
AirobotSensorEntityDescription(
key="errors",
translation_key="errors",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.errors,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
AirobotSensor(coordinator, description)
for description in SENSOR_TYPES
if description.supported_fn(coordinator.data.status)
)
class AirobotSensor(AirobotEntity, SensorEntity):
"""Representation of an Airobot sensor."""
entity_description: AirobotSensorEntityDescription
def __init__(
self,
coordinator,
description: AirobotSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data.status)

View File

@@ -43,25 +43,6 @@
} }
} }
}, },
"entity": {
"sensor": {
"air_temperature": {
"name": "Air temperature"
},
"device_uptime": {
"name": "Device uptime"
},
"errors": {
"name": "Error count"
},
"floor_temperature": {
"name": "Floor temperature"
},
"heating_uptime": {
"name": "Heating uptime"
}
}
},
"exceptions": { "exceptions": {
"authentication_failed": { "authentication_failed": {
"message": "Authentication failed, please reauthenticate." "message": "Authentication failed, please reauthenticate."

View File

@@ -159,74 +159,74 @@
"title": "Alarm control panel", "title": "Alarm control panel",
"triggers": { "triggers": {
"armed": { "armed": {
"description": "Triggers after one or more alarms become armed, regardless of the mode.", "description": "Triggers when an alarm is armed.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
} }
}, },
"name": "Alarm armed" "name": "When an alarm is armed"
}, },
"armed_away": { "armed_away": {
"description": "Triggers after one or more alarms become armed in away mode.", "description": "Triggers when an alarm is armed away.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
} }
}, },
"name": "Alarm armed away" "name": "When an alarm is armed away"
}, },
"armed_home": { "armed_home": {
"description": "Triggers after one or more alarms become armed in home mode.", "description": "Triggers when an alarm is armed home.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
} }
}, },
"name": "Alarm armed home" "name": "When an alarm is armed home"
}, },
"armed_night": { "armed_night": {
"description": "Triggers after one or more alarms become armed in night mode.", "description": "Triggers when an alarm is armed night.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
} }
}, },
"name": "Alarm armed night" "name": "When an alarm is armed night"
}, },
"armed_vacation": { "armed_vacation": {
"description": "Triggers after one or more alarms become armed in vacation mode.", "description": "Triggers when an alarm is armed vacation.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
} }
}, },
"name": "Alarm armed vacation" "name": "When an alarm is armed vacation"
}, },
"disarmed": { "disarmed": {
"description": "Triggers after one or more alarms become disarmed.", "description": "Triggers when an alarm is disarmed.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
} }
}, },
"name": "Alarm disarmed" "name": "When an alarm is disarmed"
}, },
"triggered": { "triggered": {
"description": "Triggers after one or more alarms become triggered.", "description": "Triggers when an alarm is triggered.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
} }
}, },
"name": "Alarm triggered" "name": "When an alarm is triggered"
} }
} }
} }

View File

@@ -30,7 +30,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_PASSWORD): selector.TextSelector( vol.Required(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
), ),
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
} }
) )
@@ -69,19 +68,34 @@ class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass, self.hass,
cookie_jar=CookieJar(quote_cookie=False), cookie_jar=CookieJar(quote_cookie=False),
), ),
account_number=user_input[CONF_ACCOUNT_NUMBER], account_number=user_input.get(CONF_ACCOUNT_NUMBER),
) )
) )
if isinstance(validation_response, BaseAuth): if isinstance(validation_response, BaseAuth):
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER]) account_number = (
user_input.get(CONF_ACCOUNT_NUMBER)
or validation_response.account_number
)
await self.async_set_unique_id(account_number)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_ACCOUNT_NUMBER], title=account_number,
data={ data={
**user_input, **user_input,
CONF_ACCESS_TOKEN: validation_response.refresh_token, CONF_ACCESS_TOKEN: validation_response.refresh_token,
CONF_ACCOUNT_NUMBER: account_number,
}, },
) )
if validation_response == "smart_meter_unavailable":
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA.extend(
{
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
}
),
errors={"base": validation_response},
)
errors["base"] = validation_response errors["base"] = validation_response
return self.async_show_form( return self.async_show_form(

View File

@@ -5,7 +5,6 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anglian_water", "documentation": "https://www.home-assistant.io/integrations/anglian_water",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pyanglianwater==2.1.0"] "requirements": ["pyanglianwater==2.1.0"]
} }

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite", "documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity", "integration_type": "entity",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==3.5.0"] "requirements": ["hassil==3.4.0"]
} }

View File

@@ -112,44 +112,44 @@
"title": "Assist satellite", "title": "Assist satellite",
"triggers": { "triggers": {
"idle": { "idle": {
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.", "description": "Triggers when an Assist satellite becomes idle.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
} }
}, },
"name": "Satellite became idle" "name": "When an Assist satellite becomes idle"
}, },
"listening": { "listening": {
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.", "description": "Triggers when an Assist satellite starts listening.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
} }
}, },
"name": "Satellite started listening" "name": "When an Assist satellite starts listening"
}, },
"processing": { "processing": {
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.", "description": "Triggers when an Assist satellite is processing.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
} }
}, },
"name": "Satellite started processing" "name": "When an Assist satellite is processing"
}, },
"responding": { "responding": {
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.", "description": "Triggers when an Assist satellite is responding.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
} }
}, },
"name": "Satellite started responding" "name": "When an Assist satellite is responding"
} }
} }
} }

View File

@@ -124,7 +124,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
_EXPERIMENTAL_TRIGGER_PLATFORMS = { _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel", "alarm_control_panel",
"assist_satellite", "assist_satellite",
"binary_sensor",
"climate", "climate",
"cover", "cover",
"fan", "fan",

View File

@@ -8,8 +8,6 @@
"integration_type": "system", "integration_type": "system",
"preview_features": { "preview_features": {
"new_triggers_conditions": { "new_triggers_conditions": {
"feedback_url": "https://forms.gle/fWFZqf5MzuwWTsCH8",
"learn_more_url": "https://www.home-assistant.io/blog/2025/12/03/release-202512/#purpose-specific-triggers-and-conditions",
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/automation&integration_name=Automation" "report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/automation&integration_name=Automation"
} }
}, },

View File

@@ -69,10 +69,10 @@
}, },
"preview_features": { "preview_features": {
"new_triggers_conditions": { "new_triggers_conditions": {
"description": "Enables new purpose-specific triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own purpose-specific triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.", "description": "Enables new intuitive triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own intuitive triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new purpose-specific triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.", "disable_confirmation": "Disabling this preview will cause automations and scripts that use the new intuitive triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
"enable_confirmation": "This feature is still in development and may change. These new purpose-specific triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.", "enable_confirmation": "This feature is still in development and may change. These new intuitive triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
"name": "Purpose-specific triggers and conditions" "name": "Intuitive triggers and conditions"
} }
}, },
"services": { "services": {

View File

@@ -21,29 +21,29 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.util.ssl import get_default_context from homeassistant.util.ssl import get_default_context
from .const import DOMAIN from .const import DOMAIN
from .websocket import BeoWebsocket from .websocket import BangOlufsenWebsocket
@dataclass @dataclass
class BeoData: class BangOlufsenData:
"""Dataclass for API client and WebSocket client.""" """Dataclass for API client and WebSocket client."""
websocket: BeoWebsocket websocket: BangOlufsenWebsocket
client: MozartClient client: MozartClient
type BeoConfigEntry = ConfigEntry[BeoData] type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData]
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool:
"""Set up from a config entry.""" """Set up from a config entry."""
# Remove casts to str # Remove casts to str
assert entry.unique_id assert entry.unique_id
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc. # Create device now as BangOlufsenWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
device_registry.async_get_or_create( device_registry.async_get_or_create(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
@@ -68,10 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
await client.close_api_client() await client.close_api_client()
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
websocket = BeoWebsocket(hass, entry, client) websocket = BangOlufsenWebsocket(hass, entry, client)
# Add the websocket and API client # Add the websocket and API client
entry.runtime_data = BeoData(websocket, client) entry.runtime_data = BangOlufsenData(websocket, client)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -82,7 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool: async def async_unload_entry(
hass: HomeAssistant, entry: BangOlufsenConfigEntry
) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
# Close the API client and WebSocket notification listener # Close the API client and WebSocket notification listener
entry.runtime_data.client.disconnect_notifications() entry.runtime_data.client.disconnect_notifications()

View File

@@ -47,7 +47,7 @@ _exception_map = {
} }
class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN): class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow.""" """Handle a config flow."""
_beolink_jid = "" _beolink_jid = ""

View File

@@ -14,7 +14,7 @@ from homeassistant.components.media_player import (
) )
class BeoSource: class BangOlufsenSource:
"""Class used for associating device source ids with friendly names. May not include all sources.""" """Class used for associating device source ids with friendly names. May not include all sources."""
DEEZER: Final[Source] = Source(name="Deezer", id="deezer") DEEZER: Final[Source] = Source(name="Deezer", id="deezer")
@@ -26,7 +26,7 @@ class BeoSource:
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer") URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
BEO_STATES: dict[str, MediaPlayerState] = { BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
# Dict used for translating device states to Home Assistant states. # Dict used for translating device states to Home Assistant states.
"started": MediaPlayerState.PLAYING, "started": MediaPlayerState.PLAYING,
"buffering": MediaPlayerState.PLAYING, "buffering": MediaPlayerState.PLAYING,
@@ -40,19 +40,19 @@ BEO_STATES: dict[str, MediaPlayerState] = {
} }
# Dict used for translating Home Assistant settings to device repeat settings. # Dict used for translating Home Assistant settings to device repeat settings.
BEO_REPEAT_FROM_HA: dict[RepeatMode, str] = { BANG_OLUFSEN_REPEAT_FROM_HA: dict[RepeatMode, str] = {
RepeatMode.ALL: "all", RepeatMode.ALL: "all",
RepeatMode.ONE: "track", RepeatMode.ONE: "track",
RepeatMode.OFF: "none", RepeatMode.OFF: "none",
} }
# Dict used for translating device repeat settings to Home Assistant settings. # Dict used for translating device repeat settings to Home Assistant settings.
BEO_REPEAT_TO_HA: dict[str, RepeatMode] = { BANG_OLUFSEN_REPEAT_TO_HA: dict[str, RepeatMode] = {
value: key for key, value in BEO_REPEAT_FROM_HA.items() value: key for key, value in BANG_OLUFSEN_REPEAT_FROM_HA.items()
} }
# Media types for play_media # Media types for play_media
class BeoMediaType(StrEnum): class BangOlufsenMediaType(StrEnum):
"""Bang & Olufsen specific media types.""" """Bang & Olufsen specific media types."""
FAVOURITE = "favourite" FAVOURITE = "favourite"
@@ -63,7 +63,7 @@ class BeoMediaType(StrEnum):
OVERLAY_TTS = "overlay_tts" OVERLAY_TTS = "overlay_tts"
class BeoModel(StrEnum): class BangOlufsenModel(StrEnum):
"""Enum for compatible model names.""" """Enum for compatible model names."""
# Mozart devices # Mozart devices
@@ -82,7 +82,7 @@ class BeoModel(StrEnum):
BEOREMOTE_ONE = "Beoremote One" BEOREMOTE_ONE = "Beoremote One"
class BeoAttribute(StrEnum): class BangOlufsenAttribute(StrEnum):
"""Enum for extra_state_attribute keys.""" """Enum for extra_state_attribute keys."""
BEOLINK = "beolink" BEOLINK = "beolink"
@@ -93,7 +93,7 @@ class BeoAttribute(StrEnum):
# Physical "buttons" on devices # Physical "buttons" on devices
class BeoButtons(StrEnum): class BangOlufsenButtons(StrEnum):
"""Enum for device buttons.""" """Enum for device buttons."""
BLUETOOTH = "Bluetooth" BLUETOOTH = "Bluetooth"
@@ -140,7 +140,7 @@ class WebsocketNotification(StrEnum):
DOMAIN: Final[str] = "bang_olufsen" DOMAIN: Final[str] = "bang_olufsen"
# Default values for configuration. # Default values for configuration.
DEFAULT_MODEL: Final[str] = BeoModel.BEOSOUND_BALANCE DEFAULT_MODEL: Final[str] = BangOlufsenModel.BEOSOUND_BALANCE
# Configuration. # Configuration.
CONF_SERIAL_NUMBER: Final = "serial_number" CONF_SERIAL_NUMBER: Final = "serial_number"
@@ -148,7 +148,7 @@ CONF_BEOLINK_JID: Final = "jid"
# Models to choose from in manual configuration. # Models to choose from in manual configuration.
SELECTABLE_MODELS: list[str] = [ SELECTABLE_MODELS: list[str] = [
model.value for model in BeoModel if model != BeoModel.BEOREMOTE_ONE model.value for model in BangOlufsenModel if model != BangOlufsenModel.BEOREMOTE_ONE
] ]
MANUFACTURER: Final[str] = "Bang & Olufsen" MANUFACTURER: Final[str] = "Bang & Olufsen"
@@ -160,15 +160,15 @@ ATTR_ITEM_NUMBER: Final[str] = "in"
ATTR_FRIENDLY_NAME: Final[str] = "fn" ATTR_FRIENDLY_NAME: Final[str] = "fn"
# Power states. # Power states.
BEO_ON: Final[str] = "on" BANG_OLUFSEN_ON: Final[str] = "on"
VALID_MEDIA_TYPES: Final[tuple] = ( VALID_MEDIA_TYPES: Final[tuple] = (
BeoMediaType.FAVOURITE, BangOlufsenMediaType.FAVOURITE,
BeoMediaType.DEEZER, BangOlufsenMediaType.DEEZER,
BeoMediaType.RADIO, BangOlufsenMediaType.RADIO,
BeoMediaType.TTS, BangOlufsenMediaType.TTS,
BeoMediaType.TIDAL, BangOlufsenMediaType.TIDAL,
BeoMediaType.OVERLAY_TTS, BangOlufsenMediaType.OVERLAY_TTS,
MediaType.MUSIC, MediaType.MUSIC,
MediaType.URL, MediaType.URL,
MediaType.CHANNEL, MediaType.CHANNEL,
@@ -246,7 +246,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
) )
# Device events # Device events
BEO_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event" BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
# Dict used to translate native Bang & Olufsen event names to string.json compatible ones # Dict used to translate native Bang & Olufsen event names to string.json compatible ones
EVENT_TRANSLATION_MAP: dict[str, str] = { EVENT_TRANSLATION_MAP: dict[str, str] = {
@@ -263,7 +263,7 @@ EVENT_TRANSLATION_MAP: dict[str, str] = {
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS" CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BeoButtons] DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BangOlufsenButtons]
DEVICE_BUTTON_EVENTS: Final[list[str]] = [ DEVICE_BUTTON_EVENTS: Final[list[str]] = [

View File

@@ -10,13 +10,13 @@ from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import BeoConfigEntry from . import BangOlufsenConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .util import get_device_buttons from .util import get_device_buttons
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: BeoConfigEntry hass: HomeAssistant, config_entry: BangOlufsenConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""

View File

@@ -24,8 +24,8 @@ from homeassistant.helpers.entity import Entity
from .const import DOMAIN from .const import DOMAIN
class BeoBase: class BangOlufsenBase:
"""Base class for Bang & Olufsen Home Assistant objects.""" """Base class for BangOlufsen Home Assistant objects."""
def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
"""Initialize the object.""" """Initialize the object."""
@@ -51,8 +51,8 @@ class BeoBase:
) )
class BeoEntity(Entity, BeoBase): class BangOlufsenEntity(Entity, BangOlufsenBase):
"""Base Entity for Bang & Olufsen entities.""" """Base Entity for BangOlufsen entities."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False

View File

@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry from . import BangOlufsenConfigEntry
from .const import ( from .const import (
BEO_REMOTE_CONTROL_KEYS, BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEY_EVENTS, BEO_REMOTE_KEY_EVENTS,
@@ -25,10 +25,10 @@ from .const import (
DEVICE_BUTTON_EVENTS, DEVICE_BUTTON_EVENTS,
DOMAIN, DOMAIN,
MANUFACTURER, MANUFACTURER,
BeoModel, BangOlufsenModel,
WebsocketNotification, WebsocketNotification,
) )
from .entity import BeoEntity from .entity import BangOlufsenEntity
from .util import get_device_buttons, get_remotes from .util import get_device_buttons, get_remotes
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -36,14 +36,14 @@ PARALLEL_UPDATES = 0
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: BeoConfigEntry, config_entry: BangOlufsenConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Event entities from config entry.""" """Set up Event entities from config entry."""
entities: list[BeoEvent] = [] entities: list[BangOlufsenEvent] = []
async_add_entities( async_add_entities(
BeoButtonEvent(config_entry, button_type) BangOlufsenButtonEvent(config_entry, button_type)
for button_type in get_device_buttons(config_entry.data[CONF_MODEL]) for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
) )
@@ -54,7 +54,7 @@ async def async_setup_entry(
# Add Light keys # Add Light keys
entities.extend( entities.extend(
[ [
BeoRemoteKeyEvent( BangOlufsenRemoteKeyEvent(
config_entry, config_entry,
remote, remote,
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}", f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
@@ -66,7 +66,7 @@ async def async_setup_entry(
# Add Control keys # Add Control keys
entities.extend( entities.extend(
[ [
BeoRemoteKeyEvent( BangOlufsenRemoteKeyEvent(
config_entry, config_entry,
remote, remote,
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}", f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
@@ -84,9 +84,10 @@ async def async_setup_entry(
config_entry.entry_id config_entry.entry_id
) )
for device in devices: for device in devices:
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in { if (
remote.serial_number for remote in remotes device.model == BangOlufsenModel.BEOREMOTE_ONE
}: and device.serial_number not in {remote.serial_number for remote in remotes}
):
device_registry.async_update_device( device_registry.async_update_device(
device.id, remove_config_entry_id=config_entry.entry_id device.id, remove_config_entry_id=config_entry.entry_id
) )
@@ -94,13 +95,13 @@ async def async_setup_entry(
async_add_entities(new_entities=entities) async_add_entities(new_entities=entities)
class BeoEvent(BeoEntity, EventEntity): class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
"""Base Event class.""" """Base Event class."""
_attr_device_class = EventDeviceClass.BUTTON _attr_device_class = EventDeviceClass.BUTTON
_attr_entity_registry_enabled_default = False _attr_entity_registry_enabled_default = False
def __init__(self, config_entry: BeoConfigEntry) -> None: def __init__(self, config_entry: BangOlufsenConfigEntry) -> None:
"""Initialize Event.""" """Initialize Event."""
super().__init__(config_entry, config_entry.runtime_data.client) super().__init__(config_entry, config_entry.runtime_data.client)
@@ -111,12 +112,12 @@ class BeoEvent(BeoEntity, EventEntity):
self.async_write_ha_state() self.async_write_ha_state()
class BeoButtonEvent(BeoEvent): class BangOlufsenButtonEvent(BangOlufsenEvent):
"""Event class for Button events.""" """Event class for Button events."""
_attr_event_types = DEVICE_BUTTON_EVENTS _attr_event_types = DEVICE_BUTTON_EVENTS
def __init__(self, config_entry: BeoConfigEntry, button_type: str) -> None: def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
"""Initialize Button.""" """Initialize Button."""
super().__init__(config_entry) super().__init__(config_entry)
@@ -145,14 +146,14 @@ class BeoButtonEvent(BeoEvent):
) )
class BeoRemoteKeyEvent(BeoEvent): class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
"""Event class for Beoremote One key events.""" """Event class for Beoremote One key events."""
_attr_event_types = BEO_REMOTE_KEY_EVENTS _attr_event_types = BEO_REMOTE_KEY_EVENTS
def __init__( def __init__(
self, self,
config_entry: BeoConfigEntry, config_entry: BangOlufsenConfigEntry,
remote: PairedRemote, remote: PairedRemote,
key_type: str, key_type: str,
) -> None: ) -> None:
@@ -165,8 +166,8 @@ class BeoRemoteKeyEvent(BeoEvent):
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}" self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}, identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}", name=f"{BangOlufsenModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
model=BeoModel.BEOREMOTE_ONE, model=BangOlufsenModel.BEOREMOTE_ONE,
serial_number=remote.serial_number, serial_number=remote.serial_number,
sw_version=remote.app_version, sw_version=remote.app_version,
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["mozart-api==5.3.1.108.0"], "requirements": ["mozart-api==5.1.0.247.1"],
"zeroconf": ["_bangolufsen._tcp.local."] "zeroconf": ["_bangolufsen._tcp.local."]
} }

View File

@@ -69,11 +69,11 @@ from homeassistant.helpers.entity_platform import (
) )
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from . import BeoConfigEntry from . import BangOlufsenConfigEntry
from .const import ( from .const import (
BEO_REPEAT_FROM_HA, BANG_OLUFSEN_REPEAT_FROM_HA,
BEO_REPEAT_TO_HA, BANG_OLUFSEN_REPEAT_TO_HA,
BEO_STATES, BANG_OLUFSEN_STATES,
BEOLINK_JOIN_SOURCES, BEOLINK_JOIN_SOURCES,
BEOLINK_JOIN_SOURCES_TO_UPPER, BEOLINK_JOIN_SOURCES_TO_UPPER,
CONF_BEOLINK_JID, CONF_BEOLINK_JID,
@@ -82,12 +82,12 @@ from .const import (
FALLBACK_SOURCES, FALLBACK_SOURCES,
MANUFACTURER, MANUFACTURER,
VALID_MEDIA_TYPES, VALID_MEDIA_TYPES,
BeoAttribute, BangOlufsenAttribute,
BeoMediaType, BangOlufsenMediaType,
BeoSource, BangOlufsenSource,
WebsocketNotification, WebsocketNotification,
) )
from .entity import BeoEntity from .entity import BangOlufsenEntity
from .util import get_serial_number_from_jid from .util import get_serial_number_from_jid
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -96,7 +96,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
BEO_FEATURES = ( BANG_OLUFSEN_FEATURES = (
MediaPlayerEntityFeature.BROWSE_MEDIA MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.GROUPING
@@ -119,13 +119,15 @@ BEO_FEATURES = (
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: BeoConfigEntry, config_entry: BangOlufsenConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up a Media Player entity from config entry.""" """Set up a Media Player entity from config entry."""
# Add MediaPlayer entity # Add MediaPlayer entity
async_add_entities( async_add_entities(
new_entities=[BeoMediaPlayer(config_entry, config_entry.runtime_data.client)], new_entities=[
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
],
update_before_add=True, update_before_add=True,
) )
@@ -185,7 +187,7 @@ async def async_setup_entry(
) )
class BeoMediaPlayer(BeoEntity, MediaPlayerEntity): class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Representation of a media player.""" """Representation of a media player."""
_attr_name = None _attr_name = None
@@ -286,7 +288,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
queue_settings = await self._client.get_settings_queue(_request_timeout=5) queue_settings = await self._client.get_settings_queue(_request_timeout=5)
if queue_settings.repeat is not None: if queue_settings.repeat is not None:
self._attr_repeat = BEO_REPEAT_TO_HA[queue_settings.repeat] self._attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
if queue_settings.shuffle is not None: if queue_settings.shuffle is not None:
self._attr_shuffle = queue_settings.shuffle self._attr_shuffle = queue_settings.shuffle
@@ -406,8 +408,8 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
# Check if source is line-in or optical and progress should be updated # Check if source is line-in or optical and progress should be updated
if self._source_change.id in ( if self._source_change.id in (
BeoSource.LINE_IN.id, BangOlufsenSource.LINE_IN.id,
BeoSource.SPDIF.id, BangOlufsenSource.SPDIF.id,
): ):
self._playback_progress = PlaybackProgress(progress=0) self._playback_progress = PlaybackProgress(progress=0)
@@ -448,8 +450,10 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
# Add Beolink self # Add Beolink self
self._beolink_attributes = { self._beolink_attributes = {
BeoAttribute.BEOLINK: { BangOlufsenAttribute.BEOLINK: {
BeoAttribute.BEOLINK_SELF: {self.device_entry.name: self._beolink_jid} BangOlufsenAttribute.BEOLINK_SELF: {
self.device_entry.name: self._beolink_jid
}
} }
} }
@@ -457,12 +461,12 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
peers = await self._client.get_beolink_peers() peers = await self._client.get_beolink_peers()
if len(peers) > 0: if len(peers) > 0:
self._beolink_attributes[BeoAttribute.BEOLINK][ self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BeoAttribute.BEOLINK_PEERS BangOlufsenAttribute.BEOLINK_PEERS
] = {} ] = {}
for peer in peers: for peer in peers:
self._beolink_attributes[BeoAttribute.BEOLINK][ self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BeoAttribute.BEOLINK_PEERS BangOlufsenAttribute.BEOLINK_PEERS
][peer.friendly_name] = peer.jid ][peer.friendly_name] = peer.jid
# Add Beolink listeners / leader # Add Beolink listeners / leader
@@ -484,8 +488,8 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
# Add self # Add self
group_members.append(self.entity_id) group_members.append(self.entity_id)
self._beolink_attributes[BeoAttribute.BEOLINK][ self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BeoAttribute.BEOLINK_LEADER BangOlufsenAttribute.BEOLINK_LEADER
] = { ] = {
self._remote_leader.friendly_name: self._remote_leader.jid, self._remote_leader.friendly_name: self._remote_leader.jid,
} }
@@ -523,8 +527,8 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
beolink_listener.jid beolink_listener.jid
) )
break break
self._beolink_attributes[BeoAttribute.BEOLINK][ self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BeoAttribute.BEOLINK_LISTENERS BangOlufsenAttribute.BEOLINK_LISTENERS
] = beolink_listeners_attribute ] = beolink_listeners_attribute
self._attr_group_members = group_members self._attr_group_members = group_members
@@ -583,7 +587,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
for sound_mode in sound_modes: for sound_mode in sound_modes:
label = f"{sound_mode.name} ({sound_mode.id})" label = f"{sound_mode.name} ({sound_mode.id})"
self._sound_modes[label] = cast(int, sound_mode.id) self._sound_modes[label] = sound_mode.id
if sound_mode.id == active_sound_mode.id: if sound_mode.id == active_sound_mode.id:
self._attr_sound_mode = label self._attr_sound_mode = label
@@ -596,7 +600,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
@property @property
def supported_features(self) -> MediaPlayerEntityFeature: def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported.""" """Flag media player features that are supported."""
features = BEO_FEATURES features = BANG_OLUFSEN_FEATURES
# Add seeking if supported by the current source # Add seeking if supported by the current source
if self._source_change.is_seekable is True: if self._source_change.is_seekable is True:
@@ -607,7 +611,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
@property @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
"""Return the current state of the media player.""" """Return the current state of the media player."""
return BEO_STATES[self._state] return BANG_OLUFSEN_STATES[self._state]
@property @property
def volume_level(self) -> float | None: def volume_level(self) -> float | None:
@@ -627,10 +631,10 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
def media_content_type(self) -> MediaType | str | None: def media_content_type(self) -> MediaType | str | None:
"""Return the current media type.""" """Return the current media type."""
content_type = { content_type = {
BeoSource.URI_STREAMER.id: MediaType.URL, BangOlufsenSource.URI_STREAMER.id: MediaType.URL,
BeoSource.DEEZER.id: BeoMediaType.DEEZER, BangOlufsenSource.DEEZER.id: BangOlufsenMediaType.DEEZER,
BeoSource.TIDAL.id: BeoMediaType.TIDAL, BangOlufsenSource.TIDAL.id: BangOlufsenMediaType.TIDAL,
BeoSource.NET_RADIO.id: BeoMediaType.RADIO, BangOlufsenSource.NET_RADIO.id: BangOlufsenMediaType.RADIO,
} }
# Hard to determine content type. # Hard to determine content type.
if self._source_change.id in content_type: if self._source_change.id in content_type:
@@ -761,7 +765,9 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set playback queues to repeat.""" """Set playback queues to repeat."""
await self._client.set_settings_queue( await self._client.set_settings_queue(
play_queue_settings=PlayQueueSettings(repeat=BEO_REPEAT_FROM_HA[repeat]) play_queue_settings=PlayQueueSettings(
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
)
) )
async def async_set_shuffle(self, shuffle: bool) -> None: async def async_set_shuffle(self, shuffle: bool) -> None:
@@ -865,7 +871,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
self._volume.level.level + offset_volume, 100 self._volume.level.level + offset_volume, 100
) )
if media_type == BeoMediaType.OVERLAY_TTS: if media_type == BangOlufsenMediaType.OVERLAY_TTS:
# Bang & Olufsen cloud TTS # Bang & Olufsen cloud TTS
overlay_play_request.text_to_speech = ( overlay_play_request.text_to_speech = (
OverlayPlayRequestTextToSpeechTextToSpeech( OverlayPlayRequestTextToSpeechTextToSpeech(
@@ -882,14 +888,14 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
# The "provider" media_type may not be suitable for overlay all the time. # The "provider" media_type may not be suitable for overlay all the time.
# Use it for now. # Use it for now.
elif media_type == BeoMediaType.TTS: elif media_type == BangOlufsenMediaType.TTS:
await self._client.post_overlay_play( await self._client.post_overlay_play(
overlay_play_request=OverlayPlayRequest( overlay_play_request=OverlayPlayRequest(
uri=Uri(location=media_id), uri=Uri(location=media_id),
) )
) )
elif media_type == BeoMediaType.RADIO: elif media_type == BangOlufsenMediaType.RADIO:
await self._client.run_provided_scene( await self._client.run_provided_scene(
scene_properties=SceneProperties( scene_properties=SceneProperties(
action_list=[ action_list=[
@@ -901,13 +907,13 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
) )
) )
elif media_type == BeoMediaType.FAVOURITE: elif media_type == BangOlufsenMediaType.FAVOURITE:
await self._client.activate_preset(id=int(media_id)) await self._client.activate_preset(id=int(media_id))
elif media_type in (BeoMediaType.DEEZER, BeoMediaType.TIDAL): elif media_type in (BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.TIDAL):
try: try:
# Play Deezer flow. # Play Deezer flow.
if media_id == "flow" and media_type == BeoMediaType.DEEZER: if media_id == "flow" and media_type == BangOlufsenMediaType.DEEZER:
deezer_id = None deezer_id = None
if "id" in kwargs[ATTR_MEDIA_EXTRA]: if "id" in kwargs[ATTR_MEDIA_EXTRA]:

View File

@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import DeviceEntry
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel from .const import DEVICE_BUTTONS, DOMAIN, BangOlufsenButtons, BangOlufsenModel
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry: def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
@@ -40,27 +40,16 @@ async def get_remotes(client: MozartClient) -> list[PairedRemote]:
] ]
def get_device_buttons(model: BeoModel) -> list[str]: def get_device_buttons(model: BangOlufsenModel) -> list[str]:
"""Get supported buttons for a given model.""" """Get supported buttons for a given model."""
# Beoconnect Core does not have any buttons
if model == BeoModel.BEOCONNECT_CORE:
return []
buttons = DEVICE_BUTTONS.copy() buttons = DEVICE_BUTTONS.copy()
# Models that don't have a microphone button # Beosound Premiere does not have a bluetooth button
if model in ( if model == BangOlufsenModel.BEOSOUND_PREMIERE:
BeoModel.BEOSOUND_A5, buttons.remove(BangOlufsenButtons.BLUETOOTH)
BeoModel.BEOSOUND_A9,
BeoModel.BEOSOUND_PREMIERE,
):
buttons.remove(BeoButtons.MICROPHONE)
# Models that don't have a Bluetooth button # Beoconnect Core does not have any buttons
if model in ( elif model == BangOlufsenModel.BEOCONNECT_CORE:
BeoModel.BEOSOUND_A9, buttons = []
BeoModel.BEOSOUND_PREMIERE,
):
buttons.remove(BeoButtons.BLUETOOTH)
return buttons return buttons

View File

@@ -27,20 +27,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
from .const import ( from .const import (
BEO_WEBSOCKET_EVENT, BANG_OLUFSEN_WEBSOCKET_EVENT,
CONNECTION_STATUS, CONNECTION_STATUS,
DOMAIN, DOMAIN,
EVENT_TRANSLATION_MAP, EVENT_TRANSLATION_MAP,
BeoModel, BangOlufsenModel,
WebsocketNotification, WebsocketNotification,
) )
from .entity import BeoBase from .entity import BangOlufsenBase
from .util import get_device, get_remotes from .util import get_device, get_remotes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class BeoWebsocket(BeoBase): class BangOlufsenWebsocket(BangOlufsenBase):
"""The WebSocket listeners.""" """The WebSocket listeners."""
def __init__( def __init__(
@@ -48,7 +48,7 @@ class BeoWebsocket(BeoBase):
) -> None: ) -> None:
"""Initialize the WebSocket listeners.""" """Initialize the WebSocket listeners."""
BeoBase.__init__(self, entry, client) BangOlufsenBase.__init__(self, entry, client)
self.hass = hass self.hass = hass
self._device = get_device(hass, self._unique_id) self._device = get_device(hass, self._unique_id)
@@ -178,7 +178,7 @@ class BeoWebsocket(BeoBase):
self.entry.entry_id self.entry.entry_id
) )
if device.serial_number is not None if device.serial_number is not None
and device.model == BeoModel.BEOREMOTE_ONE and device.model == BangOlufsenModel.BEOREMOTE_ONE
] ]
# Get paired remotes from device # Get paired remotes from device
remote_serial_numbers = [ remote_serial_numbers = [
@@ -274,4 +274,4 @@ class BeoWebsocket(BeoBase):
} }
_LOGGER.debug("%s", debug_notification) _LOGGER.debug("%s", debug_notification)
self.hass.bus.async_fire(BEO_WEBSOCKET_EVENT, debug_notification) self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification)

View File

@@ -174,13 +174,5 @@
"on": "mdi:window-open" "on": "mdi:window-open"
} }
} }
},
"triggers": {
"occupancy_cleared": {
"trigger": "mdi:home-outline"
},
"occupancy_detected": {
"trigger": "mdi:home"
}
} }
} }

View File

@@ -1,8 +1,4 @@
{ {
"common": {
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": { "device_automation": {
"condition_type": { "condition_type": {
"is_bat_low": "{entity_name} battery is low", "is_bat_low": "{entity_name} battery is low",
@@ -321,36 +317,5 @@
} }
} }
}, },
"selector": { "title": "Binary sensor"
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Binary sensor",
"triggers": {
"occupancy_cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"occupancy_detected": {
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
} }

View File

@@ -1,67 +0,0 @@
"""Provides triggers for binary sensors."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None
_domain: str = DOMAIN
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_class
}
def make_binary_sensor_trigger(
device_class: BinarySensorDeviceClass | None,
to_state: str,
) -> type[BinarySensorOnOffTrigger]:
"""Create an entity state trigger class."""
class CustomTrigger(BinarySensorOnOffTrigger):
"""Trigger for entity state changes."""
_device_class = device_class
_to_state = to_state
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"occupancy_detected": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
),
"occupancy_cleared": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for binary sensors."""
return TRIGGERS

View File

@@ -1,25 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
occupancy_cleared:
fields: *trigger_common_fields
target:
entity:
domain: binary_sensor
device_class: occupancy
occupancy_detected:
fields: *trigger_common_fields
target:
entity:
domain: binary_sensor
device_class: occupancy

View File

@@ -15,12 +15,12 @@
], ],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"bleak==2.0.0", "bleak==1.0.1",
"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.4", "bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2", "dbus-fast==3.1.2",
"habluetooth==5.8.0" "habluetooth==5.7.0"
] ]
} }

View File

@@ -36,7 +36,6 @@
} }
], ],
"documentation": "https://www.home-assistant.io/integrations/broadlink", "documentation": "https://www.home-assistant.io/integrations/broadlink",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["broadlink"], "loggers": ["broadlink"],
"requirements": ["broadlink==0.19.0"] "requirements": ["broadlink==0.19.0"]

View File

@@ -20,7 +20,7 @@ from aiohttp import hdrs, web
import attr import attr
from propcache.api import cached_property, under_cached_property from propcache.api import cached_property, under_cached_property
import voluptuous as vol import voluptuous as vol
from webrtc_models import RTCIceCandidateInit from webrtc_models import RTCIceCandidateInit, RTCIceServer
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
@@ -37,7 +37,6 @@ from homeassistant.components.stream import (
Stream, Stream,
create_stream, create_stream,
) )
from homeassistant.components.web_rtc import async_get_ice_servers
from homeassistant.components.websocket_api import ActiveConnection from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@@ -85,6 +84,7 @@ from .prefs import (
get_dynamic_camera_stream_settings, get_dynamic_camera_stream_settings,
) )
from .webrtc import ( from .webrtc import (
DATA_ICE_SERVERS,
CameraWebRTCProvider, CameraWebRTCProvider,
WebRTCAnswer, # noqa: F401 WebRTCAnswer, # noqa: F401
WebRTCCandidate, # noqa: F401 WebRTCCandidate, # noqa: F401
@@ -93,6 +93,7 @@ from .webrtc import (
WebRTCMessage, # noqa: F401 WebRTCMessage, # noqa: F401
WebRTCSendMessage, WebRTCSendMessage,
async_get_supported_provider, async_get_supported_provider,
async_register_ice_servers,
async_register_webrtc_provider, # noqa: F401 async_register_webrtc_provider, # noqa: F401
async_register_ws, async_register_ws,
) )
@@ -399,6 +400,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service
) )
@callback
def get_ice_servers() -> list[RTCIceServer]:
if hass.config.webrtc.ice_servers:
return hass.config.webrtc.ice_servers
return [
RTCIceServer(
urls=[
"stun:stun.home-assistant.io:80",
"stun:stun.home-assistant.io:3478",
]
),
]
async_register_ice_servers(hass, get_ice_servers)
return True return True
@@ -716,7 +731,11 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the WebRTC client configuration and extend it with the registered ice servers.""" """Return the WebRTC client configuration and extend it with the registered ice servers."""
config = self._async_get_webrtc_client_configuration() config = self._async_get_webrtc_client_configuration()
ice_servers = async_get_ice_servers(self.hass) ice_servers = [
server
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
for server in servers()
]
config.configuration.ice_servers.extend(ice_servers) config.configuration.ice_servers.extend(ice_servers)
return config return config

View File

@@ -3,7 +3,7 @@
"name": "Camera", "name": "Camera",
"after_dependencies": ["media_player"], "after_dependencies": ["media_player"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"dependencies": ["http", "web_rtc"], "dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/camera", "documentation": "https://www.home-assistant.io/integrations/camera",
"integration_type": "entity", "integration_type": "entity",
"quality_scale": "internal", "quality_scale": "internal",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from functools import cache, partial, wraps from functools import cache, partial, wraps
import logging import logging
@@ -12,7 +12,12 @@ from typing import TYPE_CHECKING, Any
from mashumaro import MissingField from mashumaro import MissingField
import voluptuous as vol import voluptuous as vol
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceCandidateInit from webrtc_models import (
RTCConfiguration,
RTCIceCandidate,
RTCIceCandidateInit,
RTCIceServer,
)
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -33,6 +38,9 @@ _LOGGER = logging.getLogger(__name__)
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey( DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
"camera_webrtc_providers" "camera_webrtc_providers"
) )
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
"camera_webrtc_ice_servers"
)
_WEBRTC = "WebRTC" _WEBRTC = "WebRTC"
@@ -359,3 +367,21 @@ async def async_get_supported_provider(
return provider return provider
return None return None
@callback
def async_register_ice_servers(
hass: HomeAssistant,
get_ice_server_fn: Callable[[], Iterable[RTCIceServer]],
) -> Callable[[], None]:
"""Register a ICE server.
The registering integration is responsible to implement caching if needed.
"""
servers = hass.data.setdefault(DATA_ICE_SERVERS, [])
def remove() -> None:
servers.remove(get_ice_server_fn)
servers.append(get_ice_server_fn)
return remove

View File

@@ -299,54 +299,54 @@
"title": "Climate", "title": "Climate",
"triggers": { "triggers": {
"started_cooling": { "started_cooling": {
"description": "Triggers after one or more climate-control devices start cooling.", "description": "Triggers when a climate started cooling.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]", "description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]" "name": "[%key:component::climate::common::trigger_behavior_name%]"
} }
}, },
"name": "Climate-control device started cooling" "name": "When a climate started cooling"
}, },
"started_drying": { "started_drying": {
"description": "Triggers after one or more climate-control devices start drying.", "description": "Triggers when a climate started drying.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]", "description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]" "name": "[%key:component::climate::common::trigger_behavior_name%]"
} }
}, },
"name": "Climate-control device started drying" "name": "When a climate started drying"
}, },
"started_heating": { "started_heating": {
"description": "Triggers after one or more climate-control devices start heating.", "description": "Triggers when a climate starts to heat.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]", "description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]" "name": "[%key:component::climate::common::trigger_behavior_name%]"
} }
}, },
"name": "Climate-control device started heating" "name": "When a climate starts to heat"
}, },
"turned_off": { "turned_off": {
"description": "Triggers after one or more climate-control devices turn off.", "description": "Triggers when a climate is turned off.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]", "description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]" "name": "[%key:component::climate::common::trigger_behavior_name%]"
} }
}, },
"name": "Climate-control device turned off" "name": "When a climate is turned off"
}, },
"turned_on": { "turned_on": {
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.", "description": "Triggers when a climate is turned on.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]", "description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]" "name": "[%key:component::climate::common::trigger_behavior_name%]"
} }
}, },
"name": "Climate-control device turned on" "name": "When a climate is turned on"
} }
} }
} }

View File

@@ -4,13 +4,12 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import logging import logging
from typing import Any, cast from typing import Any, cast
from hass_nabucasa import Cloud, NabuCasaBaseError from hass_nabucasa import Cloud
import voluptuous as vol import voluptuous as vol
from homeassistant.components import alexa, google_assistant from homeassistant.components import alexa, google_assistant
@@ -79,16 +78,13 @@ from .subscription import async_subscription_info
DEFAULT_MODE = MODE_PROD DEFAULT_MODE = MODE_PROD
PLATFORMS = [ PLATFORMS = [
Platform.AI_TASK,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.CONVERSATION,
Platform.STT, Platform.STT,
Platform.TTS, Platform.TTS,
] ]
LLM_PLATFORMS = [
Platform.AI_TASK,
Platform.CONVERSATION,
]
SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_CONNECT = "remote_connect"
SERVICE_REMOTE_DISCONNECT = "remote_disconnect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect"
@@ -435,14 +431,7 @@ def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> Non
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry.""" """Set up a config entry."""
platforms = PLATFORMS.copy() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
if (cloud := hass.data[DATA_CLOUD]).is_logged_in:
with suppress(NabuCasaBaseError):
await cloud.llm.async_ensure_token()
platforms += LLM_PLATFORMS
await hass.config_entries.async_forward_entry_setups(entry, platforms)
entry.runtime_data = {"platforms": platforms}
stt_tts_entities_added = hass.data[DATA_PLATFORMS_SETUP]["stt_tts_entities_added"] stt_tts_entities_added = hass.data[DATA_PLATFORMS_SETUP]["stt_tts_entities_added"]
stt_tts_entities_added.set() stt_tts_entities_added.set()
@@ -451,9 +440,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
entry, entry.runtime_data["platforms"]
)
@callback @callback

View File

@@ -6,6 +6,7 @@ import io
from json import JSONDecodeError from json import JSONDecodeError
import logging import logging
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import ( from hass_nabucasa.llm import (
LLMAuthenticationError, LLMAuthenticationError,
LLMError, LLMError,
@@ -19,7 +20,7 @@ from PIL import Image
from homeassistant.components import ai_task, conversation from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads from homeassistant.util.json import json_loads
@@ -93,11 +94,17 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Home Assistant Cloud AI Task entity.""" """Set up Home Assistant Cloud AI Task entity."""
cloud = hass.data[DATA_CLOUD] if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
async_add_entities([CloudAITaskEntity(cloud, config_entry)]) return
try:
await cloud.llm.async_ensure_token()
except (LLMError, NabuCasaBaseError):
return
async_add_entities([CloudLLMTaskEntity(cloud, config_entry)])
class CloudAITaskEntity(BaseCloudLLMEntity, ai_task.AITaskEntity): class CloudLLMTaskEntity(ai_task.AITaskEntity, BaseCloudLLMEntity):
"""Home Assistant Cloud AI Task entity.""" """Home Assistant Cloud AI Task entity."""
_attr_has_entity_name = True _attr_has_entity_name = True
@@ -174,7 +181,7 @@ class CloudAITaskEntity(BaseCloudLLMEntity, ai_task.AITaskEntity):
attachments=attachments, attachments=attachments,
) )
except LLMAuthenticationError as err: except LLMAuthenticationError as err:
raise HomeAssistantError("Cloud LLM authentication failed") from err raise ConfigEntryAuthFailed("Cloud LLM authentication failed") from err
except LLMRateLimitError as err: except LLMRateLimitError as err:
raise HomeAssistantError("Cloud LLM is rate limited") from err raise HomeAssistantError("Cloud LLM is rate limited") from err
except LLMResponseError as err: except LLMResponseError as err:

View File

@@ -19,8 +19,8 @@ from homeassistant.components.alexa import (
errors as alexa_errors, errors as alexa_errors,
smart_home as alexa_smart_home, smart_home as alexa_smart_home,
) )
from homeassistant.components.camera import async_register_ice_servers
from homeassistant.components.google_assistant import smart_home as ga from homeassistant.components.google_assistant import smart_home as ga
from homeassistant.components.web_rtc import async_register_ice_servers
from homeassistant.const import __version__ as HA_VERSION from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.core import Context, HassJob, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
@@ -71,7 +71,6 @@ class CloudClient(Interface):
self._google_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock()
self._relayer_region: str | None = None self._relayer_region: str | None = None
self._cloud_ice_servers_listener: Callable[[], None] | None = None self._cloud_ice_servers_listener: Callable[[], None] | None = None
self._ice_servers: list[RTCIceServer] = []
@property @property
def base_path(self) -> Path: def base_path(self) -> Path:
@@ -118,11 +117,6 @@ class CloudClient(Interface):
"""Return the connected relayer region.""" """Return the connected relayer region."""
return self._relayer_region return self._relayer_region
@property
def ice_servers(self) -> list[RTCIceServer]:
"""Return the current ICE servers."""
return self._ice_servers
async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig: async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig:
"""Return Alexa config.""" """Return Alexa config."""
if self._alexa_config is None: if self._alexa_config is None:
@@ -209,8 +203,11 @@ class CloudClient(Interface):
ice_servers: list[RTCIceServer], ice_servers: list[RTCIceServer],
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Register cloud ice server.""" """Register cloud ice server."""
self._ice_servers = ice_servers
return async_register_ice_servers(self._hass, lambda: self._ice_servers) def get_ice_servers() -> list[RTCIceServer]:
return ice_servers
return async_register_ice_servers(self._hass, get_ice_servers)
async def async_register_cloud_ice_servers_listener( async def async_register_cloud_ice_servers_listener(
prefs: CloudPreferences, prefs: CloudPreferences,
@@ -271,7 +268,6 @@ class CloudClient(Interface):
async def logout_cleanups(self) -> None: async def logout_cleanups(self) -> None:
"""Cleanup some stuff after logout.""" """Cleanup some stuff after logout."""
self._ice_servers = []
await self.prefs.async_set_username(None) await self.prefs.async_set_username(None)
if self._alexa_config: if self._alexa_config:

View File

@@ -4,6 +4,9 @@ from __future__ import annotations
from typing import Literal from typing import Literal
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import LLMError
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL from homeassistant.const import MATCH_ALL
@@ -21,13 +24,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Assistant Cloud conversation entity.""" """Set up the Home Assistant Cloud conversation entity."""
cloud = hass.data[DATA_CLOUD] if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
return
try:
await cloud.llm.async_ensure_token()
except (LLMError, NabuCasaBaseError):
return
async_add_entities([CloudConversationEntity(cloud, config_entry)]) async_add_entities([CloudConversationEntity(cloud, config_entry)])
class CloudConversationEntity( class CloudConversationEntity(
BaseCloudLLMEntity,
conversation.ConversationEntity, conversation.ConversationEntity,
BaseCloudLLMEntity,
): ):
"""Home Assistant Cloud conversation agent.""" """Home Assistant Cloud conversation agent."""

View File

@@ -8,9 +8,10 @@ import logging
import re import re
from typing import Any, Literal, cast from typing import Any, Literal, cast
from hass_nabucasa import Cloud, NabuCasaBaseError from hass_nabucasa import Cloud
from hass_nabucasa.llm import ( from hass_nabucasa.llm import (
LLMAuthenticationError, LLMAuthenticationError,
LLMError,
LLMRateLimitError, LLMRateLimitError,
LLMResponseError, LLMResponseError,
LLMServiceError, LLMServiceError,
@@ -36,7 +37,7 @@ from voluptuous_openapi import convert
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import llm from homeassistant.helpers import llm
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify from homeassistant.util import slugify
@@ -561,7 +562,7 @@ class BaseCloudLLMEntity(Entity):
"schema": _format_structured_output( "schema": _format_structured_output(
structure, chat_log.llm_api structure, chat_log.llm_api
), ),
"strict": False, "strict": True,
}, },
} }
@@ -600,14 +601,14 @@ class BaseCloudLLMEntity(Entity):
) )
except LLMAuthenticationError as err: except LLMAuthenticationError as err:
raise HomeAssistantError("Cloud LLM authentication failed") from err raise ConfigEntryAuthFailed("Cloud LLM authentication failed") from err
except LLMRateLimitError as err: except LLMRateLimitError as err:
raise HomeAssistantError("Cloud LLM is rate limited") from err raise HomeAssistantError("Cloud LLM is rate limited") from err
except LLMResponseError as err: except LLMResponseError as err:
raise HomeAssistantError(str(err)) from err raise HomeAssistantError(str(err)) from err
except LLMServiceError as err: except LLMServiceError as err:
raise HomeAssistantError("Error talking to Cloud LLM") from err raise HomeAssistantError("Error talking to Cloud LLM") from err
except NabuCasaBaseError as err: except LLMError as err:
raise HomeAssistantError(str(err)) from err raise HomeAssistantError(str(err)) from err
if not chat_log.unresponded_tool_results: if not chat_log.unresponded_tool_results:

View File

@@ -99,7 +99,6 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_hook_delete) websocket_api.async_register_command(hass, websocket_hook_delete)
websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_connect)
websocket_api.async_register_command(hass, websocket_remote_disconnect) websocket_api.async_register_command(hass, websocket_remote_disconnect)
websocket_api.async_register_command(hass, websocket_webrtc_ice_servers)
websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_get)
websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_list)
@@ -1108,7 +1107,6 @@ async def alexa_sync(
@websocket_api.websocket_command({"type": "cloud/tts/info"}) @websocket_api.websocket_command({"type": "cloud/tts/info"})
@callback
def tts_info( def tts_info(
hass: HomeAssistant, hass: HomeAssistant,
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
@@ -1136,22 +1134,3 @@ def tts_info(
) )
connection.send_result(msg["id"], {"languages": result}) connection.send_result(msg["id"], {"languages": result})
@websocket_api.websocket_command(
{
vol.Required("type"): "cloud/webrtc/ice_servers",
}
)
@_require_cloud_login
@callback
def websocket_webrtc_ice_servers(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Handle get WebRTC ICE servers websocket command."""
connection.send_result(
msg["id"],
[server.to_dict() for server in hass.data[DATA_CLOUD].client.ice_servers],
)

View File

@@ -8,11 +8,11 @@
"google_assistant" "google_assistant"
], ],
"codeowners": ["@home-assistant/cloud"], "codeowners": ["@home-assistant/cloud"],
"dependencies": ["auth", "http", "repairs", "webhook", "web_rtc"], "dependencies": ["auth", "http", "repairs", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/cloud", "documentation": "https://www.home-assistant.io/integrations/cloud",
"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.7.0"], "requirements": ["hass-nabucasa==1.6.2"],
"single_config_entry": true "single_config_entry": true
} }

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.5.0", "home-assistant-intents==2025.12.2"] "requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.24"]
} }

View File

@@ -56,6 +56,7 @@ class DeviceAutomationConditionProtocol(Protocol):
class DeviceCondition(Condition): class DeviceCondition(Condition):
"""Device condition.""" """Device condition."""
_hass: HomeAssistant
_config: ConfigType _config: ConfigType
@classmethod @classmethod
@@ -86,7 +87,7 @@ class DeviceCondition(Condition):
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition.""" """Initialize condition."""
super().__init__(hass, config) self._hass = hass
assert config.options is not None assert config.options is not None
self._config = config.options self._config = config.options

View File

@@ -6,7 +6,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["async_upnp_client"], "loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"], "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],

View File

@@ -6,7 +6,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms", "documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["async-upnp-client==0.46.0"], "requirements": ["async-upnp-client==0.46.0"],
"ssdp": [ "ssdp": [

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dnsip", "documentation": "https://www.home-assistant.io/integrations/dnsip",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["aiodns==3.6.0"] "requirements": ["aiodns==3.5.0"]
} }

View File

@@ -102,12 +102,6 @@ class ConfiguredDoorBird:
"""Get token for device.""" """Get token for device."""
return self._token return self._token
def _get_hass_url(self) -> str:
"""Get the Home Assistant URL for this device."""
if custom_url := self.custom_url:
return custom_url
return get_url(self._hass, prefer_external=False)
async def async_register_events(self) -> None: async def async_register_events(self) -> None:
"""Register events on device.""" """Register events on device."""
if not self.door_station_events: if not self.door_station_events:
@@ -152,7 +146,13 @@ class ConfiguredDoorBird:
async def _async_register_events(self) -> dict[str, Any]: async def _async_register_events(self) -> dict[str, Any]:
"""Register events on device.""" """Register events on device."""
hass_url = self._get_hass_url() # Override url if another is specified in the configuration
if custom_url := self.custom_url:
hass_url = custom_url
else:
# Get the URL of this server
hass_url = get_url(self._hass, prefer_external=False)
http_fav = await self._async_get_http_favorites() http_fav = await self._async_get_http_favorites()
if any( if any(
# Note that a list comp is used here to ensure all # Note that a list comp is used here to ensure all
@@ -191,14 +191,10 @@ class ConfiguredDoorBird:
self._get_event_name(event): event_type self._get_event_name(event): event_type
for event, event_type in DEFAULT_EVENT_TYPES for event, event_type in DEFAULT_EVENT_TYPES
} }
hass_url = self._get_hass_url()
for identifier, data in http_fav.items(): for identifier, data in http_fav.items():
title: str | None = data.get("title") title: str | None = data.get("title")
if not title or not title.startswith("Home Assistant"): if not title or not title.startswith("Home Assistant"):
continue continue
value: str | None = data.get("value")
if not value or not value.startswith(hass_url):
continue # Not our favorite - different HA instance or stale
event = title.partition("(")[2].strip(")") event = title.partition("(")[2].strip(")")
if input_type := favorite_input_type.get(identifier): if input_type := favorite_input_type.get(identifier):
events.append(DoorbirdEvent(event, input_type)) events.append(DoorbirdEvent(event, input_type))

View File

@@ -2,22 +2,33 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Coroutine, Sequence
from datetime import datetime, timedelta
import logging import logging
from typing import Any, cast
from aiohttp import ClientSession
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import (
CALLBACK_TYPE,
HassJob,
HomeAssistant,
ServiceCall,
callback,
)
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.selector import ConfigEntrySelector from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from .const import ATTR_CONFIG_ENTRY from .const import ATTR_CONFIG_ENTRY
from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator
from .helpers import update_duckdns
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -25,8 +36,17 @@ ATTR_TXT = "txt"
DOMAIN = "duckdns" DOMAIN = "duckdns"
INTERVAL = timedelta(minutes=5)
BACKOFF_INTERVALS = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
SERVICE_SET_TXT = "set_txt" SERVICE_SET_TXT = "set_txt"
UPDATE_URL = "https://www.duckdns.org/update"
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@@ -51,6 +71,8 @@ SERVICE_TXT_SCHEMA = vol.Schema(
} }
) )
type DuckDnsConfigEntry = ConfigEntry
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the DuckDNS component.""" """Initialize the DuckDNS component."""
@@ -77,12 +99,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Set up Duck DNS from a config entry.""" """Set up Duck DNS from a config entry."""
coordinator = DuckDnsUpdateCoordinator(hass, entry) session = async_get_clientsession(hass)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Add a dummy listener as we do not have regular entities async def update_domain_interval(_now: datetime) -> bool:
entry.async_on_unload(coordinator.async_add_listener(lambda: None)) """Update the DuckDNS entry."""
return await _update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
)
entry.async_on_unload(
async_track_time_interval_backoff(
hass, update_domain_interval, BACKOFF_INTERVALS
)
)
return True return True
@@ -122,7 +153,7 @@ async def update_domain_service(call: ServiceCall) -> None:
session = async_get_clientsession(call.hass) session = async_get_clientsession(call.hass)
await update_duckdns( await _update_duckdns(
session, session,
entry.data[CONF_DOMAIN], entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN], entry.data[CONF_ACCESS_TOKEN],
@@ -133,3 +164,73 @@ async def update_domain_service(call: ServiceCall) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return True return True
_SENTINEL = object()
async def _update_duckdns(
session: ClientSession,
domain: str,
token: str,
*,
txt: str | None | object = _SENTINEL,
clear: bool = False,
) -> bool:
"""Update DuckDNS."""
params = {"domains": domain, "token": token}
if txt is not _SENTINEL:
if txt is None:
# Pass in empty txt value to indicate it's clearing txt record
params["txt"] = ""
clear = True
else:
params["txt"] = cast(str, txt)
if clear:
params["clear"] = "true"
resp = await session.get(UPDATE_URL, params=params)
body = await resp.text()
if body != "OK":
_LOGGER.warning("Updating DuckDNS domain failed: %s", domain)
return False
return True
@callback
@bind_hass
def async_track_time_interval_backoff(
hass: HomeAssistant,
action: Callable[[datetime], Coroutine[Any, Any, bool]],
intervals: Sequence[timedelta],
) -> CALLBACK_TYPE:
"""Add a listener that fires repetitively at every timedelta interval."""
remove: CALLBACK_TYPE | None = None
failed = 0
async def interval_listener(now: datetime) -> None:
"""Handle elapsed intervals with backoff."""
nonlocal failed, remove
try:
failed += 1
if await action(now):
failed = 0
finally:
delay = intervals[failed] if failed < len(intervals) else intervals[-1]
remove = async_call_later(
hass, delay.total_seconds(), interval_listener_job
)
interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True)
hass.async_run_hass_job(interval_listener_job, dt_util.utcnow())
def remove_listener() -> None:
"""Remove interval listener."""
if remove:
remove()
return remove_listener

View File

@@ -8,7 +8,7 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN, CONF_NAME from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
TextSelector, TextSelector,
@@ -16,8 +16,8 @@ from homeassistant.helpers.selector import (
TextSelectorType, TextSelectorType,
) )
from . import _update_duckdns
from .const import DOMAIN from .const import DOMAIN
from .helpers import update_duckdns
from .issue import deprecate_yaml_issue from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -31,8 +31,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
} }
) )
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN): class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Duck DNS.""" """Handle a config flow for Duck DNS."""
@@ -46,7 +44,7 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]}) self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
try: try:
if not await update_duckdns( if not await _update_duckdns(
session, session,
user_input[CONF_DOMAIN], user_input[CONF_DOMAIN],
user_input[CONF_ACCESS_TOKEN], user_input[CONF_ACCESS_TOKEN],
@@ -81,37 +79,3 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
deprecate_yaml_issue(self.hass, import_success=True) deprecate_yaml_issue(self.hass, import_success=True)
return result return result
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfigure flow."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
session = async_get_clientsession(self.hass)
try:
if not await update_duckdns(
session,
entry.data[CONF_DOMAIN],
user_input[CONF_ACCESS_TOKEN],
):
errors["base"] = "update_failed"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
return self.async_update_reload_and_abort(
entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
errors=errors,
description_placeholders={CONF_NAME: entry.title},
)

View File

@@ -1,83 +0,0 @@
"""Coordinator for the Duck DNS integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aiohttp import ClientError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .helpers import update_duckdns
_LOGGER = logging.getLogger(__name__)
type DuckDnsConfigEntry = ConfigEntry[DuckDnsUpdateCoordinator]
INTERVAL = timedelta(minutes=5)
BACKOFF_INTERVALS = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
"""Duck DNS update coordinator."""
config_entry: DuckDnsConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: DuckDnsConfigEntry) -> None:
"""Initialize the Duck DNS update coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=INTERVAL,
)
self.session = async_get_clientsession(hass)
self.failed = 0
async def _async_update_data(self) -> None:
"""Update Duck DNS."""
retry_after = BACKOFF_INTERVALS[
min(self.failed, len(BACKOFF_INTERVALS))
].total_seconds()
try:
if not await update_duckdns(
self.session,
self.config_entry.data[CONF_DOMAIN],
self.config_entry.data[CONF_ACCESS_TOKEN],
):
self.failed += 1
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
},
retry_after=retry_after,
)
except ClientError as e:
self.failed += 1
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
},
retry_after=retry_after,
) from e
self.failed = 0

View File

@@ -1,35 +0,0 @@
"""Helpers for Duck DNS integration."""
from aiohttp import ClientSession
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
UPDATE_URL = "https://www.duckdns.org/update"
async def update_duckdns(
session: ClientSession,
domain: str,
token: str,
*,
txt: str | None | UndefinedType = UNDEFINED,
clear: bool = False,
) -> bool:
"""Update DuckDNS."""
params = {"domains": domain, "token": token}
if txt is not UNDEFINED:
if txt is None:
# Pass in empty txt value to indicate it's clearing txt record
params["txt"] = ""
clear = True
else:
params["txt"] = txt
if clear:
params["clear"] = "true"
resp = await session.get(UPDATE_URL, params=params)
body = await resp.text()
return body == "OK"

View File

@@ -1,23 +1,13 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}, },
"error": { "error": {
"unknown": "[%key:common::config_flow::error::unknown%]", "unknown": "[%key:common::config_flow::error::unknown%]",
"update_failed": "Updating Duck DNS failed" "update_failed": "Updating Duck DNS failed"
}, },
"step": { "step": {
"reconfigure": {
"data": {
"access_token": "[%key:component::duckdns::config::step::user::data::access_token%]"
},
"data_description": {
"access_token": "[%key:component::duckdns::config::step::user::data_description::access_token%]"
},
"title": "Re-configure {name}"
},
"user": { "user": {
"data": { "data": {
"access_token": "Token", "access_token": "Token",
@@ -32,17 +22,11 @@
} }
}, },
"exceptions": { "exceptions": {
"connection_error": {
"message": "Updating Duck DNS domain {domain} failed due to a connection error"
},
"entry_not_found": { "entry_not_found": {
"message": "Duck DNS integration entry not found" "message": "Duck DNS integration entry not found"
}, },
"entry_not_selected": { "entry_not_selected": {
"message": "Duck DNS integration entry not selected" "message": "Duck DNS integration entry not selected"
},
"update_failed": {
"message": "Updating Duck DNS domain {domain} failed"
} }
}, },
"issues": { "issues": {

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"] "requirements": ["py-sucks==0.9.11", "deebot-client==16.4.0"]
} }

View File

@@ -17,7 +17,7 @@ DEFAULT_TTS_MODEL = "eleven_multilingual_v2"
DEFAULT_STABILITY = 0.5 DEFAULT_STABILITY = 0.5
DEFAULT_SIMILARITY = 0.75 DEFAULT_SIMILARITY = 0.75
DEFAULT_STT_AUTO_LANGUAGE = False DEFAULT_STT_AUTO_LANGUAGE = False
DEFAULT_STT_MODEL = "scribe_v2" DEFAULT_STT_MODEL = "scribe_v1"
DEFAULT_STYLE = 0 DEFAULT_STYLE = 0
DEFAULT_USE_SPEAKER_BOOST = True DEFAULT_USE_SPEAKER_BOOST = True
@@ -129,5 +129,4 @@ STT_LANGUAGES = [
STT_MODELS = { STT_MODELS = {
"scribe_v1": "Scribe v1", "scribe_v1": "Scribe v1",
"scribe_v1_experimental": "Scribe v1 Experimental", "scribe_v1_experimental": "Scribe v1 Experimental",
"scribe_v2": "Scribe v2 Realtime",
} }

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["evohome", "evohomeasync", "evohomeasync2"], "loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["evohome-async==1.0.6"] "requirements": ["evohome-async==1.0.5"]
} }

View File

@@ -166,24 +166,24 @@
"title": "Fan", "title": "Fan",
"triggers": { "triggers": {
"turned_off": { "turned_off": {
"description": "Triggers after one or more fans turn off.", "description": "Triggers when a fan is turned off.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]", "description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]" "name": "[%key:component::fan::common::trigger_behavior_name%]"
} }
}, },
"name": "Fan turned off" "name": "When a fan is turned off"
}, },
"turned_on": { "turned_on": {
"description": "Triggers after one or more fans turn on.", "description": "Triggers when a fan is turned on.",
"fields": { "fields": {
"behavior": { "behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]", "description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]" "name": "[%key:component::fan::common::trigger_behavior_name%]"
} }
}, },
"name": "Fan turned on" "name": "When a fan is turned on"
} }
} }
} }

View File

@@ -12,13 +12,7 @@ from .coordinator import (
FressnapfTrackerDataUpdateCoordinator, FressnapfTrackerDataUpdateCoordinator,
) )
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
Platform.BINARY_SENSOR,
Platform.DEVICE_TRACKER,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry( async def async_setup_entry(

View File

@@ -1,65 +0,0 @@
"""Binary Sensor platform for fressnapf_tracker."""
from collections.abc import Callable
from dataclasses import dataclass
from fressnapftracker import Tracker
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry
from .entity import FressnapfTrackerEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class FressnapfTrackerBinarySensorDescription(BinarySensorEntityDescription):
"""Class describing Fressnapf Tracker binary_sensor entities."""
value_fn: Callable[[Tracker], bool]
BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[
FressnapfTrackerBinarySensorDescription, ...
] = (
FressnapfTrackerBinarySensorDescription(
key="charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.charging,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fressnapf Tracker binary_sensors."""
async_add_entities(
FressnapfTrackerBinarySensor(coordinator, sensor_description)
for sensor_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS
for coordinator in entry.runtime_data
)
class FressnapfTrackerBinarySensor(FressnapfTrackerEntity, BinarySensorEntity):
"""Fressnapf Tracker binary_sensor for general information."""
entity_description: FressnapfTrackerBinarySensorDescription
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -8,9 +8,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
from .entity import FressnapfTrackerBaseEntity from .entity import FressnapfTrackerBaseEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -42,11 +39,6 @@ class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
"""Return if entity is available.""" """Return if entity is available."""
return super().available and self.coordinator.data.position is not None return super().available and self.coordinator.data.position is not None
@property
def entity_picture(self) -> str | None:
"""Return the entity picture url."""
return self.coordinator.data.icon
@property @property
def latitude(self) -> float | None: def latitude(self) -> float | None:
"""Return latitude value of the device.""" """Return latitude value of the device."""

View File

@@ -1,7 +1,6 @@
"""fressnapf_tracker class.""" """fressnapf_tracker class."""
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FressnapfTrackerDataUpdateCoordinator from . import FressnapfTrackerDataUpdateCoordinator
@@ -26,17 +25,3 @@ class FressnapfTrackerBaseEntity(
manufacturer="Fressnapf", manufacturer="Fressnapf",
serial_number=str(self.id), serial_number=str(self.id),
) )
class FressnapfTrackerEntity(FressnapfTrackerBaseEntity):
"""Entity for fressnapf_tracker."""
def __init__(
self,
coordinator: FressnapfTrackerDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{self.id}_{entity_description.key}"

View File

@@ -4,14 +4,6 @@
"pet": { "pet": {
"default": "mdi:paw" "default": "mdi:paw"
} }
},
"switch": {
"energy_saving": {
"default": "mdi:sleep",
"state": {
"off": "mdi:sleep-off"
}
}
} }
} }
} }

View File

@@ -1,95 +0,0 @@
"""Light platform for fressnapf_tracker."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
LightEntity,
LightEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry
from .const import DOMAIN
from .entity import FressnapfTrackerEntity
PARALLEL_UPDATES = 1
LIGHT_ENTITY_DESCRIPTION = LightEntityDescription(
translation_key="led",
entity_category=EntityCategory.CONFIG,
key="led_brightness_value",
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fressnapf Tracker lights."""
async_add_entities(
FressnapfTrackerLight(coordinator, LIGHT_ENTITY_DESCRIPTION)
for coordinator in entry.runtime_data
if coordinator.data.led_activatable is not None
and coordinator.data.led_activatable.has_led
and coordinator.data.tracker_settings.features.flash_light
)
class FressnapfTrackerLight(FressnapfTrackerEntity, LightEntity):
"""Fressnapf Tracker light."""
_attr_color_mode: ColorMode = ColorMode.BRIGHTNESS
_attr_supported_color_modes: set[ColorMode] = {ColorMode.BRIGHTNESS}
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
if TYPE_CHECKING:
# The entity is not created if led_brightness_value is None
assert self.coordinator.data.led_brightness_value is not None
return int(round((self.coordinator.data.led_brightness_value / 100) * 255))
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the device."""
self.raise_if_not_activatable()
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
brightness = int((brightness / 255) * 100)
await self.coordinator.client.set_led_brightness(brightness)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the device."""
await self.coordinator.client.set_led_brightness(0)
await self.coordinator.async_request_refresh()
def raise_if_not_activatable(self) -> None:
"""Raise error with reasoning if light is not activatable."""
if TYPE_CHECKING:
# The entity is not created if led_activatable is None
assert self.coordinator.data.led_activatable is not None
error_type: str | None = None
if not self.coordinator.data.led_activatable.seen_recently:
error_type = "not_seen_recently"
elif not self.coordinator.data.led_activatable.not_charging:
error_type = "charging"
elif not self.coordinator.data.led_activatable.nonempty_battery:
error_type = "low_battery"
if error_type is not None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=error_type,
)
@property
def is_on(self) -> bool:
"""Return true if device is on."""
if self.coordinator.data.led_brightness_value is not None:
return self.coordinator.data.led_brightness_value > 0
return False

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["fressnapftracker==0.2.0"] "requirements": ["fressnapftracker==0.1.2"]
} }

View File

@@ -28,26 +28,20 @@ rules:
# Silver # Silver
action-exceptions: todo action-exceptions: todo
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: done docs-configuration-parameters: todo
docs-installation-parameters: done docs-installation-parameters: todo
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: todo
log-when-unavailable: done log-when-unavailable: todo
parallel-updates: done parallel-updates: todo
reauthentication-flow: todo reauthentication-flow: todo
test-coverage: done test-coverage: todo
# Gold # Gold
devices: done devices: done
diagnostics: todo diagnostics: todo
discovery-update-info: discovery-update-info: todo
status: exempt discovery: todo
comment: |
This integration is a cloud service and thus does not support discovery.
discovery:
status: exempt
comment: |
This integration is a cloud service and thus does not support discovery.
docs-data-update: todo docs-data-update: todo
docs-examples: todo docs-examples: todo
docs-known-limitations: todo docs-known-limitations: todo
@@ -56,15 +50,14 @@ rules:
docs-troubleshooting: todo docs-troubleshooting: todo
docs-use-cases: todo docs-use-cases: todo
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: todo
entity-device-class: done entity-device-class: todo
entity-disabled-by-default: entity-disabled-by-default: todo
entity-translations:
status: exempt status: exempt
comment: | comment: No entities to translate
This integration does not have many entities. All of them are fundamental. exception-translations: todo
entity-translations: done icon-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done reconfiguration-flow: done
repair-issues: todo repair-issues: todo
stale-devices: todo stale-devices: todo

View File

@@ -1,66 +0,0 @@
"""Sensor platform for fressnapf_tracker."""
from collections.abc import Callable
from dataclasses import dataclass
from fressnapftracker import Tracker
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry
from .entity import FressnapfTrackerEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class FressnapfTrackerSensorDescription(SensorEntityDescription):
"""Class describing Fressnapf Tracker sensor entities."""
value_fn: Callable[[Tracker], int]
SENSOR_ENTITY_DESCRIPTIONS: tuple[FressnapfTrackerSensorDescription, ...] = (
FressnapfTrackerSensorDescription(
key="battery",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.battery,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fressnapf Tracker sensors."""
async_add_entities(
FressnapfTrackerSensor(coordinator, sensor_description)
for sensor_description in SENSOR_ENTITY_DESCRIPTIONS
for coordinator in entry.runtime_data
)
class FressnapfTrackerSensor(FressnapfTrackerEntity, SensorEntity):
"""fressnapf_tracker sensor for general information."""
entity_description: FressnapfTrackerSensorDescription
@property
def native_value(self) -> int:
"""Return the state of the resources if it has been received yet."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -45,28 +45,5 @@
} }
} }
} }
},
"entity": {
"light": {
"led": {
"name": "Flashlight"
}
},
"switch": {
"energy_saving": {
"name": "Sleep mode"
}
}
},
"exceptions": {
"charging": {
"message": "The flashlight cannot be activated while charging."
},
"low_battery": {
"message": "The flashlight cannot be activated due to low battery."
},
"not_seen_recently": {
"message": "The flashlight cannot be activated when the tracker has not moved recently."
}
} }
} }

View File

@@ -1,60 +0,0 @@
"""Switch platform for Fressnapf Tracker."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry
from .entity import FressnapfTrackerEntity
PARALLEL_UPDATES = 1
SWITCH_ENTITY_DESCRIPTION = SwitchEntityDescription(
translation_key="energy_saving",
entity_category=EntityCategory.CONFIG,
device_class=SwitchDeviceClass.SWITCH,
key="energy_saving",
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fressnapf Tracker switches."""
async_add_entities(
FressnapfTrackerSwitch(coordinator, SWITCH_ENTITY_DESCRIPTION)
for coordinator in entry.runtime_data
if coordinator.data.tracker_settings.features.energy_saving_mode
)
class FressnapfTrackerSwitch(FressnapfTrackerEntity, SwitchEntity):
"""Fressnapf Tracker switch."""
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the device."""
await self.coordinator.client.set_energy_saving(True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the device."""
await self.coordinator.client.set_energy_saving(False)
await self.coordinator.async_request_refresh()
@property
def is_on(self) -> bool:
"""Return true if device is on."""
if TYPE_CHECKING:
# The entity is not created if energy_saving is None
assert self.coordinator.data.energy_saving is not None
return self.coordinator.data.energy_saving.value == 1

View File

@@ -9,7 +9,6 @@
} }
], ],
"documentation": "https://www.home-assistant.io/integrations/fronius", "documentation": "https://www.home-assistant.io/integrations/fronius",
"integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyfronius"], "loggers": ["pyfronius"],
"quality_scale": "platinum", "quality_scale": "platinum",

View File

@@ -23,5 +23,5 @@
"winter_mode": {} "winter_mode": {}
}, },
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251203.1"] "requirements": ["home-assistant-frontend==20251201.0"]
} }

View File

@@ -1,58 +0,0 @@
"""The homelink integration."""
from __future__ import annotations
from homelink.mqtt_provider import MQTTProvider
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import oauth2
from .const import DOMAIN
from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkData
PLATFORMS: list[Platform] = [Platform.EVENT]
async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
"""Set up homelink from a config entry."""
auth_implementation = oauth2.SRPAuthImplementation(hass, DOMAIN)
config_entry_oauth2_flow.async_register_implementation(
hass, DOMAIN, auth_implementation
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
authenticated_session = oauth2.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), session
)
provider = MQTTProvider(authenticated_session)
coordinator = HomeLinkCoordinator(hass, provider, entry)
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, coordinator.async_on_unload
)
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = HomeLinkData(
provider=provider, coordinator=coordinator, last_update_id=None
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.coordinator.async_on_unload(None)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,14 +0,0 @@
"""application_credentials platform for the gentex homelink integration."""
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from . import oauth2
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, _credential: ClientCredential
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
"""Return custom SRPAuth implementation."""
return oauth2.SRPAuthImplementation(hass, auth_domain)

View File

@@ -1,66 +0,0 @@
"""Config flow for homelink."""
import logging
from typing import Any
import botocore.exceptions
from homelink.auth.srp_auth import SRPAuth
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN
from .oauth2 import SRPAuthImplementation
_LOGGER = logging.getLogger(__name__)
class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle homelink OAuth2 authentication."""
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Set up the flow handler."""
super().__init__()
self.flow_impl = SRPAuthImplementation(self.hass, DOMAIN)
@property
def logger(self):
"""Get the logger."""
return _LOGGER
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Ask for username and password."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
srp_auth = SRPAuth()
try:
tokens = await self.hass.async_add_executor_job(
srp_auth.async_get_access_token,
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
)
except botocore.exceptions.ClientError:
_LOGGER.exception("Error authenticating homelink account")
errors["base"] = "srp_auth_failed"
except Exception:
_LOGGER.exception("An unexpected error occurred")
errors["base"] = "unknown"
else:
self.external_data = {"tokens": tokens}
return await self.async_step_creation()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
),
errors=errors,
)

View File

@@ -1,7 +0,0 @@
"""Constants for the homelink integration."""
DOMAIN = "gentex_homelink"
OAUTH2_TOKEN = "https://auth.homelinkcloud.com/oauth2/token"
POLLING_INTERVAL = 5
EVENT_PRESSED = "Pressed"

View File

@@ -1,113 +0,0 @@
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
import logging
from typing import TYPE_CHECKING, TypedDict
from homelink.model.device import Device
from homelink.mqtt_provider import MQTTProvider
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.ssl import get_default_context
if TYPE_CHECKING:
from .event import HomeLinkEventEntity
_LOGGER = logging.getLogger(__name__)
type HomeLinkConfigEntry = ConfigEntry[HomeLinkData]
type EventCallback = Callable[[HomeLinkEventData], None]
@dataclass
class HomeLinkData:
"""Class for HomeLink integration runtime data."""
provider: MQTTProvider
coordinator: HomeLinkCoordinator
last_update_id: str | None
class HomeLinkEventData(TypedDict):
"""Data for a single event."""
requestId: str
timestamp: int
class HomeLinkMQTTMessage(TypedDict):
"""HomeLink MQTT Event message."""
type: str
data: dict[str, HomeLinkEventData] # Each key is a button id
class HomeLinkCoordinator:
"""HomeLink integration coordinator."""
def __init__(
self,
hass: HomeAssistant,
provider: MQTTProvider,
config_entry: HomeLinkConfigEntry,
) -> None:
"""Initialize my coordinator."""
self.hass = hass
self.config_entry = config_entry
self.provider = provider
self.device_data: list[Device] = []
self.buttons: list[HomeLinkEventEntity] = []
self._listeners: dict[str, EventCallback] = {}
@callback
def async_add_event_listener(
self, update_callback: EventCallback, target_event_id: str
) -> Callable[[], None]:
"""Listen for updates."""
self._listeners[target_event_id] = update_callback
return partial(self.__async_remove_listener_internal, target_event_id)
def __async_remove_listener_internal(self, listener_id: str):
del self._listeners[listener_id]
@callback
def async_handle_state_data(self, data: dict[str, HomeLinkEventData]):
"""Notify listeners."""
for button_id, event in data.items():
if listener := self._listeners.get(button_id):
listener(event)
async def async_config_entry_first_refresh(self) -> None:
"""Refresh data for the first time when a config entry is setup."""
await self._async_setup()
async def async_on_unload(self, _event):
"""Disconnect and unregister when unloaded."""
await self.provider.disable()
async def _async_setup(self) -> None:
"""Set up the coordinator."""
await self.provider.enable(get_default_context())
await self.discover_devices()
self.provider.listen(self.on_message)
async def discover_devices(self):
"""Discover devices and build the Entities."""
self.device_data = await self.provider.discover()
def on_message(
self: HomeLinkCoordinator, _topic: str, message: HomeLinkMQTTMessage
):
"MQTT Callback function."
if message["type"] == "state":
self.hass.add_job(self.async_handle_state_data, message["data"])
if message["type"] == "requestSync":
self.hass.add_job(
self.hass.config_entries.async_reload,
self.config_entry.entry_id,
)

View File

@@ -1,83 +0,0 @@
"""Platform for Event integration."""
from __future__ import annotations
from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, EVENT_PRESSED
from .coordinator import HomeLinkCoordinator, HomeLinkEventData
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the entities for the binary sensor."""
coordinator = config_entry.runtime_data.coordinator
for device in coordinator.device_data:
buttons = [
HomeLinkEventEntity(b.id, b.name, device.id, device.name, coordinator)
for b in device.buttons
]
coordinator.buttons.extend(buttons)
async_add_entities(coordinator.buttons)
# Updates are centralized by the coordinator.
PARALLEL_UPDATES = 0
class HomeLinkEventEntity(EventEntity):
"""Event Entity."""
_attr_has_entity_name = True
_attr_event_types = [EVENT_PRESSED]
_attr_device_class = EventDeviceClass.BUTTON
def __init__(
self,
id: str,
param_name: str,
device_id: str,
device_name: str,
coordinator: HomeLinkCoordinator,
) -> None:
"""Initialize the event entity."""
self.id: str = id
self._attr_name: str = param_name
self._attr_unique_id: str = id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
name=device_name,
)
self.coordinator = coordinator
self.last_request_id: str | None = None
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_event_listener(
self._handle_event_data_update, self.id
)
)
@callback
def _handle_event_data_update(self, update_data: HomeLinkEventData) -> None:
"""Update this button."""
if update_data["requestId"] != self.last_request_id:
self._trigger_event(EVENT_PRESSED)
self.last_request_id = update_data["requestId"]
self.async_write_ha_state()
async def async_update(self):
"""Request early polling. Left intentionally blank because it's not possible in this implementation."""

View File

@@ -1,11 +0,0 @@
{
"domain": "gentex_homelink",
"name": "HomeLink",
"codeowners": ["@niaexa", "@ryanjones-gentex"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["homelink-integration-api==0.0.1"]
}

View File

@@ -1,114 +0,0 @@
"""API for homelink bound to Home Assistant OAuth."""
from json import JSONDecodeError
import logging
import time
from typing import cast
from aiohttp import ClientError, ClientSession
from homelink.auth.abstract_auth import AbstractAuth
from homelink.settings import COGNITO_CLIENT_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import OAUTH2_TOKEN
_LOGGER = logging.getLogger(__name__)
class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementation):
"""Base class to abstract OAuth2 authentication."""
def __init__(self, hass: HomeAssistant, domain) -> None:
"""Initialize the SRP Auth implementation."""
self.hass = hass
self._domain = domain
self.client_id = COGNITO_CLIENT_ID
@property
def name(self) -> str:
"""Name of the implementation."""
return "SRPAuth"
@property
def domain(self) -> str:
"""Domain that is providing the implementation."""
return self._domain
async def async_generate_authorize_url(self, flow_id: str) -> str:
"""Left intentionally blank because the auth is handled by SRP."""
return ""
async def async_resolve_external_data(self, external_data) -> dict:
"""Format the token from the source appropriately for HomeAssistant."""
tokens = external_data["tokens"]
new_token = {}
new_token["access_token"] = tokens["AuthenticationResult"]["AccessToken"]
new_token["refresh_token"] = tokens["AuthenticationResult"]["RefreshToken"]
new_token["token_type"] = tokens["AuthenticationResult"]["TokenType"]
new_token["expires_in"] = tokens["AuthenticationResult"]["ExpiresIn"]
new_token["expires_at"] = (
time.time() + tokens["AuthenticationResult"]["ExpiresIn"]
)
return new_token
async def _token_request(self, data: dict) -> dict:
"""Make a token request."""
session = async_get_clientsession(self.hass)
data["client_id"] = self.client_id
_LOGGER.debug("Sending token request to %s", OAUTH2_TOKEN)
resp = await session.post(OAUTH2_TOKEN, data=data)
if resp.status >= 400:
try:
error_response = await resp.json()
except (ClientError, JSONDecodeError):
error_response = {}
error_code = error_response.get("error", "unknown")
error_description = error_response.get(
"error_description", "unknown error"
)
_LOGGER.error(
"Token request for %s failed (%s): %s",
self.domain,
error_code,
error_description,
)
resp.raise_for_status()
return cast(dict, await resp.json())
async def _async_refresh_token(self, token: dict) -> dict:
"""Refresh tokens."""
new_token = await self._token_request(
{
"grant_type": "refresh_token",
"client_id": self.client_id,
"refresh_token": token["refresh_token"],
}
)
return {**token, **new_token}
class AsyncConfigEntryAuth(AbstractAuth):
"""Provide homelink authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize homelink auth."""
super().__init__(websession)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token["access_token"]

View File

@@ -1,76 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register any service actions
appropriate-polling:
status: exempt
comment: Integration does not poll
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register any service actions
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: Integration does not register any service actions
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: It is not necessary to update IP addresses of devices or services in this Integration
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: done
entity-category: todo
entity-device-class: todo
entity-disabled-by-default:
status: exempt
comment: Entities are not noisy and are expected to be enabled by default
entity-translations:
status: exempt
comment: Entity properties are user-defined, and therefore cannot be translated
exception-translations: todo
icon-translations:
status: exempt
comment: Entities in this integration do not use icons, and therefore do not require translation
reconfiguration-flow: todo
repair-issues: todo
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,38 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"error": {
"srp_auth_failed": "Error authenticating HomeLink account",
"unknown": "An unknown error occurred. Please try again later"
},
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Email address associated with your HomeLink account",
"password": "Password associated with your HomeLink account"
}
}
}
}
}

View File

@@ -6,7 +6,6 @@ from dataclasses import dataclass
import logging import logging
from secrets import token_hex from secrets import token_hex
import shutil import shutil
from tempfile import mkdtemp
from aiohttp import BasicAuth, ClientSession, UnixConnector from aiohttp import BasicAuth, ClientSession, UnixConnector
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
@@ -63,11 +62,11 @@ from .const import (
CONF_DEBUG_UI, CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE, DEBUG_UI_URL_MESSAGE,
DOMAIN, DOMAIN,
HA_MANAGED_UNIX_SOCKET,
HA_MANAGED_URL, HA_MANAGED_URL,
RECOMMENDED_VERSION, RECOMMENDED_VERSION,
) )
from .server import Server from .server import Server
from .util import get_go2rtc_unix_socket_path
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -155,12 +154,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
auth = BasicAuth(username, password) auth = BasicAuth(username, password)
# HA will manage the binary # HA will manage the binary
temp_dir = mkdtemp(prefix="go2rtc-")
# Manually created session (not using the helper) needs to be closed manually # Manually created session (not using the helper) needs to be closed manually
# See on_stop listener below # See on_stop listener below
session = ClientSession( session = ClientSession(
connector=UnixConnector(path=get_go2rtc_unix_socket_path(temp_dir)), connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET), auth=auth
auth=auth,
) )
server = Server( server = Server(
hass, hass,
@@ -169,7 +166,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
enable_ui=domain_config.get(CONF_DEBUG_UI, False), enable_ui=domain_config.get(CONF_DEBUG_UI, False),
username=username, username=username,
password=password, password=password,
working_dir=temp_dir,
) )
try: try:
await server.start() await server.start()

View File

@@ -6,6 +6,7 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984 HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA) # When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
# in script/hassfest/docker.py. # in script/hassfest/docker.py.
RECOMMENDED_VERSION = "1.9.12" RECOMMENDED_VERSION = "1.9.12"

View File

@@ -12,13 +12,13 @@ from go2rtc_client import Go2RtcRestClient
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL from .const import HA_MANAGED_API_PORT, HA_MANAGED_UNIX_SOCKET, HA_MANAGED_URL
from .util import get_go2rtc_unix_socket_path
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5 _TERMINATE_TIMEOUT = 5
_SETUP_TIMEOUT = 30 _SETUP_TIMEOUT = 30
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr="
_LOCALHOST_IP = "127.0.0.1"
_LOG_BUFFER_SIZE = 512 _LOG_BUFFER_SIZE = 512
_RESPAWN_COOLDOWN = 1 _RESPAWN_COOLDOWN = 1
@@ -122,9 +122,7 @@ def _format_list_for_yaml(items: tuple[str, ...]) -> str:
return f"[{formatted_items}]" return f"[{formatted_items}]"
def _create_temp_file( def _create_temp_file(enable_ui: bool, username: str, password: str) -> str:
enable_ui: bool, username: str, password: str, working_dir: str
) -> str:
"""Create temporary config file.""" """Create temporary config file."""
app_modules: tuple[str, ...] = _APP_MODULES app_modules: tuple[str, ...] = _APP_MODULES
api_paths: tuple[str, ...] = _API_ALLOW_PATHS api_paths: tuple[str, ...] = _API_ALLOW_PATHS
@@ -141,13 +139,11 @@ def _create_temp_file(
# Set delete=False to prevent the file from being deleted when the file is closed # Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually # Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile( with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
prefix="go2rtc_", suffix=".yaml", dir=working_dir, delete=False
) as file:
file.write( file.write(
_GO2RTC_CONFIG_FORMAT.format( _GO2RTC_CONFIG_FORMAT.format(
listen_config=listen_config, listen_config=listen_config,
unix_socket=get_go2rtc_unix_socket_path(working_dir), unix_socket=HA_MANAGED_UNIX_SOCKET,
app_modules=_format_list_for_yaml(app_modules), app_modules=_format_list_for_yaml(app_modules),
api_allow_paths=_format_list_for_yaml(api_paths), api_allow_paths=_format_list_for_yaml(api_paths),
username=username, username=username,
@@ -169,7 +165,6 @@ class Server:
enable_ui: bool = False, enable_ui: bool = False,
username: str, username: str,
password: str, password: str,
working_dir: str,
) -> None: ) -> None:
"""Initialize the server.""" """Initialize the server."""
self._hass = hass self._hass = hass
@@ -178,7 +173,6 @@ class Server:
self._enable_ui = enable_ui self._enable_ui = enable_ui
self._username = username self._username = username
self._password = password self._password = password
self._working_dir = working_dir
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE) self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event() self._startup_complete = asyncio.Event()
@@ -196,11 +190,7 @@ class Server:
"""Start the server.""" """Start the server."""
_LOGGER.debug("Starting go2rtc server") _LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job( config_file = await self._hass.async_add_executor_job(
_create_temp_file, _create_temp_file, self._enable_ui, self._username, self._password
self._enable_ui,
self._username,
self._password,
self._working_dir,
) )
self._startup_complete.clear() self._startup_complete.clear()

View File

@@ -1,12 +0,0 @@
"""Go2rtc utility functions."""
from pathlib import Path
_HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock"
def get_go2rtc_unix_socket_path(path: str | Path) -> str:
"""Get the Go2rtc unix socket path."""
if not isinstance(path, Path):
path = Path(path)
return str(path / _HA_MANAGED_UNIX_SOCKET_FILE)

View File

@@ -5,7 +5,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["application_credentials"], "dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google", "documentation": "https://www.home-assistant.io/integrations/google",
"integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"], "loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"] "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]

View File

@@ -59,14 +59,9 @@
"user": "Add location" "user": "Add location"
}, },
"step": { "step": {
"location": { "user": {
"data": { "data": {
"location": "[%key:common::config_flow::data::location%]", "location": "[%key:common::config_flow::data::location%]"
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"location": "[%key:component::google_air_quality::config::step::user::data_description::location%]",
"name": "[%key:component::google_air_quality::config::step::user::data_description::name%]"
}, },
"description": "Select the coordinates for which you want to create an entry.", "description": "Select the coordinates for which you want to create an entry.",
"title": "Air quality data location" "title": "Air quality data location"

View File

@@ -149,7 +149,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
} }
), ),
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
description_placeholders={"example_image_path": "/config/www/image.jpg"},
) )
return True return True

View File

@@ -23,7 +23,7 @@ CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image" RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image-preview"
CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p" CONF_TOP_P = "top_p"

View File

@@ -162,7 +162,7 @@
"fields": { "fields": {
"filenames": { "filenames": {
"description": "Attachments to add to the prompt (images, PDFs, etc)", "description": "Attachments to add to the prompt (images, PDFs, etc)",
"example": "{example_image_path}", "example": "/config/www/image.jpg",
"name": "Attachment filenames" "name": "Attachment filenames"
}, },
"prompt": { "prompt": {

View File

@@ -159,5 +159,4 @@ def async_setup_services(hass: HomeAssistant) -> None:
_async_handle_upload, _async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA, schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL, supports_response=SupportsResponse.OPTIONAL,
description_placeholders={"example_image_path": "/config/www/image.jpg"},
) )

View File

@@ -92,7 +92,7 @@
}, },
"filename": { "filename": {
"description": "Path to the image or video to upload.", "description": "Path to the image or video to upload.",
"example": "{example_image_path}", "example": "/config/www/image.jpg",
"name": "Filename" "name": "Filename"
} }
}, },

View File

@@ -4,7 +4,6 @@
"codeowners": [], "codeowners": [],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_translate", "documentation": "https://www.home-assistant.io/integrations/google_translate",
"integration_type": "service",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["gtts"], "loggers": ["gtts"],
"requirements": ["gTTS==2.5.3"] "requirements": ["gTTS==2.5.3"]

View File

@@ -18,12 +18,10 @@ from homeassistant.components.notify import (
SERVICE_SEND_MESSAGE, SERVICE_SEND_MESSAGE,
BaseNotificationService, BaseNotificationService,
NotifyEntity, NotifyEntity,
NotifyEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
CONF_ACTION, CONF_ACTION,
CONF_ENTITIES, CONF_ENTITIES,
CONF_SERVICE, CONF_SERVICE,
@@ -175,23 +173,14 @@ class NotifyGroup(GroupEntity, NotifyEntity):
async def async_send_message(self, message: str, title: str | None = None) -> None: async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to all members of the group.""" """Send a message to all members of the group."""
data = {
ATTR_MESSAGE: message,
ATTR_ENTITY_ID: self._entity_ids,
}
# add title only if supported and provided
if (
title is not None
and self._attr_supported_features & NotifyEntityFeature.TITLE
):
data[ATTR_TITLE] = title
await self.hass.services.async_call( await self.hass.services.async_call(
NOTIFY_DOMAIN, NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE, SERVICE_SEND_MESSAGE,
data, {
ATTR_MESSAGE: message,
ATTR_TITLE: title,
ATTR_ENTITY_ID: self._entity_ids,
},
blocking=True, blocking=True,
context=self._context, context=self._context,
) )
@@ -205,15 +194,3 @@ class NotifyGroup(GroupEntity, NotifyEntity):
for entity_id in self._entity_ids for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None if (state := self.hass.states.get(entity_id)) is not None
) )
# Support title if all members support it
self._attr_supported_features |= NotifyEntityFeature.TITLE
for entity_id in self._entity_ids:
state = self.hass.states.get(entity_id)
if (
state is None
or not state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
& NotifyEntityFeature.TITLE
):
self._attr_supported_features &= ~NotifyEntityFeature.TITLE
break

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